39 Commits

Author SHA1 Message Date
Nam Doan c3e00ee204 bugfix: style of card product card homepage 2025-12-29 11:25:46 +07:00
Nam Doan 4327676cb4 feat: create highlight category component and config with json file 2025-12-26 15:47:35 +07:00
namds29 e0bb44b65a Merge pull request 'feat: hovering thumbnail will jump to 2nd image thumbnail' (#33) from namds/implement-best-seller into main
Reviewed-on: #33
2025-12-26 03:35:04 +00:00
Nam Doan af3246770a feat: hovering thumbnail will jump to 2nd image thumbnail 2025-12-26 10:25:11 +07:00
namds29 f6f1f6286c Merge pull request 'Implement Section Product Grid' (#25) from namds/implement-best-seller into main
Reviewed-on: #25
Reviewed-by: Yen Nguyen <yen.nguyen@vibentec-it.io>
2025-12-25 10:22:06 +00:00
Nam Doan 15011607ae update: refactor construct json file to map with each router with component 2025-12-25 17:19:11 +07:00
Nam Doan 005b10484b update: style of show button icon product card 2025-12-24 13:43:08 +07:00
Nam Doan 6912f2605c feat: clone product card and create design UI to map with json file 2025-12-22 14:12:43 +07:00
yen.nguyen 389cf1c856 Merge pull request 'feat: add medusa starter design hero banner' (#13) from namds/implement-hero-banner into main
Reviewed-on: #13
2025-12-19 08:04:10 +00:00
Nam Doan a2e5b56eb8 refactor: combine 3bear cta section into dynamic cta section 2025-12-19 10:10:57 +07:00
Nam Doan 7afffb3f99 feat: create design for 3bear and drsquatch hero banner, create variants cta section 2025-12-17 11:12:32 +07:00
Nam Doan 9e0f6b0071 feat: add medusa starter design hero banner
feat: add hero banner for vibentec design
feat: add CTA banner vibentec design and create carousel image display
2025-12-16 14:46:36 +07:00
yen.nguyen 07a52dca2b Merge pull request 'namds/refactor-base-layout' (#8) from namds/refactor-base-layout into main
Reviewed-on: #8
2025-12-15 07:44:38 +00:00
Nam Doan fbb13ae819 refactor: add some component in footer and implement function for component 2025-12-11 12:59:00 +07:00
Nam Doan 9097a6e566 refactor: remove singular component 2025-12-11 12:54:08 +07:00
Nam Doan fe881d5aed refactor: remove singular component and replace with wrapper component 2025-12-10 14:09:46 +07:00
Nam Doan baaa0e9c62 refactor: update 3bear design json file 2025-12-08 16:36:51 +07:00
Nam Doan 216a579564 feat: add component for footer 2025-12-08 16:36:16 +07:00
Nam Doan dbe12845b0 refactor: update config json file 2025-12-08 11:40:28 +07:00
Nam Doan f869d12c7a feat: reuse country select and refactor some component 2025-12-08 11:39:41 +07:00
Nam Doan d8e78b71e4 refactor: cart button with icon 2025-12-08 11:29:41 +07:00
Nam Doan 043cc4d11a update: create design footer for 3bear page 2025-12-03 22:43:34 +07:00
Nam Doan 1e99ead658 feat: add footer drSquatch page 2025-12-03 22:10:28 +07:00
Nam Doan f3fb70d073 refactor: update common input and text component 2025-12-03 22:10:07 +07:00
Nam Doan efe48a200f feat: create footer vibentec design and common component text 2025-12-02 10:59:28 +07:00
Nam Doan 154d52732d feat: implement footer and update json file design 2025-12-01 16:34:47 +07:00
Nam Doan c0b492b394 refactor: dynamic icon button with medusajs and customize icon 2025-12-01 16:34:06 +07:00
Nam Doan 2f9102c4c4 bugfix: add condition check undefined value mega menu 2025-11-28 13:40:02 +07:00
Nam Doan 0dfa281333 refactor: move component to correct folder 2025-11-28 13:34:39 +07:00
Nam Doan 06968a77f3 refactor: name folder of vtButton 2025-11-28 13:32:22 +07:00
Nam Doan 96387dac11 bugfix: style nav config style 2025-11-28 13:26:23 +07:00
Nam Doan 9be3fb44b4 bugfix: change variant cart button in drsquatch page 2025-11-28 13:15:36 +07:00
Nam Doan 497c060756 feat:create component add vibentec config design page 2025-11-28 13:12:00 +07:00
Nam Doan 09b01f1d6b feat: created Drsquatch header component and json file 2025-11-27 15:04:56 +07:00
Nam Doan b76719fb32 feat: create components and map with data json file of 3bear design 2025-11-27 14:02:13 +07:00
Nam Doan c8853bac1c feat: add header banner 3bear design template 2025-11-26 11:22:58 +07:00
Nam Doan 60700ee11e refactor: add interface each component to define props config in JSON file 2025-11-26 11:22:08 +07:00
Stephan 3e5a88f42d added a filenamemapper , used to faster switch between filenames. 2025-11-25 15:41:46 +01:00
Stephan 39fe0c81e5 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,
2025-11-25 15:17:18 +01:00
88 changed files with 6493 additions and 1722 deletions
+455 -393
View File
@@ -1,424 +1,486 @@
[
{
"AnnouncementBanner": {
"props": {
"label": [
{
"layout": [
{
"Header": {
"config": {
"sticky": true,
"variant": "ticker"
},
"children": [
{
"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": "sticky top-0 z-20 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"
"Banner": {
"config": {
"variant": "ticker",
"className": "h-12 bg-[#009b93] text-[#fff] gap-12",
"speed": 24,
"items": [
{
"Link": {
"config": {
"label": "NEU: Overnight Oats Sallys Nussecke 😍",
"href": "/"
}
}
},
"children": [
{
"NavMenu": {
"props": {
"label": "Shop",
"className": "",
"data-testid": "nav-categories-link",
"isShowArrow": true
},
"menuItems": [
{
"title": {
"text": "Categories",
"className": "text-[#003F31] text-xl font-bold"
},
"links": [
{
"label": "Overnight Oats",
"href": "/categories/overnight-oats",
"className": "text-red-500"
},
{
"label": "Porridge",
"href": "/categories/porridge"
},
{
"label": "Cereals",
"href": "/categories/cereals"
},
{
"label": "Granola",
"href": "/categories/granola"
},
{
"label": "Glasses & Bowls",
"href": "/categories/glasses-bowls"
},
{
"label": "Oat Bars",
"href": "/categories/oat-bars"
},
{
"label": "Nut butters",
"href": "/categories/nut-butters"
}
]
},
{
"title": {
"text": "Specials",
"className": "text-[#003F31] text-xl font-bold"
},
"links": [
{
"label": "Advent calendar ✨",
"href": "/collections/advent-calendar"
},
{
"label": "Saver subscription",
"href": "/collections/saver-subscription"
},
{
"label": "bestseller",
"href": "/collections/bestseller"
},
{
"label": "New 🔥",
"href": "/collections/new"
},
{
"label": "Bluey Kidsrange",
"href": "/collections/bluey-kidsrange"
},
{
"label": "Value sets",
"href": "/collections/value-sets"
},
{
"label": "Sale",
"href": "/collections/sale"
}
]
},
{
"title": {
"text": "All products",
"className": "text-[#003F31] text-xl font-bold"
},
"links": [
{
"label": "Shop all",
"href": "/store"
}
]
}
]
{
"Link": {
"config": {
"label": "Versandkostenfrei ab 45 € 💛",
"href": "/"
}
},
{
"NavMenu": {
"props": {
"label": "About us",
"className": "",
"data-testid": "nav-categories-link",
"isShowArrow": true
},
"menuItems": [
{
"title": {
"text": "About us",
"className": "text-[#003F31] text-xl font-bold"
},
"links": [
{
"label": "Check",
"href": "/categories/overnight-oats",
"className": "text-red-500"
},
{
"label": "Porridge",
"href": "/categories/porridge"
},
{
"label": "Cereals",
"href": "/categories/cereals"
},
{
"label": "Granola",
"href": "/categories/granola"
},
{
"label": "Glasses & Bowls",
"href": "/categories/glasses-bowls"
},
{
"label": "Oat Bars",
"href": "/categories/oat-bars"
},
{
"label": "Nut butters",
"href": "/categories/nut-butters"
}
]
},
{
"title": {
"text": "Specials",
"className": "text-[#003F31] text-xl font-bold"
},
"links": [
{
"label": "Advent calendar ✨",
"href": "/collections/advent-calendar"
},
{
"label": "Saver subscription",
"href": "/collections/saver-subscription"
},
{
"label": "bestseller",
"href": "/collections/bestseller"
},
{
"label": "New 🔥",
"href": "/collections/new"
},
{
"label": "Bluey Kidsrange",
"href": "/collections/bluey-kidsrange"
},
{
"label": "Value sets",
"href": "/collections/value-sets"
},
{
"label": "Sale",
"href": "/collections/sale"
}
]
},
{
"title": {
"text": "All products",
"className": "text-[#003F31] text-xl font-bold"
},
"links": [
{
"label": "Shop all",
"href": "/store"
}
]
}
]
}
},
{
"Link": {
"config": {
"label": "Gratis Geschenk ab 60 € Warenkorbwert 🎁",
"href": "/"
}
},
{
"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"
}
}
}
]
}
}
]
}
}
},
{
"Nav": {
"config": {
"className": "h-24 bg-white text-[#003F31] gap-12",
"left": [
{
"Logo": {
"config": {
"src": "/3bear-logo.png",
"alt": "MyShop",
"className": "h-[150px] w-[180px]",
"objectFit": "contain"
}
},
{
"LocalizedClientLink": {
"props": {
"href": "/recipe",
"label": "Recipes",
"className": "hover:text-ui-fg-base",
"data-testid": "nav-register-link"
}
},
{
"VtMegaMenu": {
"config": {
"navLabel": {
"text": "Shop",
"className": "font-bold text-[1rem] text-[#003F31] flex items-center mr-8 gap-1 hover:text-[#009b93]",
"isShowArrow": true
}
}
},
{
"LocalizedClientLink": {
"props": {
"href": "/meet-harry-kane",
"label": "Meet Harry Kane",
"className": "hover:text-ui-fg-base",
"data-testid": "nav-register-link"
}
},
{
"Dropdown": {
"config": {
"trigger": {
"text": "Über Uns",
"className": "font-bold text-[1rem] text-[#003F31] flex items-center mr-8 gap-1 hover:text-[#009b93]",
"isShowArrow": true
},
"items": [
{
"text": "Unser Unternehmen",
"href": "/"
},
{
"text": "Loren ipsum",
"href": "/"
},
{
"text": "Not a Link"
}
]
}
}
},
{
"Dropdown": {
"config": {
"trigger": {
"text": "Über unsere Produkte",
"className": "font-bold text-[1rem] text-[#003F31] flex items-center mr-8 gap-1 hover:text-[#009b93]",
"isShowArrow": true
},
"items": [
{
"text": "Unser Unternehmen",
"href": "/"
},
{
"text": "Loren ipsum",
"href": "/"
},
{
"text": "Not a Link"
}
]
}
}
},
{
"Link": {
"config": {
"label": "Rezepte",
"href": "/",
"className": "font-bold text-[1rem] text-[#003F31] flex items-center mr-8 gap-1 hover:text-[#009b93]"
}
}
},
{
"Link": {
"config": {
"label": "Triff Harry Kane",
"href": "/",
"className": "font-bold text-[1rem] text-[#003F31] flex items-center gap-1 hover:text-[#009b93]"
}
}
}
],
"right": [
{
"VtCountryCodeSelect": {
"config": {
"trigger": {
"className": "w-auto font-bold text-[13px] text-[#11314E] flex justify-start items-center gap-1 hover:text-[#009b93] bg-transparent shadow-none hover:bg-transparent",
"isFlag": true,
"isDisplayFullname": true
}
}
}
},
{
"Button": {
"config": {
"icon": "MagnifyingGlass",
"className": "shadow-none"
}
}
},
{
"AccountButton": {
"config": {
"icon": "User",
"className": " flex items-center gap-1 shadow-none"
}
}
},
{
"VtCartButton": {
"config": {
"icon": "ShoppingBag",
"className": "shadow-none bg-transparent text-black w-[50px]"
}
}
}
]
}
}
}
]
}
},
{
"PropsChildren": {}
},
{
"Footer": {
"config": {
"className": "content-container border-none flex w-full bg-[#003f31] text-white border justify-between pb-8 pt-14",
"leftClassName": "flex-col ml-3",
"centerClassName": "",
"rightClassName": "flex gap-[10rem] mr-[80px]",
"left": [
{
"VtFooterHero": {
"config": {
"logoClassName": "h-[100px] w-[200px]",
"logoSrc": "/3bear-white-logo.avif",
"logoAlt": "3Bear",
"title": "Melde dich für unsere Oatnews an 💛",
"email": {
"emailInputClassName": "w-[300px] ml-8"
},
"socials": [
{
"icon": "Twitter",
"href": "/",
"className": "w-5 h-5 text-white"
},
{
"icon": "Twitter",
"href": "/",
"className": "w-5 h-5 text-white"
},
{
"icon": "Twitter",
"href": "/",
"className": "w-5 h-5 text-white"
},
{
"icon": "Twitter",
"href": "/",
"className": "w-5 h-5 text-white"
}
],
"socialsClassName": "ml-8 mt-10",
"className": "",
"ctaClassName": "ml-8",
"titleClassName": "ml-8 text-white w-full",
"descriptionClassName": "ml-8"
}
}
}
],
"center": [],
"right": [
{
"VtMenuItem": {
"config": {
"title": "Information",
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-white hover:text-white",
"itemClassName": "text-[1rem] font-[400] opacity-70 hover:text-white",
"items": [
{
"text": "Über Uns",
"href": "/"
},
{
"text": "Placeholder",
"href": "/categories/shoes"
},
{
"text": "Placeholder",
"href": "/categories/accessories"
}
]
}
}
]
}
},
{
"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": [
{
"VtMenuItem": {
"config": {
"title": "Kundendienst",
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-white hover:text-white",
"itemClassName": "text-[1rem] font-[400] flex items-center opacity-70 hover:text-white",
"items": [
{
"DropdownMenuItems": {
"props": {
"label": "All Categories",
"className": "hover:text-ui-fg-base",
"data-testid": "nav-all-categories-link"
}
}
"text": "Twitter",
"href": "/"
},
{
"DropdownMenuItems": {
"props": {
"label": "New Arrivals",
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-new-arrivals-link"
}
}
"text": "Facebook",
"href": "/categories/shoes"
},
{
"DropdownMenuItems": {
"props": {
"label": "Best Sellers",
"className": "hover:text-ui-fg-base flex items-center gap-2",
"data-testid": "nav-best-sellers-link"
}
}
"text": "Pinterest",
"href": "/categories/accessories"
}
]
}
},
{
"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": [
}
},
{
"VtMenuItem": {
"config": {
"title": "Weiteres",
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-white",
"itemClassName": "text-[1rem] font-[400] w-[150px] opacity-70 hover:text-white",
"items": [
{
"CartButton": {}
"text": "Karriere",
"href": "/"
},
{
"text": "Unser Team",
"href": "/categories/shoes"
},
{
"text": "B2B",
"href": "/categories/accessories"
},
{
"text": "Presse",
"href": "/categories/accessories"
}
]
}
}
}
]
}
}
},
{
"Footer": {
"config": {
"className": "content-container bg-[#003f31] w-full text text-[#11314E] flex items-center justify-between",
"leftClassName": "w-full",
"left": [],
"center": [],
"right": [
{
"VtFooterBottom": {
"config": {
"className": " mr-[80px]",
"icons": [
"Mastercard",
"PayPal",
"Visa",
"Mastercard",
"Mastercard",
"Mastercard",
"Mastercard"
]
}
}
}
]
}
}
}
],
"pages": {
"Home": [
{
"Hero": {
"config": {
"className": "h-[35rem]",
"ImageDisplayer": {
"config": {
"duration": 0,
"images": [
"./banner-hero.webp"
],
"links": [
"/account"
]
}
},
"left": [
{
"VtCtaBanner": {
"config": {
"className": "left-[120px] top-[80px] relative w-full p-12 flex flex-col items-start justify-center text-left bg-transperant border-none shadow-none",
"buttonTextClassName": "inline-flex items-center justify-center bg-[#FCEE56] hover:bg-[#FCEE56]/90 text-[#0D382E] px-8 py-3 rounded-full font-bold text-lg shadow-none border-none",
"tagTextClassName": "text-[#0D382E] text-lg font-medium mb-2 bg-transparent",
"tagText": "So einfach kann Frühstück sein mit unseren leckeren Overnight Oats.",
"titleText": "breakfast made easy.",
"buttonText": "Jetzt entdecken"
}
}
}
],
"center": [],
"right": []
}
}
},
{
"VtFeaturedProducts": {
"config": {
"title": "best-seller",
"styles": {
"container": "content-container py-12 small:py-20",
"header": {
"container": "ml-16",
"title": "text-2xl mb-12",
"isShowViewAll": false
},
"list": "grid grid-cols-2 small:grid-cols-3 gap-x-6 gap-y-24 small:gap-y-36",
"productCard": {
"badgeText": "Bestseller",
"card": "relative flex flex-col items-center bg-transparent shadow-none border-none p-0",
"badge": {
"container": "absolute top-0 left-0 z-[1]",
"text": "px-3 py-1 rounded-full bg-[#009b93] text-white text-[12px] font-semibold"
},
"thumbnail": {
"className": "rounded-2xl bg-white h-[320px] object-contain shadow-none",
"size": "full"
},
"content": "flex flex-col items-center justify-start text-center p-0 mt-6",
"title": "text-[#003F31] text-[28px] font-semibold",
"description": "order-3 text-[#003f31b3]",
"price": "mt-2 text-[#0D382E] text-[24px] font-bold order-2 flex gap-2",
"reviews": {
"container": "mt-3 flex items-center gap-2 order-1",
"stars": "flex gap-1",
"star": "text-[#F59E0B] text-xl leading-none",
"emptyStar": "text-[#F59E0B] text-xl opacity-30 leading-none",
"text": "text-[#003F31] text-[14px]",
"rating": 4.5,
"count": 38
},
"button": {
"addToCart": "hidden",
"moreInfo": "hidden",
"isShowIcon": false
}
}
}
}
}
},
{
"CartMismatchBanner": {
"config": {
"show": true
}
}
},
{
"VtCategoryHighlight": {
"config": {
"title": "Oder lieber stöbern? Hier findest du sicher deine neuen Hafer-Favoriten.",
"className": "content-container py-12 small:py-20",
"gridClassName": "grid grid-cols-4 gap-6 w-full",
"labelClassName": "absolute left-4 bottom-4 text-[#003F31] text-[18px] font-semibold",
"items": [
{
"imageSrc": "/overnight-oat.webp",
"href": "/categories/overnight-oats",
"label": "Overnight Oats",
"className": "bg-[#CFECD9] col-start-1 col-end-3 row-start-1 row-end-3",
"imageClassName": "object-contain"
},
{
"imageSrc": "/overnight-oat.webp",
"href": "/categories/porridge",
"label": "Porridge",
"className": "bg-[#F9E0B0]",
"imageClassName": "object-contain"
},
{
"imageSrc": "/overnight-oat.webp",
"href": "/categories/cereals",
"label": "Cereals",
"className": "bg-[#F59E0B]",
"imageClassName": "object-contain"
},
{
"imageSrc": "/overnight-oat.webp",
"href": "/categories/granola",
"label": "Granola",
"className": "bg-[#A7D8F0]",
"imageClassName": "object-contain"
},
{
"imageSrc": "/overnight-oat.webp",
"href": "/categories/oat-bars",
"label": "Oat Bars",
"className": "bg-[#EED7F2]",
"imageClassName": "object-contain"
}
]
}
}
]
}
},
{
"CartMismatchBanner": {
"show": true
}
},
{
"FreeShippingPriceNudge": {
"variant": "popup"
}
},
{
"PropsChildren": {}
},
{
"Footer": {
"copyrightText": "© 2025 MyShop"
}
},
{
"FreeShippingPriceNudge": {
"config": {
"variant": "popup"
}
}
}
]
}
]
}
+347 -272
View File
@@ -1,286 +1,361 @@
[
{
"AnnouncementBanner": {
"props": {
"label": [
{
"layout": [
{
"Header": {
"config": {
"sticky": true
},
"children": [
{
"text": "BLACK FRIDAY PRE-SALE",
"className": " #b31b1f font-bold"
},
{
"text": "FREE MYSTERY GIFT on $70",
"className": ""
},
{
"text": "SHOP NOW",
"className": "font-bold text-sm underline"
}
],
"className": "bg-[#b31b1f] text-white"
}
}
},
{
"Nav": {
"props": {
"className": "bg-[#1f3621] text-white"
},
"children": [
{
"Div": {
"props": {
"className": "flex items-center h-full"
},
"children": [
{
"Image": {
"props": {
"src": "https://www.drsquatch.com/cdn/shop/files/2drsq_horiz_logo_white_3_1.svg?v=1743539246",
"alt": "Medusa Store",
"width": 120,
"height": 40,
"className": "cursor-pointer ml-8"
}
}
},
{
"Div": {
"props": {
"className": "flex items-center h-full gap-16 font-bold"
},
"children": [
{
"DropdownMenus": {
"props": {
"label": "SUBCRIBE",
"className": "hover:underline flex items-center gap-2 ml-16",
"data-testid": "nav-categories-link",
"isShowArrow": false
},
"children": [
{
"DropdownMenuItems": {
"props": {
"label": "All Categories",
"className": "",
"data-testid": "nav-all-categories-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "New Arrivals",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-new-arrivals-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "Best Sellers",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-best-sellers-link"
}
}
}
]
}
},
{
"DropdownMenus": {
"props": {
"label": "REWARD",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-categories-link"
},
"children": [
{
"DropdownMenuItems": {
"props": {
"label": "All Categories",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-all-categories-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "New Arrivals",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-new-arrivals-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "Best Sellers",
"className": "hover:underline",
"data-testid": "nav-best-sellers-link"
}
}
}
]
}
},
{
"DropdownMenus": {
"props": {
"label": "SHOP",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-categories-link",
"isShowArrow": false
},
"children": [
{
"DropdownMenuItems": {
"props": {
"label": "All Categories",
"className": "hover:underline",
"data-testid": "nav-all-categories-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "New Arrivals",
"className": "hover:underline",
"data-testid": "nav-new-arrivals-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "Best Sellers",
"className": "hover:underline",
"data-testid": "nav-best-sellers-link"
}
}
}
]
}
},
{
"DropdownMenus": {
"props": {
"label": "OUR STORY",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-categories-link",
"isShowArrow": false
},
"children": [
{
"DropdownMenuItems": {
"props": {
"label": "OUR STORY",
"className": "hover:underline",
"data-testid": "nav-all-categories-link",
"isShowArrow": false
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "New Arrivals",
"className": "hover:underline",
"data-testid": "nav-new-arrivals-link",
"isShowArrow": false
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "Best Sellers",
"className": "hover:underline",
"data-testid": "nav-best-sellers-link",
"isShowArrow": false
}
}
}
]
"Banner": {
"config": {
"variant": "nav",
"className": "h-12 bg-[#e6c981] text-black gap-12",
"center": [
{
"Link": {
"config": {
"label": "BLACK FRIDAY",
"href": "/",
"className": "font-bold text-[1rem] flex items-center gap-1"
}
}
},
{
"Link": {
"config": {
"label": "Up to 55% off Bundles",
"href": "/",
"className": "text-[1rem] flex items-center gap-1"
}
}
},
{
"Link": {
"config": {
"label": "SHOP NOW",
"href": "/",
"className": "font-bold text-[1rem] flex items-center gap-1 underline"
}
}
}
]
}
}
},
{
"Nav": {
"config": {
"className": "h-24 bg-[#1f3521] text-white gap-12",
"left": [
{
"Logo": {
"config": {
"src": "/drsquatch-logo.webp",
"alt": "MyShop",
"className": "h-auto w-40 mr-24",
"objectFit": "contain"
}
}
},
{
"Link": {
"config": {
"label": "SUBCRIBE",
"href": "/",
"className": "font-bold text-[1rem] text-white flex items-center mr-8 gap-1 hover:underline hover:text-white"
}
}
},
{
"Link": {
"config": {
"label": "REWARD",
"href": "/",
"className": "font-bold text-[1rem] text-white flex items-center mr-8 gap-1 hover:underline hover:text-white"
}
}
},
{
"VtMegaMenu": {
"config": {
"navLabel": {
"text": "SHOP",
"className": "font-bold text-[1rem] flex items-center mr-8 gap-1 hover:bg-transparent hover:underline hover:text-white"
}
}
}
},
{
"VtMegaMenu": {
"config": {
"navLabel": {
"text": "OUR STORY",
"className": "font-bold text-[1rem] text-white flex items-center hover:bg-transparent hover:underline hover:text-white"
}
}
}
}
],
"right": [
{
"VtCountryCodeSelect": {
"config": {
"trigger": {
"className": "w-auto font-bold text-[13px] text-white flex justify-start items-center gap-1 hover:text-[#009b93] bg-transparent shadow-none hover:bg-transparent",
"isFlag": true
}
}
}
},
{
"Button": {
"config": {
"icon": "User",
"className": "shadow-none bg-transparent text-white hover:text-black"
}
}
},
{
"VtCartButton": {
"config": {
"icon": "ShoppingCart",
"className": "shadow-none bg-transparent text-black w-[50px]"
}
}
}
]
}
}
}
]
}
},
{ "PropsChildren": {} },
{
"Footer": {
"config": {
"className": "content-container border-none bg-[#1f3621] flex w-full border justify-between pb-8 gap-10 pt-16 px-[120px]",
"leftClassName": "flex ml-6 gap-x-24",
"centerClassName": "flex",
"rightClassName": "flex",
"left": [
{
"VtMenuItem": {
"config": {
"title": "Help",
"className": "flex flex-col gap-y-2 text-[16px] font-semibold text-white gap-8",
"itemClassName": "text-[14px] font-[400] mt-3",
"items": [
{ "text": "FAQ", "href": "/" },
{ "text": "Track my order", "href": "/categories/shoes" },
{ "text": "Placeholder", "href": "/categories/accessories" },
{ "text": "Placeholder", "href": "/categories/accessories" },
{ "text": "Placeholder", "href": "/categories/accessories" },
{ "text": "Placeholder", "href": "/categories/accessories" }
]
}
}
]
}
},
{
"Div": {
"props": {
"className": "flex items-center gap-x-6 h-full justify-end"
},
"children": [
{
"SearchButton": {
"props": {
"className": "hover:underline hover:text-black flex items-center gap-2 text-white",
"data-testid": "nav-account-link"
}
}
},
{
"UserButton": {
"props": {
"className": "hover:underline hover:text-black flex items-center gap-2 text-white",
"data-testid": "nav-account-link"
}
}
},
{
"Suspense": {
"props": {
"fallback": [
{
"LocalizedClientLink": {
"props": {
"href": "/cart",
"label": "Cart (0)",
"className": "hover:underline flex gap-2",
"data-testid": "nav-cart-link"
}
}
}
]
},
"children": [
{
"CartButton": {}
}
{
"VtMenuItem": {
"config": {
"title": "Shop",
"className": "flex flex-col gap-y-2 text-[16px] font-semibold text-white",
"itemClassName": "text-[14px] font-[400] flex items-center mt-3",
"items": [
{ "text": "Twitter", "href": "/" },
{ "text": "Facebook", "href": "/categories/shoes" },
{ "text": "Pinterest", "href": "/categories/accessories" },
{ "text": "Placeholder", "href": "/categories/accessories" },
{ "text": "Placeholder", "href": "/categories/accessories" },
{ "text": "Placeholder", "href": "/categories/accessories" },
{ "text": "Placeholder", "href": "/categories/accessories" }
]
}
}
},
{
"VtMenuItem": {
"config": {
"title": "Info",
"className": "flex flex-col gap-y-2 text-[16px] font-semibold text-white",
"itemClassName": "text-[14px] font-[400] w-[200px] mt-3",
"items": [
{ "text": "The Squatch Difference", "href": "/" },
{ "text": "Why Natural Products", "href": "/categories/shoes" },
{ "text": "No Harmful Ingredients", "href": "/categories/accessories" }
]
}
}
}
],
"center": [
{
"Logo": {
"config": {
"src": "/b-corp-logo.webp",
"alt": "MyShop",
"className": "h-auto w-[90px] mr-24",
"objectFit": "contain"
}
}
}
],
"right": [
{
"VtFooterSignUp": {
"config": {
"title": "Don't miss out on hot deals! Sign up and get up to 30% off.",
"className": "max-w-[760px]",
"titleClassName": "text-white text-[18px]",
"formClassName": "mt-2 w-full",
"inputClassName": "w-full",
"buttonClassName": "bg-[#C4622C] w-[90px]",
"socialsClassName": "mt-4 gap-8",
"socials": [
{ "icon": "Twitter", "href": "/", "className": "w-5 h-5 text-white" },
{ "icon": "Twitter", "href": "/", "className": "w-5 h-5 text-white" },
{ "icon": "Twitter", "href": "/", "className": "w-5 h-5 text-white" },
{ "icon": "Twitter", "href": "/", "className": "w-5 h-5 text-white" }
]
}
}
}
]
}
}
},
{
"VtFooterBottom": {
"config": {
"className": "text-white text-[14px] font-[400] bg-[#1f3621] flex items-center justify-center",
"text": "©2025 Vibentec IT. All rights reserved",
"linksClassName": "flex items-center text-orange-500 mt-2 pl-2",
"links": [
{ "label": "Privacy Policy", "href": "/" },
{ "label": "Terms of Service", "href": "/categories/shoes" },
{ "label": "Cookie Policy", "href": "/categories/accessories" }
]
}
}
}
],
"pages": {
"Home": [
{
"Hero": {
"config": {
"className": "h-[35rem]",
"ImageDisplayer": {
"config": {
"duration": 0,
"images": ["./drsquatch-banner.jpg"],
"links": ["/account"]
}
},
"left": [
{
"VtCtaBanner": {
"config": {
"variant": "default",
"className": "left-[120px] top-[120px] bg-transparent border-none text-white text-center",
"tagTextClassName": "text-white bg-transparent",
"titleTextClassName": "text-white font-bold leading-normal text-[30px]",
"descriptionTextClassName": "text-white text-[1rem] w-[300px] ml-[6.6rem]",
"buttonTextClassName": "text-white bg-orange-500 w-[300px]",
"tagText": "ALL NEW!",
"titleText": "LUMBERJACK LODGE",
"descriptionText": "Step into the lodge with the warm, sweet scent of maple and vanilla.",
"buttonText": "SHOP NOW"
}
}
}
],
"center": [],
"right": []
}
}
},
{
"VtFeaturedProducts": {
"config": {
"title": "drsquatch-best-seller",
"styles": {
"container": "content-container py-12 px-[100px] small:py-24",
"header": {
"container": "flex justify-center mb-8",
"title": "text-[28px] font-bold text-[#1f3521]",
"isShowViewAll": false
},
"list": "grid grid-cols-2 small:grid-cols-3 gap-x-6 gap-y-24 small:gap-y-36",
"productCard": {
"card": "shadow-none border-none",
"className": "relative overflow-hidden rounded-2xl bg-white shadow-elevation-card-rest h-full flex flex-col",
"badgeText": "LIMITED EDITION",
"badge": {
"container": "absolute z-[1] top-0 left-0 pt-4",
"text": "uppercase px-4 py-2 bg-[#3B6F47] text-white"
},
"thumbnail": { "className": "rounded-none h-[300px] shadow-none", "size": "full" },
"content": " flex flex-col flex-1",
"title": "mt-2 text-[#1f3521] text-[22px] font-bold",
"price": "mt-2 text-[#3B6F47] font-bold text-[20px] flex gap-3 flex-row-reverse justify-end",
"description": "mt-2",
"reviews": {
"container": "mt-3 flex items-center gap-2",
"stars": "flex gap-1",
"star": "text-[#C4622C] text-xl leading-none",
"emptyStar": "text-[#C4622C] text-xl opacity-30 leading-none",
"text": "text-[#1f3521]",
"rating": 3.6,
"count": 59
},
"button": {
"addToCart": "mt-6 w-full bg-[#C4622C] hover:bg-[#C4622C]/90 shadow-none text-white rounded-none h-fit font-bold",
"moreInfo": "hidden"
}
}
}
}
}
},
{
"VtCategoryHighlight": {
"config": {
"title": "Oder lieber stöbern? Hier findest du sicher deine neuen Hafer-Favoriten.",
"className": "content-container py-12 small:py-20",
"gridClassName": "grid grid-cols-2 gap-6 w-full",
"labelClassName": "absolute left-4 bottom-4 text-[#003F31] text-[18px] font-semibold",
"items": [
{
"imageSrc": "/overnight-oat.webp",
"href": "/categories/overnight-oats",
"label": "Overnight Oats",
"className": "bg-[#CFECD9] h-[250px]",
"imageClassName": "object-contain"
},
{
"headingLabel": "The Squatch Difference",
"descriptionLabel": "Learn why men everywhere are loving Dr. Squatch.",
"buttonLabel": "Learn more",
"className": "flex-col bg-[#F9E0B0] p-6 justify-center",
"headingClassName": "text-[#003F31] text-[28px] font-semibold",
"descriptionClassName": "text-[#003f31b3]",
"buttonClassName": "mt-4 text-[#003F31] text-[18px] font-semibold bg-orange-500 py-2 px-16 rounded text-white"
}
]
}
}
]
}
},
{
"CartMismatchBanner": {
"show": true
}
},
{
"FreeShippingPriceNudge": {
"variant": "popup"
}
},
{
"PropsChildren": {}
},
{
"Footer": {
"copyrightText": "© 2025 MyShop"
}
},
{ "CartMismatchBanner": { "config": { "show": true } } },
{ "FreeShippingPriceNudge": { "config": { "variant": "popup" } } }
],
"Product": [
{ "VtFeaturedProducts": { "config": { "title": "drsquatch-best-seller" } } }
],
"StorePage": [
{ "VtFeaturedProducts": { "config": { "title": "drsquatch-best-seller" } } }
]
}
]
}
+264
View File
@@ -0,0 +1,264 @@
{
"layout": [
{
"Header": {
"config": {
"sticky": true
},
"children": [
{
"Banner": {
"config": {
"variant": "nav",
"className": "h-12 bg-[#E6EFFC] text-[#11314E] gap-12 pl-16",
"left": [
{
"Link": {
"config": {
"label": "Über Uns",
"href": "/",
"className": "text-[13px] flex items-center gap-1 cursor-pointer"
}
}
},
{
"Link": {
"config": {
"label": "Kontaktieren Uns",
"href": "/",
"className": "text-[13px] flex items-center gap-1"
}
}
}
],
"center": [
{
"Link": {
"config": {
"label": "Einsparung durch Digitalisierung in der Arztpraxis",
"href": "/",
"className": "text-[13px] flex items-center gap-1 "
}
}
},
{
"Button": {
"config": {
"label": "Mehr Info",
"href": "/",
"className": "text-[13px] flex items-center bg-[#112638] gap-1 "
}
}
}
],
"right": [
{
"Dropdown": {
"config": {
"trigger": {
"text": "EURO",
"className": "font-bold text-[13px] text-[#11314E] flex items-center gap-1 hover:text-[#009b93]",
"isShowArrow": true
},
"items": [
{
"icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Flag_of_Germany.svg/1200px-Flag_of_Germany.svg.png",
"text": "EURO",
"href": "/"
}
]
}
}
},
{
"VtCountryCodeSelect": {
"config": {
"trigger": {
"className": "w-auto font-bold text-[13px] text-[#11314E] flex justify-start items-center gap-1 hover:text-[#009b93] bg-transparent shadow-none hover:bg-transparent",
"isFlag": false
}
}
}
}
]
}
}
},
{
"Nav": {
"config": {
"left": [
{
"VtSideMenu": {}
},
{
"VtMegaMenu": {
"config": {
"navLabel": {
"text": "Sale",
"className": "text-[13px] text-[#11314E] flex items-center mr-8 gap-1 hover:bg-transparent hover:underline hover:text-[#009b93]"
}
}
}
}
],
"center": [
{
"HomeButton": {
"config": {
"label": "Medusa Store"
}
}
}
],
"right": [
{
"AccountButton": {
"config": {
"label": "Account",
"className": "hover:text-ui-fg-base"
}
}
},
{
"VtCartButton": {
"config": {
"variant": "link",
"className": "hover:text-ui-fg-base"
}
}
}
]
}
}
}
]
}
},
{ "PropsChildren": {} },
{
"Footer": {
"config": {
"className": "content-container flex w-full border h-[300px] justify-between",
"left": [
{
"VtMenuItem": {
"config": {
"title": "category",
"className": "flex flex-col gap-y-2",
"itemClassName": "text-ui-fg-subtle txt-small ml-3",
"items": [
{ "text": "Clothing", "href": "/" },
{ "text": "Shoes", "href": "/categories/shoes" },
{ "text": "Accessories", "href": "/categories/accessories" }
]
}
}
}
],
"center": [
{
"VtMenuItem": {
"config": {
"title": "category",
"className": "flex flex-col gap-y-2",
"itemClassName": "text-ui-fg-subtle txt-small ml-3",
"items": [
{ "text": "Clothing", "href": "/" },
{ "text": "Shoes", "href": "/categories/shoes" },
{ "text": "Accessories", "href": "/categories/accessories" }
]
}
}
}
],
"right": [
{
"Text": {
"config": {
"label": "Medusa Check",
"className": "text-[13px] text-[#A6A6A6]"
}
}
}
]
}
}
}
],
"pages": {
"Home": [
{
"Hero": {
"config": {
"variant": "default",
"className": "bg-custom-gradient"
}
}
},
{
"VtFeaturedProducts": {
"config": {
"title": "best-seller"
}
}
},
{
"VtCategoryHighlight": {
"config": {
"title": "Oder lieber stöbern? Hier findest du sicher deine neuen Hafer-Favoriten.",
"className": "content-container py-12 small:py-20",
"gridClassName": "grid grid-cols-2 gap-6 w-full",
"labelClassName": "absolute left-4 bottom-4 text-[#003F31] text-[18px] font-semibold",
"items": [
{
"imageSrc": "/overnight-oat.webp",
"href": "/categories/overnight-oats",
"label": "Overnight Oats",
"className": "bg-[#CFECD9] h-[250px]",
"imageClassName": "object-contain"
},
{
"headingLabel": "The Squatch Difference",
"descriptionLabel": "Learn why men everywhere are loving Dr. Squatch.",
"buttonLabel": "Learn more",
"className": "flex-col bg-[#F9E0B0] p-6 justify-center",
"headingClassName": "text-[#003F31] text-[28px] font-semibold",
"descriptionClassName": "text-[#003f31b3]",
"buttonClassName": "mt-4 text-[#003F31] text-[18px] font-semibold bg-orange-500 py-2 px-16 rounded text-white"
}
]
}
}
},
{
"CartMismatchBanner": {
"config": { "show": true }
}
},
{
"FreeShippingPriceNudge": {
"config": { "variant": "popup" }
}
}
],
"Product": [
{
"VtFeaturedProducts": {
"config": {
"title": "best-seller"
}
}
}
],
"StorePage": [
{
"VtFeaturedProducts": {
"config": {
"title": "best-seller"
}
}
}
]
}
}
+372 -270
View File
@@ -1,290 +1,392 @@
[
{
"AnnouncementBanner": {
"props": {
"className": "bg-[#E6EFFC] text-[#285A86] flex items-center text-xs"
},
"children": [
{
"Div": {
"props": {
"className": "flex items-center h-full gap-8 "
},
"children": [
{
"Text": {
"props": {
"className": "text-sm font-medium",
"label": "Über Uns"
}
}
},
{
"Text": {
"props": {
"className": "text-sm font-medium",
"label": "Kontaktieren Uns"
}
}
}
]
}
{
"layout": [
{
"Header": {
"config": {
"sticky": true
},
{
"Div": {
"props": {
"className": "flex items-center h-full gap-6"
},
"children": [
{
"Text": {
"props": {
"className": "text-sm font-medium",
"label": "Einsparung durch Digitalisierung in der Arztpraxis"
}
}
},
{
"Button": {
"props": {
"className": "text-xs font-medium",
"label": "Mehr Info"
}
}
}
]
}
},
{
"Div": {
"props": {
"className": "flex items-center h-full gap-4"
},
"children": [
{
"DropdownMenus": {
"props": {
"label": "EURO",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-categories-link",
"isShowArrow": true
},
"children": [
{
"DropdownMenuItems": {
"props": {
"label": "USD",
"className": "",
"data-testid": "nav-all-categories-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "VND",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-new-arrivals-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "EURO",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-best-sellers-link"
}
"children": [
{
"Banner": {
"config": {
"variant": "nav",
"className": "h-12 bg-[#E6EFFC] text-[#11314E] gap-12 pl-16",
"left": [
{
"Link": {
"config": {
"label": "Über Uns",
"href": "/",
"className": "text-[13px] flex items-center gap-1 cursor-pointer"
}
}
]
}
},
{
"DropdownMenus": {
"props": {
"label": "DE",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-categories-link",
"isShowArrow": true
},
"children": [
{
"DropdownMenuItems": {
"props": {
"label": "EN",
"className": "",
"data-testid": "nav-all-categories-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "KR",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-new-arrivals-link"
}
}
},
{
"DropdownMenuItems": {
"props": {
"label": "DE",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-best-sellers-link"
}
{
"Link": {
"config": {
"label": "Kontaktieren Uns",
"href": "/",
"className": "text-[13px] flex items-center gap-1"
}
}
]
}
}
]
}
}
]
}
},
{
"Nav": {
"props": {
"className": "bg-white text-black text-sm pr-16"
},
"children": [
{
"Div": {
"props": {
"className": "flex items-center h-full"
},
"children": [
{
"Image": {
"props": {
"src": "/VibentecIT-logo.svg",
"alt": "Medusa Store",
"width": 150,
"height": 80,
"className": "cursor-pointer ml-14"
}
}
},
{
"Div": {
"props": {
"className": "flex items-center h-full gap-8 ml-16"
},
"children": [
{
"LocalizedClientLink": {
"props": {
"href": "/",
"label": "Home",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-home-link"
}
}
},
{
"LocalizedClientLink": {
"props": {
"href": "/collections/all",
"label": "Shop",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-collections-link"
}
}
},
{
"LocalizedClientLink": {
"props": {
"href": "/pages/about-us",
"label": "Sale",
"className": "hover:underline flex items-center gap-2",
"data-testid": "nav-about-link"
}
],
"center": [
{
"Link": {
"config": {
"label": "Einsparung durch Digitalisierung in der Arztpraxis",
"href": "/",
"className": "text-[13px] flex items-center gap-1 "
}
}
},
{
"Link": {
"config": {
"label": "Mehr Info",
"href": "/",
"className": "text-[13px] rounded-md hover:text-white w-[80px] text-white text-center flex items-center bg-[#112638] flex justify-center h-[28px] "
}
}
]
}
}
]
}
},
{
"Div": {
"props": {
"className": "flex items-center gap-x-6 h-full justify-end"
},
"children": [
{
"InputSearchButton": {
"props": {
"className": "flex items-center",
"data-testid": "nav-account-link"
}
}
},
{
"UserButton": {
"props": {
"className": "hover:underline hover:text-black flex items-center",
"data-testid": "nav-account-link"
}
}
},
{
"FavoriteButton": {
"props": {
"className": "hover:underline hover:text-black flex items-center",
"data-testid": "nav-account-link"
}
}
},
{
"Suspense": {
"props": {
"fallback": [
{
"LocalizedClientLink": {
"props": {
"href": "/cart",
"label": "Cart (0)",
"className": "hover:underline flex gap-2",
"data-testid": "nav-cart-link"
],
"right": [
{
"Dropdown": {
"config": {
"trigger": {
"text": "EURO",
"className": "font-bold text-[13px] text-[#11314E] flex items-center gap-1 hover:text-[#009b93]",
"isShowArrow": true
},
"items": [
{
"icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Flag_of_Germany.svg/1200px-Flag_of_Germany.svg.png",
"text": "EURO",
"href": "/"
}
]
}
}
},
{
"VtCountryCodeSelect": {
"config": {
"trigger": {
"className": "w-auto font-bold text-[13px] text-[#11314E] flex justify-start items-center gap-1 hover:text-[#009b93] bg-transparent shadow-none hover:bg-transparent",
"isFlag": false
}
}
]
},
"children": [
{
"CartButton": {}
}
}
]
}
}
},
{
"Nav": {
"config": {
"className": "h-24 bg-[white] text-[#11314E] gap-12 pl-16",
"left": [
{
"Logo": {
"config": {
"src": "/VibentecIT-logo.svg",
"alt": "MyShop",
"className": "h-full w-[180px] mr-4",
"objectFit": "contain"
}
}
},
{
"Link": {
"config": {
"label": "Home",
"href": "/",
"className": "text-[13px] text-[#11314E] flex items-center mr-8 gap-1 hover:underline hover:text-[#009b93]"
}
}
},
{
"Link": {
"config": {
"label": "Shop",
"href": "/",
"className": "text-[13px] text-[#11314E] flex items-center mr-6 gap-1 hover:underline hover:text-[#009b93]"
}
}
},
{
"VtMegaMenu": {
"config": {
"navLabel": {
"text": "Sale",
"className": "text-[13px] text-[#11314E] flex items-center mr-8 gap-1 hover:bg-transparent hover:underline hover:text-[#009b93]"
}
}
}
},
{
"VtMegaMenu": {
"config": {
"navLabel": {
"text": "OUR STORY",
"className": "font-bold text-[1rem] text-white flex items-center mr-8 gap-1 hover:bg-transparent hover:underline hover:text-white"
}
}
}
}
],
"right": [
{
"SearchInput": {
"config": {
"placeholder": "Search"
}
}
},
{
"AccountButton": {
"config": {
"icon": "User",
"className": " flex items-center gap-1 shadow-none"
}
}
},
{
"Button": {
"config": {
"icon": "Heart",
"className": " flex items-center gap-1 shadow-none w-[50px]"
}
}
},
{
"VtCartButton": {
"config": {
"icon": "ShoppingCart",
"className": "shadow-none bg-transparent text-black w-[50px]"
}
}
}
]
}
}
}
]
}
},
{ "PropsChildren": {} },
{
"Footer": {
"config": {
"className": "content-container flex w-full border justify-between pb-8",
"leftClassName": "flex-col ml-6",
"centerClassName": "flex mt-[130px] gap-24",
"rightClassName": "flex mt-[160px]",
"left": [
{
"VtFooterHero": {
"config": {
"logoClassName": "h-[100px] w-[255px]",
"logoSrc": "/VibentecIT-logo.svg",
"logoAlt": "Vibentec IT",
"title": "Der Wegbereiter für innovative IT-Lösungen",
"description": "Tauchen Sie ein in eine Welt modernster Technologien, zuverlässiger Support und proaktiver Innovation gemeinsam gestalten wir die digitale Zukunft Ihres Unternehmens.",
"cta": {
"label": "Kontaktieren Sie uns",
"href": "/"
},
"className": "",
"ctaClassName": "ml-8",
"titleClassName": "ml-8",
"descriptionClassName": "ml-8 w-[320px]"
}
}
}
],
"center": [
{
"VtMenuItem": {
"config": {
"title": "Unternehmen",
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-[#11314E]",
"itemClassName": "text-[1rem] font-[400]",
"items": [
{ "text": "Über Uns", "href": "/" },
{ "text": "Placeholder", "href": "/categories/shoes" },
{ "text": "Placeholder", "href": "/categories/accessories" }
]
}
}
]
},
{
"VtMenuItem": {
"config": {
"title": "Social Media",
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-[#11314E]",
"itemClassName": "text-[1rem] font-[400] flex items-center",
"items": [
{ "text": "Twitter", "href": "/", "icon": "X" },
{ "text": "Facebook", "href": "/categories/shoes", "icon": "X" },
{ "text": "Pinterest", "href": "/categories/accessories", "icon": "X" }
]
}
}
},
{
"VtMenuItem": {
"config": {
"title": "Addresse",
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-[#11314E]",
"itemClassName": "text-[1rem] font-[400] w-[150px]",
"items": [
{ "text": "Hopfenstr. 10c76185 Karlsruhe Deutschland", "href": "/" },
{ "text": "+497271 5970098", "href": "/categories/shoes" },
{ "text": "info@vibentec-it.io", "href": "/categories/accessories" }
]
}
}
}
],
"right": [
{
"VtMenuItem": {
"config": {
"className": "flex flex-col gap-y-2 text-[24px] font-semibold text-[#11314E]",
"itemClassName": "text-[1rem] font-[400] w-[150px]",
"items": [
{ "text": "Datenschutz", "href": "/" },
{ "text": "Impressum", "href": "/categories/shoes" },
{ "text": "Installation Info", "href": "/categories/accessories" }
]
}
}
}
]
}
}
},
{
"Footer": {
"config": {
"className": "content-container h-[128px] w-full text text-[#11314E] flex items-center justify-between px-20 mt-2",
"leftClassName": "w-full",
"left": [
{
"VtFooterBottom": {
"config": {
"text": "©2025 Vibentec IT. All rights reserved",
"icons": ["Mastercard", "PayPal", "Visa"]
}
}
}
]
}
}
}
],
"pages": {
"Home": [
{
"Hero": {
"config": {
"className": "h-[35rem]",
"ImageDisplayer": {
"config": {
"duration": 5,
"images": ["./banner-hero.webp", "./banner-hero-1.webp", "./banner-hero-2.webp"],
"links": ["/", "/account", "/product"]
}
},
"left": [
{
"VtCtaBanner": {
"config": {
"variant": "default",
"className": "left-[120px] top-[80px]",
"titleTextClassName": "leading-normal",
"tagText": "Besonders Aktion",
"titleText": "Willkommen in Vibentec IT Shop",
"descriptionText": "Insert the accordion description here. It would look better as two lines of text or more.",
"buttonText": "Zum Einkaufen"
}
}
}
],
"center": [],
"right": []
}
}
]
}
},
{
"CartMismatchBanner": {
"show": true
}
},
{
"FreeShippingPriceNudge": {
"variant": "popup"
}
},
{
"PropsChildren": {}
},
{
"Footer": {
"copyrightText": "© 2025 MyShop"
}
},
{
"VtFeaturedProducts": {
"config": {
"title": "best-seller",
"styles": {
"container": "content-container py-12 px-[100px] small:py-24",
"header": {
"container": "flex justify-between mb-8",
"title": "text-[56px] text-[#11314E]",
"isShowViewAll": false
},
"list": "grid grid-cols-2 small:grid-cols-3 gap-x-6 gap-y-24 small:gap-y-36",
"productCard": {
"card": "relative overflow-hidden rounded-2xl border border-[#285A86] bg-ui-bg-base shadow-elevation-card-rest h-full flex flex-col",
"badge": {
"container": "p-4",
"text": "z-20 px-3 py-1 border-[0.5px] rounded bg-[#c9e0f5] txt-compact-small-plus shadow-borders-base text-[#285A86]"
},
"thumbnail": { "className": "rounded-none h-[240px]", "size": "full" },
"subtitle": "text-ui-fg-subtle text-[14px]",
"content": "flex flex-col flex-1 justify-between p-4",
"title": "text-ui-fg-subtle text-[18px]",
"description": "mt-2 text-ui-fg-subtle text-[14px]",
"price": "flex items-center gap-x-1 text-[#285A86] font-bold border-b pb-4",
"button": {
"addToCart": "w-fit h-[40px] bg-black text-white rounded-md",
"moreInfo": "w-fit h-[40px] border border-[#285A86] text-[#285A86] rounded-md"
}
}
}
}
}
},
{
"VtFeaturedProducts": {
"config": {
"title": "produkten",
"styles": {
"container": "content-container py-12 px-[100px] small:py-24",
"header": {
"container": "flex justify-between mb-8",
"title": "text-[56px] text-[#11314E]",
"isShowViewAll": false
},
"list": "grid grid-cols-2 small:grid-cols-3 gap-x-6 gap-y-24 small:gap-y-36",
"productCard": {
"card": "relative overflow-hidden rounded-2xl border border-[#285A86] bg-ui-bg-base shadow-elevation-card-rest h-full flex flex-col",
"badge": {
"container": "p-4",
"text": "z-20 px-3 py-1 border-[0.5px] rounded bg-[#c9e0f5] txt-compact-small-plus shadow-borders-base text-[#285A86]"
},
"thumbnail": { "className": "rounded-none h-[240px]", "size": "full" },
"subtitle": "text-ui-fg-subtle text-[14px]",
"content": "flex flex-col flex-1 justify-between p-4",
"title": "text-ui-fg-subtle text-[18px]",
"price": "flex items-center gap-x-1 text-[#285A86] font-bold border-b pb-4",
"button": {
"addToCart": "w-fit h-[40px] bg-black text-white rounded-md",
"moreInfo": "w-fit h-[40px] border border-[#285A86] text-[#285A86] rounded-md"
}
}
}
}
}
},
{ "CartMismatchBanner": { "config": { "show": true } } },
{ "FreeShippingPriceNudge": { "config": { "variant": "popup" } } }
]
}
]
}
+25 -58
View File
@@ -1,68 +1,35 @@
[
{
"Nav": {
"props": {},
"children": [
{
"Header" : {
"config" : {"sticky": true},
"children" : [
{
"Div": {
"props": { "className": "flex items-center h-full" },
"children": [
{
"LocalizedClientLink": {
"props": {
"href": "/",
"label": "Medusa Store",
"className": "bg-black txt-compact-xlarge-plus hover:text-ui-fg-base uppercase",
"data-testid": "nav-store-link"
"Nav": {
"config": {
"left": [
{ "VtSideMenu": {} }
],
"center": [
{ "HomeButton": { "config" : {"label":"Medusa Store"}} }
],
"right": [
{ "AccountButton": {
"config": {
"label": "Account",
"className": "hover:text-ui-fg-base"
}
}
}
}
]
}
},
{
"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": {} }
]
}
}
]
},
{ "VtCartButton" : {} }
]
}
}
}
]
}
},
{ "CartMismatchBanner": { "show": true } },
{ "FreeShippingPriceNudge": { "variant": "popup" } },
{ "CartMismatchBanner": { "config": {"show": true} } },
{ "FreeShippingPriceNudge": { "config": {"variant": "popup" }} },
{ "PropsChildren" : {}},
{ "Footer": { "copyrightText": "© 2025 MyShop" } }
{ "Footer": { "config" : {"copyrightText": "© 2025 MyShop"} } }
]
+70
View File
@@ -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"} } }
]
+1411 -5
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -21,6 +21,8 @@
"@radix-ui/react-accordion": "^1.2.1",
"@stripe/react-stripe-js": "^1.7.2",
"@stripe/stripe-js": "^1.29.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"lodash": "^4.17.21",
"next": "^15.3.1",
"pg": "^8.11.3",
@@ -28,6 +30,9 @@
"react": "19.0.0-rc-66855b96-20241106",
"react-country-flag": "^3.1.0",
"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",
"tailwindcss-radix": "^2.8.0",
"webpack": "^5"
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

@@ -1,18 +0,0 @@
export const DESIGN_JSON_FILE = [
{
id: "drsquatch",
file: "nam.drsquatch.design.json",
},
{
id: "3bear",
file: "nam.3bear.design.json",
},
{
id: "vibentec",
file: "nam.vibentec.design.json",
},
{
id: "medusa-starter",
file: "ste.medusa-starter.design.json",
}
]
+16 -10
View File
@@ -4,17 +4,23 @@ 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 { LayoutContext, LayoutComponentNode } from "vibentec/component-map"
import { DynamicLayoutRenderer } from "vibentec/renderer"
import { loadDesignConfig } from "vibentec/configloader"
import { DESIGN_JSON_FILE } from "./config-json-file"
import { DynamicLayoutRenderer } from "../../../vibentec/renderer"
import { LayoutContext, LayoutComponentNode } from "../../../vibentec/component-map"
import { loadLayoutConfig } from "vibentec/configloader"
import { getRegion } from "@lib/data/regions"
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.
export default async function PageLayout(props: {
children: React.ReactNode
params: Promise<{ countryCode: string }>
}) {
const params = await props.params
const { countryCode } = params
const region = await getRegion(countryCode)
const customer = await retrieveCustomer()
const cart = await retrieveCart()
let shippingOptions: StoreCartShippingOption[] = []
@@ -25,16 +31,16 @@ export default async function PageLayout(props: { children: React.ReactNode }) {
shippingOptions = shipping_options
}
const nodes: LayoutComponentNode[] = await loadDesignConfig(
DESIGN_JSON_FILE[1].file
)
const nodes: LayoutComponentNode[] = await loadLayoutConfig()
const context: LayoutContext = {
customer,
cart,
shippingOptions,
contentChildren: props.children,
designId: DESIGN_JSON_FILE[1].id,
countryCode,
region,
}
return <DynamicLayoutRenderer nodes={nodes} context={context} />
}
+21 -10
View File
@@ -4,6 +4,10 @@ import FeaturedProducts from "@modules/home/components/featured-products"
import Hero from "@modules/home/components/hero"
import { listCollections } from "@lib/data/collections"
import { getRegion } from "@lib/data/regions"
import VtFeaturedProducts from "@modules/home/components/vt-featured-products"
import { DynamicLayoutRenderer } from "@vibentec/renderer"
import { LayoutContext, LayoutComponentNode } from "@vibentec/component-map"
import { loadPageConfig } from "@vibentec/configloader"
export const metadata: Metadata = {
title: "Medusa Next.js Starter Template",
@@ -24,18 +28,25 @@ export default async function Home(props: {
fields: "id, handle, title",
})
console.log('collections:',collections)
if (!collections || !region) {
return null
}
const nodes: LayoutComponentNode[] = await loadPageConfig("Home")
return (
<>
<Hero />
<div className="py-12">
<ul className="flex flex-col gap-x-6">
<FeaturedProducts collections={collections} region={region} />
</ul>
</div>
</>
)
if (!region) {
return null
}
const context: LayoutContext = {
customer: null,
cart: null,
shippingOptions: [],
contentChildren: null,
countryCode,
region,
}
return <DynamicLayoutRenderer nodes={nodes} context={context} />
}
@@ -3,6 +3,9 @@ import { notFound } from "next/navigation"
import { listProducts } from "@lib/data/products"
import { getRegion, listRegions } from "@lib/data/regions"
import ProductTemplate from "@modules/products/templates"
import { DynamicLayoutRenderer } from "@vibentec/renderer"
import { LayoutContext, LayoutComponentNode } from "@vibentec/component-map"
import { loadPageConfig } from "@vibentec/configloader"
type Props = {
params: Promise<{ countryCode: string; handle: string }>
@@ -96,11 +99,25 @@ export default async function ProductPage(props: Props) {
notFound()
}
const nodes: LayoutComponentNode[] = await loadPageConfig("Product")
const context: LayoutContext = {
customer: null,
cart: null,
shippingOptions: [],
contentChildren: null,
countryCode: params.countryCode,
region,
}
return (
<ProductTemplate
product={pricedProduct}
region={region}
countryCode={params.countryCode}
/>
<>
<ProductTemplate
product={pricedProduct}
region={region}
countryCode={params.countryCode}
/>
<DynamicLayoutRenderer nodes={nodes} context={context} />
</>
)
}
+24 -7
View File
@@ -2,6 +2,10 @@ import { Metadata } from "next"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import StoreTemplate from "@modules/store/templates"
import { LayoutComponentNode, LayoutContext } from "@vibentec/component-map"
import { getRegion } from "@lib/data/regions"
import { loadPageConfig } from "@vibentec/configloader"
import { DynamicLayoutRenderer } from "@vibentec/renderer"
export const metadata: Metadata = {
title: "Store",
@@ -19,15 +23,28 @@ type Params = {
}
export default async function StorePage(props: Params) {
const params = await props.params;
const searchParams = await props.searchParams;
const params = await props.params
const searchParams = await props.searchParams
const region = await getRegion(params.countryCode)
const { sortBy, page } = searchParams
const nodes: LayoutComponentNode[] = await loadPageConfig("Store")
const context: LayoutContext = {
customer: null,
cart: null,
shippingOptions: [],
contentChildren: null,
countryCode: params.countryCode,
region,
}
return (
<StoreTemplate
sortBy={sortBy}
page={page}
countryCode={params.countryCode}
/>
<>
<StoreTemplate
sortBy={sortBy}
page={page}
countryCode={params.countryCode}
/>
<DynamicLayoutRenderer nodes={nodes} context={context} />
</>
)
}
@@ -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>
);
}
-1
View File
@@ -36,7 +36,6 @@ export const listCollections = async (
{
query: queryParams,
next,
cache: "force-cache",
}
)
.then(({ collections }) => ({ collections, count: collections.length }))
+1 -2
View File
@@ -63,12 +63,11 @@ export const listProducts = async ({
offset,
region_id: region?.id,
fields:
"*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags",
"*variants.calculated_price,+variants.inventory_quantity,*metadata,+tags",
...queryParams,
},
headers,
next,
cache: "force-cache",
}
)
.then(({ products, count }) => {
+19
View File
@@ -0,0 +1,19 @@
export { default as Back } from "./back"
export { default as Bancontact } from "./bancontact"
export { default as ChevronDown } from "./chevron-down"
export { default as Eye } from "./eye"
export { default as EyeOff } from "./eye-off"
export { default as FastDelivery } from "./fast-delivery"
export { default as Ideal } from "./ideal"
export { default as MapPin } from "./map-pin"
export { default as Medusa } from "./medusa"
export { default as Nextjs } from "./nextjs"
export { default as Package } from "./package"
export { default as PayPal } from "./paypal"
export { default as PlaceholderImage } from "./placeholder-image"
export { default as Refresh } from "./refresh"
export { default as Spinner } from "./spinner"
export { default as Trash } from "./trash"
export { default as User } from "./user"
export { default as X } from "./x"
export { default as Twitter } from "./twitter"
+27
View File
@@ -0,0 +1,27 @@
import React from "react"
import { IconProps } from "types/icon"
const Twitter: React.FC<IconProps> = ({
size = "20",
color = "currentColor",
...attributes
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 15 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...attributes}
>
<path
d="M12.8069 2.87388C12.8069 3.01339 12.8069 3.125 12.8069 3.26451C12.8069 7.14286 9.8772 11.5792 4.49215 11.5792C2.81805 11.5792 1.28345 11.1049 -3.52859e-05 10.2679C0.223179 10.2958 0.446393 10.3237 0.697509 10.3237C2.0647 10.3237 3.32028 9.84933 4.32474 9.06808C3.04126 9.04018 1.95309 8.20312 1.59037 7.03125C1.78568 7.05915 1.95309 7.08705 2.1484 7.08705C2.39952 7.08705 2.67854 7.03125 2.90175 6.97545C1.56246 6.69643 0.558 5.52455 0.558 4.10156V4.07366C0.948625 4.29687 1.42296 4.40848 1.89729 4.43638C1.08813 3.90625 0.585902 3.01339 0.585902 2.00893C0.585902 1.45089 0.725411 0.94866 0.976527 0.530133C2.42742 2.28795 4.60376 3.45982 7.03122 3.59933C6.97541 3.37612 6.94751 3.1529 6.94751 2.92969C6.94751 1.31138 8.25889 -6.37025e-07 9.8772 -6.37025e-07C10.7143 -6.37025e-07 11.4676 0.334821 12.0256 0.920758C12.6674 0.781249 13.3091 0.530133 13.8672 0.195312C13.6439 0.892857 13.1975 1.45089 12.5837 1.81362C13.1696 1.75781 13.7555 1.5904 14.2578 1.36719C13.8672 1.95312 13.3649 2.45536 12.8069 2.87388Z"
fill={color}
/>
</svg>
)
}
export default Twitter
@@ -0,0 +1,86 @@
import { clx } from "@medusajs/ui"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
export default async function VtCategoryHighlight({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config ?? {}
const title: string = props.title ?? ""
const items = props.items ?? []
const classes = {
container: props.className ?? "content-container py-12",
title:
props.titleClassName ?? "text-[#003F31] text-[28px] font-semibold mb-6",
grid: props.gridClassName ?? "grid grid-cols-3 gap-6 w-full",
tile:
props.tileClassName ??
"relative rounded-2xl overflow-hidden bg-white aspect-square w-full",
label: props.labelClassName ?? "text-[#003F31] text-[18px] font-semibold",
image: props.imageClassName ?? "w-full h-full object-contain",
}
if (!items || items.length === 0) {
return null
}
const renderTile = (tile: any, idx: number) => {
const imageEl = tile.imageSrc ? (
<img
src={tile.imageSrc}
alt={tile.label ?? `category-${idx}`}
className={clx(classes.image, tile.imageClassName)}
/>
) : (
<div
className={clx(
"w-full h-full flex items-center justify-center",
tile.className
)}
>
<div className={clx(tile.headingClassName)}>{tile.headingLabel}</div>
<div className={tile.descriptionClassName}>{tile.descriptionLabel}</div>
<button className={tile.buttonClassName}>{tile.buttonLabel}</button>
</div>
)
const content = (
<div className={clx("relative w-full h-full")}>
{imageEl}
{tile.label && <span className={classes.label}>{tile.label}</span>}
</div>
)
return tile.href ? (
<LocalizedClientLink
key={`tile-${idx}`}
href={tile.href}
className={clx("w-full h-full", tile.className)}
>
{content}
</LocalizedClientLink>
) : (
<div className={clx(tile.className, "w-full h-full")} key={`tile-${idx}`}>
{content}
</div>
)
}
return (
<section className={classes.container}>
{title && <h2 className={classes.title}>{title}</h2>}
<div className={classes.grid}>
{items.map((tile: any, idx: number) => renderTile(tile, idx))}
</div>
</section>
)
}
@@ -0,0 +1,52 @@
import { HttpTypes } from "@medusajs/types"
import ProductRail from "./product-rail"
import { listCollections } from "@lib/data/collections"
import { LayoutComponentDefinition, LayoutContext } from "@vibentec/component-map"
export default async function VtFeaturedProducts(props: {
collections?: HttpTypes.StoreCollection[]
region?: HttpTypes.StoreRegion
countryCode?: string
nodes?: LayoutComponentDefinition
context?: LayoutContext
}) {
let { collections, region, countryCode } = props
const { nodes, context } = props
if (context) {
if (!region) region = context.region
if (!countryCode) countryCode = context.countryCode
}
if (!collections && region) {
const result = await listCollections({
fields: "id, handle, title",
})
collections = result.collections
}
if (!collections || !region || !countryCode) {
return null
}
const configTitle = nodes?.config?.title
const styles = nodes?.config?.styles
let displayCollections = collections
if (configTitle) {
displayCollections = collections.filter(
(c) => c.handle === configTitle || c.title === configTitle
)
}
return displayCollections.map((collection) => (
<li key={collection.id}>
<ProductRail
collection={collection}
region={region}
countryCode={countryCode}
styles={styles}
/>
</li>
))
}
@@ -0,0 +1,68 @@
import { listProducts } from "@lib/data/products"
import { HttpTypes } from "@medusajs/types"
import { Text, clx } from "@medusajs/ui"
import InteractiveLink from "@modules/common/components/interactive-link"
import ProductCard from "@modules/products/components/vt-product-card"
export default async function ProductRail({
collection,
region,
countryCode,
styles,
}: {
collection: HttpTypes.StoreCollection
region: HttpTypes.StoreRegion
countryCode: string
styles?: any
}) {
const {
response: { products: pricedProducts },
} = await listProducts({
regionId: region.id,
queryParams: {
collection_id: collection.id,
fields: "*variants.calculated_price",
},
})
if (!pricedProducts) {
return null
}
const classes = {
container: styles?.container ?? "content-container py-12 px-[100px] small:py-24",
header: {
container: styles?.header?.container ?? "flex justify-between mb-8",
title: styles?.header?.title ?? "txt-xlarge",
isShowViewAll: styles?.header.isShowViewAll ?? true,
},
list: styles?.list ?? "grid grid-cols-2 small:grid-cols-3 gap-x-6 gap-y-24 small:gap-y-36",
}
return (
<div className={classes.container}>
<div className={classes.header.container}>
<Text className={classes.header.title}>{collection.title}</Text>
{classes.header.isShowViewAll && (
<InteractiveLink href={`/collections/${collection.handle}`}>
View all
</InteractiveLink>
)}
</div>
<ul className={classes.list}>
{pricedProducts &&
pricedProducts.map((product) => (
<li key={product.id}>
<ProductCard
product={product}
countryCode={countryCode}
styles={styles?.productCard}
badgeText={styles?.productCard?.badgeText}
/>
</li>
))}
</ul>
</div>
)
}
@@ -1,8 +1,8 @@
import { retrieveCart } from "@lib/data/cart"
import CartDropdown from "../cart-dropdown"
export default async function CartButton() {
export default async function CartButton({ iconName }: { iconName?: string }) {
const cart = await retrieveCart().catch(() => null)
return <CartDropdown cart={cart} />
return <CartDropdown cart={cart} iconName={iconName} />
}
@@ -6,10 +6,10 @@ 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"
import * as MedusaIcons from "@medusajs/icons"
import DeleteButton from "@modules/common/components/delete-button"
import LineItemOptions from "@modules/common/components/line-item-options"
import LineItemPrice from "@modules/common/components/line-item-price"
@@ -20,8 +20,10 @@ import { Fragment, useEffect, useRef, useState } from "react"
const CartDropdown = ({
cart: cartState,
iconName,
}: {
cart?: HttpTypes.StoreCart | null
iconName?: string
}) => {
const [activeTimer, setActiveTimer] = useState<NodeJS.Timer | undefined>(
undefined
@@ -74,6 +76,10 @@ const CartDropdown = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [totalItems, itemRef.current])
const Icon = iconName
? (MedusaIcons as Record<string, React.ComponentType<any>>)[iconName]
: undefined
return (
<div
className="h-full z-50"
@@ -82,13 +88,15 @@ const CartDropdown = ({
>
<Popover className="relative h-full">
<PopoverButton className="h-full">
<LocalizedClientLink
className="hover:text-ui-fg-base mr-10 flex items-center"
href="/cart"
data-testid="nav-cart-link"
>
<ShoppingBag />
</LocalizedClientLink>
{Icon ? (
<Icon />
) : (
<LocalizedClientLink
className="hover:text-ui-fg-base"
href="/cart"
data-testid="nav-cart-link"
>{`Cart (${totalItems})`}</LocalizedClientLink>
)}
</PopoverButton>
<Transition
show={cartDropdownOpen}
@@ -1,125 +0,0 @@
"use client"
import * as React from "react"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { clx } from "@medusajs/ui"
import { ChevronDownMini } from "@medusajs/icons"
interface NavigationMenuProps {
props: {
label: string,
className: string,
"data-testid": string,
isShowArrow: boolean
},
menuItems: MenuSection[]
}
type MenuSection = {
title: { text: string; className?: string }
links: MenuLink[]
}
type MenuLink = { label: string; href: string; className?: string }
// Structured menu data used to map UI
const menuSections: MenuSection[] = [
{
title: { text: "Categories", className: "text-red" },
links: [
{
label: "Overnight Oats",
href: "/categories/overnight-oats",
className: "text-red",
},
{ label: "Porridge", href: "/categories/porridge" },
{ label: "Cereals", href: "/categories/cereals" },
{ label: "Granola", href: "/categories/granola" },
{ label: "Glasses & Bowls", href: "/categories/glasses-bowls" },
{ label: "Oat Bars", href: "/categories/oat-bars" },
{ label: "Nut butters", href: "/categories/nut-butters" },
],
},
{
title: { text: "Specials" },
links: [
{ label: "Advent calendar ✨", href: "/collections/advent-calendar" },
{ label: "Saver subscription", href: "/collections/saver-subscription" },
{ label: "bestseller", href: "/collections/bestseller" },
{ label: "New 🔥", href: "/collections/new" },
{ label: "Bluey Kidsrange", href: "/collections/bluey-kidsrange" },
{ label: "Value sets", href: "/collections/value-sets" },
{ label: "Sale", href: "/collections/sale" },
],
},
{
title: { text: "All products" },
links: [{ label: "Shop all", href: "/store" }],
},
]
const MenuLinkItem = ({ href, label, className }: MenuLink) => (
<li key={label}>
<LocalizedClientLink
href={href}
className={clx(
"block rounded-md py-2 hover:bg-ui-bg-subtle ",
className ?? ""
)}
role="menuitem"
>
<div className="txt-small text-ui-fg-muted">{label}</div>
</LocalizedClientLink>
</li>
)
const MenuSectionItem = ({ title, links }: MenuSection) => (
<div key={title.text} className="space-y-1">
<div className={clx("txt-small text-ui-fg-muted", title.className ?? "")}>
{title.text}
</div>
<ul className="space-y-1">
{links.map((link) => (
<MenuLinkItem
key={link.label}
href={link.href}
label={link.label}
className={link.className ?? ""}
/>
))}
</ul>
</div>
)
export default function NavigationMenu({
props,
menuItems = menuSections,
}: NavigationMenuProps) {
return (
<div className="group relative h-full flex items-center">
<LocalizedClientLink
href="/"
className="txt-compact-xlarge-plus text-ui-fg-subtle hover:text-ui-fg-base group-hover:text-ui-fg-base flex items-center gap-2 transition-colors duration-200"
>
{props.label} {props?.isShowArrow ? (
<span className="transition-transform duration-400 ease-out group-hover:rotate-180">
<ChevronDownMini />
</span>
) : null}
</LocalizedClientLink>
<div
role="menu"
className="pointer-events-none left-0 fixed top-[180px] invisible opacity-0 transform-gpu translate-y-2 transition-all duration-600 ease-out group-hover:visible group-hover:opacity-100 group-hover:translate-y-0 group-hover:pointer-events-auto group-focus-within:visible group-focus-within:opacity-100 group-focus-within:translate-y-0 group-focus-within:pointer-events-auto"
>
<div className="w-[100vw] bg-white shadow-borders-base py-4 px-20">
<div className="grid grid-cols-1 small:grid-cols-3 gap-6">
{menuItems.map((section) => (
<MenuSectionItem
key={section.title.text}
title={section.title}
links={section.links}
/>
))}
</div>
</div>
</div>
</div>
)
}
@@ -1,42 +0,0 @@
"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>
)
}
@@ -0,0 +1,39 @@
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import {
LayoutComponentDefinition,
LayoutContext,
} from "vibentec/component-map"
import { clx } from "@medusajs/ui"
import * as MedusaIcons from "@medusajs/icons"
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"
const iconName = props.icon
const Icon = iconName
? (MedusaIcons as Record<string, React.ComponentType<any>>)[iconName]
: undefined
return (
<div className="flex items-center h-full">
<LocalizedClientLink
href={href}
className={className}
data-testid="nav-account-link"
>
{Icon ? <Icon /> : label}
</LocalizedClientLink>
</div>
)
}
export default AccountButton
@@ -0,0 +1,36 @@
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 iconName={props.icon} />
</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,28 @@
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
import { clx } from "@medusajs/ui"
interface VtLinkProps {
className?: string
href?: string
label?: string
}
export const VtLink = ({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) => {
const props = nodes.config as VtLinkProps ?? {}
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,34 @@
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"
interface MegaMenuProps {
navLabel: {
text: string
className?: string
}
}
export default function VtMegaMenu({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const { navLabel } = nodes.config as MegaMenuProps ?? {}
return (
<nav>
<ul className="space-x-4 hidden small:flex">
<li>
<Suspense fallback={<SkeletonMegaMenu />}>
<MegaMenuWrapper navLabel={navLabel} />
</Suspense>
</li>
</ul>
</nav>
)
}
@@ -0,0 +1,12 @@
import { listCategories } from "@lib/data/categories"
import MegaMenu from "./mega-menu"
export async function MegaMenuWrapper({ navLabel }: { navLabel: { text: string; className?: string } }) {
const categories = await listCategories().catch(() => [])
return <MegaMenu navLabel={navLabel} categories={categories} />
}
export default MegaMenuWrapper
@@ -0,0 +1,149 @@
"use client"
import { HttpTypes } from "@medusajs/types"
import { clx } from "@medusajs/ui"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import ChevronDown from "@modules/common/icons/chevron-down"
import { usePathname } from "next/navigation"
import { useEffect, useState } from "react"
const MegaMenu = ({
navLabel,
categories,
}: {
navLabel: { text: string; className?: string, isShowArrow?: boolean },
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={clx(
"hover:text-ui-fg-base hover:bg-neutral-100 rounded-full px-3 py-2",
navLabel?.className
)}
href="/store"
>
{navLabel?.text ?? "Product"} {navLabel?.isShowArrow && <ChevronDown />}
</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 text-black 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>
)
}
@@ -1,25 +0,0 @@
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>
)
}
@@ -1,39 +0,0 @@
'use client'
import { DropdownMenu } from "@medusajs/ui"
import { ChevronDownMini } from "@medusajs/icons"
interface DropdownMenuProps {
className?: string
"data-testid"?: string
label?: string
isShowArrow?: boolean
}
export default function DropdownMenuComponent({
children,
props,
}: {
children?: React.ReactNode
props?: DropdownMenuProps
}) {
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 className="abc">
{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>
)
}
@@ -1,25 +0,0 @@
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-4">
{label.map((item, index) => {
return (
<div key={`${index}-${item.text}`} className={`last:mr-0 ${item.className || ""}`}>
{item.text}
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1,45 @@
import { Github } from "@medusajs/icons"
import { Button, Heading } from "@medusajs/ui"
import { VtCarousel } from "../vt-carousel"
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map"
export default function HeroDefault({ node, context }: { node: LayoutComponentDefinition; context: LayoutContext }) {
const props = node.config ?? {}
const imageDisplayer = props.ImageDisplayer
if (imageDisplayer) {
return (
<div className="absolute inset-0 z-auto w-full h-full">
<VtCarousel nodes={{ config: imageDisplayer.config }} context={context} />
</div>
)
}
return (
<div className="absolute inset-0 z-auto flex flex-col justify-center items-center text-center small:p-32 gap-6">
<span>
<Heading
level="h1"
className="text-3xl leading-10 text-ui-fg-base font-normal"
>
Ecommerce Starter Template
</Heading>
<Heading
level="h2"
className="text-3xl leading-10 text-ui-fg-subtle font-normal"
>
Powered by Medusa and Next.js
</Heading>
</span>
<a
href="https://github.com/medusajs/nextjs-starter-medusa"
target="_blank"
>
<Button variant="secondary">
View on GitHub
<Github />
</Button>
</a>
</div>
)
}
@@ -0,0 +1,45 @@
import {
LayoutComponentDefinition,
LayoutContext,
} from "vibentec/component-map"
import { clx } from "@medusajs/ui"
import BannerHero from "./banner-hero"
import { DynamicLayoutRenderer } from "vibentec/renderer"
export default async function Hero({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config ?? {}
const left = nodes.config?.left ?? []
const center = nodes.config?.center ?? []
const right = nodes.config?.right ?? []
const heroClassName = clx(
"min-h-[30rem] w-full border-b border-ui-border-base relative",
props.className
)
return (
<div className={heroClassName}>
<BannerHero node={nodes} context={context} />
<div className="absolute z-20">
<nav className="content-container txt-xsmall-plus flex items-center justify-between text-small-regular">
<div className="flex items-center gap-x-4">
{left && <DynamicLayoutRenderer nodes={left} context={context} />}
</div>
<div className="flex items-center gap-x-4">
{center && (
<DynamicLayoutRenderer nodes={center} context={context} />
)}
</div>
<div className="flex items-center gap-x-4 justify-end">
{right && <DynamicLayoutRenderer nodes={right} context={context} />}
</div>
</nav>
</div>
</div>
)
}
@@ -1,17 +0,0 @@
import { DynamicLayoutRenderer, DynamicLayoutRendererProps } from "vibentec/renderer"
type TextItem = { text: string; className?: string }
export default async function AnnouncementBanner({
className,
nodes,
context,
}: DynamicLayoutRendererProps & { className: string; label: TextItem[] }) {
return (
<div className={className}>
<div className="w-full mx-auto flex justify-between items-center py-2 px-20">
{nodes && <DynamicLayoutRenderer nodes={nodes} context={context} />}
</div>
</div>
)
}
@@ -0,0 +1,59 @@
"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
}
interface BannerConfigProps {
text?: string
href?: string
iconLeft?: string
iconRight?: string
className?: string
}
export default function BannerCTA({ node, context }: BannerCTAProps) {
const props = node.config as BannerConfigProps ?? {}
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,25 @@
import { DynamicLayoutRenderer } from "vibentec/renderer"
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
interface BannerNavProps {
left?: LayoutComponentDefinition[]
center?: LayoutComponentDefinition[]
right?: LayoutComponentDefinition[]
}
export default function BannerNav({ node, context }: { node: LayoutComponentDefinition; context: LayoutContext }) {
const props = node.config as BannerNavProps ?? {};
return (
<nav className="content-container txt-xsmall-plus flex items-center justify-between w-full h-full text-small-regular">
<div className="flex items-center gap-x-4 w-full h-full">
{props.left && <DynamicLayoutRenderer nodes={props.left} context={context} />}
</div>
<div className="flex items-center gap-x-4 w-full h-full">
{props.center && <DynamicLayoutRenderer nodes={props.center} context={context} />}
</div>
<div className="flex items-center gap-x-4 w-full h-full justify-end">
{props.right && <DynamicLayoutRenderer nodes={props.right} context={context} />}
</div>
</nav>
)
}
@@ -0,0 +1,17 @@
@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%;
gap: 3rem
}
@@ -0,0 +1,23 @@
import styles from "./banner-ticker.module.css"
import { DynamicLayoutRenderer } from "vibentec/renderer"
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map"
interface BannerTickerProps {
node: LayoutComponentDefinition
context: LayoutContext
}
interface BannerConfigProps {
items?: LayoutComponentDefinition[]
speed?: number
}
export default function BannerTicker({ node, context }: BannerTickerProps) {
const props = node.config as BannerConfigProps ?? {}
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,42 @@
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"
interface BannerProps {
variant: "nav" | "cta" | "ticker"
className?: string
speed?: number
}
export default async function Banner({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = (nodes.config as BannerProps) ?? {}
const bannerClassName = clx(
"relative h-8 mx-auto border-b duration-200 bg-white border-ui-border-base",
props.className
)
if (!props.variant) return null
const variants = {
nav: BannerNav,
cta: BannerCTA,
ticker: BannerTicker,
}
const Component = variants[props.variant]
return (
<div className={bannerClassName}>
<Component node={nodes} context={context} />
</div>
)
}
@@ -0,0 +1,32 @@
import { Button, IconButton } from "@medusajs/ui"
import * as MedusaIcons from "@medusajs/icons"
import * as CustomIcons from "@modules/common/icons"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
export default function VtButton({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config || {}
const iconName = props.icon as string | undefined
const IconComponent = iconName
? (MedusaIcons as Record<string, any>)[iconName] ??
(CustomIcons as Record<string, any>)[iconName]
: undefined
return (
<IconButton className={props?.className ?? ""}>
{IconComponent && (
<IconComponent className={props?.iconClassName ?? ""} />
)}
{props?.label && (
<span className={props?.labelClassName ?? ""}>{props.label}</span>
)}
</IconButton>
)
}
@@ -0,0 +1,75 @@
import React, { useCallback, useEffect, useState } from 'react'
import style from './index.module.css'
export const usePrevNextButtons = (emblaApi: any) => {
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true)
const [nextBtnDisabled, setNextBtnDisabled] = useState(true)
const onPrevButtonClick = useCallback(() => {
if (!emblaApi) return
emblaApi.scrollPrev()
}, [emblaApi])
const onNextButtonClick = useCallback(() => {
if (!emblaApi) return
emblaApi.scrollNext()
}, [emblaApi])
const onSelect = useCallback((emblaApi: any) => {
setPrevBtnDisabled(!emblaApi.canScrollPrev())
setNextBtnDisabled(!emblaApi.canScrollNext())
}, [])
useEffect(() => {
if (!emblaApi) return
onSelect(emblaApi)
emblaApi.on('reInit', onSelect).on('select', onSelect)
}, [emblaApi, onSelect])
return {
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick
}
}
export const PrevButton = (props: any) => {
const { children, ...restProps } = props
return (
<button
className={style['embla__button']}
type="button"
{...restProps}
>
<svg className={style['embla__button__svg']} viewBox="0 0 532 532">
<path
fill="currentColor"
d="M355.66 11.354c13.793-13.805 36.208-13.805 50.001 0 13.785 13.804 13.785 36.238 0 50.034L201.22 266l204.442 204.61c13.785 13.805 13.785 36.239 0 50.044-13.793 13.796-36.208 13.796-50.002 0a5994246.277 5994246.277 0 0 0-229.332-229.454 35.065 35.065 0 0 1-10.326-25.126c0-9.2 3.393-18.26 10.326-25.2C172.192 194.973 332.731 34.31 355.66 11.354Z"
/>
</svg>
{children}
</button>
)
}
export const NextButton = (props: any) => {
const { children, ...restProps } = props
return (
<button
className={style['embla__button']}
type="button"
{...restProps}
>
<svg className={style['embla__button__svg']} viewBox="0 0 532 532">
<path
fill="currentColor"
d="M176.34 520.646c-13.793 13.805-36.208 13.805-50.001 0-13.785-13.804-13.785-36.238 0-50.034L330.78 266 126.34 61.391c-13.785-13.805-13.785-36.239 0-50.044 13.793-13.796 36.208-13.796 50.002 0 22.928 22.947 206.395 206.507 229.332 229.454a35.065 35.065 0 0 1 10.326 25.126c0 9.2-3.393 18.26-10.326 25.2-45.865 45.901-206.404 206.564-229.332 229.52Z"
/>
</svg>
{children}
</button>
)
}
@@ -0,0 +1,46 @@
import React, { useCallback, useEffect, useState } from 'react'
export const useDotButton = (emblaApi: any) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const [scrollSnaps, setScrollSnaps] = useState([])
const onDotButtonClick = useCallback(
(index: number) => {
if (!emblaApi) return
emblaApi.scrollTo(index)
},
[emblaApi]
)
const onInit = useCallback((emblaApi: any) => {
setScrollSnaps(emblaApi.scrollSnapList())
}, [])
const onSelect = useCallback((emblaApi: any) => {
setSelectedIndex(emblaApi.selectedScrollSnap())
}, [])
useEffect(() => {
if (!emblaApi) return
onInit(emblaApi)
onSelect(emblaApi)
emblaApi.on('reInit', onInit).on('reInit', onSelect).on('select', onSelect)
}, [emblaApi, onInit, onSelect])
return {
selectedIndex,
scrollSnaps,
onDotButtonClick
}
}
export const DotButton = (props: any) => {
const { children, ...restProps } = props
return (
<button type="button" {...restProps}>
{children}
</button>
)
}
@@ -0,0 +1,139 @@
.embla {
width: 100%;
height: 100%;
position: relative;
margin: auto;
--slide-height: 19rem;
--slide-spacing: 1rem;
--slide-size: 100%;
}
.embla__viewport {
height: 100%;
overflow: hidden;
}
.embla__container {
display: flex;
height: 100%;
touch-action: pan-y pinch-zoom;
margin-left: calc(var(--slide-spacing) * -1);
--slide-spacing: 1rem;
}
.embla__slide {
--slide-size: 100%;
--slide-spacing: 1rem;
transform: translate3d(0, 0, 0);
flex: 0 0 var(--slide-size);
min-width: 0;
padding-left: var(--slide-spacing);
}
.embla__slide__number {
height: 100%;
font-size: 4rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.embla__slide__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.embla__controls {
display: grid;
position: absolute;
top: 0;
z-index: 1000;
width: 100%;
height: 100%;
grid-template-columns: auto 1fr;
justify-content: space-between;
gap: 1.2rem;
}
.embla__buttons {
position: absolute;
top: 45%;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1000;
}
.embla__button {
--text-high-contrast-rgb-value: 49, 49, 49;
--detail-high-contrast: rgb(192, 192, 192);
--text-body: rgb(54, 49, 61);
-webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5);
-webkit-appearance: none;
appearance: none;
background-color: transparent;
touch-action: manipulation;
text-decoration: none;
cursor: pointer;
border: 0;
padding: 0;
margin: 0;
width: 3.6rem;
height: 3.6rem;
z-index: 1001;
border-radius: 50%;
color: var(--text-body);
display: flex;
align-items: center;
justify-content: center;
}
.embla__button:disabled {
--detail-high-contrast: rgb(192, 192, 192);
color: var(--detail-high-contrast);
}
.embla__button__svg {
width: 35%;
height: 35%;
}
.embla__dots {
display: flex;
position: absolute;
bottom: 0;
left: 48%;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
z-index: 1000;
}
.embla__dot {
--text-high-contrast-rgb-value: 49, 49, 49;
--text-body: rgb(54, 49, 61);
-webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5);
-webkit-appearance: none;
appearance: none;
background-color: transparent;
touch-action: manipulation;
text-decoration: none;
cursor: pointer;
border: 0;
padding: 0;
margin: 0;
width: 1.6rem;
height: 1.6rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.embla__dot:after {
--detail-medium-contrast: rgb(234, 234, 234);
--detail-medium-contrast-rgb-value: 234, 234, 234;
box-shadow: inset 0 0 0 0.2rem var(--detail-medium-contrast);
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
display: flex;
align-items: center;
content: "";
}
.embla__dot--selected:after {
--text-body: black;
box-shadow: inset 0 0 0 0.2rem var(--text-body);
}
@@ -0,0 +1,92 @@
"use client"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
import styles from "./index.module.css"
import useEmblaCarousel from "embla-carousel-react"
import Autoplay from "embla-carousel-autoplay"
import { useMemo } from "react"
import { DotButton, useDotButton } from "./carousel-dot-button"
import { NextButton, PrevButton, usePrevNextButtons } from "./carousel-arrow-button"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
export function VtCarousel({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config ?? {}
const { options } = props as any
const images: string[] = props.images ?? props.slides ?? []
const links: (string | undefined)[] = props.links ?? []
const durationSeconds: number = props.duration ?? 4
const showControls = images.length > 1
const plugins = useMemo(() => {
if (!durationSeconds || durationSeconds <= 0) return []
return [
Autoplay({
delay: durationSeconds * 1000,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
]
}, [durationSeconds])
const [emblaRef, emblaApi] = useEmblaCarousel(options, plugins)
const { selectedIndex, scrollSnaps, onDotButtonClick } =
useDotButton(emblaApi)
const {
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick,
} = usePrevNextButtons(emblaApi)
return (
<section className={styles["embla"]}>
<div className={styles["embla__viewport"]} ref={emblaRef}>
<div className={styles["embla__container"]}>
{images && images.map((src: string, index: number) => (
<div className={styles["embla__slide"]} key={index + src}>
<div className={styles["embla__slide__number"]}>
{links[index] ? (
<LocalizedClientLink href={links[index]}>
<img src={src} alt={`slide-${index + 1}`} className={styles["embla__slide__image"]} />
</LocalizedClientLink>
) : (
<img src={src} alt={`slide-${index + 1}`} className={styles["embla__slide__image"]} />
)}
</div>
</div>
))}
</div>
</div>
{showControls && (
<div className={styles["embla__controls"]}>
<div className={styles["embla__buttons"]}>
<PrevButton onClick={onPrevButtonClick} disabled={prevBtnDisabled} />
<NextButton onClick={onNextButtonClick} disabled={nextBtnDisabled} />
</div>
<div className={styles["embla__dots"]}>
{scrollSnaps.map((_, index) => (
<DotButton
key={index}
onClick={() => onDotButtonClick(index)}
className={[
styles["embla__dot"],
index === selectedIndex ? styles["embla__dot--selected"] : "",
].filter(Boolean).join(" ")}
/>
))}
</div>
</div>
)}
</section>
)
}
@@ -0,0 +1,110 @@
"use client"
import { Select } from "@medusajs/ui"
import ChevronDown from "@modules/common/icons/chevron-down"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
import { useMemo, useState, useEffect } from "react"
import { useParams, usePathname, useRouter } from "next/navigation"
import ReactCountryFlag from "react-country-flag"
import { HttpTypes } from "@medusajs/types"
export default function VtCountrySelectClient({
nodes,
context,
regions,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
regions?: HttpTypes.StoreRegion[]
}) {
const props = nodes.config ?? {}
const triggerText = props?.trigger?.text
const [items, setItems] = useState<{ text: string; label?: string }[]>([])
const { countryCode } = useParams()
useEffect(() => {
if (!regions || regions.length === 0) {
setItems([])
return
}
const opts = regions
.map((r) =>
(r.countries || []).map((c) => ({
text: c.iso_2 ?? "",
label: c.display_name,
}))
)
.flat()
.filter((o) => o.text)
.sort((a, b) => (a.label ?? "").localeCompare(b.label ?? ""))
setItems(opts)
}, [regions])
const initialValue = (countryCode as string) || triggerText || ""
const [value, setValue] = useState(initialValue)
const pathname = usePathname()
const router = useRouter()
const handleChange = (next: string) => {
setValue(next)
if (!pathname || !next) return
const parts = pathname.split("/")
if (parts.length > 1) {
parts[1] = next.toLowerCase()
const newPath = parts.join("/")
router.replace(newPath)
}
}
const selectedItem = useMemo(() => {
return items.find((i) => i.text === value)
}, [items, value])
if (!triggerText && items.length === 0) {
return null
}
return (
<Select value={value} onValueChange={handleChange}>
<Select.Trigger
className={
(props.trigger?.className ?? "") +
"flex items-center gap-1 [&_svg:not(:first-of-type)]:hidden"
}
>
<span className="txt-compact-small flex items-center">
{/* @ts-ignore */}
{props.trigger?.isFlag && (
<ReactCountryFlag
svg
style={{
width: "16px",
height: "16px",
}}
countryCode={value ?? ""}
/>
)}
</span>
{props.trigger?.isDisplayFullname ? (selectedItem?.label || value) : (selectedItem?.text.toUpperCase() || value)} <ChevronDown />
</Select.Trigger>
<Select.Content>
{items.length > 0 &&
items.map((item: { text: string; label?: string }, index: number) => (
<Select.Item value={item.text || ""} key={item.text + index}>
<div className="flex items-center w-full gap-3">
{props.trigger?.isFlag && item.text && (
<ReactCountryFlag
svg
style={{
width: "16px",
height: "16px",
}}
countryCode={item.text ?? ""}
/>
)}
{item.text.toUpperCase()}
</div>
</Select.Item>
))}
</Select.Content>
</Select>
)
}
@@ -0,0 +1,22 @@
import { listRegions, getRegion } from "@lib/data/regions"
import { HttpTypes } from "@medusajs/types"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
import VtCountryCodeSelectClient from "./index"
export default async function VtCountryCodeSelect({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const regions = (await listRegions()) as HttpTypes.StoreRegion[]
return (
<VtCountryCodeSelectClient nodes={nodes} context={context} regions={regions} />
)
}
@@ -0,0 +1,68 @@
"use client"
import { Button, clx } from "@medusajs/ui"
import { ChevronRightMini } from "@medusajs/icons"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
export function DefaultCtaBanner({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config ?? {}
return (
<div
className={clx(
"relative w-[544px] bg-white rounded-[24px] border border-[#E6EFFC] shadow-[0_12px_40px_rgba(17,49,78,0.10)] p-6",
props.className
)}
>
{props.tagText && (
<div
className={clx(
"inline-flex items-center rounded-full bg-[#FCE9DA] text-[#E68445] px-3 py-1 text-sm font-medium",
props.tagTextClassName
)}
>
{props.tagText}
</div>
)}
{props.titleText && (
<h1
className={clx(
"mt-4 text-[#11314E] font-semibold leading-normal text-[56px]",
props.titleTextClassName
)}
>
{props.titleText}
</h1>
)}
{props.descriptionText && (
<p
className={clx(
"mt-5 text-[#285A86] text-[16px] sm:text-xl opacity-80",
props.descriptionTextClassName
)}
>
{props.descriptionText}
</p>
)}
<Button
className={clx(
"mt-8 inline-flex items-center gap-2 bg-[#0F2740] hover:bg-[#173551] text-white px-6 py-3 rounded-[12px] shadow-md",
props.buttonTextClassName
)}
>
{props.buttonText}
<ChevronRightMini />
</Button>
</div>
)
}
@@ -0,0 +1,25 @@
"use client"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
import { DefaultCtaBanner } from "./default-cta"
export function VtCtaBanner({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config ?? {}
const variant = props.variant ?? "default"
const variants: Record<string, any> = {
default: DefaultCtaBanner,
}
const Component = variants[variant] || DefaultCtaBanner
return <Component nodes={nodes} context={context} />
}
@@ -0,0 +1,72 @@
"use client"
import { Select } from "@medusajs/ui"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import ChevronDown from "@modules/common/icons/chevron-down"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
export default function VtCurrencySelect({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config ?? {}
if (!props.trigger.text || props.items.length === 0) {
return null
}
return (
<Select>
<Select.Trigger
className={props.trigger.className + " flex items-center gap-1"}
>
{props.trigger.icon && (
<img
src={props.trigger.icon}
alt={props.trigger.text}
className="w-5 h-5 rounded-[50%] mr-3"
/>
)}
{props.trigger.text} {props.trigger.isShowArrow && <ChevronDown />}
</Select.Trigger>
<Select.Content>
{props.items.length > 0 &&
props.items.map(
(
item: {
text: string
className?: string
href?: string
icon?: string
},
index: number
) => (
<Select.Item
value={item.text || ""}
key={item.text + index}
className={item.className || ""}
>
{item.icon && (
<img
src={item.icon}
alt={item.text}
className="w-5 h-5 rounded-[50%] mr-3"
/>
)}
{item.href ? (
<LocalizedClientLink href={item.href}>
{item.text}
</LocalizedClientLink>
) : (
item.text
)}
</Select.Item>
)
)}
</Select.Content>
</Select>
)
}
@@ -0,0 +1,71 @@
"use client"
import { DropdownMenu } from "@medusajs/ui"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import ChevronDown from "@modules/common/icons/chevron-down"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
export default function VtDropdown({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config ?? {}
if (!props.trigger.text || props.items.length === 0) {
return null
}
return (
<DropdownMenu>
<DropdownMenu.Trigger
className={props.trigger.className + " flex items-center gap-1"}
>
{props.trigger.icon && (
<img
src={props.trigger.icon}
alt={props.trigger.text}
className="w-5 h-5 rounded-[50%] mr-3"
/>
)}
{props.trigger.text} {props.trigger.isShowArrow && <ChevronDown />}
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{props.items.length > 0 &&
props.items.map(
(
item: {
text: string
className?: string
href?: string
icon?: string
},
index: number
) => (
<DropdownMenu.Item
key={item.text + index}
className={item.className || ""}
>
{item.icon && (
<img
src={item.icon}
alt={item.text}
className="w-5 h-5 rounded-[50%] mr-3"
/>
)}
{item.href ? (
<LocalizedClientLink href={item.href}>
{item.text}
</LocalizedClientLink>
) : (
item.text
)}
</DropdownMenu.Item>
)
)}
</DropdownMenu.Content>
</DropdownMenu>
)
}
+27 -141
View File
@@ -1,158 +1,44 @@
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 { DynamicLayoutRenderer } from "@vibentec/renderer"
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 ?? {}
return (
<footer className="border-t border-ui-border-base w-full">
<div className="content-container flex flex-col w-full">
<div className="flex flex-col gap-y-6 xsmall:flex-row items-start justify-between py-40">
<div>
<LocalizedClientLink
href="/"
className="txt-compact-xlarge-plus text-ui-fg-subtle hover:text-ui-fg-base uppercase"
>
Medusa Store
</LocalizedClientLink>
</div>
<div className="text-small-regular gap-10 md:gap-x-16 grid grid-cols-2 sm:grid-cols-3">
{productCategories && productCategories?.length > 0 && (
<div className="flex flex-col gap-y-2">
<span className="txt-small-plus txt-ui-fg-base">
Categories
</span>
<ul
className="grid grid-cols-1 gap-2"
data-testid="footer-categories"
>
{productCategories?.slice(0, 6).map((c) => {
if (c.parent_category) {
return
}
const children =
c.category_children?.map((child) => ({
name: child.name,
handle: child.handle,
id: child.id,
})) || null
return (
<li
className="flex flex-col gap-2 text-ui-fg-subtle txt-small"
key={c.id}
>
<LocalizedClientLink
className={clx(
"hover:text-ui-fg-base",
children && "txt-small-plus"
)}
href={`/categories/${c.handle}`}
data-testid="category-link"
>
{c.name}
</LocalizedClientLink>
{children && (
<ul className="grid grid-cols-1 ml-3 gap-2">
{children &&
children.map((child) => (
<li key={child.id}>
<LocalizedClientLink
className="hover:text-ui-fg-base"
href={`/categories/${child.handle}`}
data-testid="category-link"
>
{child.name}
</LocalizedClientLink>
</li>
))}
</ul>
)}
</li>
)
})}
</ul>
</div>
)}
{collections && collections.length > 0 && (
<div className="flex flex-col gap-y-2">
<span className="txt-small-plus txt-ui-fg-base">
Collections
</span>
<ul
className={clx(
"grid grid-cols-1 gap-2 text-ui-fg-subtle txt-small",
{
"grid-cols-2": (collections?.length || 0) > 3,
}
)}
>
{collections?.slice(0, 6).map((c) => (
<li key={c.id}>
<LocalizedClientLink
className="hover:text-ui-fg-base"
href={`/collections/${c.handle}`}
>
{c.title}
</LocalizedClientLink>
</li>
))}
</ul>
</div>
)}
<div className="flex flex-col gap-y-2">
<span className="txt-small-plus txt-ui-fg-base">Medusa</span>
<ul className="grid grid-cols-1 gap-y-2 text-ui-fg-subtle txt-small">
<li>
<a
href="https://github.com/medusajs"
target="_blank"
rel="noreferrer"
className="hover:text-ui-fg-base"
>
GitHub
</a>
</li>
<li>
<a
href="https://docs.medusajs.com"
target="_blank"
rel="noreferrer"
className="hover:text-ui-fg-base"
>
Documentation
</a>
</li>
<li>
<a
href="https://github.com/medusajs/nextjs-starter-medusa"
target="_blank"
rel="noreferrer"
className="hover:text-ui-fg-base"
>
Source code
</a>
</li>
</ul>
</div>
</div>
<footer className={props?.className ?? ""}>
{props?.left && (
<div className={clx("flex h-full", props?.leftClassName)}>
<DynamicLayoutRenderer nodes={props?.left} context={context} />
</div>
<div className="flex w-full mb-16 justify-between text-ui-fg-muted">
<Text className="txt-compact-small">
{copyrightText || `© ${new Date().getFullYear()} Medusa Store. All rights reserved.`}
</Text>
<MedusaCTA />
)}
{props?.center && (
<div className={clx("flex h-full", props?.centerClassName)}>
<DynamicLayoutRenderer nodes={props?.center} context={context} />
</div>
</div>
)}
{props?.right && (
<div className={clx("flex h-full", props?.rightClassName)}>
<DynamicLayoutRenderer nodes={props?.right} context={context} />
</div>
)}
</footer>
)
}
@@ -0,0 +1,74 @@
import * as MedusaIcons from "@medusajs/icons"
import { clx } from "@medusajs/ui"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
export default function VtFooterBottom({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config ?? {}
const renderIcon = (name: string, idx: number) => {
const IconComp = (MedusaIcons as Record<string, any>)[name]
if (!IconComp) return null
return <IconComp key={`${name}-${idx}`} className="shadow-none" />
}
const renderLink = (item: any, idx: number, total: number) => {
const LinkEl = (
<LocalizedClientLink
href={item?.href ?? "#"}
className={clx(
"hover:underline",
props.linkClassName
)}
>
{item?.label ?? ""}
</LocalizedClientLink>
)
return (
<span
key={`link-${idx}`}
className={clx("flex items-center", props.linkItemClassName)}
>
{LinkEl}
{idx < total - 1 && (
<span className={clx("mx-2 text-white", props.separatorClassName)}>|</span>
)}
</span>
)
}
return (
<div
className={clx(
"flex w-full items-center justify-between",
props.className
)}
>
<span className={clx("text-[14px] font-[400] pt-2", props.textClassName)}>
{props.text}
</span>
<div className={clx("flex items-center gap-4")}>
{props.links && props.links.length > 0 && (
<div className={clx("flex items-center", props.linksClassName)}>
{props.links.map((l: any, idx: number) =>
renderLink(l, idx, props.links.length)
)}
</div>
)}
<div className={clx("flex items-center gap-2", props.iconsClassName)}>
{(props.icons ?? []).map(renderIcon)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,158 @@
import { clx } from "@medusajs/ui"
import { ChevronRightMini } from "@medusajs/icons"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
import * as MedusaIcons from "@medusajs/icons"
import * as CustomIcons from "@modules/common/icons"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
interface SocialIcon {
href?: string
icon?: string
imgSrc?: string
className?: string
}
export default function VtFooterHero({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config ?? {}
const IconComp = (name?: string) => {
if (!name) return null
return (
(MedusaIcons as Record<string, any>)[name] ||
(CustomIcons as Record<string, any>)[name] ||
null
)
}
const renderSocialContent = (item: SocialIcon) => {
const Icon = IconComp(item.icon)
if (Icon) return <Icon className={item.className} />
if (item.imgSrc)
return (
<img
src={item.imgSrc}
alt={item.icon ?? "social"}
className={item.className}
/>
)
return (
<span className="w-4 h-4 rounded-md bg-white/10 text-white flex items-center justify-center text-sm">
{item.icon?.[0] ?? "?"}
</span>
)
}
const renderSocialIcon = (icon: SocialIcon, idx: number) => {
const content = renderSocialContent(icon)
return icon.href ? (
<a
key={idx}
href={icon.href}
className={clx("hover:opacity-80", icon.className)}
>
{content}
</a>
) : (
<span key={idx} className={clx("", icon.className)}>
{content}
</span>
)
}
return (
<div className={clx("flex flex-col items-start", props.className)}>
{props.logoSrc && (
<img
src={props.logoSrc}
alt={props.logoAlt ?? "logo"}
className={clx("object-contain", props.logoClassName)}
/>
)}
{props.title && (
<h2
className={clx(
"mt-4 w-[320px] text-[24px] font-semibold text-[#11314E]",
props.titleClassName
)}
>
{props.title}
</h2>
)}
{props.description && (
<p
className={clx(
"mt-2 text-ui-fg-subtle txt-small",
props.descriptionClassName
)}
>
{props.description}
</p>
)}
{props.cta && (
<LocalizedClientLink
href={props.cta.href}
className={clx(
"bg-black text-white items-center flex gap-2 px-4 mt-[24px] py-2 rounded-md w-fit text-[14px]",
props.ctaClassName
)}
>
<span>{props.cta.label}</span>
<ChevronRightMini className="order-1" />
</LocalizedClientLink>
)}
{props.email && (
<form
className={clx(
"mt-4 w-full max-w-[500px]",
props.email?.emailInputClassName
)}
>
<div className="relative flex items-center justify-between border border-white/40 rounded-md px-4 py-3">
<input
id="vt-footer-hero-email"
type="email"
name="contact[email]"
placeholder=" "
autoComplete="email"
required
className="peer bg-transparent outline-none text-white/90 placeholder-transparent flex-1"
/>
<label
htmlFor="vt-footer-hero-email"
className="absolute left-4 text-white/60 pointer-events-none transition-all duration-300
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:text-white/50
peer-focus:top-3 peer-focus:-translate-y-2 peer-focus:left-3 peer-focus:text-white
peer-[:not(:placeholder-shown)]:translate-y-[-0.85rem]"
>
E-Mail
</label>
<button
type="submit"
className="ml-4 w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center"
>
<span className="sr-only">Abonnieren</span>
<ChevronRightMini />
</button>
</div>
</form>
)}
{props.socials && props.socials.length > 0 ? (
<div
className={clx(
"flex items-center gap-4 mt-2",
props.socialsClassName
)}
>
{props.socials.map(renderSocialIcon)}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,114 @@
"use client"
import { Button, Input } from "@medusajs/ui"
import { clx } from "@medusajs/ui"
import * as MedusaIcons from "@medusajs/icons"
import * as CustomIcons from "@modules/common/icons"
import { LayoutComponentDefinition, LayoutContext } from "@vibentec/component-map"
import React, { useState } from "react"
interface SocialIcon {
href?: string
icon?: string
imgSrc?: string
className?: string
}
interface VtFooterSignupConfig {
title?: string
placeholder?: string
buttonLabel?: string
className?: string
titleClassName?: string
formClassName?: string
inputClassName?: string
buttonClassName?: string
socialsClassName?: string
socials?: SocialIcon[]
}
export default function VtFooterSignUp({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = (nodes.config as VtFooterSignupConfig) ?? {}
const [email, setEmail] = useState("")
const IconComp = (name?: string) => {
if (!name) return null
return (
(MedusaIcons as Record<string, any>)[name] ||
(CustomIcons as Record<string, any>)[name] ||
null
)
}
const onSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log("newsletter_signup", { email })
}
const renderSocialContent = (item: SocialIcon) => {
const Icon = IconComp(item.icon)
if (Icon) return <Icon className={item.className} />
if (item.imgSrc) return <img src={item.imgSrc} alt={item.icon ?? "social"} className={item.className} />
return (
<span className="w-4 h-4 rounded-md bg-white/10 text-white flex items-center justify-center text-sm">
{item.icon?.[0] ?? "?"}
</span>
)
}
const renderSocialIcon = (icon: SocialIcon, idx: number) => {
const content = renderSocialContent(icon)
return icon.href ? (
<a key={idx} href={icon.href} className={clx("hover:opacity-80", icon.className)}>
{content}
</a>
) : (
<span key={idx} className={clx("", icon.className)}>
{content}
</span>
)
}
return (
<div className={clx("flex flex-col gap-4", props.className)}>
{props.title && (
<p className={clx("text-white text-[18px]", props.titleClassName)}>
{props.title}
</p>
)}
<form onSubmit={onSubmit} className={clx("flex items-center gap-4", props.formClassName)}>
<input
type="email"
placeholder={props.placeholder ?? "Email"}
value={email}
onChange={(e) => setEmail(e.target.value)}
className={clx(
"h-[48px] rounded-lg bg-white text-black px-4",
props.inputClassName
)}
/>
<Button
type="submit"
className={clx(
"h-[48px] rounded-lg bg-[#C4622C] text-white",
props.buttonClassName
)}
>
{props.buttonLabel ?? "Sign Up"}
</Button>
</form>
{props.socials && props.socials.length > 0 ? (
<div className={clx("flex items-center gap-4 mt-2", props.socialsClassName)}>
{props.socials.map(renderSocialIcon)}
</div>
) : null}
</div>
)
}
@@ -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>
)
}
@@ -0,0 +1,25 @@
import { clx } from "@medusajs/ui"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
export interface VtImageConfig {
src: string
alt: string
className?: string,
objectFit?: string,
}
export default function VtLogo({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = (nodes.config as VtImageConfig) ?? {}
return (
<div className={clx("relative", props.className)}>
<img src={props.src} alt={props.alt} className={clx("w-full h-full", props.objectFit)} />
</div>
)
}
@@ -0,0 +1,56 @@
"use client"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import * as MedusaIcons from "@medusajs/icons"
import * as CustomIcons from "@modules/common/icons"
export default function VtMenuItem({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config ?? {}
const title = props.title ?? ""
const items = props.items ?? []
const icon = props.icon ?? ""
const getIconComponent = (icon: string | undefined) => {
return icon
? (MedusaIcons as Record<string, any>)[icon] ??
(CustomIcons as Record<string, any>)[icon]
: undefined
}
return (
<div className={props.className ?? "flex flex-col gap-y-2"}>
<span>{title}</span>
<ul className="grid grid-cols-1 gap-2" data-testid="footer-categories">
{items.map((item: { text: string; href?: string; icon?: string }, index: number) => {
const Icon = getIconComponent(item.icon)
return (
<li
key={`${item.text}-${index}`}
className={props.itemClassName ?? "text-ui-fg-subtle txt-small"}
>
{Icon && <Icon className={props.iconClassName ?? "inline-block mr-2"} />}
{item.href ? (
<LocalizedClientLink
className="hover:text-ui-fg-base"
href={item.href}
data-testid="category-link"
>
{item.text}
</LocalizedClientLink>
) : (
<span className="hover:text-ui-fg-base">{item.text}</span>
)}
</li>
)
})}
</ul>
</div>
)
}
+24 -23
View File
@@ -1,28 +1,29 @@
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"
import { DynamicLayoutRenderer } from "vibentec/renderer"
import { LayoutComponentDefinition, LayoutContext } from "vibentec/component-map";
import { clx } from "@medusajs/ui";
export default async function VtNav({ nodes, context, className }: DynamicLayoutRendererProps & { className?: string }) {
console.log({nodes, context})
const regions = await listRegions().then((regions: StoreRegion[]) => regions)
interface BannerNavProps {
className?: string;
left?: LayoutComponentDefinition[];
center?: LayoutComponentDefinition[];
right?: LayoutComponentDefinition[];
}
export default function VtNav({ nodes, context }: { nodes: LayoutComponentDefinition; context: LayoutContext }) {
const props = nodes.config as BannerNavProps ?? {}
return (
<div className="sticky top-[40px] inset-x-0 z-50">
<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> */}
{nodes && (
<DynamicLayoutRenderer nodes={nodes} context={context} />
)}
</nav>
</header>
<div className="relative mx-auto border-b duration-200 bg-white border-ui-border-base">
<nav className={clx("content-container txt-xsmall-plus flex items-center justify-between w-full h-full text-small-regular", props.className)}>
<div className="flex items-center gap-x-4 h-full">
{props.left && <DynamicLayoutRenderer nodes={props.left} context={context} />}
</div>
<div className="flex items-center gap-x-4 h-full">
{props.center && <DynamicLayoutRenderer nodes={props.center} context={context} />}
</div>
<div className="flex items-center gap-x-4 h-full justify-end">
{props.right && <DynamicLayoutRenderer nodes={props.right} context={context} />}
</div>
</nav>
</div>
)
}
}
@@ -0,0 +1,40 @@
import React from "react"
import { MagnifyingGlass } from "@medusajs/icons"
import {
LayoutComponentDefinition,
LayoutContext,
} from "@vibentec/component-map"
export default function VtSearchInput({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config || {}
const placeholder = props.placeholder ?? "Search"
const shortcut = props.shortcut ?? "⌘K"
return (
<div
className={
`flex items-center gap-3 w-full max-w-xl bg-white border border-grey-20 rounded-rounded shadow-sm px-4 py-2 ${
props?.className ?? ""
}`
}
>
<MagnifyingGlass className="text-[#11314E]" />
<input
type="search"
placeholder={placeholder}
className="flex-1 bg-transparent outline-none text-[#11314E] placeholder:text-[#11314E]"
/>
{shortcut && (
<span className="text-[#11314E] border border-grey-30 rounded-rounded px-2 py-0.5 text-sm">
{shortcut}
</span>
)}
</div>
)
}
@@ -0,0 +1,17 @@
import { LayoutComponentDefinition, LayoutContext } from "@vibentec/component-map"
import { DynamicLayoutRenderer } from "@vibentec/renderer"
export default function VtSocialLinks({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config ?? {}
return (
<div className={props.className ?? ""}>
{nodes.children && <DynamicLayoutRenderer nodes={nodes.children} context={context} />}
</div>
)
}
@@ -0,0 +1,18 @@
import { LayoutComponentDefinition } from "@vibentec/component-map"
import { LayoutContext } from "@vibentec/component-map"
export default function VtText({
nodes,
context,
}: {
nodes: LayoutComponentDefinition
context: LayoutContext
}) {
const props = nodes.config || {}
return (
<span className={props?.className ?? ""}>
{props?.label ?? ""}
</span>
)
}
@@ -18,7 +18,7 @@ export default async function PreviewPrice({ price }: { price: VariantPrice }) {
)}
<Text
className={clx("text-ui-fg-muted", {
"text-ui-fg-interactive": price.price_type === "sale",
"text-green-700": price.price_type === "sale",
})}
data-testid="price"
>
@@ -0,0 +1,225 @@
import { HttpTypes } from "@medusajs/types"
import { Button, Heading, Text, clx } from "@medusajs/ui"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import Divider from "@modules/common/components/divider"
import PreviewPrice from "@modules/products/components/product-preview/price"
import { getProductPrice } from "@lib/util/get-product-price"
import { addToCart } from "@lib/data/cart"
import VtThumbnail from "../vt-thumbnail"
import { Plus, ChevronRight } from "@medusajs/icons"
type ProductCardProps = {
product: HttpTypes.StoreProduct
badgeText?: string
deliveryTime?: string
className?: string
countryCode: string
styles?: any
}
export default function ProductCard({
product,
badgeText = "Saved up to 20%",
deliveryTime = "2-4 Wochen",
className,
countryCode,
styles,
}: ProductCardProps) {
const firstVariant = product.variants?.[0]
const inStock = (() => {
if (!firstVariant) return false
if (!firstVariant.manage_inventory) return true
if (firstVariant.allow_backorder) return true
return (firstVariant.inventory_quantity || 0) > 0
})()
console.dir(product, { depth: null })
const { cheapestPrice } = getProductPrice({ product })
async function handleAddToCart() {
"use server"
if (!firstVariant?.id) return
await addToCart({
variantId: firstVariant.id,
quantity: 1,
countryCode,
})
}
const description = (() => {
const prodDescription = product.description || ""
const textSlice =
prodDescription.length > 120
? prodDescription.slice(0, 117) + "…"
: prodDescription
return textSlice
})()
const classes = {
card: styles?.card ?? className ?? "",
badge: {
container: styles?.badge?.container ?? " pb-4",
text:
styles?.badge?.text ??
"z-20 px-3 py-1 border-[0.5px] rounded bg-[#c9e0f5] txt-compact-small-plus shadow-borders-base text-[#285A86] ",
},
thumbnail: {
className: styles?.thumbnail?.className ?? "rounded-none h-[240px]",
size: styles?.thumbnail?.size ?? "full",
},
subtitle: styles?.subtitle ?? "",
content: styles?.content ?? "p-6 flex flex-col flex-1",
title: styles?.title ?? "",
price: styles?.price ?? "",
description: styles?.description ?? "",
reviews: {
container: styles?.reviews?.container ?? undefined,
stars: styles?.reviews?.stars ?? "flex gap-1",
star: styles?.reviews?.star ?? "text-[#C4622C] text-xl leading-none",
emptyStar:
styles?.reviews?.emptyStar ??
"text-[#C4622C] text-xl opacity-30 leading-none",
text: styles?.reviews?.text ?? "txt-small text-ui-fg-subtle",
rating: styles?.reviews?.rating,
count: styles?.reviews?.count,
},
button: {
addToCart: styles?.button?.addToCart ?? "",
moreInfo: styles?.button?.moreInfo ?? "",
isShowIcon: styles?.button?.isShowIcon ?? false,
},
}
return (
<div className={clx(classes.card)}>
<LocalizedClientLink
href={`/products/${product.handle}`}
className="block"
>
<div className="relative">
{badgeText && (
<div className={classes.badge.container}>
<span className={classes.badge.text}>{badgeText}</span>
</div>
)}
<VtThumbnail
thumbnail={product.thumbnail}
className={classes.thumbnail.className}
images={product.images}
size={classes.thumbnail.size}
isFeatured
/>
</div>
</LocalizedClientLink>
<div className={classes.content}>
{classes.subtitle && product.collection && (
<LocalizedClientLink
href={`/collections/${product.collection.handle}`}
className="txt-small text-ui-fg-muted hover:text-ui-fg-subtle"
>
{product.subtitle}
</LocalizedClientLink>
)}
<LocalizedClientLink
href={`/products/${product.handle}`}
className="block"
>
<Heading
level="h3"
className={classes.title}
data-testid="product-card-title"
>
{product.title}
</Heading>
</LocalizedClientLink>
{classes.price && (
<div className={classes.price}>
{cheapestPrice && <PreviewPrice price={cheapestPrice} />}
</div>
)}
{(classes.reviews.rating !== undefined ||
classes.reviews.count !== undefined) && (
<div
className={
classes.reviews.container || "mt-2 flex items-center gap-3"
}
>
<div className="relative inline-block">
<div className={classes.reviews.stars}>
{Array.from({ length: 5 }).map((_, i) => (
<span
key={`star-empty-${i}`}
className={classes.reviews.emptyStar}
>
</span>
))}
</div>
<div
className="absolute inset-0 overflow-hidden"
style={{
width: `${Math.max(
0,
Math.min(
100,
(((classes.reviews.rating as number) ?? 0) / 5) * 100
)
)}%`,
}}
>
<div className={classes.reviews.stars}>
{Array.from({ length: 5 }).map((_, i) => (
<span
key={`star-fills-${i}`}
className={classes.reviews.star}
>
</span>
))}
</div>
</div>
</div>
{typeof classes.reviews.count === "number" && (
<span className={classes.reviews.text}>
{classes.reviews.count} Reviews
</span>
)}
</div>
)}
{classes.description && (
<Text className={clx(classes.description, "txt-small my-4")}>
{description}
</Text>
)}
<div className="flex gap-3 mt-auto">
{classes.button?.addToCart && (
<Button
formAction={handleAddToCart}
disabled={!inStock}
variant="primary"
className={classes.button.addToCart}
>
Add to cart {classes.button.isShowIcon && <Plus />}
</Button>
)}
{classes.button?.moreInfo && (
<LocalizedClientLink
href={`/products/${product.handle}`}
className="flex-1"
>
<Button variant="secondary" className={classes.button.moreInfo}>
More Info {classes.button.isShowIcon && <ChevronRight />}
</Button>
</LocalizedClientLink>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,88 @@
import { Container, clx } from "@medusajs/ui"
import Image from "next/image"
import React from "react"
import PlaceholderImage from "@modules/common/icons/placeholder-image"
type ThumbnailProps = {
thumbnail?: string | null
// TODO: Fix image typings
images?: any[] | null
size?: "small" | "medium" | "large" | "full" | "square"
isFeatured?: boolean
className?: string
"data-testid"?: string
}
const VtThumbnail: React.FC<ThumbnailProps> = ({
thumbnail,
images,
size = "small",
isFeatured,
className,
"data-testid": dataTestid,
}) => {
const imageUrls = images?.map((i: any) => i.url) || []
const initialImage = thumbnail || imageUrls?.[0]
let hoverImage: string | undefined = initialImage
if (imageUrls.length > 1) {
hoverImage = imageUrls.find((u) => u !== initialImage)
}
return (
<Container
className={clx(
"group relative w-full overflow-hidden p-4 bg-ui-bg-subtle shadow-elevation-card-rest group-hover:shadow-elevation-card-hover transition-shadow ease-in-out duration-150",
className,
{
"aspect-[11/14]": isFeatured,
"aspect-[9/16]": !isFeatured && size !== "square",
"aspect-[1/1]": size === "square",
"w-[180px]": size === "small",
"w-[290px]": size === "medium",
"w-[440px]": size === "large",
"w-full": size === "full",
}
)}
data-testid={dataTestid}
>
<ImageOrPlaceholder
image={initialImage}
size={size}
className="opacity-100 group-hover:opacity-0 transition-opacity duration-300"
/>
{hoverImage && (
<ImageOrPlaceholder
image={hoverImage}
size={size}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-300"
/>
)}
</Container>
)
}
const ImageOrPlaceholder = ({
image,
size,
className,
}: Pick<ThumbnailProps, "size"> & { image?: string; className?: string }) => {
return image ? (
<Image
src={image}
alt="Thumbnail"
className={clx("absolute inset-0 object-cover object-center", className)}
draggable={false}
quality={50}
sizes="(max-width: 576px) 280px, (max-width: 768px) 360px, (max-width: 992px) 480px, 800px"
fill
/>
) : (
<div className={clx("w-full h-full absolute inset-0 flex items-center justify-center", className)}>
<PlaceholderImage size={size === "small" ? 16 : 24} />
</div>
)
}
export default VtThumbnail
@@ -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>
)
}
+3 -3
View File
@@ -51,7 +51,8 @@
@layer components {
.content-container {
@apply max-w-[1440px] w-full mx-auto px-6;
/* @apply max-w-[1440px] w-full mx-auto px-6; */
@apply w-full mx-auto px-6;
}
.contrast-btn {
@@ -110,7 +111,6 @@
@apply text-[32px] leading-[44px] font-semibold;
}
}
[data-radix-popper-content-wrapper]{
z-index: 100 !important;
z-index: 51 !important;
}
+76 -129
View File
@@ -1,35 +1,47 @@
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"
import NavigationMenu from "@modules/layout/components/navigation-menu"
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"
import VtDropdown from "@modules/layout/templates/vt-dropdown"
import VtSearchInput from "@modules/layout/templates/vt-search-input"
import VtCurrencySelect from "@modules/layout/templates/vt-currency-select"
import VtMenuItem from "@modules/layout/templates/vt-menu-item"
import VtCountryCodeSelect from "@modules/layout/templates/vt-country-select/server"
import VtSocialLinks from "@modules/layout/templates/vt-social-link"
import VtFooterHero from "@modules/layout/templates/vt-footer/vt-footer-hero"
import VtFooterBottom from "@modules/layout/templates/vt-footer/vt-footer-bottom"
import VtLogo from "@modules/layout/templates/vt-logo"
import VtFooterSignUp from "@modules/layout/templates/vt-footer/vt-footer-signup"
import Hero from "@modules/layout/templates/hero"
import { VtCarousel } from "@modules/layout/templates/vt-carousel"
import { VtCtaBanner } from "@modules/layout/templates/vt-cta-banner"
import VtFeaturedProducts from "@modules/home/components/vt-featured-products"
import VtCategoryHighlight from "@modules/home/components/vt-category-highlight"
type ComponentConfig = Record<string, any>
export interface LayoutComponentDefinition {
props?: Record<string, any>
config?: ComponentConfig
children?: LayoutComponentNode[]
}
//maps key = componentName to value = props + children
export type LayoutComponentNode = Record<string, LayoutComponentDefinition>
export interface LayoutContext {
customer: any
cart: any
shippingOptions: any[]
contentChildren: React.ReactNode
designId?: string
countryCode?: string
region?: any
}
export type ComponentRenderer = {
@@ -39,129 +51,64 @@ export type ComponentRenderer = {
) => React.ReactNode
}
// Utility, wenn eine Komponente nur props hat und keine children
const simple = (Component: React.ComponentType<any>): ComponentRenderer => ({
render: (entry) => <Component {...entry.props} />,
// Utility methods
const configOnly = (
Component: React.ComponentType<any>
): ComponentRenderer => ({
render: (entry) => <Component {...entry.config} />,
})
const nodesContextRenderer = (
Component: React.ComponentType<any>
): ComponentRenderer => ({
render: (entry: any, ctx: LayoutContext) => (
<Component nodes={entry} context={ctx} />
),
})
// Helper für Kinder-Rendering
const renderChildren = (entry: LayoutComponentDefinition, ctx: LayoutContext) =>
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 {...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}
</div>
),
},
Text: {
render: (entry: any, ctx: LayoutContext) => (
<div {...entry.props}>{entry.props.label}</div>
),
},
DropdownMenus: {
render: (entry: any, ctx: LayoutContext) => (
<DropdownMenuComponent {...entry} />
),
},
NavMenu: {
render: (entry: any, ctx: LayoutContext) => (
<NavigationMenu props={entry.props} menuItems={entry.menuItems} />
),
},
LocalizedClientLink: {
render: (entry: any) => (
<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: {
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),
Header: nodesContextRenderer(VtHeader),
Nav: nodesContextRenderer(VtNav),
Hero: nodesContextRenderer(Hero),
VtMegaMenu: nodesContextRenderer(VtMegaMenu),
VtSideMenu: nodesContextRenderer(VtSideMenu),
Banner: nodesContextRenderer(Banner),
HomeButton: nodesContextRenderer(HomeButton),
Logo: nodesContextRenderer(VtLogo),
AccountButton: nodesContextRenderer(AccountButton),
SearchInput: nodesContextRenderer(VtSearchInput),
VtCartButton: nodesContextRenderer(VtCartButton),
VtCurrencySelect: nodesContextRenderer(VtCurrencySelect),
VtCountryCodeSelect: nodesContextRenderer(VtCountryCodeSelect),
VtSocialLinks: nodesContextRenderer(VtSocialLinks),
Link: nodesContextRenderer(VtLink),
Dropdown: nodesContextRenderer(VtDropdown),
VtMenuItem: nodesContextRenderer(VtMenuItem),
CartMismatchBanner: configOnly(CartMismatchBanner),
FreeShippingPriceNudge: configOnly(FreeShippingPriceNudge),
PropsChildren: {
render: (_props, ctx) => ctx.contentChildren, // PageLayout's props.children
},
Footer: simple(VtFooter),
VtCtaBanner: nodesContextRenderer(VtCtaBanner),
VtFooterHero: nodesContextRenderer(VtFooterHero),
VtFooterBottom: nodesContextRenderer(VtFooterBottom),
VtFooterSignUp: nodesContextRenderer(VtFooterSignUp),
Footer: nodesContextRenderer(VtFooter),
ImageDisplayer: nodesContextRenderer(VtCarousel),
VtFeaturedProducts: nodesContextRenderer(VtFeaturedProducts),
VtCategoryHighlight: nodesContextRenderer(VtCategoryHighlight),
}
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
View File
@@ -1 +0,0 @@
export interface FooterProps { copyrightText?: string }
+22 -2
View File
@@ -1,8 +1,28 @@
import fs from "fs"
import path from "path"
import { jsonFileNames } from "./devJsonFileNames"
export async function loadDesignConfig(designFile: string) {
const filePath = path.join(process.cwd(), "config", designFile)
const fileName = jsonFileNames.nam3Bear
async function readDesignFile() {
const filePath = path.join(process.cwd(), "config", fileName)
const fileData = await fs.promises.readFile(filePath, "utf-8")
return JSON.parse(fileData)
}
export async function loadLayoutConfig() {
const config = await readDesignFile()
if (Array.isArray(config)) return config
return config.layout ?? []
}
export async function loadPageConfig(pageKey: string) {
const config = await readDesignFile()
if (Array.isArray(config)) return []
const pages = config.pages ?? {}
return pages[pageKey] ?? []
}
export async function loadDesignConfig() {
return loadLayoutConfig()
}
+8
View File
@@ -0,0 +1,8 @@
export const jsonFileNames = {
steMedusaStarter: "ste.medusa-starter.design.json",
stePlayGround: "ste.playground.design.json",
nam3Bear: "nam.3bear.design.json",
namDrsquatch: "nam.drsquatch.design.json",
namVibentec: "nam.vibentec.design.json",
namStarter: "nam.mds-starter-design.json",
};
+21 -20
View File
@@ -1,27 +1,28 @@
import React from "react"
import {
LayoutComponentNode,
LayoutContext,
componentMap,
} from "./component-map"
import { ComponentName, LayoutComponentDefinition, LayoutComponentNode, LayoutContext, componentMap } from "./component-map"
export interface DynamicLayoutRendererProps {
nodes: LayoutComponentNode[]
context: LayoutContext
}
export function DynamicLayoutRenderer({
nodes,
context,
}: DynamicLayoutRendererProps) {
return nodes.map((entry, index) => {
const [key, value] = Object.entries(entry)[0] as [string, any]
const component = componentMap[key]
if (!component) return null
return (
<React.Fragment key={index}>
{component.render(value, context)}
</React.Fragment>
)
})
}
export function DynamicLayoutRenderer({ nodes, context } : DynamicLayoutRendererProps) {
const nodeArray = Array.isArray(nodes) ? nodes : [nodes];
return nodeArray.map((entry, index) => {
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>
})
}
-23
View File
@@ -1,23 +0,0 @@
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>
+1 -41
View File
@@ -9,7 +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",
"./config/*json",
],
theme: {
extend: {
@@ -141,38 +141,6 @@ module.exports = {
"0%": { transform: "translateY(-100%)" },
"100%": { transform: "translateY(0)" },
},
enterFromRight: {
from: { opacity: "0", transform: "translateX(200px)" },
to: { opacity: "1", transform: "translateX(0)" },
},
enterFromLeft: {
from: { opacity: "0", transform: "translateX(-200px)" },
to: { opacity: "1", transform: "translateX(0)" },
},
exitToRight: {
from: { opacity: "1", transform: "translateX(0)" },
to: { opacity: "0", transform: "translateX(200px)" },
},
exitToLeft: {
from: { opacity: "1", transform: "translateX(0)" },
to: { opacity: "0", transform: "translateX(-200px)" },
},
scaleIn: {
from: { opacity: "0", transform: "rotateX(-10deg) scale(0.9)" },
to: { opacity: "1", transform: "rotateX(0deg) scale(1)" },
},
scaleOut: {
from: { opacity: "1", transform: "rotateX(0deg) scale(1)" },
to: { opacity: "0", transform: "rotateX(-10deg) scale(0.95)" },
},
fadeIn: {
from: { opacity: "0" },
to: { opacity: "1" },
},
fadeOut: {
from: { opacity: "1" },
to: { opacity: "0" },
},
},
animation: {
ring: "ring 2.2s cubic-bezier(0.5, 0, 0.5, 1) infinite",
@@ -188,14 +156,6 @@ module.exports = {
enter: "enter 200ms ease-out",
"slide-in": "slide-in 1.2s cubic-bezier(.41,.73,.51,1.02)",
leave: "leave 150ms ease-in forwards",
scaleIn: "scaleIn 200ms ease",
scaleOut: "scaleOut 200ms ease",
fadeIn: "fadeIn 200ms ease",
fadeOut: "fadeOut 200ms ease",
enterFromLeft: "enterFromLeft 250ms ease",
enterFromRight: "enterFromRight 250ms ease",
exitToLeft: "exitToLeft 250ms ease",
exitToRight: "exitToRight 250ms ease",
},
},
},
+2 -1
View File
@@ -18,7 +18,8 @@
"paths": {
"@lib/*": ["lib/*"],
"@modules/*": ["modules/*"],
"@pages/*": ["pages/*"]
"@pages/*": ["pages/*"],
"@vibentec/*": ["vibentec/*"],
},
"plugins": [
{
+790 -3
View File
File diff suppressed because it is too large Load Diff