refactor: make dynamic config loader json file and create template for 3bear design

This commit is contained in:
Nam Doan 2025-11-24 15:42:38 +07:00
parent 51d6ee2051
commit 1bace8a023
12 changed files with 580 additions and 41 deletions

View File

@ -0,0 +1,308 @@
[
{
"AnnouncementBanner": {
"props": {
"label": [
{
"text": "Free shipping on orders over $100",
"className": "font-medium"
},
{
"text": ".",
"className": ""
},
{
"text": "Free gift with every purchase",
"className": ""
},
{
"text": ".",
"className": ""
},
{
"text": "Free returns",
"className": ""
}
],
"className": "bg-[#009b93] text-white"
}
}
},
{
"Nav": {
"props": {},
"children": [
{
"Div": {
"props": {
"className": "flex items-center h-full ml-16"
},
"children": [
{
"Image": {
"props": {
"src": "https://3bears.de/cdn/shop/files/3Bears_Logo-Schutzzone_negativ_RGB.png?v=1676382997&width=335",
"alt": "Medusa Store",
"width": 160,
"height": 40
}
}
},
{
"Div": {
"props": {
"className": "flex items-center h-full gap-10"
},
"children": [
{
"DropdownMenus": {
"props": {
"label": "Shop",
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-categories-link",
"isShowArrow": true
},
"children": [
{
"DropdownMenuItems": {
"props": {
"label": "All Categories",
"className": "hover:text-ui-fg-base",
"data-testid": "nav-all-categories-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "New Arrivals",
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-new-arrivals-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "Best Sellers",
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-best-sellers-link"
}
}
}
]
}
},
{
"DropdownMenus": {
"props": {
"label": "About us",
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-categories-link",
"isShowArrow": true
},
"children": [
{
"DropdownMenuItems": {
"props": {
"label": "All Categories",
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-all-categories-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "New Arrivals",
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-new-arrivals-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "Best Sellers",
"className": "hover:text-ui-fg-base",
"data-testid": "nav-best-sellers-link"
}
}
}
]
}
},
{
"DropdownMenus": {
"props": {
"label": "About our product",
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-categories-link",
"isShowArrow": true
},
"children": [
{
"DropdownMenuItems": {
"props": {
"label": "All Categories",
"className": "hover:text-ui-fg-base",
"data-testid": "nav-all-categories-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "New Arrivals",
"className": "hover:text-ui-fg-base",
"data-testid": "nav-new-arrivals-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "Best Sellers",
"className": "hover:text-ui-fg-base",
"data-testid": "nav-best-sellers-link"
}
}
}
]
}
},
{
"LocalizedClientLink": {
"props": {
"href": "/recipe",
"label": "Recipes",
"className": "hover:text-ui-fg-base",
"data-testid": "nav-register-link"
}
}
},
{
"LocalizedClientLink": {
"props": {
"href": "/meet-harry-kane",
"label": "Meet Harry Kane",
"className": "hover:text-ui-fg-base",
"data-testid": "nav-register-link"
}
}
}
]
}
}
]
}
},
{
"Div": {
"props": {
"className": "flex items-center gap-x-6 h-full justify-end"
},
"children": [
{
"DropdownMenus": {
"props": {
"label": "Germany (EUR €)",
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-categories-link",
"isShowArrow": true
},
"children": [
{
"DropdownMenuItems": {
"props": {
"label": "All Categories",
"className": "hover:text-ui-fg-base",
"data-testid": "nav-all-categories-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "New Arrivals",
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-new-arrivals-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "Best Sellers",
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-best-sellers-link"
}
}
}
]
}
},
{
"SearchButton": {
"props": {
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-account-link"
}
}
},
{
"UserButton": {
"props": {
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-account-link"
}
}
},
{
"Suspense": {
"props": {
"fallback": [
{
"LocalizedClientLink": {
"props": {
"href": "/cart",
"label": "Cart (0)",
"className": "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"
}
}
]

View File

@ -4,15 +4,17 @@ 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 { DynamicLayoutRenderer } from "../../../vibentec/renderer" import { LayoutContext, LayoutComponentNode } from "vibentec/component-map"
import { LayoutContext, LayoutComponentNode, } from "../../../vibentec/component-map" import { DynamicLayoutRenderer } from "vibentec/renderer"
import { loadDesignConfig } from "vibentec/configloader" import { loadDesignConfig } from "vibentec/configloader"
import { DESIGN_JSON_FILE } from "./config-json-file"
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(getBaseURL()), metadataBase: new URL(getBaseURL()),
} }
export default async function PageLayout(props: { children: React.ReactNode }) { export default async function PageLayout(props: { children: React.ReactNode }) {
// Choose which design JSON to load. Swap this constant as needed.
const customer = await retrieveCustomer() const customer = await retrieveCustomer()
const cart = await retrieveCart() const cart = await retrieveCart()
let shippingOptions: StoreCartShippingOption[] = [] let shippingOptions: StoreCartShippingOption[] = []
@ -23,14 +25,16 @@ export default async function PageLayout(props: { children: React.ReactNode }) {
shippingOptions = shipping_options shippingOptions = shipping_options
} }
const nodes: LayoutComponentNode[] = await loadDesignConfig() const nodes: LayoutComponentNode[] = await loadDesignConfig(
DESIGN_JSON_FILE[1].file
)
const context: LayoutContext = { const context: LayoutContext = {
customer, customer,
cart, cart,
shippingOptions, shippingOptions,
contentChildren: props.children, contentChildren: props.children,
designId: DESIGN_JSON_FILE[1].id,
} }
return <DynamicLayoutRenderer nodes={nodes} context={context} /> return <DynamicLayoutRenderer nodes={nodes} context={context} />
} }

View File

@ -6,6 +6,7 @@ import {
PopoverPanel, PopoverPanel,
Transition, Transition,
} from "@headlessui/react" } from "@headlessui/react"
import { ShoppingBag } from "@medusajs/icons"
import { convertToLocale } from "@lib/util/money" import { convertToLocale } from "@lib/util/money"
import { HttpTypes } from "@medusajs/types" import { HttpTypes } from "@medusajs/types"
import { Button } from "@medusajs/ui" import { Button } from "@medusajs/ui"
@ -82,10 +83,12 @@ const CartDropdown = ({
<Popover className="relative h-full"> <Popover className="relative h-full">
<PopoverButton className="h-full"> <PopoverButton className="h-full">
<LocalizedClientLink <LocalizedClientLink
className="hover:text-ui-fg-base" className="hover:text-ui-fg-base mr-10 flex items-center"
href="/cart" href="/cart"
data-testid="nav-cart-link" data-testid="nav-cart-link"
>{`Cart (${totalItems})`}</LocalizedClientLink> >
<ShoppingBag />
</LocalizedClientLink>
</PopoverButton> </PopoverButton>
<Transition <Transition
show={cartDropdownOpen} show={cartDropdownOpen}

View File

@ -0,0 +1,42 @@
"use client"
import React from "react"
import { Button } from "@medusajs/ui"
import { MagnifyingGlassMini } from "@medusajs/icons"
type SearchButtonProps = {
label?: string
shortcut?: string
onClick?: () => void
className?: string
}
export default function SearchButton({
label = "Search",
shortcut = "⌘K",
onClick,
className,
}: SearchButtonProps) {
return (
<Button
variant="secondary"
className={
[
"w-[250px] h-11 justify-between gap-3 border border-ui-border-base bg-ui-bg-subtle hover:bg-ui-bg-field-hover rounded-lg",
className,
]
.filter(Boolean)
.join(" ")
}
onClick={onClick}
>
<span className="flex items-center gap-2">
<MagnifyingGlassMini className="text-ui-fg-base" />
<span className="text-ui-fg-base">{label}</span>
</span>
<span className="flex items-center rounded-md border border-ui-border-base px-2 py-0.5 bg-white text-xs text-ui-fg-muted">
{shortcut}
</span>
</Button>
)
}

View File

@ -0,0 +1,25 @@
type TextItem = { text: string; className?: string }
export default async function AnnouncementBanner({
className,
label,
...props
}: { className: string; label: TextItem[] }) {
return (
<div className={className}>
<div className="container mx-auto flex justify-center items-center py-2">
<div className="flex items-center gap-20">
{label.map((item, index) => {
return (
<div key={`${index}-${item.text}`} className={`last:mr-0 ${item.className || ""}`}>
{item.text}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,37 @@
'use client'
import { DropdownMenu } from "@medusajs/ui"
import { ChevronDownMini } from "@medusajs/icons"
export default function DropdownMenuComponent({
children,
props,
}: {
children?: React.ReactNode
props?: {
className?: string
"data-testid"?: string
label?: string
isShowArrow?: boolean
}
}) {
const itemNodes = Array.isArray(children) ? children : []
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<button
className={props?.className}
data-testid={props?.["data-testid"]}
>
{props?.label ?? "Menu"} {props?.isShowArrow ? <ChevronDownMini /> : null}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{itemNodes.map((item, index) => {
const props = item?.DropdownMenuItems?.props
return props ? <DropdownMenu.Item key={props.label ?? index} className={props.className} data-testid={props["data-testid"]}>{props.label}</DropdownMenu.Item> : null
})}
</DropdownMenu.Content>
</DropdownMenu>
)
}

View File

@ -2,19 +2,21 @@ import { listRegions } from "@lib/data/regions"
import { StoreRegion } from "@medusajs/types" import { StoreRegion } from "@medusajs/types"
import SideMenu from "@modules/layout/components/side-menu" import SideMenu from "@modules/layout/components/side-menu"
import { DynamicLayoutRenderer, DynamicLayoutRendererProps } from "vibentec/renderer" import { DynamicLayoutRenderer, DynamicLayoutRendererProps } from "vibentec/renderer"
import { clx } from "@medusajs/ui"
export default async function VtNav({ nodes, context }: DynamicLayoutRendererProps) { export default async function VtNav({ nodes, context, className }: DynamicLayoutRendererProps & { className?: string }) {
console.log({nodes, context})
const regions = await listRegions().then((regions: StoreRegion[]) => regions) const regions = await listRegions().then((regions: StoreRegion[]) => regions)
return ( return (
<div className="sticky top-0 inset-x-0 z-50 group"> <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"> <header className={clx("relative mx-auto border-b duration-200 border-ui-border-base", className ?? "bg-white") }>
<nav className="content-container txt-xsmall-plus text-ui-fg-subtle flex items-center justify-between w-full h-full text-small-regular"> <nav className="flex justify-between w-full items-center h-full">
<div className="flex-1 basis-0 h-full flex items-center"> {/* <div className="flex-1 basis-0 h-full flex items-center">
<div className="h-full"> <div className="h-full">
<SideMenu regions={regions} /> <SideMenu regions={regions} />
</div> </div>
</div> </div> */}
{nodes && ( {nodes && (
<DynamicLayoutRenderer nodes={nodes} context={context} /> <DynamicLayoutRenderer nodes={nodes} context={context} />

View File

@ -2,10 +2,18 @@ import CartMismatchBanner from "@modules/layout/components/cart-mismatch-banner"
import FreeShippingPriceNudge from "@modules/shipping/components/free-shipping-price-nudge" import FreeShippingPriceNudge from "@modules/shipping/components/free-shipping-price-nudge"
import LocalizedClientLink from "@modules/common/components/localized-client-link" import LocalizedClientLink from "@modules/common/components/localized-client-link"
import CartButton from "@modules/layout/components/cart-button" import CartButton from "@modules/layout/components/cart-button"
import { DynamicLayoutRenderer } from "./renderer" import { DynamicLayoutRenderer } from "./renderer"
import React from "react" import React from "react"
import VtNav from "@modules/layout/templates/vt-nav" import VtNav from "@modules/layout/templates/vt-nav"
import VtFooter from "@modules/layout/templates/vt-footer" import VtFooter from "@modules/layout/templates/vt-footer"
import AnnouncementBannerDefault from "@modules/layout/templates/3bear-template/announcement-bar"
import AnnouncementBannerDrsquatch from "@modules/layout/templates/drsquatch-template/announcement-bar"
import { Button } from "@medusajs/ui"
import { User, MagnifyingGlassMini, Heart } from "@medusajs/icons"
import DropdownMenuComponent from "@modules/layout/templates/dropdown-menu/dropdown-menu"
import AnnouncementBannerVibenTec from "@modules/layout/templates/vibentec-template/announcement-bar"
import SearchButton from "@modules/layout/components/search-button"
export interface LayoutComponentDefinition { export interface LayoutComponentDefinition {
props?: Record<string, any> props?: Record<string, any>
@ -15,43 +23,118 @@ export interface LayoutComponentDefinition {
//maps key = componentName to value = props + children //maps key = componentName to value = props + children
export type LayoutComponentNode = Record<string, LayoutComponentDefinition> export type LayoutComponentNode = Record<string, LayoutComponentDefinition>
export interface LayoutContext { export interface LayoutContext {
customer: any; customer: any
cart: any; cart: any
shippingOptions: any[]; shippingOptions: any[]
contentChildren: React.ReactNode; contentChildren: React.ReactNode
designId?: string
} }
export type ComponentRenderer = { export type ComponentRenderer = {
render: (entry: LayoutComponentDefinition, ctx: LayoutContext) => React.ReactNode render: (
entry: LayoutComponentDefinition,
ctx: LayoutContext
) => React.ReactNode
} }
// Utility, wenn eine Komponente nur props hat und keine children // Utility, wenn eine Komponente nur props hat und keine children
const simple = (Component: React.ComponentType<any>): ComponentRenderer => ({ const simple = (Component: React.ComponentType<any>): ComponentRenderer => ({
render: (entry) => <Component {...entry.props} /> render: (entry) => <Component {...entry.props} />,
}) })
// Helper für Kinder-Rendering // Helper für Kinder-Rendering
const renderChildren = (entry: LayoutComponentDefinition, ctx: LayoutContext) => const renderChildren = (entry: LayoutComponentDefinition, ctx: LayoutContext) =>
entry.children ? <DynamicLayoutRenderer nodes={entry.children} context={ctx} /> : null entry.children ? (
<DynamicLayoutRenderer nodes={entry.children} context={ctx} />
) : null
// Map design identifiers to banner component variants (no conditionals)
const AnnouncementBannerVariants: Record<string, React.ComponentType<any>> = {
drsquatch: AnnouncementBannerDrsquatch,
vibentec: AnnouncementBannerVibenTec,
"3bear": AnnouncementBannerDefault,
"medusa-starter": AnnouncementBannerDefault,
default: AnnouncementBannerDefault,
}
// Component Map // Component Map
export const componentMap: Record<string, ComponentRenderer> = { export const componentMap: Record<string, ComponentRenderer> = {
Nav: { AnnouncementBanner: {
render: (entry: any, ctx: LayoutContext) => ( <VtNav nodes={entry.children} context={ctx} /> ), render: (entry: any, ctx: LayoutContext) => {
const key = ctx.designId ?? "default"
const Comp =
AnnouncementBannerVariants[key] ?? AnnouncementBannerVariants.default
return <Comp {...entry.props} nodes={entry.children} context={ctx} />
},
},
Nav: {
render: (entry: any, ctx: LayoutContext) => (
<VtNav {...entry.props} nodes={entry.children} context={ctx} />
),
}, },
Div: { Div: {
render: (entry: any, ctx: LayoutContext) => ( render: (entry: any, ctx: LayoutContext) => (
<div {...entry.props}> <div {...entry.props}>
{entry.children ? <DynamicLayoutRenderer nodes={entry.children} context={ctx} /> : null} {entry.children ? (
<DynamicLayoutRenderer nodes={entry.children} context={ctx} />
) : null}
</div> </div>
) ),
},
Text: {
render: (entry: any, ctx: LayoutContext) => (
<div {...entry.props}>{entry.props.label}</div>
),
},
DropdownMenus: {
render: (entry: any, ctx: LayoutContext) => (
<DropdownMenuComponent {...entry} />
),
}, },
LocalizedClientLink: { LocalizedClientLink: {
render: (entry: any) => ( render: (entry: any) => (
<LocalizedClientLink {...entry.props}>{entry.props.label}</LocalizedClientLink> <LocalizedClientLink {...entry.props}>
) {entry.props.label}
</LocalizedClientLink>
),
},
Image: {
render: (entry: any) => {
console.log(entry.props)
return <img {...entry.props} alt={entry.props?.alt ?? ""} />
},
},
Button: {
render: (entry: any, ctx: LayoutContext) => (
<Button {...entry.props}>{entry.props.label}</Button>
),
},
SearchButton: {
render: (entry: any) => (
<Button variant="transparent">
<MagnifyingGlassMini {...entry.props}>
{entry.props.label}
</MagnifyingGlassMini>
</Button>
),
},
InputSearchButton: {
render: (entry: any) => <SearchButton {...entry.props} />,
},
UserButton: {
render: (entry: any) => (
<Button variant="transparent">
<User {...entry.props}>{entry.props.label}</User>
</Button>
),
},
FavoriteButton: {
render: (entry: any) => (
<Button variant="transparent">
<Heart {...entry.props}>{entry.props.label}</Heart>
</Button>
),
}, },
CartButton: simple(CartButton), CartButton: simple(CartButton),
Suspense: { Suspense: {
@ -63,14 +146,16 @@ export const componentMap: Record<string, ComponentRenderer> = {
) : null ) : null
} }
> >
{entry.children ? <DynamicLayoutRenderer nodes={entry.children} context={ctx} /> : null} {entry.children ? (
<DynamicLayoutRenderer nodes={entry.children} context={ctx} />
) : null}
</React.Suspense> </React.Suspense>
), ),
}, },
CartMismatchBanner: simple(CartMismatchBanner), CartMismatchBanner: simple(CartMismatchBanner),
FreeShippingPriceNudge: simple(FreeShippingPriceNudge), FreeShippingPriceNudge: simple(FreeShippingPriceNudge),
PropsChildren: { PropsChildren: {
render: (_props, ctx) => ctx.contentChildren, // PageLayout's props.children render: (_props, ctx) => ctx.contentChildren, // PageLayout's props.children
}, },
Footer: simple(VtFooter), Footer: simple(VtFooter),
} }

View File

@ -1,10 +1,8 @@
import fs from "fs" import fs from "fs"
import path from "path" import path from "path"
const fileName = "ste.medusa-starter.design.json"; export async function loadDesignConfig(designFile: string) {
const filePath = path.join(process.cwd(), "config", designFile)
export async function loadDesignConfig() {
const filePath = path.join(process.cwd(), "config", fileName)
const fileData = await fs.promises.readFile(filePath, "utf-8") const fileData = await fs.promises.readFile(filePath, "utf-8")
return JSON.parse(fileData) return JSON.parse(fileData)
} }

View File

@ -1,16 +1,27 @@
import React from "react" import React from "react"
import { LayoutComponentNode, LayoutContext, componentMap } from "./component-map" import {
LayoutComponentNode,
LayoutContext,
componentMap,
} from "./component-map"
export interface DynamicLayoutRendererProps { export interface DynamicLayoutRendererProps {
nodes: LayoutComponentNode[] nodes: LayoutComponentNode[]
context: LayoutContext context: LayoutContext
} }
export function DynamicLayoutRenderer({ nodes, context } : DynamicLayoutRendererProps) { export function DynamicLayoutRenderer({
return nodes.map((entry, index) => { nodes,
const [key, value] = Object.entries(entry)[0] as [string, any] context,
const component = componentMap[key] }: DynamicLayoutRendererProps) {
if (!component) return null return nodes.map((entry, index) => {
return <React.Fragment key={index}>{component.render(value, context)}</React.Fragment> 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>
)
})
}

23
src/vibentec/types.ts Normal file
View File

@ -0,0 +1,23 @@
import React from "react"
import { HttpTypes } from "@medusajs/types"
export interface LayoutContext {
customer: HttpTypes.StoreCustomer | null
cart: HttpTypes.StoreCart | null
shippingOptions: HttpTypes.StoreShippingOption[]
contentChildren: React.ReactNode
}
export interface LayoutComponentDefinition<P = Record<string, unknown>> {
props?: P
children?: LayoutComponentNode[]
}
// Maps key = componentName to value = props + children
export type LayoutComponentNode = Record<string, LayoutComponentDefinition>
export type ComponentRenderer<P = unknown> = {
render: (entry: LayoutComponentDefinition<P>, ctx: LayoutContext) => React.ReactNode
}
export type ComponentMap = Record<string, ComponentRenderer>

View File

@ -9,6 +9,7 @@ module.exports = {
"./src/components/**/*.{js,ts,jsx,tsx}", "./src/components/**/*.{js,ts,jsx,tsx}",
"./src/modules/**/*.{js,ts,jsx,tsx}", "./src/modules/**/*.{js,ts,jsx,tsx}",
"./node_modules/@medusajs/ui/dist/**/*.{js,jsx,ts,tsx}", "./node_modules/@medusajs/ui/dist/**/*.{js,jsx,ts,tsx}",
"./config/**/*.json",
], ],
theme: { theme: {
extend: { extend: {