@akinon/projectzero 2.0.43-rc.0 → 2.0.44

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +8 -4
  2. package/app-template/.env.example +0 -1
  3. package/app-template/AGENTS.md +0 -8
  4. package/app-template/CHANGELOG.md +64 -78
  5. package/app-template/README.md +1 -25
  6. package/app-template/next.config.mjs +1 -7
  7. package/app-template/package.json +41 -41
  8. package/app-template/src/app/[pz]/orders/checkout/layout.tsx +30 -11
  9. package/app-template/src/app/[pz]/orders/checkout/page.tsx +31 -18
  10. package/app-template/src/components/checkbox.tsx +21 -2
  11. package/app-template/src/hooks/index.ts +0 -2
  12. package/app-template/src/plugins.js +0 -1
  13. package/app-template/src/proxy.ts +1 -2
  14. package/app-template/src/views/checkout/__tests__/part-style-reachability.test.ts +121 -0
  15. package/app-template/src/views/checkout/step-button.tsx +13 -2
  16. package/app-template/src/views/checkout/step-list.tsx +5 -1
  17. package/app-template/src/views/checkout/steps/payment/agreements.tsx +9 -1
  18. package/app-template/src/views/checkout/steps/payment/index.tsx +10 -2
  19. package/app-template/src/views/checkout/steps/payment/options/credit-card/index.tsx +2 -0
  20. package/app-template/src/views/checkout/steps/payment/options/funds-transfer.tsx +2 -0
  21. package/app-template/src/views/checkout/steps/payment/payment-header.tsx +5 -1
  22. package/app-template/src/views/checkout/steps/payment/payment-option-buttons.tsx +16 -4
  23. package/app-template/src/views/checkout/steps/shipping/addresses.tsx +2 -1
  24. package/app-template/src/views/checkout/steps/shipping/index.tsx +2 -0
  25. package/app-template/src/views/checkout/steps/shipping/shipping-options.tsx +2 -0
  26. package/app-template/src/views/checkout/summary.tsx +56 -18
  27. package/app-template/src/views/checkout/variants/__tests__/registry.test.tsx +82 -0
  28. package/app-template/src/views/checkout/variants/__tests__/use-resolved-config.test.tsx +208 -0
  29. package/app-template/src/views/checkout/variants/basket-and-checkout/index.tsx +7 -4
  30. package/app-template/src/views/checkout/variants/multi-step/index.tsx +7 -2
  31. package/app-template/src/views/checkout/variants/one-page/accordion-section.tsx +22 -0
  32. package/app-template/src/views/checkout/variants/one-page/index.tsx +7 -4
  33. package/app-template/src/views/checkout/variants/one-page/one-page-summary.tsx +80 -18
  34. package/app-template/src/views/checkout/variants/one-page/payment-options-grid.tsx +34 -22
  35. package/app-template/src/views/checkout/variants/one-page/sections/address-section.tsx +2 -0
  36. package/app-template/src/views/checkout/variants/one-page/sections/payment-section.tsx +2 -0
  37. package/app-template/src/views/checkout/variants/one-page/sections/shipping-section.tsx +2 -0
  38. package/app-template/src/views/checkout/variants/registry.ts +13 -2
  39. package/app-template/src/views/checkout/variants/use-resolved-config.ts +77 -5
  40. package/app-template/src/views/header/search/index.tsx +5 -13
  41. package/app-template/src/views/product/slider.tsx +38 -85
  42. package/commands/plugins.ts +0 -4
  43. package/dist/commands/plugins.js +0 -4
  44. package/package.json +1 -1
  45. package/app-template/src/app/api/client-env/route.ts +0 -5
  46. package/app-template/src/app/global-error.tsx +0 -22
@@ -23,10 +23,21 @@ export const UNIFIED_VARIANTS: ReadonlySet<string> = new Set([
23
23
  ]);
24
24
 
25
25
  export const getCheckoutVariant = (variant: CheckoutVariantId | undefined) => {
26
- const loader = variantLoaders[variant ?? 'multi-step'] ?? variantLoaders['multi-step'];
26
+ // Own-key lookup only (see `isKnownVariant`): an inherited member would
27
+ // resolve to a truthy non-loader and `dynamic()` would never render a flow.
28
+ const loader = isKnownVariant(variant)
29
+ ? variantLoaders[variant]
30
+ : variantLoaders['multi-step'];
27
31
 
28
32
  return dynamic(loader, { ssr: false });
29
33
  };
30
34
 
35
+ /**
36
+ * `in` also answers true for inherited Object.prototype members ('toString',
37
+ * 'constructor', '__proto__', …), which would let a saved setting name a
38
+ * "known" variant with no loader — `getCheckoutVariant` would then hand
39
+ * `dynamic()` a non-function and the flow would be lost. Own keys only.
40
+ */
31
41
  export const isKnownVariant = (value: unknown): value is CheckoutVariantId =>
32
- typeof value === 'string' && value in variantLoaders;
42
+ typeof value === 'string' &&
43
+ Object.prototype.hasOwnProperty.call(variantLoaders, value);
@@ -1,7 +1,20 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useState } from 'react';
3
+ import {
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useState,
8
+ useSyncExternalStore
9
+ } from 'react';
4
10
  import settings from '@theme/settings';
11
+ import {
12
+ getCheckoutFlowVariantOverride,
13
+ isThemeEditorDesigner,
14
+ noFlowVariantOnServer,
15
+ subscribeCheckoutPieces,
16
+ type CheckoutFlowVariantPreference
17
+ } from '@akinon/pz-theme/src/chrome/checkout-pieces';
5
18
  import { isKnownVariant } from './registry';
6
19
  import type { CheckoutVariantId, CheckoutVariantOptions } from './types';
7
20
 
@@ -17,6 +30,21 @@ interface RawCheckoutConfig {
17
30
 
18
31
  const FALLBACK_VARIANT: CheckoutVariantId = 'multi-step';
19
32
 
33
+ /**
34
+ * The only two layouts a composition may steer to, mapped to variant ids. A
35
+ * closed record on purpose: an unknown preference resolves to `undefined` here
36
+ * and the storefront's configured variant survives — and the unified
37
+ * basket-and-checkout family stays unreachable from a composition, so the
38
+ * basket-to-checkout redirect pair can never be broken by a page edit.
39
+ */
40
+ const COMPOSITION_VARIANT_MAP: Record<
41
+ CheckoutFlowVariantPreference,
42
+ CheckoutVariantId
43
+ > = {
44
+ 'one-page': 'one-page',
45
+ 'step-by-step': 'multi-step'
46
+ };
47
+
20
48
  const coerceOptions = (
21
49
  raw: Record<string, unknown> | undefined
22
50
  ): CheckoutVariantOptions => {
@@ -56,17 +84,30 @@ const mergeOverride = (
56
84
  };
57
85
 
58
86
  /**
59
- * Resolves checkout config from two layers (each optional):
87
+ * Resolves checkout config from three layers (each optional):
60
88
  *
61
89
  * 1. settings.js (sync, always available — baseline / fallback)
62
90
  * 2. /api/theme-settings (backend theme-config widget — persistent override)
91
+ * 3. the composition's checkout flow outlet (`flowVariant`) — highest
63
92
  *
64
93
  * Layer 2 falls back silently if backend is unreachable or no config is saved.
65
94
  * The editor is OPTIONAL — when no theme-config is saved, the storefront keeps
66
95
  * using its built-in settings.js.
96
+ *
97
+ * Layer 3 only ever names a LAYOUT, never options, and only through the
98
+ * closed `COMPOSITION_VARIANT_MAP`. It is read ONCE, in a layout effect, so a
99
+ * composed checkout resolves its variant before first paint (the outlet's own
100
+ * registration layout effect runs in an earlier sibling subtree of the same
101
+ * hydration commit) and never flips mid-session — a late registration, or the
102
+ * theme-settings response landing afterwards, can no longer swap the flow out
103
+ * from under a shopper. On the designer canvas the live value is followed
104
+ * instead, so a `Checkout Type` edit previews immediately.
105
+ *
106
+ * With no outlet registered, nothing overrides: the resolution is exactly the
107
+ * two-layer result the storefront had before the bridge existed.
67
108
  */
68
109
  export const useResolvedCheckoutConfig = (): ResolvedCheckoutConfig => {
69
- const [config, setConfig] = useState<ResolvedCheckoutConfig>(readBaseFromSettings);
110
+ const [merged, setMerged] = useState<ResolvedCheckoutConfig>(readBaseFromSettings);
70
111
 
71
112
  useEffect(() => {
72
113
  let cancelled = false;
@@ -75,7 +116,7 @@ export const useResolvedCheckoutConfig = (): ResolvedCheckoutConfig => {
75
116
  .then((data: { checkout?: RawCheckoutConfig } | null) => {
76
117
  if (cancelled) return;
77
118
  if (!data?.checkout) return;
78
- setConfig(() => mergeOverride(readBaseFromSettings(), data.checkout));
119
+ setMerged(() => mergeOverride(readBaseFromSettings(), data.checkout));
79
120
  })
80
121
  .catch(() => {
81
122
  // Silent: backend not reachable or widget missing — keep settings.js baseline
@@ -85,5 +126,36 @@ export const useResolvedCheckoutConfig = (): ResolvedCheckoutConfig => {
85
126
  };
86
127
  }, []);
87
128
 
88
- return config;
129
+ const liveOverride = useSyncExternalStore(
130
+ subscribeCheckoutPieces,
131
+ getCheckoutFlowVariantOverride,
132
+ noFlowVariantOnServer
133
+ );
134
+
135
+ const [latchedOverride, setLatchedOverride] =
136
+ useState<CheckoutFlowVariantPreference | null>(null);
137
+
138
+ useLayoutEffect(() => {
139
+ setLatchedOverride(getCheckoutFlowVariantOverride());
140
+ }, []);
141
+
142
+ // The designer flag rides a postMessage and can arrive after mount; every
143
+ // claim change notifies the pieces store, which re-runs this render, so the
144
+ // canvas picks it up before an outlet edit could matter.
145
+ const composition = isThemeEditorDesigner() ? liveOverride : latchedOverride;
146
+ // Own-key lookup: an inherited member ('constructor', 'toString', …) would
147
+ // otherwise resolve to a truthy non-variant and lose the flow entirely.
148
+ const compositionVariant =
149
+ composition &&
150
+ Object.prototype.hasOwnProperty.call(COMPOSITION_VARIANT_MAP, composition)
151
+ ? COMPOSITION_VARIANT_MAP[composition]
152
+ : undefined;
153
+
154
+ return useMemo(
155
+ () =>
156
+ compositionVariant
157
+ ? { ...merged, variantId: compositionVariant }
158
+ : merged,
159
+ [merged, compositionVariant]
160
+ );
89
161
  };
@@ -4,7 +4,8 @@ import { useEffect, useRef, useState } from 'react';
4
4
  import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
5
5
  import { closeSearch } from '@akinon/next/redux/reducers/header';
6
6
  import clsx from 'clsx';
7
- import { Icon, Input } from '@theme/components';
7
+
8
+ import { Icon } from '@theme/components';
8
9
  import Results from './results';
9
10
  import { ROUTES } from '@theme/routes';
10
11
  import { useLocalization, useRouter } from '@akinon/next/hooks';
@@ -41,14 +42,6 @@ export default function Search() {
41
42
  };
42
43
  }, [isSearchOpen, dispatch]);
43
44
 
44
- const handleSearchTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
45
- setSearchText(e.target.value);
46
- };
47
-
48
- const handleCloseSearch = () => {
49
- dispatch(closeSearch());
50
- };
51
-
52
45
  return (
53
46
  <>
54
47
  <div
@@ -74,9 +67,9 @@ export default function Search() {
74
67
  {t('common.search.results_for')}
75
68
  </span>
76
69
  <div className="flex items-center">
77
- <Input
70
+ <input
78
71
  value={searchText}
79
- onChange={handleSearchTextChange}
72
+ onChange={(e) => setSearchText(e.target.value)}
80
73
  onKeyDown={(e) => {
81
74
  if (e.key === 'Enter' && searchText.trim() !== '') {
82
75
  router.push(`${ROUTES.LIST}/?search_text=${searchText}`);
@@ -98,12 +91,11 @@ export default function Search() {
98
91
  <Icon
99
92
  name="close"
100
93
  size={14}
101
- onClick={handleCloseSearch}
94
+ onClick={() => dispatch(closeSearch())}
102
95
  className="cursor-pointer"
103
96
  />
104
97
  </div>
105
98
  </div>
106
-
107
99
  <Results searchText={searchText} />
108
100
  </div>
109
101
  </div>
@@ -36,100 +36,53 @@ export default function ProductInfoSlider({ product }: ProductSliderItem) {
36
36
  carouselRef.current?.next();
37
37
  };
38
38
 
39
- const handleThumbnailClick = (index: number) => {
39
+ const handleThumbnailClick = (index) => {
40
40
  setActiveIndex(index);
41
41
  carouselRef.current?.goToSlide(index);
42
42
  };
43
43
 
44
44
  return (
45
- <>
46
- <div className="lg:grid lg:grid-cols-6">
47
- <div className="lg:col-span-1">
48
- <div className="flex flex-col items-center justify-center md:mr-[6px]">
49
- <button
50
- onClick={goToPrev}
51
- className={twMerge(
52
- 'hidden justify-center p-2 mb-3 border border-gray-100 rounded-full cursor-pointer lg:block',
53
- [activeIndex === 0 && 'cursor-not-allowed opacity-45']
54
- )}
55
- disabled={activeIndex === 0}
56
- >
57
- <Icon name="chevron-up" size={15} className="fill-[#000000]" />
58
- </button>
59
- <div className="hidden flex-col items-center overflow-scroll w-[80px] max-h-[620px] lg:block">
60
- {product?.productimage_set?.map((item, index) => (
61
- <Image
62
- key={index}
63
- src={item.image}
64
- alt={`Thumbnail ${index}`}
65
- width={80}
66
- height={128}
67
- aspectRatio={80 / 128}
68
- className={twMerge('cursor-pointer', [
69
- activeIndex === index && 'border-2 border-primary'
70
- ])}
71
- onClick={() => handleThumbnailClick(index)}
72
- />
73
- ))}
74
- </div>
75
- <button
76
- onClick={goToNext}
77
- className={twMerge(
78
- 'hidden justify-center p-2 mt-3 border border-gray-100 rounded-full cursor-pointer lg:block',
79
- [
80
- activeIndex === product.productimage_set.length - 1 &&
81
- 'cursor-not-allowed opacity-45'
82
- ]
83
- )}
84
- disabled={activeIndex === product.productimage_set.length - 1}
85
- >
86
- <Icon name="chevron-down" size={15} className="fill-[#000000]" />
87
- </button>
88
- </div>
89
- </div>
90
-
91
- <div className="relative lg:col-span-5">
92
- <FavButton className="absolute right-8 top-6 z-[20] sm:hidden" />
93
-
94
- <PluginModule
95
- component={Component.ProductImageSearchFeature}
96
- props={{
97
- product,
98
- activeIndex,
99
- showResetButton: true
100
- }}
101
- />
102
-
103
- <CarouselCore
104
- responsive={{
105
- all: {
106
- breakpoint: { max: 5000, min: 0 },
107
- items: 1
108
- }
109
- }}
110
- arrows={false}
111
- swipeable={true}
112
- ref={carouselRef}
113
- afterChange={(previousSlide, { currentSlide }) => {
114
- setActiveIndex(currentSlide);
115
- }}
116
- containerAspectRatio={{ mobile: 520 / 798, desktop: 484 / 726 }}
45
+ <div className="lg:grid lg:grid-cols-6">
46
+ <div className="lg:col-span-1">
47
+ <div className="flex flex-col items-center justify-center md:mr-[6px]">
48
+ <button
49
+ onClick={goToPrev}
50
+ className={twMerge(
51
+ 'hidden justify-center p-2 mb-3 border border-gray-100 rounded-full cursor-pointer lg:block',
52
+ [activeIndex === 0 && 'cursor-not-allowed opacity-45']
53
+ )}
54
+ disabled={activeIndex === 0}
117
55
  >
118
- {product?.productimage_set?.map((item, i) => (
56
+ <Icon name="chevron-up" size={15} className="fill-[#000000]" />
57
+ </button>
58
+ <div className="hidden flex-col items-center overflow-scroll w-[80px] max-h-[620px] lg:block">
59
+ {product?.productimage_set?.map((item, index) => (
119
60
  <Image
120
- key={i}
61
+ key={index}
121
62
  src={item.image}
122
- alt={product?.name || 'Product image'}
123
- draggable={false}
124
- aspectRatio={484 / 726}
125
- sizes="(min-width: 425px) 512px,
126
- (min-width: 601px) 576px,
127
- (min-width: 768px) 336px,
128
- (min-width: 1024px) 484px, 368px"
129
- fill
63
+ alt={`Thumbnail ${index}`}
64
+ width={80}
65
+ height={128}
66
+ className={twMerge('cursor-pointer', [
67
+ activeIndex === index && 'border-2 border-primary'
68
+ ])}
69
+ onClick={() => handleThumbnailClick(index)}
130
70
  />
131
71
  ))}
132
- </CarouselCore>
72
+ </div>
73
+ <button
74
+ onClick={goToNext}
75
+ className={twMerge(
76
+ 'hidden justify-center p-2 mt-3 border border-gray-100 rounded-full cursor-pointer lg:block',
77
+ [
78
+ activeIndex === product.productimage_set.length - 1 &&
79
+ 'cursor-not-allowed opacity-45'
80
+ ]
81
+ )}
82
+ disabled={activeIndex === product.productimage_set.length - 1}
83
+ >
84
+ <Icon name="chevron-down" size={15} className="fill-[#000000]" />
85
+ </button>
133
86
  </div>
134
87
  </div>
135
88
 
@@ -187,6 +140,6 @@ export default function ProductInfoSlider({ product }: ProductSliderItem) {
187
140
  ))}
188
141
  </CarouselCore>
189
142
  </div>
190
- </>
143
+ </div>
191
144
  );
192
145
  }
@@ -164,10 +164,6 @@ export default async () => {
164
164
  name: 'Tamara Payment Extension',
165
165
  value: 'pz-tamara-extension'
166
166
  },
167
- {
168
- name: 'Similar Products',
169
- value: 'pz-similar-products'
170
- },
171
167
  {
172
168
  name: 'Hepsipay',
173
169
  value: 'pz-hepsipay'
@@ -183,10 +183,6 @@ exports.default = () => __awaiter(void 0, void 0, void 0, function* () {
183
183
  name: 'Tamara Payment Extension',
184
184
  value: 'pz-tamara-extension'
185
185
  },
186
- {
187
- name: 'Similar Products',
188
- value: 'pz-similar-products'
189
- },
190
186
  {
191
187
  name: 'Hepsipay',
192
188
  value: 'pz-hepsipay'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akinon/projectzero",
3
- "version": "2.0.43-rc.0",
3
+ "version": "2.0.44",
4
4
  "private": false,
5
5
  "description": "CLI tool to manage your Project Zero Next project",
6
6
  "bin": {
@@ -1,5 +0,0 @@
1
- export { GET } from '@akinon/next/api/client-env';
2
-
3
- // NEXT_PUBLIC_* values are read from the server runtime, not the build. Force
4
- // dynamic so Next never statically caches an (empty) build-time response.
5
- export const dynamic = 'force-dynamic';
@@ -1,22 +0,0 @@
1
- 'use client';
2
-
3
- import { useSentryUncaughtErrors } from '@akinon/next/hooks';
4
-
5
- export default function GlobalError({
6
- error,
7
- reset
8
- }: {
9
- error: Error & { digest?: string };
10
- reset: () => void;
11
- }) {
12
- useSentryUncaughtErrors(error);
13
-
14
- return (
15
- <html>
16
- <body>
17
- <h2>Something went wrong!</h2>
18
- <button onClick={() => reset()}>Try again</button>
19
- </body>
20
- </html>
21
- );
22
- }