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 { getBaseURL } from "@lib/util/env"
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 { DESIGN_JSON_FILE } from "./config-json-file"
export const metadata: Metadata = {
metadataBase: new URL(getBaseURL()),
}
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 cart = await retrieveCart()
let shippingOptions: StoreCartShippingOption[] = []
@ -23,14 +25,16 @@ export default async function PageLayout(props: { children: React.ReactNode }) {
shippingOptions = shipping_options
}
const nodes: LayoutComponentNode[] = await loadDesignConfig()
const nodes: LayoutComponentNode[] = await loadDesignConfig(
DESIGN_JSON_FILE[1].file
)
const context: LayoutContext = {
customer,
cart,
shippingOptions,
contentChildren: props.children,
designId: DESIGN_JSON_FILE[1].id,
}
return <DynamicLayoutRenderer nodes={nodes} context={context} />
}

View File

@ -6,6 +6,7 @@ import {
PopoverPanel,
Transition,
} from "@headlessui/react"
import { ShoppingBag } from "@medusajs/icons"
import { convertToLocale } from "@lib/util/money"
import { HttpTypes } from "@medusajs/types"
import { Button } from "@medusajs/ui"
@ -82,10 +83,12 @@ const CartDropdown = ({
<Popover className="relative h-full">
<PopoverButton className="h-full">
<LocalizedClientLink
className="hover:text-ui-fg-base"
className="hover:text-ui-fg-base mr-10 flex items-center"
href="/cart"
data-testid="nav-cart-link"
>{`Cart (${totalItems})`}</LocalizedClientLink>
>
<ShoppingBag />
</LocalizedClientLink>
</PopoverButton>
<Transition
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 SideMenu from "@modules/layout/components/side-menu"
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)
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">
<header className={clx("relative mx-auto border-b duration-200 border-ui-border-base", className ?? "bg-white") }>
<nav className="flex justify-between w-full items-center h-full">
{/* <div className="flex-1 basis-0 h-full flex items-center">
<div className="h-full">
<SideMenu regions={regions} />
</div>
</div>
</div> */}
{nodes && (
<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 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"
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 {
props?: Record<string, any>
@ -16,42 +24,117 @@ export interface LayoutComponentDefinition {
export type LayoutComponentNode = Record<string, LayoutComponentDefinition>
export interface LayoutContext {
customer: any;
cart: any;
shippingOptions: any[];
contentChildren: React.ReactNode;
customer: any
cart: any
shippingOptions: any[]
contentChildren: React.ReactNode
designId?: string
}
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
const simple = (Component: React.ComponentType<any>): ComponentRenderer => ({
render: (entry) => <Component {...entry.props} />
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
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
export const componentMap: Record<string, ComponentRenderer> = {
AnnouncementBanner: {
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 nodes={entry.children} context={ctx} /> ),
render: (entry: any, ctx: LayoutContext) => (
<VtNav {...entry.props} nodes={entry.children} context={ctx} />
),
},
Div: {
render: (entry: any, ctx: LayoutContext) => (
<div {...entry.props}>
{entry.children ? <DynamicLayoutRenderer nodes={entry.children} context={ctx} /> : null}
{entry.children ? (
<DynamicLayoutRenderer nodes={entry.children} context={ctx} />
) : null}
</div>
)
),
},
Text: {
render: (entry: any, ctx: LayoutContext) => (
<div {...entry.props}>{entry.props.label}</div>
),
},
DropdownMenus: {
render: (entry: any, ctx: LayoutContext) => (
<DropdownMenuComponent {...entry} />
),
},
LocalizedClientLink: {
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),
Suspense: {
@ -63,7 +146,9 @@ export const componentMap: Record<string, ComponentRenderer> = {
) : null
}
>
{entry.children ? <DynamicLayoutRenderer nodes={entry.children} context={ctx} /> : null}
{entry.children ? (
<DynamicLayoutRenderer nodes={entry.children} context={ctx} />
) : null}
</React.Suspense>
),
},

View File

@ -1,10 +1,8 @@
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)
export async function loadDesignConfig(designFile: string) {
const filePath = path.join(process.cwd(), "config", designFile)
const fileData = await fs.promises.readFile(filePath, "utf-8")
return JSON.parse(fileData)
}

View File

@ -1,16 +1,27 @@
import React from "react"
import { LayoutComponentNode, LayoutContext, componentMap } from "./component-map"
import {
LayoutComponentNode,
LayoutContext,
componentMap,
} from "./component-map"
export interface DynamicLayoutRendererProps {
nodes: LayoutComponentNode[]
context: LayoutContext
}
export function DynamicLayoutRenderer({ nodes, context } : DynamicLayoutRendererProps) {
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>
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/modules/**/*.{js,ts,jsx,tsx}",
"./node_modules/@medusajs/ui/dist/**/*.{js,jsx,ts,tsx}",
"./config/**/*.json",
],
theme: {
extend: {