added packages to support markdown
added dummy pages for about and contact. updated tailwind.config.js to include config/*.json to scan for classes. refactored system basics. Removed component-props, using generic LayoutComponentDefinition and LayoutContext. cleanup component-map. Updated renderer to be more failsafe. new abstracted components: vt-mega-menu, vt-sidemenu, vt-banner with 3 variants, vt-header, vt-homebutton, vt-cartbutton, vt-accountbutton, vt-linkbutton,
This commit is contained in:
parent
51d6ee2051
commit
39fe0c81e5
|
|
@ -1,68 +1,35 @@
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"Nav": {
|
"Header" : {
|
||||||
"props": {},
|
"config" : {"sticky": true},
|
||||||
"children": [
|
"children" : [
|
||||||
{
|
{
|
||||||
"Div": {
|
"Nav": {
|
||||||
"props": { "className": "flex items-center h-full" },
|
"config": {
|
||||||
"children": [
|
"left": [
|
||||||
{
|
{ "VtSideMenu": {} }
|
||||||
"LocalizedClientLink": {
|
],
|
||||||
"props": {
|
"center": [
|
||||||
"href": "/",
|
{ "HomeButton": { "config" : {"label":"Medusa Store"}} }
|
||||||
"label": "Medusa Store",
|
],
|
||||||
"className": "bg-black txt-compact-xlarge-plus hover:text-ui-fg-base uppercase",
|
"right": [
|
||||||
"data-testid": "nav-store-link"
|
{ "AccountButton": {
|
||||||
|
"config": {
|
||||||
|
"label": "Account",
|
||||||
|
"className": "hover:text-ui-fg-base"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
{ "VtCartButton" : {} }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"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 } },
|
{ "CartMismatchBanner": { "config": {"show": true} } },
|
||||||
{ "FreeShippingPriceNudge": { "variant": "popup" } },
|
{ "FreeShippingPriceNudge": { "config": {"variant": "popup" }} },
|
||||||
{ "PropsChildren" : {}},
|
{ "PropsChildren" : {}},
|
||||||
{ "Footer": { "copyrightText": "© 2025 MyShop" } }
|
{ "Footer": { "config" : {"copyrightText": "© 2025 MyShop"} } }
|
||||||
]
|
]
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Header" : {
|
||||||
|
"config" : {"sticky": true},
|
||||||
|
"children" : [
|
||||||
|
{
|
||||||
|
"Banner": {
|
||||||
|
"config": {
|
||||||
|
"variant": "nav",
|
||||||
|
"className": "h-12 bg-[#E6EFFC] text-[#285A86]",
|
||||||
|
"left":[ { "Link": { "config" : {"label":"About us", "href":"/vt-about"}} }],
|
||||||
|
"center":[ { "Link": { "config" : {"label":"Contact", "href":"/vt-contact"}} }],
|
||||||
|
"right":[ { "HomeButton": { "config" : {"label":"Vibentec IT"}} }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Banner": {
|
||||||
|
"config": {
|
||||||
|
"variant": "ticker",
|
||||||
|
"speed":24,
|
||||||
|
"className": "h-12 bg-[#E6EFFC] text-[#285A86]",
|
||||||
|
"items":[ {
|
||||||
|
"Link": { "config" : {"label":"Vibentec IT"}}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Banner": {
|
||||||
|
"config": {
|
||||||
|
"variant": "cta",
|
||||||
|
"text": "**Black Friday Vorverkauf**\nKOSTENLOSES MISTERY GESCHENK ab 60€ <u>jetzt shoppen</u>",
|
||||||
|
"href" : "/",
|
||||||
|
"className": "h-12 bg-[#E6EFFC] text-[#285A86]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Nav": {
|
||||||
|
"config": {
|
||||||
|
"left": [
|
||||||
|
{ "VtMegaMenu": {} } ,
|
||||||
|
{ "HomeButton": { "config" : {"label":"Vibentec IT"}} }
|
||||||
|
],
|
||||||
|
"center": [
|
||||||
|
{ "AccountButton": {
|
||||||
|
"config": {
|
||||||
|
"label": "Accounto",
|
||||||
|
"bgColor": "#123456",
|
||||||
|
"textColor": "#abcdef",
|
||||||
|
"className": "hover:text-ui-fg-base"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"right": [
|
||||||
|
{ "VtCartButton" : {} }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ "CartMismatchBanner": { "config": {"show": true} } },
|
||||||
|
{ "FreeShippingPriceNudge": { "config": {"variant": "popup" }} },
|
||||||
|
{ "PropsChildren" : {}},
|
||||||
|
{ "Footer": { "config" : {"copyrightText": "© 2025 MyShop"} } }
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -28,6 +28,9 @@
|
||||||
"react": "19.0.0-rc-66855b96-20241106",
|
"react": "19.0.0-rc-66855b96-20241106",
|
||||||
"react-country-flag": "^3.1.0",
|
"react-country-flag": "^3.1.0",
|
||||||
"react-dom": "19.0.0-rc-66855b96-20241106",
|
"react-dom": "19.0.0-rc-66855b96-20241106",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"tailwindcss-radix": "^2.8.0",
|
"tailwindcss-radix": "^2.8.0",
|
||||||
"webpack": "^5"
|
"webpack": "^5"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<div className="content-container py-16">
|
||||||
|
<h1 className="text-3xl font-bold mb-4">Über uns</h1>
|
||||||
|
<p>
|
||||||
|
Willkommen bei Vibentec IT! Wir bieten maßgeschneiderte Softwarelösungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
return (
|
||||||
|
<div className="content-container py-16">
|
||||||
|
<h1 className="text-3xl font-bold mb-4">Kontakt</h1>
|
||||||
|
<p>
|
||||||
|
Schreibe uns: <a href="mailto:info@vibentec-it.de">info@vibentec-it.de</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
|
||||||
|
export const AccountButton = ({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) => {
|
||||||
|
const props = nodes.config ?? {}
|
||||||
|
const className = clx("hover:text-ui-fg-base", props.className);
|
||||||
|
const style: React.CSSProperties = {};
|
||||||
|
if (props.bgColor) style.backgroundColor = props.bgColor;
|
||||||
|
if (props.textColor) style.color = props.textColor;
|
||||||
|
const href = props.href ?? "/account"
|
||||||
|
const label = props.label ?? "Account"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center h-full" style={style}>
|
||||||
|
<LocalizedClientLink
|
||||||
|
href={href}
|
||||||
|
className={className}
|
||||||
|
data-testid="nav-account-link"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountButton
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import CartButton from "@modules/layout/components/cart-button";
|
||||||
|
|
||||||
|
export const VtCartButton = ({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) => {
|
||||||
|
const props = nodes.config ?? {}
|
||||||
|
const className = clx("hover:text-ui-fg-base flex gap-2", props.className)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<LocalizedClientLink
|
||||||
|
className={className}
|
||||||
|
href="/cart"
|
||||||
|
data-testid="nav-cart-link"
|
||||||
|
>
|
||||||
|
Cart (0)
|
||||||
|
</LocalizedClientLink>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CartButton />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VtCartButton
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
|
||||||
|
export const HomeButton = ({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) => {
|
||||||
|
const props = nodes.config ?? {}
|
||||||
|
const className = clx("txt-compact-xlarge-plus hover:text-ui-fg-base uppercase", props.className)
|
||||||
|
const href = props.href ?? "/"
|
||||||
|
const label = props.label ?? "Medusa Store"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center h-full">
|
||||||
|
<LocalizedClientLink
|
||||||
|
href={href}
|
||||||
|
className={className}
|
||||||
|
data-testid="nav-store-link"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomeButton
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
|
||||||
|
export const VtLink = ({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) => {
|
||||||
|
const props = nodes.config ?? {}
|
||||||
|
const className = clx("txt-compact-xlarge-plus hover:text-ui-fg-base", props.className)
|
||||||
|
const href = props.href ?? "/"
|
||||||
|
const label = props.label ?? "Medusa Store"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center h-full">
|
||||||
|
<LocalizedClientLink
|
||||||
|
href={href}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</LocalizedClientLink>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VtLink
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||||
|
import React, { Suspense } from "react";
|
||||||
|
import SkeletonMegaMenu from "@modules/skeletons/components/vt-skeleton-mega-menu";
|
||||||
|
import MegaMenuWrapper from "@modules/layout/components/vt-mega-menu/mega-menu-wrapper";
|
||||||
|
|
||||||
|
export default function VtMegaMenu({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) {
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
<ul className="space-x-4 hidden small:flex">
|
||||||
|
<li>
|
||||||
|
<Suspense fallback={<SkeletonMegaMenu />}>
|
||||||
|
<MegaMenuWrapper />
|
||||||
|
</Suspense>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { listCategories } from "@lib/data/categories"
|
||||||
|
import MegaMenu from "./mega-menu"
|
||||||
|
|
||||||
|
export async function MegaMenuWrapper() {
|
||||||
|
const categories = await listCategories().catch(() => [])
|
||||||
|
|
||||||
|
return <MegaMenu categories={categories} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MegaMenuWrapper
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
const MegaMenu = ({
|
||||||
|
categories,
|
||||||
|
}: {
|
||||||
|
categories: HttpTypes.StoreProductCategory[]
|
||||||
|
}) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<
|
||||||
|
HttpTypes.StoreProductCategory["id"] | null
|
||||||
|
>(null)
|
||||||
|
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
const mainCategories = categories.filter(
|
||||||
|
(category) => !category.parent_category_id
|
||||||
|
)
|
||||||
|
|
||||||
|
const getSubCategories = (categoryId: string) => {
|
||||||
|
return categories.filter(
|
||||||
|
(category) => category.parent_category_id === categoryId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let menuTimeout: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
const handleMenuHover = () => {
|
||||||
|
if (menuTimeout) {
|
||||||
|
clearTimeout(menuTimeout)
|
||||||
|
}
|
||||||
|
setIsHovered(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuLeave = () => {
|
||||||
|
menuTimeout = setTimeout(() => {
|
||||||
|
setIsHovered(false)
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (menuTimeout) {
|
||||||
|
clearTimeout(menuTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let categoryTimeout: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
const handleCategoryHover = (categoryId: string) => {
|
||||||
|
categoryTimeout = setTimeout(() => {
|
||||||
|
setSelectedCategory(categoryId)
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (categoryTimeout) {
|
||||||
|
clearTimeout(categoryTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCategoryLeave = () => {
|
||||||
|
if (categoryTimeout) {
|
||||||
|
clearTimeout(categoryTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsHovered(false)
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onMouseEnter={handleMenuHover}
|
||||||
|
onMouseLeave={handleMenuLeave}
|
||||||
|
className="z-50"
|
||||||
|
>
|
||||||
|
<LocalizedClientLink
|
||||||
|
className="hover:text-ui-fg-base hover:bg-neutral-100 rounded-full px-3 py-2"
|
||||||
|
href="/store"
|
||||||
|
>
|
||||||
|
Products
|
||||||
|
</LocalizedClientLink>
|
||||||
|
{isHovered && (
|
||||||
|
<div className="absolute top-full left-0 right-0 flex gap-32 py-10 px-20 bg-white border-b border-neutral-200 ">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{mainCategories.map((category) => (
|
||||||
|
<LocalizedClientLink
|
||||||
|
key={category.id}
|
||||||
|
href={`/categories/${category.handle}`}
|
||||||
|
className={clx(
|
||||||
|
"hover:bg-neutral-100 hover:cursor-pointer rounded-full px-3 py-2 w-fit font-medium",
|
||||||
|
selectedCategory === category.id && "bg-neutral-100"
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => handleCategoryHover(category.id)}
|
||||||
|
onMouseLeave={handleCategoryLeave}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</LocalizedClientLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedCategory && (
|
||||||
|
<div className="grid grid-cols-4 gap-16">
|
||||||
|
{getSubCategories(selectedCategory).map((category) => (
|
||||||
|
<div key={category.id} className="flex flex-col gap-2">
|
||||||
|
<LocalizedClientLink
|
||||||
|
className="font-medium text-zinc-500 hover:underline"
|
||||||
|
href={`/categories/${category.handle}`}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</LocalizedClientLink>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{getSubCategories(category.id).map((subCategory) => (
|
||||||
|
<LocalizedClientLink
|
||||||
|
key={subCategory.id}
|
||||||
|
className="hover:underline"
|
||||||
|
href={`/categories/${subCategory.handle}`}
|
||||||
|
>
|
||||||
|
{subCategory.name}
|
||||||
|
</LocalizedClientLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isHovered && (
|
||||||
|
<div className="fixed inset-0 mt-[60px] blur-sm backdrop-blur-sm z-[-1]" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MegaMenu
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||||
|
import React from "react";
|
||||||
|
import SideMenu from "../side-menu";
|
||||||
|
import { listRegions } from "@lib/data/regions"
|
||||||
|
import { StoreRegion } from "@medusajs/types"
|
||||||
|
|
||||||
|
export default async function VtSideMenu({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) {
|
||||||
|
const regions = await listRegions().then((regions: StoreRegion[]) => regions)
|
||||||
|
return (
|
||||||
|
<div className="flex-1 basis-0 h-full flex items-center">
|
||||||
|
<div className="h-full">
|
||||||
|
<SideMenu regions={regions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map"
|
||||||
|
import { clx } from "@medusajs/ui"
|
||||||
|
import ReactMarkdown from "react-markdown"
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
import rehypeRaw from "rehype-raw"
|
||||||
|
import remarkBreaks from "remark-breaks"
|
||||||
|
|
||||||
|
interface BannerCTAProps {
|
||||||
|
node: LayoutComponentDefinition
|
||||||
|
context: LayoutContext
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BannerCTA({ node, context }: BannerCTAProps) {
|
||||||
|
const props = node.config ?? {}
|
||||||
|
const text = props.text ?? ""
|
||||||
|
const href = props.href ?? undefined
|
||||||
|
const iconLeft = props.iconLeft
|
||||||
|
const iconRight = props.iconRight
|
||||||
|
|
||||||
|
const className = clx(
|
||||||
|
"content-container flex justify-center items-center text-center w-full h-full text-xs font-medium",
|
||||||
|
props.className
|
||||||
|
)
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{iconLeft && <DynamicIcon name={iconLeft} />}
|
||||||
|
<div className="leading-none">
|
||||||
|
<ReactMarkdown
|
||||||
|
children={text}
|
||||||
|
remarkPlugins={[remarkBreaks]}
|
||||||
|
rehypePlugins={[rehypeRaw]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{iconRight && <DynamicIcon name={iconRight} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return href ? (
|
||||||
|
<LocalizedClientLink href={href} className={className}>
|
||||||
|
{content}
|
||||||
|
</LocalizedClientLink>
|
||||||
|
) : (
|
||||||
|
<div className={className}>{content}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DynamicIcon({ name }: { name: string }) {
|
||||||
|
return <span className="material-symbols-outlined text-sm">{name}</span>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { DynamicLayoutRenderer } from "vibentec/renderer"
|
||||||
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||||
|
|
||||||
|
export default function BannerNav({ node: node, context }: { node: LayoutComponentDefinition; context: LayoutContext }) {
|
||||||
|
const props = node.config ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 items-center gap-x-4">
|
||||||
|
{props.left && <DynamicLayoutRenderer nodes={props.left} context={context} />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
{props.center && <DynamicLayoutRenderer nodes={props.center} context={context} />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
{props.right && <DynamicLayoutRenderer nodes={props.right} context={context} />}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
@keyframes bannerTicker {
|
||||||
|
0% { transform: translateX(100%); }
|
||||||
|
100% { transform: translateX(-100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker {
|
||||||
|
display: flex;
|
||||||
|
white-space: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
animation: bannerTicker linear infinite;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import styles from "./banner-ticker.module.css"
|
||||||
|
import { DynamicLayoutRenderer } from "vibentec/renderer"
|
||||||
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map"
|
||||||
|
|
||||||
|
export default function BannerTicker({ node, context }: { node: LayoutComponentDefinition; context: LayoutContext }) {
|
||||||
|
const props = node.config ?? {}
|
||||||
|
const speed = props.speed ?? 10;
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden w-full h-full">
|
||||||
|
<div className={styles.ticker} style={{ animationDuration: `${speed}s` }} >
|
||||||
|
<DynamicLayoutRenderer nodes={props.items} context={context} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||||
|
import { clx } from "@medusajs/ui";
|
||||||
|
import BannerNav from "./banner-nav";
|
||||||
|
import BannerCTA from "./banner-cta";
|
||||||
|
import BannerTicker from "./banner-ticker";
|
||||||
|
|
||||||
|
export default async function Banner({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) {
|
||||||
|
const props = nodes.config ?? {};
|
||||||
|
const bannerClassName = clx("relative h-8 mx-auto border-b duration-200 bg-white border-ui-border-base", props.className);
|
||||||
|
const bannerVariant = props.variant as "nav" | "cta" | "ticker";
|
||||||
|
if (!bannerVariant) return null;
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
"nav": BannerNav,
|
||||||
|
"cta": BannerCTA,
|
||||||
|
"ticker": BannerTicker,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Component = variants[bannerVariant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={bannerClassName}>
|
||||||
|
<Component node={nodes} context={context}/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import { listCategories } from "@lib/data/categories"
|
import { listCategories } from "@lib/data/categories"
|
||||||
import { listCollections } from "@lib/data/collections"
|
import { listCollections } from "@lib/data/collections"
|
||||||
import { Text, clx } from "@medusajs/ui"
|
import { Text, clx } from "@medusajs/ui"
|
||||||
import { FooterProps } from "vibentec/component-props"
|
|
||||||
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
import MedusaCTA from "@modules/layout/components/medusa-cta"
|
import MedusaCTA from "@modules/layout/components/medusa-cta"
|
||||||
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||||
|
|
||||||
|
export default async function VtFooter({ nodes, context }: { nodes?: LayoutComponentDefinition; context: LayoutContext }) {
|
||||||
export default async function VtFooter({copyrightText}:FooterProps) {
|
|
||||||
const { collections } = await listCollections({
|
const { collections } = await listCollections({
|
||||||
fields: "*products",
|
fields: "*products",
|
||||||
})
|
})
|
||||||
const productCategories = await listCategories()
|
const productCategories = await listCategories()
|
||||||
|
const props = nodes?.config ?? {}
|
||||||
|
const copyrightText = props.copyrightText ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-ui-border-base w-full">
|
<footer className="border-t border-ui-border-base w-full">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { DynamicLayoutRenderer } from "vibentec/renderer"
|
||||||
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||||
|
import { clx } from "@medusajs/ui";
|
||||||
|
|
||||||
|
export default function VtHeader({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) {
|
||||||
|
const { sticky = true } = nodes.config ?? {};
|
||||||
|
const cName = clx(sticky && "sticky top-0","inset-x-0 z-50 group");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={cName}>
|
||||||
|
{ nodes.children && <DynamicLayoutRenderer nodes={nodes.children} context={context} /> }
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,22 @@
|
||||||
import { listRegions } from "@lib/data/regions"
|
import { DynamicLayoutRenderer } from "vibentec/renderer"
|
||||||
import { StoreRegion } from "@medusajs/types"
|
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
|
||||||
import SideMenu from "@modules/layout/components/side-menu"
|
|
||||||
import { DynamicLayoutRenderer, DynamicLayoutRendererProps } from "vibentec/renderer"
|
|
||||||
|
|
||||||
export default async function VtNav({ nodes, context }: DynamicLayoutRendererProps) {
|
export default function VtNav({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) {
|
||||||
const regions = await listRegions().then((regions: StoreRegion[]) => regions)
|
const props = nodes.config ?? {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 inset-x-0 z-50 group">
|
<div className="relative h-16 mx-auto border-b duration-200 bg-white border-ui-border-base">
|
||||||
<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">
|
||||||
<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 items-center gap-x-4">
|
||||||
<div className="flex-1 basis-0 h-full flex items-center">
|
{props.left && <DynamicLayoutRenderer nodes={props.left} context={context} />}
|
||||||
<div className="h-full">
|
</div>
|
||||||
<SideMenu regions={regions} />
|
<div className="flex items-center gap-x-4">
|
||||||
</div>
|
{props.center && <DynamicLayoutRenderer nodes={props.center} context={context} />}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
{nodes && (
|
{props.right && <DynamicLayoutRenderer nodes={props.right} context={context} />}
|
||||||
<DynamicLayoutRenderer nodes={nodes} context={context} />
|
</div>
|
||||||
)}
|
</nav>
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
||||||
|
|
||||||
|
export default function SkeletonMegaMenu() {
|
||||||
|
return (
|
||||||
|
<LocalizedClientLink
|
||||||
|
className="hover:text-ui-fg-base hover:bg-neutral-100 rounded-full px-3 py-2"
|
||||||
|
href="/store"
|
||||||
|
>
|
||||||
|
Products
|
||||||
|
</LocalizedClientLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,25 @@
|
||||||
import CartMismatchBanner from "@modules/layout/components/cart-mismatch-banner"
|
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 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 HomeButton from "@modules/layout/components/vt-homebutton"
|
||||||
|
import AccountButton from "@modules/layout/components/vt-accountbutton"
|
||||||
|
import VtCartButton from "@modules/layout/components/vt-cartbutton"
|
||||||
|
import VtHeader from "@modules/layout/templates/vt-header"
|
||||||
|
import Banner from "@modules/layout/templates/vt-banner"
|
||||||
|
import VtMegaMenu from "@modules/layout/components/vt-mega-menu"
|
||||||
|
import VtLink from "@modules/layout/components/vt-linkbutton"
|
||||||
|
import VtSideMenu from "@modules/layout/components/vt-sidemenu"
|
||||||
|
|
||||||
|
type ComponentConfig = Record<string, any>;
|
||||||
|
|
||||||
export interface LayoutComponentDefinition {
|
export interface LayoutComponentDefinition {
|
||||||
props?: Record<string, any>
|
config?: ComponentConfig
|
||||||
children?: LayoutComponentNode[]
|
children?: LayoutComponentNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
//maps key = componentName to value = props + children
|
|
||||||
export type LayoutComponentNode = Record<string, LayoutComponentDefinition>
|
|
||||||
|
|
||||||
export interface LayoutContext {
|
export interface LayoutContext {
|
||||||
customer: any;
|
customer: any;
|
||||||
cart: any;
|
cart: any;
|
||||||
|
|
@ -26,51 +31,41 @@ 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 methods
|
||||||
const simple = (Component: React.ComponentType<any>): ComponentRenderer => ({
|
const configOnly = (Component: React.ComponentType<any>): ComponentRenderer => ({
|
||||||
render: (entry) => <Component {...entry.props} />
|
render: (entry) => <Component {...entry.config} />
|
||||||
})
|
})
|
||||||
|
|
||||||
// Helper für Kinder-Rendering
|
const nodesContextRenderer = (Component: React.ComponentType<any>): ComponentRenderer => ({
|
||||||
|
render: (entry: any, ctx: LayoutContext) => <Component nodes={entry} context={ctx} />
|
||||||
|
});
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
// Component Map
|
// Component Map
|
||||||
export const componentMap: Record<string, ComponentRenderer> = {
|
export const componentMap: Record<string, ComponentRenderer> = {
|
||||||
Nav: {
|
Header: nodesContextRenderer(VtHeader),
|
||||||
render: (entry: any, ctx: LayoutContext) => ( <VtNav nodes={entry.children} context={ctx} /> ),
|
Nav: nodesContextRenderer(VtNav),
|
||||||
},
|
VtMegaMenu: nodesContextRenderer(VtMegaMenu),
|
||||||
Div: {
|
VtSideMenu: nodesContextRenderer(VtSideMenu),
|
||||||
render: (entry: any, ctx: LayoutContext) => (
|
Banner: nodesContextRenderer(Banner),
|
||||||
<div {...entry.props}>
|
HomeButton: nodesContextRenderer(HomeButton),
|
||||||
{entry.children ? <DynamicLayoutRenderer nodes={entry.children} context={ctx} /> : null}
|
AccountButton: nodesContextRenderer(AccountButton),
|
||||||
</div>
|
VtCartButton: nodesContextRenderer(VtCartButton),
|
||||||
)
|
Link: nodesContextRenderer(VtLink),
|
||||||
},
|
CartMismatchBanner: configOnly(CartMismatchBanner),
|
||||||
LocalizedClientLink: {
|
FreeShippingPriceNudge: configOnly(FreeShippingPriceNudge),
|
||||||
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: {
|
PropsChildren: {
|
||||||
render: (_props, ctx) => ctx.contentChildren, // PageLayout's props.children
|
render: (_props, ctx) => ctx.contentChildren, // PageLayout's props.children
|
||||||
},
|
},
|
||||||
Footer: simple(VtFooter),
|
Footer: nodesContextRenderer(VtFooter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type ComponentName = keyof typeof componentMap
|
||||||
|
|
||||||
|
// //maps key = componentName to value = props + children
|
||||||
|
// export type LayoutComponentNode = Record<string, LayoutComponentDefinition>
|
||||||
|
export type LayoutComponentNode = { [K in ComponentName]: LayoutComponentDefinition }[ComponentName]
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export interface FooterProps { copyrightText?: string }
|
|
||||||
|
|
@ -1,7 +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";
|
//const fileName = "ste.medusa-starter.design.json";
|
||||||
|
const fileName = "ste.playground.design.json";
|
||||||
|
|
||||||
export async function loadDesignConfig() {
|
export async function loadDesignConfig() {
|
||||||
const filePath = path.join(process.cwd(), "config", fileName)
|
const filePath = path.join(process.cwd(), "config", fileName)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { LayoutComponentNode, LayoutContext, componentMap } from "./component-map"
|
import { ComponentName, LayoutComponentDefinition, LayoutComponentNode, LayoutContext, componentMap } from "./component-map"
|
||||||
|
|
||||||
export interface DynamicLayoutRendererProps {
|
export interface DynamicLayoutRendererProps {
|
||||||
nodes: LayoutComponentNode[]
|
nodes: LayoutComponentNode[]
|
||||||
|
|
@ -7,10 +7,22 @@ export interface DynamicLayoutRendererProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
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 nodeArray = Array.isArray(nodes) ? nodes : [nodes];
|
||||||
const component = componentMap[key]
|
|
||||||
if (!component) return null
|
return nodeArray.map((entry, index) => {
|
||||||
return <React.Fragment key={index}>{component.render(value, context)}</React.Fragment>
|
const [key, value] = Object.entries(entry)[0] as [ComponentName, LayoutComponentDefinition]
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
console.warn(`[UI-Builder] Component definition is undefined: ${key}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = componentMap[key];
|
||||||
|
if (!component) {
|
||||||
|
console.warn(`[UI-Builder] Unknown component: ${key}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <React.Fragment key={`${key}-${index}`}>{component.render(value, context)}</React.Fragment>
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
"paths": {
|
"paths": {
|
||||||
"@lib/*": ["lib/*"],
|
"@lib/*": ["lib/*"],
|
||||||
"@modules/*": ["modules/*"],
|
"@modules/*": ["modules/*"],
|
||||||
"@pages/*": ["pages/*"]
|
"@pages/*": ["pages/*"],
|
||||||
|
"@vibentec/*": ["vibentec/*"],
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue