@akinon/projectzero 2.0.39-beta.0 → 2.0.39

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/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @akinon/projectzero
2
2
 
3
- ## 2.0.39-beta.0
3
+ ## 2.0.39
4
4
 
5
5
  ## 2.0.38
6
6
 
@@ -1,37 +1,37 @@
1
1
  # projectzeronext
2
2
 
3
- ## 2.0.39-beta.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [cbbbfd75]
8
- - @akinon/next@2.0.39-beta.0
9
- - @akinon/pz-theme@2.0.39-beta.0
10
- - @akinon/pz-virtual-try-on@2.0.39-beta.0
11
- - @akinon/pz-akifast@2.0.39-beta.0
12
- - @akinon/pz-apple-pay@2.0.39-beta.0
13
- - @akinon/pz-b2b@2.0.39-beta.0
14
- - @akinon/pz-basket-gift-pack@2.0.39-beta.0
15
- - @akinon/pz-bkm@2.0.39-beta.0
16
- - @akinon/pz-checkout-gift-pack@2.0.39-beta.0
17
- - @akinon/pz-click-collect@2.0.39-beta.0
18
- - @akinon/pz-credit-payment@2.0.39-beta.0
19
- - @akinon/pz-cybersource-uc@2.0.39-beta.0
20
- - @akinon/pz-flow-payment@2.0.39-beta.0
21
- - @akinon/pz-google-pay@2.0.39-beta.0
22
- - @akinon/pz-gpay@2.0.39-beta.0
23
- - @akinon/pz-haso@2.0.39-beta.0
24
- - @akinon/pz-hepsipay@2.0.39-beta.0
25
- - @akinon/pz-masterpass@2.0.39-beta.0
26
- - @akinon/pz-masterpass-rest@2.0.39-beta.0
27
- - @akinon/pz-multi-basket@2.0.39-beta.0
28
- - @akinon/pz-one-click-checkout@2.0.39-beta.0
29
- - @akinon/pz-otp@2.0.39-beta.0
30
- - @akinon/pz-pay-on-delivery@2.0.39-beta.0
31
- - @akinon/pz-saved-card@2.0.39-beta.0
32
- - @akinon/pz-similar-products@2.0.39-beta.0
33
- - @akinon/pz-tabby-extension@2.0.39-beta.0
34
- - @akinon/pz-tamara-extension@2.0.39-beta.0
3
+ ## 2.0.39
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [115c5602]
8
+ - @akinon/pz-theme@2.0.39
9
+ - @akinon/next@2.0.39
10
+ - @akinon/pz-akifast@2.0.39
11
+ - @akinon/pz-apple-pay@2.0.39
12
+ - @akinon/pz-b2b@2.0.39
13
+ - @akinon/pz-basket-gift-pack@2.0.39
14
+ - @akinon/pz-bkm@2.0.39
15
+ - @akinon/pz-checkout-gift-pack@2.0.39
16
+ - @akinon/pz-click-collect@2.0.39
17
+ - @akinon/pz-credit-payment@2.0.39
18
+ - @akinon/pz-cybersource-uc@2.0.39
19
+ - @akinon/pz-flow-payment@2.0.39
20
+ - @akinon/pz-google-pay@2.0.39
21
+ - @akinon/pz-gpay@2.0.39
22
+ - @akinon/pz-haso@2.0.39
23
+ - @akinon/pz-hepsipay@2.0.39
24
+ - @akinon/pz-masterpass@2.0.39
25
+ - @akinon/pz-masterpass-rest@2.0.39
26
+ - @akinon/pz-multi-basket@2.0.39
27
+ - @akinon/pz-one-click-checkout@2.0.39
28
+ - @akinon/pz-otp@2.0.39
29
+ - @akinon/pz-pay-on-delivery@2.0.39
30
+ - @akinon/pz-saved-card@2.0.39
31
+ - @akinon/pz-similar-products@2.0.39
32
+ - @akinon/pz-tabby-extension@2.0.39
33
+ - @akinon/pz-tamara-extension@2.0.39
34
+ - @akinon/pz-virtual-try-on@2.0.39
35
35
 
36
36
  ## 2.0.38
37
37
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectzeronext",
3
- "version": "2.0.39-beta.0",
3
+ "version": "2.0.39",
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "scripts": {
@@ -24,33 +24,33 @@
24
24
  "test:middleware": "jest middleware-matcher.test.ts --bail"
25
25
  },
26
26
  "dependencies": {
27
- "@akinon/next": "2.0.39-beta.0",
28
- "@akinon/pz-akifast": "2.0.39-beta.0",
29
- "@akinon/pz-apple-pay": "2.0.39-beta.0",
30
- "@akinon/pz-b2b": "2.0.39-beta.0",
31
- "@akinon/pz-basket-gift-pack": "2.0.39-beta.0",
32
- "@akinon/pz-bkm": "2.0.39-beta.0",
33
- "@akinon/pz-checkout-gift-pack": "2.0.39-beta.0",
34
- "@akinon/pz-click-collect": "2.0.39-beta.0",
35
- "@akinon/pz-credit-payment": "2.0.39-beta.0",
36
- "@akinon/pz-cybersource-uc": "2.0.39-beta.0",
37
- "@akinon/pz-flow-payment": "2.0.39-beta.0",
38
- "@akinon/pz-google-pay": "2.0.39-beta.0",
39
- "@akinon/pz-gpay": "2.0.39-beta.0",
40
- "@akinon/pz-haso": "2.0.39-beta.0",
41
- "@akinon/pz-hepsipay": "2.0.39-beta.0",
42
- "@akinon/pz-masterpass": "2.0.39-beta.0",
43
- "@akinon/pz-masterpass-rest": "2.0.39-beta.0",
44
- "@akinon/pz-multi-basket": "2.0.39-beta.0",
45
- "@akinon/pz-one-click-checkout": "2.0.39-beta.0",
46
- "@akinon/pz-otp": "2.0.39-beta.0",
47
- "@akinon/pz-pay-on-delivery": "2.0.39-beta.0",
48
- "@akinon/pz-saved-card": "2.0.39-beta.0",
49
- "@akinon/pz-similar-products": "2.0.39-beta.0",
50
- "@akinon/pz-tabby-extension": "2.0.39-beta.0",
51
- "@akinon/pz-tamara-extension": "2.0.39-beta.0",
52
- "@akinon/pz-theme": "2.0.39-beta.0",
53
- "@akinon/pz-virtual-try-on": "2.0.39-beta.0",
27
+ "@akinon/next": "2.0.39",
28
+ "@akinon/pz-akifast": "2.0.39",
29
+ "@akinon/pz-apple-pay": "2.0.39",
30
+ "@akinon/pz-b2b": "2.0.39",
31
+ "@akinon/pz-basket-gift-pack": "2.0.39",
32
+ "@akinon/pz-bkm": "2.0.39",
33
+ "@akinon/pz-checkout-gift-pack": "2.0.39",
34
+ "@akinon/pz-click-collect": "2.0.39",
35
+ "@akinon/pz-credit-payment": "2.0.39",
36
+ "@akinon/pz-cybersource-uc": "2.0.39",
37
+ "@akinon/pz-flow-payment": "2.0.39",
38
+ "@akinon/pz-google-pay": "2.0.39",
39
+ "@akinon/pz-gpay": "2.0.39",
40
+ "@akinon/pz-haso": "2.0.39",
41
+ "@akinon/pz-hepsipay": "2.0.39",
42
+ "@akinon/pz-masterpass": "2.0.39",
43
+ "@akinon/pz-masterpass-rest": "2.0.39",
44
+ "@akinon/pz-multi-basket": "2.0.39",
45
+ "@akinon/pz-one-click-checkout": "2.0.39",
46
+ "@akinon/pz-otp": "2.0.39",
47
+ "@akinon/pz-pay-on-delivery": "2.0.39",
48
+ "@akinon/pz-saved-card": "2.0.39",
49
+ "@akinon/pz-similar-products": "2.0.39",
50
+ "@akinon/pz-tabby-extension": "2.0.39",
51
+ "@akinon/pz-tamara-extension": "2.0.39",
52
+ "@akinon/pz-theme": "2.0.39",
53
+ "@akinon/pz-virtual-try-on": "2.0.39",
54
54
  "@hookform/resolvers": "2.9.0",
55
55
  "@next/third-parties": "16.2.6",
56
56
  "@react-google-maps/api": "2.17.1",
@@ -74,7 +74,7 @@
74
74
  "yup": "0.32.11"
75
75
  },
76
76
  "devDependencies": {
77
- "@akinon/eslint-plugin-projectzero": "2.0.39-beta.0",
77
+ "@akinon/eslint-plugin-projectzero": "2.0.39",
78
78
  "@semantic-release/changelog": "6.0.2",
79
79
  "@semantic-release/exec": "6.0.3",
80
80
  "@semantic-release/git": "10.0.1",
@@ -1,6 +1,8 @@
1
1
  import AccountBackButton from '@theme/views/account/back-button';
2
2
  import AccountMenu from '@theme/views/account/account-menu';
3
- import ThemePlaceholder from '@akinon/pz-theme/src/theme-placeholder';
3
+ import ChromeSlot from '@akinon/pz-theme/src/chrome/chrome-slot';
4
+ import { PLACEHOLDER_SLUGS } from '@akinon/pz-theme/src/chrome/placeholder-slugs';
5
+ import { buildAccountPageContext } from '@akinon/pz-theme/src/chrome/page-context';
4
6
  import { auth } from 'auth';
5
7
  import { ROUTES } from 'routes';
6
8
  import { redirect } from '@akinon/next/utils/redirect';
@@ -21,7 +23,16 @@ export default async function AccountLayout({
21
23
  <AccountMenu />
22
24
  <div className="w-full flex-1">
23
25
  <AccountBackButton />
24
- <ThemePlaceholder slug="account-page-body" />
26
+ {/* The session is already awaited above, so the account context costs
27
+ no extra fetch. Profile-only fields (phone, gender) stay empty
28
+ until a live provider merges them in. */}
29
+ <ChromeSlot
30
+ slug={PLACEHOLDER_SLUGS.accountShell}
31
+ pageContext={buildAccountPageContext({
32
+ path: ROUTES.ACCOUNT,
33
+ sessionUser: session.user as Record<string, unknown>
34
+ })}
35
+ />
25
36
  {children}
26
37
  </div>
27
38
  </div>
@@ -1,5 +1,29 @@
1
1
  import React from 'react';
2
+ import ChromeSlot from '@akinon/pz-theme/src/chrome/chrome-slot';
3
+ import { PLACEHOLDER_SLUGS } from '@akinon/pz-theme/src/chrome/placeholder-slugs';
4
+ import { LiveAccountOrderPageContext } from '@akinon/pz-theme/src/chrome/live-order-page-context';
2
5
 
3
- export default function Layout({ children }: { children: React.ReactNode }) {
4
- return <div>{children}</div>;
6
+ /**
7
+ * Account order detail. Publishes the SAME `order` root as the success page,
8
+ * so a composition authored on one resolves on the other.
9
+ */
10
+ export default async function Layout({
11
+ children,
12
+ params
13
+ }: {
14
+ children: React.ReactNode;
15
+ params: Promise<{ id: string }>;
16
+ }) {
17
+ const { id } = await params;
18
+
19
+ return (
20
+ <div>
21
+ <LiveAccountOrderPageContext orderId={id}>
22
+ <ChromeSlot
23
+ slug={PLACEHOLDER_SLUGS.accountOrderDetail}
24
+ fallback={children}
25
+ />
26
+ </LiveAccountOrderPageContext>
27
+ </div>
28
+ );
5
29
  }
@@ -0,0 +1,25 @@
1
+ import ChromeSlot from '@akinon/pz-theme/src/chrome/chrome-slot';
2
+ import { LiveBasketPageContext } from '@akinon/pz-theme/src/chrome/live-basket-page-context';
3
+ import { PLACEHOLDER_SLUGS } from '@akinon/pz-theme/src/chrome/placeholder-slugs';
4
+ import { ROUTES } from '@theme/routes';
5
+
6
+ /**
7
+ * The basket body is handed to the theme editor here rather than in page.tsx,
8
+ * because the slot is an async server component and the page is a client one.
9
+ * With no composition published the slot renders the page untouched.
10
+ *
11
+ * The provider sits above the slot so the composition binds the shopper's live
12
+ * basket: the basket is session-scoped and has no server data module, so a
13
+ * server-built context would be a snapshot that never moves.
14
+ */
15
+ export default function BasketLayout({
16
+ children
17
+ }: {
18
+ children: React.ReactNode;
19
+ }) {
20
+ return (
21
+ <LiveBasketPageContext path={ROUTES.BASKET}>
22
+ <ChromeSlot slug={PLACEHOLDER_SLUGS.basket} fallback={children} />
23
+ </LiveBasketPageContext>
24
+ );
25
+ }
@@ -2,7 +2,7 @@ import { getCategoryData } from '@akinon/next/data/server';
2
2
  import { withSegmentDefaults } from '@akinon/next/hocs/server';
3
3
  import { ResolvedPageProps } from '@akinon/next/types';
4
4
  import CategoryLayout from '@theme/views/category/layout';
5
- import { buildListingPageContext } from '@theme/utils/theme-page-context';
5
+ import { buildListingPageContext } from '@akinon/pz-theme/src/chrome/page-context';
6
6
  import { notFound } from 'next/navigation';
7
7
 
8
8
  async function Page({ params, searchParams }: ResolvedPageProps<{ pk: number }>) {
@@ -1,5 +1,7 @@
1
1
  'use client';
2
2
 
3
+ import './configure-page-context';
4
+
3
5
  export default function ClientRoot({
4
6
  children
5
7
  }: {
@@ -0,0 +1,17 @@
1
+ import { configurePageContextRoutes } from '@akinon/pz-theme/src/chrome/page-context';
2
+ import { ROUTES, ORDER_ROUTES, ACCOUNT_ROUTES } from '@theme/routes';
3
+
4
+ /**
5
+ * The packaged page-context builders default to `/basket`; this storefront
6
+ * routes it at `/baskets/basket`. Without this the published context would
7
+ * carry a basket path no link block could follow.
8
+ *
9
+ * Imported from BOTH the server layout and the client root: the routes live in
10
+ * module state, and the client bundle gets its own copy of that module.
11
+ */
12
+ configurePageContextRoutes({
13
+ home: ROUTES.HOME,
14
+ basket: ROUTES.BASKET,
15
+ checkout: ORDER_ROUTES.CHECKOUT,
16
+ account: ACCOUNT_ROUTES.ACCOUNT
17
+ });
@@ -1,3 +1,6 @@
1
+ import ChromeSlot from '@akinon/pz-theme/src/chrome/chrome-slot';
2
+ import { PLACEHOLDER_SLUGS } from '@akinon/pz-theme/src/chrome/placeholder-slugs';
3
+ import { buildFlatPageContext } from '@akinon/pz-theme/src/chrome/page-context';
1
4
  import { getFlatPageData, getSeoData } from '@akinon/next/data/server';
2
5
  import { withSegmentDefaults } from '@akinon/next/hocs/server';
3
6
  import {
@@ -65,9 +68,17 @@ async function Page({ params }: ResolvedPageProps<{ pk: number }>) {
65
68
 
66
69
  return (
67
70
  <div className="container mx-auto py-6">
68
- <div
69
- className="mx-auto prose prose-headings:text-primary"
70
- dangerouslySetInnerHTML={{ __html: data.flat_page.content }}
71
+ <ChromeSlot
72
+ slug={PLACEHOLDER_SLUGS.flatPage}
73
+ pageContext={buildFlatPageContext(
74
+ data.flat_page as unknown as Record<string, unknown>
75
+ )}
76
+ fallback={
77
+ <div
78
+ className="mx-auto prose prose-headings:text-primary"
79
+ dangerouslySetInnerHTML={{ __html: data.flat_page.content }}
80
+ />
81
+ }
71
82
  />
72
83
  </div>
73
84
  );
@@ -16,6 +16,7 @@ import { InstallPrompt } from '@theme/components/install-prompt';
16
16
  import type { Viewport } from 'next';
17
17
  import settings from '@theme/settings';
18
18
  import RouteHandler from '@theme/components/route-handler';
19
+ import './configure-page-context';
19
20
 
20
21
  export const viewport: Viewport = {
21
22
  width: 'device-width',
@@ -3,7 +3,7 @@ import { withSegmentDefaults } from '@akinon/next/hocs/server';
3
3
  import { ResolvedPageProps } from '@akinon/next/types';
4
4
  import CategoryLayout from '@theme/views/category/layout';
5
5
  import { ROUTES } from '@theme/routes';
6
- import { buildListingPageContext } from '@theme/utils/theme-page-context';
6
+ import { buildListingPageContext } from '@akinon/pz-theme/src/chrome/page-context';
7
7
 
8
8
  async function Page({ searchParams }: ResolvedPageProps) {
9
9
  const data = await getListData({ searchParams });
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import ChromeSlot from '@akinon/pz-theme/src/chrome/chrome-slot';
3
+ import { PLACEHOLDER_SLUGS } from '@akinon/pz-theme/src/chrome/placeholder-slugs';
4
+ import { LiveCheckoutPageContext } from '@akinon/pz-theme/src/chrome/live-checkout-page-context';
5
+
6
+ /**
7
+ * Checkout is code-owned: only the zones above and below the flow are
8
+ * composable, so neither slot takes `fallback={children}` — a published
9
+ * composition must never be able to replace the payment steps.
10
+ */
11
+ export default function CheckoutLayout({
12
+ children
13
+ }: {
14
+ children: React.ReactNode;
15
+ }) {
16
+ return (
17
+ <LiveCheckoutPageContext>
18
+ <ChromeSlot slug={PLACEHOLDER_SLUGS.checkoutTop} />
19
+ {children}
20
+ <ChromeSlot slug={PLACEHOLDER_SLUGS.checkoutBottom} />
21
+ </LiveCheckoutPageContext>
22
+ );
23
+ }
@@ -1,5 +1,27 @@
1
1
  import React from 'react';
2
+ import ChromeSlot from '@akinon/pz-theme/src/chrome/chrome-slot';
3
+ import { PLACEHOLDER_SLUGS } from '@akinon/pz-theme/src/chrome/placeholder-slugs';
4
+ import { LiveOrderSuccessPageContext } from '@akinon/pz-theme/src/chrome/live-order-page-context';
2
5
 
3
- export default function Layout({ children }: { children: React.ReactNode }) {
4
- return <div>{children}</div>;
6
+ /**
7
+ * The order-success body is composable and binds the shopper's own order.
8
+ * There is no server order fetcher, so the context comes from the client
9
+ * provider — which also keeps the order out of any cached shell.
10
+ */
11
+ export default async function Layout({
12
+ children,
13
+ params
14
+ }: {
15
+ children: React.ReactNode;
16
+ params: Promise<{ token: string }>;
17
+ }) {
18
+ const { token } = await params;
19
+
20
+ return (
21
+ <div>
22
+ <LiveOrderSuccessPageContext token={token}>
23
+ <ChromeSlot slug={PLACEHOLDER_SLUGS.orderSuccess} fallback={children} />
24
+ </LiveOrderSuccessPageContext>
25
+ </div>
26
+ );
5
27
  }
@@ -2,7 +2,7 @@ import 'server-only';
2
2
 
3
3
  import { withSegmentDefaults } from '@akinon/next/hocs/server';
4
4
  import ThemePlaceholder from '@akinon/pz-theme/src/theme-placeholder';
5
- import { buildHomepagePageContext } from '@theme/utils/theme-page-context';
5
+ import { buildHomepagePageContext } from '@akinon/pz-theme/src/chrome/page-context';
6
6
 
7
7
  export const dynamic = 'force-static';
8
8
  export const revalidate = 600;
@@ -1,7 +1,7 @@
1
1
  import ThemePlaceholder from '@akinon/pz-theme/src/theme-placeholder';
2
2
  import { withSegmentDefaults } from '@akinon/next/hocs/server';
3
3
  import { ResolvedPageProps } from '@akinon/next/types';
4
- import { buildCustomPageContext } from '@theme/utils/theme-page-context';
4
+ import { buildCustomPageContext } from '@akinon/pz-theme/src/chrome/page-context';
5
5
 
6
6
  export const dynamic = 'force-dynamic';
7
7
 
@@ -2,6 +2,7 @@ import { getSpecialPageData } from '@akinon/next/data/server';
2
2
  import { withSegmentDefaults } from '@akinon/next/hocs/server';
3
3
  import { ResolvedPageProps } from '@akinon/next/types';
4
4
  import CategoryLayout from '@theme/views/category/layout';
5
+ import { buildListingPageContext } from '@akinon/pz-theme/src/chrome/page-context';
5
6
  import SpecialPageBanner from '@theme/widgets/special-page-banner';
6
7
  import SpecialPageCarousel from '@theme/widgets/special-page-carousel';
7
8
  import { notFound } from 'next/navigation';
@@ -12,7 +13,15 @@ async function Page({ params, searchParams }: ResolvedPageProps<{ pk: number }>)
12
13
 
13
14
  return (
14
15
  <>
15
- <CategoryLayout data={data}>
16
+ <CategoryLayout
17
+ data={data}
18
+ pageContext={buildListingPageContext({
19
+ data,
20
+ pageType: 'special',
21
+ path: `/special-page/${params.pk}`,
22
+ title: (data.special_page as { name?: string } | undefined)?.name
23
+ })}
24
+ >
16
25
  {data.special_page && (
17
26
  <>
18
27
  <SpecialPageBanner data={data.special_page} />
@@ -3,11 +3,14 @@ import { BreadcrumbResultType, GetCategoryResponse } from '@akinon/next/types';
3
3
  import Breadcrumb from '@theme/views/breadcrumb';
4
4
  import { CategoryBanner } from '@theme/views/category/category-banner';
5
5
  import ListPage from '@theme/views/category/category-info';
6
+ import ChromeSlot from '@akinon/pz-theme/src/chrome/chrome-slot';
7
+ import { PLACEHOLDER_SLUGS } from '@akinon/pz-theme/src/chrome/placeholder-slugs';
6
8
 
7
9
  export default async function Layout({
8
10
  data,
9
11
  children,
10
- breadcrumbData
12
+ breadcrumbData,
13
+ pageContext
11
14
  }: {
12
15
  data: GetCategoryResponse;
13
16
  children?: React.ReactNode;
@@ -35,7 +38,14 @@ export default async function Layout({
35
38
  </div>
36
39
  <CategoryBanner {...data?.category?.attributes?.category_banner} />
37
40
  </div>
38
- <ListPage data={data} />
41
+ {/* The listing context was built on every request and thrown away: the
42
+ prop was declared and never read, so no listing binding has ever
43
+ resolved on any brand. */}
44
+ <ChromeSlot
45
+ slug={PLACEHOLDER_SLUGS.productListing}
46
+ fallback={<ListPage data={data} />}
47
+ pageContext={pageContext}
48
+ />
39
49
  </>
40
50
  );
41
51
  }
@@ -1,8 +1,9 @@
1
1
  import Breadcrumb from '@theme/views/breadcrumb';
2
2
  import { ProductInfoSlider } from '@theme/views/product';
3
- import ThemePlaceholder from '@akinon/pz-theme/src/theme-placeholder';
3
+ import ChromeSlot from '@akinon/pz-theme/src/chrome/chrome-slot';
4
+ import { PLACEHOLDER_SLUGS } from '@akinon/pz-theme/src/chrome/placeholder-slugs';
4
5
  import { BreadcrumbResultType, ProductResult } from '@akinon/next/types';
5
- import { buildProductPageContext } from '@theme/utils/theme-page-context';
6
+ import { buildProductPageContext } from '@akinon/pz-theme/src/chrome/page-context';
6
7
 
7
8
  interface DeliveryReturnItem {
8
9
  product_delivery_returns?: {
@@ -23,7 +24,9 @@ export default async function ProductLayout({
23
24
  breadcrumbData,
24
25
  children
25
26
  }: ProductPageProps) {
26
- const productPageContext = buildProductPageContext(data);
27
+ // Breadcrumbs travel with the product context so the PDP is not the one
28
+ // page type whose composition cannot build a trail.
29
+ const productPageContext = buildProductPageContext(data, breadcrumbData);
27
30
  const categoryIds = breadcrumbData
28
31
  ?.map((item) => item.extra_context?.attributes?.category_id)
29
32
  .filter(Boolean)
@@ -51,8 +54,8 @@ export default async function ProductLayout({
51
54
  <div className="w-full">{children}</div>
52
55
  </div>
53
56
  </div>
54
- <ThemePlaceholder
55
- slug="product-page-new"
57
+ <ChromeSlot
58
+ slug={PLACEHOLDER_SLUGS.productDetail}
56
59
  pageContext={productPageContext}
57
60
  />
58
61
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akinon/projectzero",
3
- "version": "2.0.39-beta.0",
3
+ "version": "2.0.39",
4
4
  "private": false,
5
5
  "description": "CLI tool to manage your Project Zero Next project",
6
6
  "bin": {
@@ -1,148 +0,0 @@
1
- import {
2
- buildAccountPageContext,
3
- buildBasketPageContext,
4
- buildCustomPageContext,
5
- buildHomepagePageContext,
6
- buildListingPageContext,
7
- buildProductPageContext
8
- } from '../theme-page-context';
9
-
10
- describe('theme-page-context utils', () => {
11
- it('builds homepage and custom page contexts', () => {
12
- const homepage = buildHomepagePageContext();
13
- const customPage = buildCustomPageContext('summer-campaign');
14
-
15
- expect(homepage.homepage.slug).toBe('homepage');
16
- expect(customPage.customPage.slug).toBe('summer-campaign');
17
- expect(customPage.customPage.title).toBe('Summer Campaign');
18
- });
19
-
20
- it('builds product page context with normalized product aliases', () => {
21
- const productPageContext = buildProductPageContext({
22
- product: {
23
- pk: 1,
24
- name: 'Spring Sneaker',
25
- absolute_url: '/products/spring-sneaker',
26
- productimage_set: [{ image: 'https://cdn.example.com/image.jpg' }],
27
- price: '199.90',
28
- retail_price: '249.90',
29
- currency_type: 'TRY',
30
- attributes_kwargs: {}
31
- },
32
- selected_variant: null,
33
- variants: []
34
- } as never);
35
-
36
- // The normalized aliases (title/url/images) are added dynamically and
37
- // are not part of the declared product type.
38
- const product = productPageContext.product as Record<string, any>;
39
- expect(product.title).toBe('Spring Sneaker');
40
- expect(product.url).toBe('/products/spring-sneaker');
41
- expect(product.images[0].url).toBe('https://cdn.example.com/image.jpg');
42
- });
43
-
44
- it('builds listing context aliases for category/search/plp style pages', () => {
45
- const listingContext = buildListingPageContext({
46
- data: {
47
- category: {
48
- name: 'Sneakers',
49
- absolute_url: '/category/sneakers',
50
- attributes: {
51
- category_seo_title: {
52
- value: {
53
- text: 'Sneaker landing'
54
- }
55
- }
56
- }
57
- },
58
- products: [
59
- {
60
- pk: 1,
61
- name: 'Spring Sneaker',
62
- absolute_url: '/products/spring-sneaker',
63
- productimage_set: [{ image: 'https://cdn.example.com/image.jpg' }],
64
- price: '199.90',
65
- retail_price: '249.90',
66
- currency_type: 'TRY',
67
- attributes_kwargs: {}
68
- }
69
- ],
70
- facets: [{ key: 'color', name: 'Color' }],
71
- pagination: { count: 12 },
72
- sorters: [{ label: 'Newest', value: 'newest' }],
73
- search_text: 'sneaker'
74
- } as never,
75
- breadcrumbData: [
76
- { label: 'Sneakers', path: '/category/sneakers' }
77
- ] as never,
78
- pageType: 'search',
79
- path: '/list'
80
- });
81
-
82
- expect(listingContext.page.type).toBe('search');
83
- expect(listingContext.category.name).toBe('Sneakers');
84
- expect(listingContext.search.query).toBe('sneaker');
85
- expect(listingContext.plp.products).toHaveLength(1);
86
- expect(listingContext.listing.totalProducts).toBe(12);
87
- });
88
-
89
- it('builds basket and account contexts from live data', () => {
90
- const basketContext = buildBasketPageContext({
91
- basket: {
92
- pk: 10,
93
- total_quantity: 3,
94
- total_amount: '799.90',
95
- total_product_amount: '899.90',
96
- total_discount_amount: '100.00',
97
- voucher_code: 'SPRING',
98
- discounts: [],
99
- basketitem_set: [
100
- {
101
- id: 1,
102
- quantity: 2,
103
- price: '399.95',
104
- retail_price: '449.95',
105
- total_amount: '799.90',
106
- unit_price: '399.95',
107
- currency_type: 'TRY',
108
- image: 'https://cdn.example.com/image.jpg',
109
- attributes_kwargs: {
110
- color: { label: 'Color', value: 'Black' }
111
- },
112
- product: {
113
- pk: 1,
114
- name: 'Spring Sneaker',
115
- absolute_url: '/products/spring-sneaker',
116
- productimage_set: [
117
- { image: 'https://cdn.example.com/image.jpg' }
118
- ],
119
- price: '399.95',
120
- retail_price: '449.95',
121
- currency_type: 'TRY',
122
- attributes_kwargs: {}
123
- }
124
- }
125
- ]
126
- } as never,
127
- path: '/baskets/basket'
128
- });
129
-
130
- const accountContext = buildAccountPageContext({
131
- path: '/account/profile',
132
- profileInfo: {
133
- first_name: 'Ada',
134
- last_name: 'Lovelace',
135
- email: 'ada@example.com'
136
- },
137
- sessionUser: {
138
- name: 'Ada Lovelace',
139
- email: 'ada@example.com'
140
- }
141
- });
142
-
143
- expect(basketContext.basket.totalQuantity).toBe(3);
144
- expect(basketContext.basket.items[0].title).toBe('Spring Sneaker');
145
- expect(accountContext.account.user.fullName).toBe('Ada Lovelace');
146
- expect(accountContext.account.route).toBe('/account/profile');
147
- });
148
- });
@@ -1,309 +0,0 @@
1
- import { ROUTES } from '@theme/routes';
2
- import type {
3
- Basket,
4
- BreadcrumbResultType,
5
- GetCategoryResponse,
6
- Product,
7
- ProductResult
8
- } from '@akinon/next/types';
9
-
10
- type UnknownRecord = Record<string, unknown>;
11
-
12
- const toRecord = (value: unknown): UnknownRecord =>
13
- value && typeof value === 'object' ? (value as UnknownRecord) : {};
14
-
15
- const toStringValue = (value: unknown, fallback = ''): string =>
16
- typeof value === 'string' ? value : fallback;
17
-
18
- const humanizeSlug = (value: string) =>
19
- value
20
- .split('-')
21
- .filter(Boolean)
22
- .map(part => part.charAt(0).toUpperCase() + part.slice(1))
23
- .join(' ');
24
-
25
- const normalizeProductImages = (
26
- product?: Product | null
27
- ): Array<Record<string, unknown>> =>
28
- (product?.productimage_set || []).map((item, index) => ({
29
- ...item,
30
- url: item.image,
31
- alt: `${product?.name || 'Product'} ${index + 1}`
32
- }));
33
-
34
- const normalizeAttributeEntries = (
35
- attributes: unknown
36
- ): Array<Record<string, string>> => {
37
- const record = toRecord(attributes);
38
-
39
- return Object.values(record)
40
- .map(entry => {
41
- const typedEntry = toRecord(entry);
42
- const label = toStringValue(typedEntry.label);
43
- const value = toStringValue(typedEntry.value);
44
-
45
- if (!label && !value) {
46
- return null;
47
- }
48
-
49
- return { label, value };
50
- })
51
- .filter((entry): entry is { label: string; value: string } => entry !== null);
52
- };
53
-
54
- export const normalizeProductEntry = (
55
- product?: Product | null
56
- ): Record<string, unknown> | null => {
57
- if (!product) {
58
- return null;
59
- }
60
-
61
- const activePrice = toRecord((product as Product & UnknownRecord).active_price);
62
- const images = normalizeProductImages(product);
63
-
64
- return {
65
- ...product,
66
- title: product.name,
67
- name: product.name,
68
- url: product.absolute_url,
69
- image: product.image || images[0]?.url || '',
70
- images,
71
- price: activePrice.price ?? product.price,
72
- retailPrice: activePrice.retail_price ?? product.retail_price,
73
- currency: activePrice.currency_type ?? product.currency_type ?? '',
74
- description: product.description || '',
75
- attributes: normalizeAttributeEntries(product.attributes_kwargs)
76
- };
77
- };
78
-
79
- export const buildProductPageContext = (data: ProductResult) => {
80
- const normalizedProduct = normalizeProductEntry(data.product);
81
- const normalizedSelectedVariant = normalizeProductEntry(data.selected_variant);
82
-
83
- return {
84
- page: {
85
- type: 'product',
86
- title: normalizedProduct?.title || 'Product',
87
- path: normalizedProduct?.url || ''
88
- },
89
- product: {
90
- ...normalizedProduct,
91
- selectedVariant: normalizedSelectedVariant,
92
- selected_variant: normalizedSelectedVariant,
93
- variants: data.variants || []
94
- }
95
- };
96
- };
97
-
98
- export const buildHomepagePageContext = () => ({
99
- page: {
100
- type: 'homepage',
101
- title: 'Homepage',
102
- slug: 'homepage',
103
- path: ROUTES.HOME
104
- },
105
- homepage: {
106
- title: 'Homepage',
107
- slug: 'homepage',
108
- path: ROUTES.HOME
109
- }
110
- });
111
-
112
- export const buildCustomPageContext = (slug: string) => {
113
- const title = humanizeSlug(slug) || 'Custom Page';
114
- const path = `/pages/${slug}`;
115
-
116
- return {
117
- page: {
118
- type: 'custom-page',
119
- title,
120
- slug,
121
- path
122
- },
123
- customPage: {
124
- title,
125
- slug,
126
- path
127
- }
128
- };
129
- };
130
-
131
- export const buildListingPageContext = ({
132
- data,
133
- breadcrumbData,
134
- pageType,
135
- path
136
- }: {
137
- data: GetCategoryResponse;
138
- breadcrumbData?: BreadcrumbResultType[];
139
- pageType: 'category' | 'search' | 'plp';
140
- path?: string;
141
- }) => {
142
- const normalizedProducts = (data.products || [])
143
- .map(product => normalizeProductEntry(product))
144
- .filter((product): product is Record<string, unknown> => product !== null);
145
- const categorySeoTitle = toStringValue(
146
- data.category?.attributes?.category_seo_title?.value?.text
147
- );
148
- const effectivePath = path || data.category?.absolute_url || ROUTES.HOME;
149
- const totalProducts =
150
- Number(toRecord(data.pagination).count) || normalizedProducts.length;
151
- const title =
152
- pageType === 'search'
153
- ? data.search_text
154
- ? `Search results for ${data.search_text}`
155
- : 'Search Results'
156
- : data.category?.name || 'Product Listing';
157
-
158
- const category = {
159
- ...(data.category || {}),
160
- title: data.category?.name || '',
161
- name: data.category?.name || '',
162
- url: data.category?.absolute_url || '',
163
- description: categorySeoTitle,
164
- seoTitle: categorySeoTitle,
165
- facets: data.facets || [],
166
- products: normalizedProducts
167
- };
168
-
169
- const listingBase = {
170
- title,
171
- path: effectivePath,
172
- query: data.search_text || '',
173
- products: normalizedProducts,
174
- totalProducts,
175
- resultCount: totalProducts,
176
- facets: data.facets || [],
177
- sorters: data.sorters || [],
178
- pagination: data.pagination || {},
179
- category
180
- };
181
-
182
- return {
183
- page: {
184
- type: pageType,
185
- title,
186
- path: effectivePath
187
- },
188
- category,
189
- listing: {
190
- ...listingBase,
191
- breadcrumbs: breadcrumbData || []
192
- },
193
- search: {
194
- ...listingBase,
195
- title: data.search_text
196
- ? `Search results for ${data.search_text}`
197
- : 'Search Results'
198
- },
199
- plp: {
200
- ...listingBase,
201
- title: data.category?.name || 'Product Listing'
202
- },
203
- breadcrumbs: breadcrumbData || []
204
- };
205
- };
206
-
207
- export const buildBasketPageContext = ({
208
- basket,
209
- path
210
- }: {
211
- basket?: Basket | null;
212
- path?: string;
213
- }) => {
214
- const normalizedItems = (basket?.basketitem_set || []).map(item => {
215
- const normalizedProduct = normalizeProductEntry(item.product);
216
-
217
- return {
218
- ...item,
219
- title: normalizedProduct?.title || item.product?.name || '',
220
- name: normalizedProduct?.name || item.product?.name || '',
221
- url: normalizedProduct?.url || item.product?.absolute_url || '',
222
- image:
223
- item.image ||
224
- (typeof normalizedProduct?.image === 'string'
225
- ? normalizedProduct.image
226
- : ''),
227
- price: item.price || normalizedProduct?.price || '',
228
- retailPrice: item.retail_price || normalizedProduct?.retailPrice || '',
229
- totalAmount: item.total_amount || '',
230
- unitPrice: item.unit_price || '',
231
- currency: item.currency_type || normalizedProduct?.currency || '',
232
- quantity: item.quantity,
233
- attributes: normalizeAttributeEntries(
234
- item.attributes_kwargs || item.product?.attributes_kwargs
235
- ),
236
- product: normalizedProduct
237
- };
238
- });
239
-
240
- return {
241
- page: {
242
- type: 'basket',
243
- title: 'Basket',
244
- path: path || ROUTES.BASKET
245
- },
246
- basket: {
247
- id: basket?.pk || null,
248
- path: path || ROUTES.BASKET,
249
- checkoutUrl: ROUTES.CHECKOUT,
250
- continueShoppingUrl: ROUTES.HOME,
251
- isEmpty: normalizedItems.length === 0,
252
- itemCount: normalizedItems.length,
253
- totalQuantity: basket?.total_quantity || 0,
254
- totalAmount: basket?.total_amount || '',
255
- totalProductAmount: basket?.total_product_amount || '',
256
- totalDiscountAmount: basket?.total_discount_amount || '',
257
- voucherCode: basket?.voucher_code || '',
258
- discounts: basket?.discounts || [],
259
- items: normalizedItems
260
- }
261
- };
262
- };
263
-
264
- export const buildAccountPageContext = ({
265
- path,
266
- profileInfo,
267
- sessionUser
268
- }: {
269
- path?: string;
270
- profileInfo?: UnknownRecord | null;
271
- sessionUser?: UnknownRecord | null;
272
- }) => {
273
- const normalizedPath = path || ROUTES.ACCOUNT;
274
- const firstName =
275
- toStringValue(profileInfo?.first_name) ||
276
- toStringValue(sessionUser?.firstName) ||
277
- toStringValue(sessionUser?.name).split(' ')[0] ||
278
- '';
279
- const lastName =
280
- toStringValue(profileInfo?.last_name) ||
281
- toStringValue(sessionUser?.lastName) ||
282
- toStringValue(sessionUser?.name).split(' ').slice(1).join(' ') ||
283
- '';
284
- const email =
285
- toStringValue(profileInfo?.email) || toStringValue(sessionUser?.email);
286
- const fullName =
287
- [firstName, lastName].filter(Boolean).join(' ') ||
288
- toStringValue(sessionUser?.name);
289
-
290
- return {
291
- page: {
292
- type: 'account',
293
- title: 'Account',
294
- path: normalizedPath
295
- },
296
- account: {
297
- path: normalizedPath,
298
- route: normalizedPath,
299
- user: {
300
- firstName,
301
- lastName,
302
- fullName,
303
- email,
304
- phone: toStringValue(profileInfo?.phone),
305
- gender: toStringValue(profileInfo?.gender)
306
- }
307
- }
308
- };
309
- };