feat(layout-system basics): Added component-map, component-props, configloader and renderer.
Added vt-template for nav and poc example footer. Added ste.medusa-starter.design.json. Probable starting point (main)/layout.tsx refactored to use dynamic layout renderer instead of hardcoded template.
This commit is contained in:
parent
b7c67b5834
commit
51d6ee2051
|
|
@ -0,0 +1,68 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Nav": {
|
||||||
|
"props": {},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"Div": {
|
||||||
|
"props": { "className": "flex items-center h-full" },
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"LocalizedClientLink": {
|
||||||
|
"props": {
|
||||||
|
"href": "/",
|
||||||
|
"label": "Medusa Store",
|
||||||
|
"className": "bg-black txt-compact-xlarge-plus hover:text-ui-fg-base uppercase",
|
||||||
|
"data-testid": "nav-store-link"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Div": {
|
||||||
|
"props": { "className": "flex items-center gap-x-6 h-full flex-1 basis-0 justify-end" },
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"LocalizedClientLink": {
|
||||||
|
"props": {
|
||||||
|
"href": "/account",
|
||||||
|
"label": "Account",
|
||||||
|
"className": "hover:text-ui-fg-base bg-black",
|
||||||
|
"data-testid": "nav-account-link"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Suspense": {
|
||||||
|
"props": {
|
||||||
|
"fallback": [
|
||||||
|
{
|
||||||
|
"LocalizedClientLink": {
|
||||||
|
"props": {
|
||||||
|
"href": "/cart",
|
||||||
|
"label": "Cart (0)",
|
||||||
|
"className": "bg-black hover:text-ui-fg-base flex gap-2",
|
||||||
|
"data-testid": "nav-cart-link"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{ "CartButton": {} }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ "CartMismatchBanner": { "show": true } },
|
||||||
|
{ "FreeShippingPriceNudge": { "variant": "popup" } },
|
||||||
|
{ "PropsChildren" : {}},
|
||||||
|
{ "Footer": { "copyrightText": "© 2025 MyShop" } }
|
||||||
|
]
|
||||||
|
|
@ -4,10 +4,9 @@ import { listCartOptions, retrieveCart } from "@lib/data/cart"
|
||||||
import { retrieveCustomer } from "@lib/data/customer"
|
import { retrieveCustomer } from "@lib/data/customer"
|
||||||
import { getBaseURL } from "@lib/util/env"
|
import { getBaseURL } from "@lib/util/env"
|
||||||
import { StoreCartShippingOption } from "@medusajs/types"
|
import { StoreCartShippingOption } from "@medusajs/types"
|
||||||
import CartMismatchBanner from "@modules/layout/components/cart-mismatch-banner"
|
import { DynamicLayoutRenderer } from "../../../vibentec/renderer"
|
||||||
import Footer from "@modules/layout/templates/footer"
|
import { LayoutContext, LayoutComponentNode, } from "../../../vibentec/component-map"
|
||||||
import Nav from "@modules/layout/templates/nav"
|
import { loadDesignConfig } from "vibentec/configloader"
|
||||||
import FreeShippingPriceNudge from "@modules/shipping/components/free-shipping-price-nudge"
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(getBaseURL()),
|
metadataBase: new URL(getBaseURL()),
|
||||||
|
|
@ -24,22 +23,14 @@ export default async function PageLayout(props: { children: React.ReactNode }) {
|
||||||
shippingOptions = shipping_options
|
shippingOptions = shipping_options
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const nodes: LayoutComponentNode[] = await loadDesignConfig()
|
||||||
<>
|
const context: LayoutContext = {
|
||||||
<Nav />
|
customer,
|
||||||
{customer && cart && (
|
cart,
|
||||||
<CartMismatchBanner customer={customer} cart={cart} />
|
shippingOptions,
|
||||||
)}
|
contentChildren: props.children,
|
||||||
|
}
|
||||||
|
|
||||||
{cart && (
|
|
||||||
<FreeShippingPriceNudge
|
return <DynamicLayoutRenderer nodes={nodes} context={context} />
|
||||||
variant="popup"
|
|
||||||
cart={cart}
|
|
||||||
shippingOptions={shippingOptions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{props.children}
|
|
||||||
<Footer />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { listCategories } from "@lib/data/categories"
|
||||||
|
import { listCollections } from "@lib/data/collections"
|
||||||
|
import { Text, clx } from "@medusajs/ui"
|
||||||
|
import { FooterProps } from "vibentec/component-props"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import MedusaCTA from "@modules/layout/components/medusa-cta"
|
||||||
|
|
||||||
|
|
||||||
|
export default async function VtFooter({copyrightText}:FooterProps) {
|
||||||
|
const { collections } = await listCollections({
|
||||||
|
fields: "*products",
|
||||||
|
})
|
||||||
|
const productCategories = await listCategories()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-ui-border-base w-full">
|
||||||
|
<div className="content-container flex flex-col w-full">
|
||||||
|
<div className="flex flex-col gap-y-6 xsmall:flex-row items-start justify-between py-40">
|
||||||
|
<div>
|
||||||
|
<LocalizedClientLink
|
||||||
|
href="/"
|
||||||
|
className="txt-compact-xlarge-plus text-ui-fg-subtle hover:text-ui-fg-base uppercase"
|
||||||
|
>
|
||||||
|
Medusa Store
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</div>
|
||||||
|
<div className="text-small-regular gap-10 md:gap-x-16 grid grid-cols-2 sm:grid-cols-3">
|
||||||
|
{productCategories && productCategories?.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<span className="txt-small-plus txt-ui-fg-base">
|
||||||
|
Categories
|
||||||
|
</span>
|
||||||
|
<ul
|
||||||
|
className="grid grid-cols-1 gap-2"
|
||||||
|
data-testid="footer-categories"
|
||||||
|
>
|
||||||
|
{productCategories?.slice(0, 6).map((c) => {
|
||||||
|
if (c.parent_category) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const children =
|
||||||
|
c.category_children?.map((child) => ({
|
||||||
|
name: child.name,
|
||||||
|
handle: child.handle,
|
||||||
|
id: child.id,
|
||||||
|
})) || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className="flex flex-col gap-2 text-ui-fg-subtle txt-small"
|
||||||
|
key={c.id}
|
||||||
|
>
|
||||||
|
<LocalizedClientLink
|
||||||
|
className={clx(
|
||||||
|
"hover:text-ui-fg-base",
|
||||||
|
children && "txt-small-plus"
|
||||||
|
)}
|
||||||
|
href={`/categories/${c.handle}`}
|
||||||
|
data-testid="category-link"
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</LocalizedClientLink>
|
||||||
|
{children && (
|
||||||
|
<ul className="grid grid-cols-1 ml-3 gap-2">
|
||||||
|
{children &&
|
||||||
|
children.map((child) => (
|
||||||
|
<li key={child.id}>
|
||||||
|
<LocalizedClientLink
|
||||||
|
className="hover:text-ui-fg-base"
|
||||||
|
href={`/categories/${child.handle}`}
|
||||||
|
data-testid="category-link"
|
||||||
|
>
|
||||||
|
{child.name}
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collections && collections.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<span className="txt-small-plus txt-ui-fg-base">
|
||||||
|
Collections
|
||||||
|
</span>
|
||||||
|
<ul
|
||||||
|
className={clx(
|
||||||
|
"grid grid-cols-1 gap-2 text-ui-fg-subtle txt-small",
|
||||||
|
{
|
||||||
|
"grid-cols-2": (collections?.length || 0) > 3,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{collections?.slice(0, 6).map((c) => (
|
||||||
|
<li key={c.id}>
|
||||||
|
<LocalizedClientLink
|
||||||
|
className="hover:text-ui-fg-base"
|
||||||
|
href={`/collections/${c.handle}`}
|
||||||
|
>
|
||||||
|
{c.title}
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<span className="txt-small-plus txt-ui-fg-base">Medusa</span>
|
||||||
|
<ul className="grid grid-cols-1 gap-y-2 text-ui-fg-subtle txt-small">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/medusajs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="hover:text-ui-fg-base"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://docs.medusajs.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="hover:text-ui-fg-base"
|
||||||
|
>
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/medusajs/nextjs-starter-medusa"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="hover:text-ui-fg-base"
|
||||||
|
>
|
||||||
|
Source code
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full mb-16 justify-between text-ui-fg-muted">
|
||||||
|
<Text className="txt-compact-small">
|
||||||
|
{copyrightText || `© ${new Date().getFullYear()} Medusa Store. All rights reserved.`}
|
||||||
|
</Text>
|
||||||
|
<MedusaCTA />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { listRegions } from "@lib/data/regions"
|
||||||
|
import { StoreRegion } from "@medusajs/types"
|
||||||
|
import SideMenu from "@modules/layout/components/side-menu"
|
||||||
|
import { DynamicLayoutRenderer, DynamicLayoutRendererProps } from "vibentec/renderer"
|
||||||
|
|
||||||
|
export default async function VtNav({ nodes, context }: DynamicLayoutRendererProps) {
|
||||||
|
const regions = await listRegions().then((regions: StoreRegion[]) => regions)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 inset-x-0 z-50 group">
|
||||||
|
<header className="relative h-16 mx-auto border-b duration-200 bg-white border-ui-border-base">
|
||||||
|
<nav className="content-container txt-xsmall-plus text-ui-fg-subtle flex items-center justify-between w-full h-full text-small-regular">
|
||||||
|
<div className="flex-1 basis-0 h-full flex items-center">
|
||||||
|
<div className="h-full">
|
||||||
|
<SideMenu regions={regions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nodes && (
|
||||||
|
<DynamicLayoutRenderer nodes={nodes} context={context} />
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import CartMismatchBanner from "@modules/layout/components/cart-mismatch-banner"
|
||||||
|
import FreeShippingPriceNudge from "@modules/shipping/components/free-shipping-price-nudge"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import CartButton from "@modules/layout/components/cart-button"
|
||||||
|
import { DynamicLayoutRenderer } from "./renderer"
|
||||||
|
import React from "react"
|
||||||
|
import VtNav from "@modules/layout/templates/vt-nav"
|
||||||
|
import VtFooter from "@modules/layout/templates/vt-footer"
|
||||||
|
|
||||||
|
export interface LayoutComponentDefinition {
|
||||||
|
props?: Record<string, any>
|
||||||
|
children?: LayoutComponentNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
//maps key = componentName to value = props + children
|
||||||
|
export type LayoutComponentNode = Record<string, LayoutComponentDefinition>
|
||||||
|
|
||||||
|
export interface LayoutContext {
|
||||||
|
customer: any;
|
||||||
|
cart: any;
|
||||||
|
shippingOptions: any[];
|
||||||
|
contentChildren: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComponentRenderer = {
|
||||||
|
render: (entry: LayoutComponentDefinition, ctx: LayoutContext) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility, wenn eine Komponente nur props hat und keine children
|
||||||
|
const simple = (Component: React.ComponentType<any>): ComponentRenderer => ({
|
||||||
|
render: (entry) => <Component {...entry.props} />
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper für Kinder-Rendering
|
||||||
|
const renderChildren = (entry: LayoutComponentDefinition, ctx: LayoutContext) =>
|
||||||
|
entry.children ? <DynamicLayoutRenderer nodes={entry.children} context={ctx} /> : null
|
||||||
|
|
||||||
|
|
||||||
|
// Component Map
|
||||||
|
export const componentMap: Record<string, ComponentRenderer> = {
|
||||||
|
Nav: {
|
||||||
|
render: (entry: any, ctx: LayoutContext) => ( <VtNav nodes={entry.children} context={ctx} /> ),
|
||||||
|
},
|
||||||
|
Div: {
|
||||||
|
render: (entry: any, ctx: LayoutContext) => (
|
||||||
|
<div {...entry.props}>
|
||||||
|
{entry.children ? <DynamicLayoutRenderer nodes={entry.children} context={ctx} /> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
LocalizedClientLink: {
|
||||||
|
render: (entry: any) => (
|
||||||
|
<LocalizedClientLink {...entry.props}>{entry.props.label}</LocalizedClientLink>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
CartButton: simple(CartButton),
|
||||||
|
Suspense: {
|
||||||
|
render: (entry: any, ctx: LayoutContext) => (
|
||||||
|
<React.Suspense
|
||||||
|
fallback={
|
||||||
|
entry.props?.fallback ? (
|
||||||
|
<DynamicLayoutRenderer nodes={entry.props.fallback} context={ctx} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{entry.children ? <DynamicLayoutRenderer nodes={entry.children} context={ctx} /> : null}
|
||||||
|
</React.Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
CartMismatchBanner: simple(CartMismatchBanner),
|
||||||
|
FreeShippingPriceNudge: simple(FreeShippingPriceNudge),
|
||||||
|
PropsChildren: {
|
||||||
|
render: (_props, ctx) => ctx.contentChildren, // PageLayout's props.children
|
||||||
|
},
|
||||||
|
Footer: simple(VtFooter),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export interface FooterProps { copyrightText?: string }
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
const fileName = "ste.medusa-starter.design.json";
|
||||||
|
|
||||||
|
export async function loadDesignConfig() {
|
||||||
|
const filePath = path.join(process.cwd(), "config", fileName)
|
||||||
|
const fileData = await fs.promises.readFile(filePath, "utf-8")
|
||||||
|
return JSON.parse(fileData)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import React from "react"
|
||||||
|
import { LayoutComponentNode, LayoutContext, componentMap } from "./component-map"
|
||||||
|
|
||||||
|
export interface DynamicLayoutRendererProps {
|
||||||
|
nodes: LayoutComponentNode[]
|
||||||
|
context: LayoutContext
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicLayoutRenderer({ nodes, context } : DynamicLayoutRendererProps) {
|
||||||
|
return nodes.map((entry, index) => {
|
||||||
|
const [key, value] = Object.entries(entry)[0] as [string, any]
|
||||||
|
const component = componentMap[key]
|
||||||
|
if (!component) return null
|
||||||
|
return <React.Fragment key={index}>{component.render(value, context)}</React.Fragment>
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue