Compare commits

..

2 Commits

12 changed files with 404 additions and 11 deletions

View File

@ -142,6 +142,13 @@
}
}
},
{
"VtFeaturedProducts": {
"config": {
"title": "best-seller"
}
}
},
{
"CartMismatchBanner": {
"config": {

View File

@ -211,6 +211,40 @@
}
}
},
{
"VtFeaturedProducts": {
"config": {
"title": "best-seller",
"styles": {
"container": "content-container py-12 px-[100px] small:py-24",
"header": {
"container": "flex justify-between mb-8",
"title": "txt-xlarge",
"link": ""
},
"list": "grid grid-cols-2 small:grid-cols-3 gap-x-6 gap-y-24 small:gap-y-36",
"productCard": {
"className": "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"
},
"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",
"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": {

View File

@ -8,11 +8,19 @@ import { DynamicLayoutRenderer } from "../../../vibentec/renderer"
import { LayoutContext, LayoutComponentNode, } from "../../../vibentec/component-map"
import { loadDesignConfig } 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 }) {
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[] = []
@ -29,6 +37,8 @@ export default async function PageLayout(props: { children: React.ReactNode }) {
cart,
shippingOptions,
contentChildren: props.children,
countryCode,
region,
}

View File

@ -4,6 +4,7 @@ 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"
export const metadata: Metadata = {
title: "Medusa Next.js Starter Template",
@ -23,22 +24,20 @@ export default async function Home(props: {
const { collections } = await listCollections({
fields: "id, handle, title",
})
const res = await listCollections({
fields: "id, handle, title",
})
console.log('collections:',collections)
if (!collections || !region) {
return null
}
console.log(res, '--------------')
return (
<>
{/* <Hero /> */}
<div className="py-12">
{/* <div className="py-12">
<ul className="flex flex-col gap-x-6">
<FeaturedProducts collections={collections} region={region} />
<VtFeaturedProducts collections={collections} region={region} countryCode={countryCode} />
</ul>
</div>
</div> */}
</>
)
}

View File

@ -36,7 +36,6 @@ export const listCollections = async (
{
query: queryParams,
next,
cache: "force-cache",
}
)
.then(({ collections }) => ({ collections, count: collections.length }))

View File

@ -68,7 +68,6 @@ export const listProducts = async ({
},
headers,
next,
cache: "force-cache",
}
)
.then(({ products, count }) => {

View File

@ -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>
))
}

View File

@ -0,0 +1,64 @@
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",
},
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>
<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}
/>
</li>
))}
</ul>
</div>
)
}

View File

@ -0,0 +1,155 @@
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
})()
const { cheapestPrice } = getProductPrice({ product })
async function handleAddToCart() {
"use server"
if (!firstVariant?.id) return
await addToCart({
variantId: firstVariant.id,
quantity: 1,
countryCode,
})
}
const description = (() => {
const description = product.description || ""
const textSlice = description.length > 120 ? description.slice(0, 117) + "…" : description
return textSlice
})()
const classes = {
card: styles?.className || className || "relative overflow-hidden rounded-2xl border border-[#285A86] bg-ui-bg-base shadow-elevation-card-rest h-full flex flex-col",
badge: {
container: styles?.badge?.container || "p-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",
},
content: styles?.content || "p-6 flex flex-col flex-1",
title: styles?.title || "mt-2 text-ui-fg-base",
price: styles?.price || "mt-2 flex items-baseline gap-2",
button: {
addToCart: styles?.button?.addToCart || "flex-1",
moreInfo: styles?.button?.moreInfo || "w-full",
isShowIcon: styles?.button?.isShowIcon || true,
},
}
return (
<div
className={clx(
classes.card
)}
>
<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>
<div className={classes.content}>
{product.collection && (
<LocalizedClientLink
href={`/collections/${product.collection.handle}`}
className="txt-small text-ui-fg-muted hover:text-ui-fg-subtle"
>
{product.collection.title}
</LocalizedClientLink>
)}
<Heading
level="h3"
className={classes.title}
data-testid="product-card-title"
>
{product.title}
</Heading>
<div className={classes.price}>
{cheapestPrice && <PreviewPrice price={cheapestPrice} />}
</div>
<Text className="mt-1 txt-compact-small text-ui-fg-muted">
inkl. MwSt. zzgl. Versandkosten
</Text>
<div className="my-4">
<Divider />
</div>
<Text className="txt-small text-ui-fg-subtle">{description}</Text>
<Text className="my-3 txt-small text-ui-fg-muted">
Lieferzeit: {deliveryTime}
</Text>
<div className="flex gap-3 mt-auto">
<Button
formAction={handleAddToCart}
disabled={!inStock}
variant="primary"
className={classes.button.addToCart}
>
Add to cart {classes.button.isShowIcon && <Plus />}
</Button>
<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>
)
}

View File

@ -0,0 +1,70 @@
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 initialImage = thumbnail || images?.[0]?.url
return (
<Container
className={clx(
"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} />
</Container>
)
}
const ImageOrPlaceholder = ({
image,
size,
}: Pick<ThumbnailProps, "size"> & { image?: string }) => {
return image ? (
<Image
src={image}
alt="Thumbnail"
className="absolute inset-0 object-cover object-center"
draggable={false}
quality={50}
sizes="(max-width: 576px) 280px, (max-width: 768px) 360px, (max-width: 992px) 480px, 800px"
fill
/>
) : (
<div className="w-full h-full absolute inset-0 flex items-center justify-center">
<PlaceholderImage size={size === "small" ? 16 : 24} />
</div>
)
}
export default VtThumbnail

View File

@ -25,6 +25,7 @@ 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"
type ComponentConfig = Record<string, any>
@ -38,6 +39,8 @@ export interface LayoutContext {
cart: any
shippingOptions: any[]
contentChildren: React.ReactNode
countryCode?: string
region?: any
}
export type ComponentRenderer = {
@ -97,6 +100,7 @@ export const componentMap: Record<string, ComponentRenderer> = {
VtFooterSignUp: nodesContextRenderer(VtFooterSignUp),
Footer: nodesContextRenderer(VtFooter),
ImageDisplayer: nodesContextRenderer(VtCarousel),
VtFeaturedProducts: nodesContextRenderer(VtFeaturedProducts),
}
export type ComponentName = keyof typeof componentMap

View File

@ -2,7 +2,7 @@ import fs from "fs"
import path from "path"
import { jsonFileNames } from "./devJsonFileNames";
const fileName = jsonFileNames.namStarter;
const fileName = jsonFileNames.namVibentec;
export async function loadDesignConfig() {
const filePath = path.join(process.cwd(), "config", fileName)