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:
2025-11-25 15:17:18 +01:00
parent 51d6ee2051
commit 39fe0c81e5
30 changed files with 2849 additions and 142 deletions
@@ -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 { 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"
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
export default async function VtFooter({copyrightText}:FooterProps) {
export default async function VtFooter({ nodes, context }: { nodes?: LayoutComponentDefinition; context: LayoutContext }) {
const { collections } = await listCollections({
fields: "*products",
})
const productCategories = await listCategories()
const props = nodes?.config ?? {}
const copyrightText = props.copyrightText ?? "";
return (
<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>
)
}
+17 -21
View File
@@ -1,26 +1,22 @@
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 { DynamicLayoutRenderer } from "vibentec/renderer"
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
export default async function VtNav({ nodes, context }: DynamicLayoutRendererProps) {
const regions = await listRegions().then((regions: StoreRegion[]) => regions)
export default function VtNav({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) {
const props = nodes.config ?? {}
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 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 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>
</div>
)
}
}