@pradip1995/storefront-controllers 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@pradip1995/storefront-controllers",
3
+ "version": "1.0.0",
4
+ "description": "Medusa storefront page controllers — data loading and composition",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public",
8
+ "registry": "https://registry.npmjs.org/"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/SmartByteLabs/medusa-storefront-kit.git",
13
+ "directory": "packages/storefront-controllers"
14
+ },
15
+ "sideEffects": false,
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "exports": {
20
+ "./account/login-page": "./src/account/login-page.tsx",
21
+ "./account/load-account-data": "./src/account/load-account-data.ts",
22
+ "./cart/cart-page": "./src/cart/cart-page.tsx",
23
+ "./cart/load-cart-data": "./src/cart/load-cart-data.ts",
24
+ "./checkout/checkout-page": "./src/checkout/checkout-page.tsx",
25
+ "./checkout/load-checkout-data": "./src/checkout/load-checkout-data.ts",
26
+ "./home/home-page": "./src/home/home-page.tsx",
27
+ "./home/load-home-data": "./src/home/load-home-data.ts",
28
+ "./layout/main-layout": "./src/layout/main-layout.tsx",
29
+ "./layout/load-layout-data": "./src/layout/load-layout-data.ts",
30
+ "./order/order-details-controller": "./src/order/order-details-controller.tsx",
31
+ "./order/load-order-data": "./src/order/load-order-data.ts",
32
+ "./product/product-page": "./src/product/product-page.tsx",
33
+ "./product/product-actions-wrapper": "./src/product/product-actions-wrapper.tsx"
34
+ },
35
+ "peerDependencies": {
36
+ "next": ">=15",
37
+ "react": ">=19",
38
+ "@pradip1995/commerce-core": "1.0.0",
39
+ "@pradip1995/storefront-registry": "1.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@medusajs/types": "latest",
43
+ "@types/react": "^19",
44
+ "eslint": "^8.57.0",
45
+ "next": "15.3.8",
46
+ "react": "19.0.3",
47
+ "typescript": "^5.7.2",
48
+ "@pradip1995/commerce-core": "1.0.0",
49
+ "@pradip1995/storefront-registry": "1.0.0"
50
+ },
51
+ "scripts": {
52
+ "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
53
+ "typecheck:full": "tsc --noEmit",
54
+ "lint": "tsc --noEmit -p tsconfig.typecheck.json"
55
+ }
56
+ }
@@ -0,0 +1,5 @@
1
+ import type { AccountPageData } from "@core/types/account"
2
+
3
+ export async function loadAccountData(countryCode: string): Promise<AccountPageData> {
4
+ return { countryCode }
5
+ }
@@ -0,0 +1,8 @@
1
+ import { resolveComponent } from "@registry/resolve-component"
2
+ import type { AccountPageData } from "@core/types/account"
3
+
4
+ export async function LoginPage({ data }: { data: AccountPageData }) {
5
+ const LoginTemplate = (await resolveComponent("slots/account/LoginTemplate")).default
6
+
7
+ return <LoginTemplate countryCode={data.countryCode} />
8
+ }
@@ -0,0 +1,13 @@
1
+ import CartTemplate from "@modules/cart/templates"
2
+ import type { CartPageData } from "@core/types/cart"
3
+
4
+ export function CartPage({ data }: { data: CartPageData }) {
5
+ return (
6
+ <CartTemplate
7
+ cart={data.cart}
8
+ customer={data.customer}
9
+ countryCode={data.countryCode}
10
+ abandonedCarts={data.abandonedCarts}
11
+ />
12
+ )
13
+ }
@@ -0,0 +1,18 @@
1
+ import { retrieveCart, getAbandonedCarts } from "@core/data/cart"
2
+ import { retrieveCustomer } from "@core/data/customer"
3
+ import type { CartPageData } from "@core/types/cart"
4
+
5
+ export async function loadCartData(countryCode: string): Promise<CartPageData> {
6
+ const [cart, customer, abandonedCarts] = await Promise.all([
7
+ retrieveCart(),
8
+ retrieveCustomer(),
9
+ getAbandonedCarts(),
10
+ ])
11
+
12
+ return {
13
+ cart,
14
+ customer,
15
+ countryCode,
16
+ abandonedCarts,
17
+ }
18
+ }
@@ -0,0 +1,39 @@
1
+ import PaymentWrapper from "@modules/checkout/components/payment-wrapper"
2
+ import CheckoutBeginTracker from "@modules/checkout/components/checkout-begin-tracker"
3
+ import CheckoutForm from "@modules/checkout/templates/checkout-form"
4
+ import CheckoutSummary from "@modules/checkout/templates/checkout-summary"
5
+ import type { CheckoutPageData } from "./load-checkout-data"
6
+
7
+ type CheckoutPageProps = {
8
+ data: CheckoutPageData
9
+ }
10
+
11
+ export function CheckoutPage({ data }: CheckoutPageProps) {
12
+ const { cart, customer } = data
13
+
14
+ return (
15
+ <div className="w-full max-w-[1360px] mx-auto px-3 min-[550px]:px-4 sm:px-6 md:px-8 min-[1023px]:px-4 min-[1150px]:px-6 min-[1360px]:px-8 py-4 min-[550px]:py-6 sm:py-8 md:py-12 min-[1023px]:py-10 min-[1150px]:py-12 min-[1360px]:py-12">
16
+ <CheckoutBeginTracker cart={cart} />
17
+ <div className="grid grid-cols-1 min-[1024px]:grid-cols-[1fr_416px] gap-4 min-[550px]:gap-5 sm:gap-6 md:gap-8 min-[768px]:gap-6 min-[1023px]:gap-4 min-[1150px]:gap-6 min-[1200px]:gap-10 min-[1360px]:gap-12 w-full items-start">
18
+ <div className="w-full min-[1024px]:sticky min-[1024px]:top-20">
19
+ <style
20
+ dangerouslySetInnerHTML={{
21
+ __html: `
22
+ .checkout-form-scroll::-webkit-scrollbar { display: none; }
23
+ .checkout-form-scroll { -ms-overflow-style: none; scrollbar-width: none; }
24
+ `,
25
+ }}
26
+ />
27
+ <div className="w-full min-[1024px]:max-h-[calc(100vh-140px)] min-[1024px]:overflow-y-auto min-[1024px]:pr-4 checkout-form-scroll">
28
+ <PaymentWrapper cart={cart}>
29
+ <CheckoutForm cart={cart} customer={customer} />
30
+ </PaymentWrapper>
31
+ </div>
32
+ </div>
33
+ <div className="w-full min-[1024px]:sticky min-[1024px]:top-20">
34
+ <CheckoutSummary cart={cart} customer={customer} />
35
+ </div>
36
+ </div>
37
+ </div>
38
+ )
39
+ }
@@ -0,0 +1,21 @@
1
+ import { retrieveCart } from "@core/data/cart"
2
+ import { retrieveCustomer } from "@core/data/customer"
3
+ import type { HttpTypes } from "@medusajs/types"
4
+
5
+ export type CheckoutPageData = {
6
+ cart: HttpTypes.StoreCart
7
+ customer: HttpTypes.StoreCustomer | null
8
+ }
9
+
10
+ export async function loadCheckoutData(
11
+ cartIdFromUrl?: string
12
+ ): Promise<CheckoutPageData | null> {
13
+ const cart = await retrieveCart(cartIdFromUrl)
14
+ if (!cart) {
15
+ return null
16
+ }
17
+
18
+ const customer = await retrieveCustomer()
19
+
20
+ return { cart, customer }
21
+ }
@@ -0,0 +1,95 @@
1
+ import { storefrontConfig } from "@registry/storefront.config"
2
+ import { resolveComponent } from "@registry/resolve-component"
3
+ import type { HomePageData } from "@core/types/home"
4
+ import type { HomeBlockType } from "@registry/storefront.config"
5
+ import { colorClasses } from "@theme/tokens"
6
+
7
+ function getBlockProps(
8
+ blockType: HomeBlockType,
9
+ data: HomePageData
10
+ ): Record<string, unknown> {
11
+ switch (blockType) {
12
+ case "hero":
13
+ return data.hero
14
+ case "shopByAge":
15
+ return data.shopByAge
16
+ case "shopByCategory":
17
+ return data.shopByCategory
18
+ case "whyChooseUs":
19
+ return data.whyChooseUs
20
+ case "newArrivals":
21
+ return data.newArrivals
22
+ case "lovedByMoms":
23
+ return data.lovedByMoms
24
+ case "testimonials":
25
+ return data.testimonials
26
+ case "features":
27
+ return data.features
28
+ default:
29
+ return {}
30
+ }
31
+ }
32
+
33
+ export async function HomePage({ data }: { data: HomePageData }) {
34
+ const blocks = storefrontConfig.blocks.home
35
+
36
+ const resolvedBlocks = await Promise.all(
37
+ blocks.map(async (block) => {
38
+ const mod = await resolveComponent(block.component)
39
+ return {
40
+ block,
41
+ Component: mod.default,
42
+ }
43
+ })
44
+ )
45
+
46
+ const jsonLd = {
47
+ "@context": "https://schema.org",
48
+ "@type": "WebSite",
49
+ name: "Chocomelon",
50
+ alternateName: "Chocomelon Kids Wear",
51
+ url: "https://chocomelon.in",
52
+ potentialAction: {
53
+ "@type": "SearchAction",
54
+ target: "https://chocomelon.in/store?search={search_term_string}",
55
+ "query-input": "required name=search_term_string",
56
+ },
57
+ }
58
+
59
+ const organizationJsonLd = {
60
+ "@context": "https://schema.org",
61
+ "@type": "Organization",
62
+ name: "Chocomelon",
63
+ url: "https://chocomelon.in",
64
+ logo: "https://chocomelon.in/Logo.png",
65
+ sameAs: [
66
+ "https://www.instagram.com/chocomelon_kids_wear/",
67
+ "https://www.facebook.com/chocomelon.kids.wear",
68
+ ],
69
+ contactPoint: {
70
+ "@type": "ContactPoint",
71
+ telephone: "+91 82382 57652",
72
+ contactType: "customer service",
73
+ areaServed: "IN",
74
+ availableLanguage: ["en", "gu", "hi"],
75
+ },
76
+ }
77
+
78
+ return (
79
+ <div className={`${colorClasses.pageBg} w-full`}>
80
+ <script
81
+ type="application/ld+json"
82
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
83
+ />
84
+ <script
85
+ type="application/ld+json"
86
+ dangerouslySetInnerHTML={{
87
+ __html: JSON.stringify(organizationJsonLd),
88
+ }}
89
+ />
90
+ {resolvedBlocks.map(({ block, Component }) => (
91
+ <Component key={block.blockType} {...getBlockProps(block.blockType, data)} />
92
+ ))}
93
+ </div>
94
+ )
95
+ }
@@ -0,0 +1,118 @@
1
+ import { listCollections } from "@core/data/collections"
2
+ import { listCategories } from "@core/data/categories"
3
+ import { listProductsWithSort, getProductsByTag } from "@core/data/products"
4
+ import { getRegion } from "@core/data/regions"
5
+ import {
6
+ getTestimonialsFromConfig,
7
+ getHomeBannersFromConfig,
8
+ getAppBannersFromConfig,
9
+ getFeaturesFromConfig,
10
+ } from "@core/data/dynamic-config"
11
+ import { fetchRatings } from "@core/data/reviews"
12
+ import type { HomePageData, BannerData } from "@core/types/home"
13
+
14
+ const defaultBanner: BannerData = {
15
+ image: "/newbanner.png",
16
+ title: "Premium Kids Shop",
17
+ subtitle: "Little Trends, Quality Kids Wear",
18
+ description:
19
+ "Shop stylish kids wear for little ones and children of all years—from newborn up to 15 years. Crafted for play, absolute comfort, and everyday magic.",
20
+ buttonName: "Shop Kids Wear",
21
+ buttonLink: "/store",
22
+ }
23
+
24
+ function buildBannerData(
25
+ homeBanners: BannerData[] | null | undefined,
26
+ appBanners: BannerData[] | null | undefined
27
+ ): HomePageData["hero"] {
28
+ const homeBanner =
29
+ homeBanners && homeBanners.length > 0 ? homeBanners[0] : defaultBanner
30
+ const homeBannerImage = homeBanner.image || "/newbanner.png"
31
+ const firstAppBanner = appBanners && appBanners.length > 0 ? appBanners[0] : null
32
+
33
+ return {
34
+ homeBanner: { ...homeBanner, image: homeBannerImage },
35
+ appBanner: {
36
+ image: firstAppBanner?.image || "/appbanner.png",
37
+ title: firstAppBanner?.title || homeBanner.title,
38
+ subtitle: firstAppBanner?.subtitle || homeBanner.subtitle,
39
+ description: firstAppBanner?.description || homeBanner.description,
40
+ buttonName: firstAppBanner?.buttonName || homeBanner.buttonName,
41
+ buttonLink: firstAppBanner?.buttonLink || homeBanner.buttonLink,
42
+ },
43
+ }
44
+ }
45
+
46
+ export async function loadHomeData(countryCode: string): Promise<HomePageData | null> {
47
+ const region = await getRegion(countryCode)
48
+ if (!region) {
49
+ return null
50
+ }
51
+
52
+ const [
53
+ collectionsResult,
54
+ categoriesResult,
55
+ newArrivalsResult,
56
+ bestsellersResult,
57
+ testimonialsData,
58
+ allRatings,
59
+ homeBanners,
60
+ appBanners,
61
+ featuresConfig,
62
+ ] = await Promise.all([
63
+ listCollections({ fields: "id, handle, title, metadata" }).catch(() => ({
64
+ collections: [],
65
+ })),
66
+ listCategories({ limit: 50 }).catch(() => []),
67
+ listProductsWithSort({
68
+ page: 1,
69
+ queryParams: { limit: 8 },
70
+ sortBy: "created_at",
71
+ countryCode,
72
+ }).catch(() => ({ response: { products: [] } })),
73
+ getProductsByTag({
74
+ tagValue: "bestseller",
75
+ limit: 10,
76
+ countryCode,
77
+ }).catch(() => []),
78
+ getTestimonialsFromConfig().catch(() => null),
79
+ fetchRatings(),
80
+ getHomeBannersFromConfig().catch(() => null),
81
+ getAppBannersFromConfig().catch(() => null),
82
+ getFeaturesFromConfig().catch(() => null),
83
+ ])
84
+
85
+ const collections =
86
+ (collectionsResult as { collections?: unknown[] }).collections || []
87
+ const categories = categoriesResult || []
88
+ const newArrivals =
89
+ (newArrivalsResult as { response: { products: unknown[] } }).response.products || []
90
+ const bestsellers = bestsellersResult || []
91
+
92
+ return {
93
+ hero: buildBannerData(
94
+ homeBanners as BannerData[] | null,
95
+ appBanners as BannerData[] | null
96
+ ),
97
+ shopByAge: { collections: collections as HomePageData["shopByAge"]["collections"] },
98
+ shopByCategory: {
99
+ categories: categories as HomePageData["shopByCategory"]["categories"],
100
+ },
101
+ whyChooseUs: {
102
+ title: featuresConfig?.title || "Why Choose Chocomelon?",
103
+ features: featuresConfig?.features || [],
104
+ },
105
+ newArrivals: {
106
+ products: newArrivals as HomePageData["newArrivals"]["products"],
107
+ region,
108
+ ratings: allRatings,
109
+ },
110
+ lovedByMoms: {
111
+ products: bestsellers as HomePageData["lovedByMoms"]["products"],
112
+ region,
113
+ ratings: allRatings,
114
+ },
115
+ testimonials: { initialData: testimonialsData },
116
+ features: {},
117
+ }
118
+ }
@@ -0,0 +1,73 @@
1
+ import { listCartOptions, retrieveCart } from "@core/data/cart"
2
+ import { retrieveCustomer } from "@core/data/customer"
3
+ import { listCategories } from "@core/data/categories"
4
+ import { listRegions } from "@core/data/regions"
5
+ import { getLocale } from "@core/data/locale-actions"
6
+ import { getPromoBarConfig, getSocialLinksFromConfig } from "@core/data/dynamic-config"
7
+ import type { MainLayoutData } from "@core/types/layout"
8
+
9
+ export async function loadLayoutData(): Promise<MainLayoutData> {
10
+ const [
11
+ customer,
12
+ cart,
13
+ regions,
14
+ currentLocale,
15
+ promoBarConfig,
16
+ allCategories,
17
+ fetchedSocialLinks,
18
+ ] = await Promise.all([
19
+ retrieveCustomer(),
20
+ retrieveCart(),
21
+ listRegions(),
22
+ getLocale(),
23
+ getPromoBarConfig(),
24
+ listCategories({ limit: 20 }),
25
+ getSocialLinksFromConfig(),
26
+ ])
27
+
28
+ let shippingOptions: MainLayoutData["shippingOptions"] = []
29
+ if (cart) {
30
+ const { shipping_options } = await listCartOptions()
31
+ shippingOptions = shipping_options
32
+ }
33
+
34
+ const categories = allCategories.filter((c) => !c.parent_category_id).slice(0, 6)
35
+
36
+ const defaultSocialLinks = [
37
+ { name: "LinkedIn", url: "#", icon: "/LINK.svg" },
38
+ { name: "Facebook", url: "#", icon: "/FACE.svg" },
39
+ { name: "Instagram", url: "#", icon: "/INSTA.svg" },
40
+ { name: "YouTube", url: "#", icon: "/YOU.svg" },
41
+ ]
42
+
43
+ const getFallbackIcon = (name: string) => {
44
+ const n = name.toLowerCase()
45
+ if (n.includes("facebook")) return "/FACE.svg"
46
+ if (n.includes("linkedin")) return "/LINK.svg"
47
+ if (n.includes("instagram")) return "/INSTA.svg"
48
+ if (n.includes("youtube")) return "/YOU.svg"
49
+ return null
50
+ }
51
+
52
+ const socialLinks = (
53
+ fetchedSocialLinks && fetchedSocialLinks.length > 0
54
+ ? fetchedSocialLinks
55
+ : defaultSocialLinks
56
+ ).map((link) => ({
57
+ ...link,
58
+ icon: link.icon || getFallbackIcon(link.name),
59
+ }))
60
+
61
+ return {
62
+ customer,
63
+ cart,
64
+ shippingOptions,
65
+ nav: {
66
+ regions,
67
+ currentLocale: currentLocale || "in",
68
+ customer,
69
+ },
70
+ promoBar: promoBarConfig,
71
+ footer: { categories, socialLinks },
72
+ }
73
+ }
@@ -0,0 +1,46 @@
1
+ import { WishlistProvider } from "@core/context/wishlist-context"
2
+ import { resolveComponent } from "@registry/resolve-component"
3
+ import type { MainLayoutData } from "@core/types/layout"
4
+ import { MainLayoutShell } from "@theme/layouts/MainLayoutShell"
5
+ import CartMismatchBanner from "@modules/layout/components/cart-mismatch-banner"
6
+ import FreeShippingPriceNudge from "@modules/shipping/components/free-shipping-price-nudge"
7
+ import PushNotificationManager from "@modules/layout/components/push-notification-manager"
8
+ import DeletionPendingModal from "@modules/account/components/deletion-pending-modal"
9
+ import VerificationBanner from "@modules/layout/components/verification-banner"
10
+
11
+ type MainLayoutProps = {
12
+ children: React.ReactNode
13
+ data: MainLayoutData
14
+ }
15
+
16
+ export async function MainLayout({ children, data }: MainLayoutProps) {
17
+ const [PromoBar, Nav, Footer] = await Promise.all([
18
+ resolveComponent("slots/layout/PromoBar").then((m) => m.default),
19
+ resolveComponent("slots/layout/Nav").then((m) => m.default),
20
+ resolveComponent("slots/layout/Footer").then((m) => m.default),
21
+ ])
22
+
23
+ const { customer, cart, shippingOptions } = data
24
+
25
+ return (
26
+ <WishlistProvider>
27
+ <PromoBar {...data.promoBar} />
28
+ <Nav {...data.nav} />
29
+ {customer && <VerificationBanner customer={customer} />}
30
+ {customer && cart && <CartMismatchBanner customer={customer} cart={cart} />}
31
+ {cart && (
32
+ <FreeShippingPriceNudge
33
+ variant="popup"
34
+ cart={cart}
35
+ shippingOptions={shippingOptions}
36
+ />
37
+ )}
38
+ <MainLayoutShell>{children}</MainLayoutShell>
39
+ <PushNotificationManager customerId={customer?.id} />
40
+ <Footer {...data.footer} />
41
+ {customer?.id === "pending_deletion" && (
42
+ <DeletionPendingModal isOpen={true} email={customer.email} />
43
+ )}
44
+ </WishlistProvider>
45
+ )
46
+ }
@@ -0,0 +1,8 @@
1
+ import { retrieveOrder } from "@core/data/orders"
2
+ import type { HttpTypes } from "@medusajs/types"
3
+
4
+ export async function loadOrderData(
5
+ orderId: string
6
+ ): Promise<HttpTypes.StoreOrder | null> {
7
+ return retrieveOrder(orderId).catch(() => null)
8
+ }
@@ -0,0 +1,12 @@
1
+ import { resolveComponent } from "@registry/resolve-component"
2
+ import type { HttpTypes } from "@medusajs/types"
3
+
4
+ type OrderDetailsControllerProps = {
5
+ order: HttpTypes.StoreOrder
6
+ }
7
+
8
+ export async function OrderDetailsController({ order }: OrderDetailsControllerProps) {
9
+ const OrderDetails = (await resolveComponent("slots/order/OrderDetails")).default
10
+
11
+ return <OrderDetails order={order} />
12
+ }
@@ -0,0 +1,33 @@
1
+ import { listProducts } from "@core/data/products"
2
+ import { retrieveCart } from "@core/data/cart"
3
+ import { HttpTypes } from "@medusajs/types"
4
+ import { resolveComponent } from "@registry/resolve-component"
5
+ import { storefrontConfig } from "@registry/storefront.config"
6
+
7
+ /**
8
+ * Fetches real-time pricing for a product and renders the theme ProductActions slot.
9
+ */
10
+ export default async function ProductActionsWrapper({
11
+ id,
12
+ region,
13
+ }: {
14
+ id: string
15
+ region: HttpTypes.StoreRegion
16
+ }) {
17
+ const [product, cart] = await Promise.all([
18
+ listProducts({
19
+ queryParams: { id: [id] },
20
+ regionId: region.id,
21
+ }).then(({ response }) => response.products[0]),
22
+ retrieveCart().catch(() => null),
23
+ ])
24
+
25
+ if (!product) {
26
+ return null
27
+ }
28
+
29
+ const mod = await resolveComponent(storefrontConfig.slots.product.ProductActions)
30
+ const ProductActions = mod.default
31
+
32
+ return <ProductActions product={product} region={region} cart={cart} />
33
+ }
@@ -0,0 +1,98 @@
1
+ import React, { Suspense } from "react"
2
+ import { notFound } from "next/navigation"
3
+ import { HttpTypes } from "@medusajs/types"
4
+ import ImageGallery from "@modules/products/components/image-gallery"
5
+ import RelatedProducts from "@modules/products/components/related-products"
6
+ import SkeletonRelatedProducts from "@modules/skeletons/templates/skeleton-related-products"
7
+ import Breadcrumb from "@modules/common/components/breadcrumb"
8
+ import { ProductProvider } from "@modules/products/context/product-context"
9
+ import ProductActionsWrapper from "@controllers/product/product-actions-wrapper"
10
+ import { resolveComponent } from "@registry/resolve-component"
11
+ import { storefrontConfig } from "@registry/storefront.config"
12
+
13
+ type ProductPageProps = {
14
+ product: HttpTypes.StoreProduct
15
+ region: HttpTypes.StoreRegion
16
+ countryCode: string
17
+ images: HttpTypes.StoreProductImage[]
18
+ }
19
+
20
+ async function ProductActionsFallback({
21
+ product,
22
+ region,
23
+ }: {
24
+ product: HttpTypes.StoreProduct
25
+ region: HttpTypes.StoreRegion
26
+ }) {
27
+ const mod = await resolveComponent(storefrontConfig.slots.product.ProductActions)
28
+ const ProductActions = mod.default
29
+
30
+ return <ProductActions disabled product={product} region={region} />
31
+ }
32
+
33
+ export async function ProductPage({
34
+ product,
35
+ region,
36
+ countryCode,
37
+ images,
38
+ }: ProductPageProps) {
39
+ if (!product?.id) {
40
+ return notFound()
41
+ }
42
+
43
+ const ProductInfo = (
44
+ await resolveComponent(storefrontConfig.slots.product.ProductInfo)
45
+ ).default
46
+
47
+ return (
48
+ <>
49
+ <div className="bg-page-bg min-h-screen">
50
+ <ProductProvider>
51
+ <div
52
+ className="mx-auto px-4 sm:px-6 py-6 lg:py-10"
53
+ style={{ maxWidth: "var(--container-max)" }}
54
+ >
55
+ <Breadcrumb product={product} />
56
+
57
+ <div
58
+ className="flex flex-col lg:flex-row gap-8 lg:gap-14 mt-6 lg:mt-8"
59
+ data-testid="product-container"
60
+ >
61
+ <div
62
+ id="product-gallery-container"
63
+ className="w-full lg:w-[55%] lg:sticky lg:top-28 lg:self-start"
64
+ >
65
+ <ImageGallery images={images} product={product} />
66
+ </div>
67
+
68
+ <div className="w-full lg:w-[45%] flex flex-col gap-6 lg:pt-2">
69
+ <ProductInfo product={product} region={region} />
70
+ <Suspense
71
+ fallback={
72
+ <ProductActionsFallback product={product} region={region} />
73
+ }
74
+ >
75
+ <ProductActionsWrapper id={product.id} region={region} />
76
+ </Suspense>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </ProductProvider>
81
+ </div>
82
+
83
+ <div
84
+ id="related-products"
85
+ className="mx-auto px-4 sm:px-6 py-12 lg:py-20 border-t border-[var(--color-header-border)]"
86
+ style={{ maxWidth: "var(--container-max)" }}
87
+ data-testid="related-products-container"
88
+ >
89
+ <h2 className="font-display text-2xl md:text-3xl text-heading text-center mb-10">
90
+ You may also like
91
+ </h2>
92
+ <Suspense fallback={<SkeletonRelatedProducts />}>
93
+ <RelatedProducts product={product} countryCode={countryCode} />
94
+ </Suspense>
95
+ </div>
96
+ </>
97
+ )
98
+ }