@akinon/projectzero 2.0.32 → 2.0.33-beta.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/app-template/CHANGELOG.md +34 -0
  3. package/app-template/package.json +29 -29
  4. package/app-template/public/locales/en/checkout.json +50 -0
  5. package/app-template/public/locales/tr/checkout.json +50 -0
  6. package/app-template/src/app/[pz]/basket/page.tsx +33 -6
  7. package/app-template/src/app/[pz]/orders/checkout/page.tsx +57 -92
  8. package/app-template/src/app/api/theme-settings/route.ts +21 -2
  9. package/app-template/src/data/server/theme.ts +6 -25
  10. package/app-template/src/views/checkout/variants/basket-and-checkout/basket-section.tsx +149 -0
  11. package/app-template/src/views/checkout/variants/basket-and-checkout/index.tsx +132 -0
  12. package/app-template/src/views/checkout/variants/multi-step/index.tsx +73 -0
  13. package/app-template/src/views/checkout/variants/one-page/accordion-section.tsx +140 -0
  14. package/app-template/src/views/checkout/variants/one-page/hooks/use-checkout-funnel-gtm.ts +60 -0
  15. package/app-template/src/views/checkout/variants/one-page/hooks/use-section-state.ts +99 -0
  16. package/app-template/src/views/checkout/variants/one-page/index.tsx +125 -0
  17. package/app-template/src/views/checkout/variants/one-page/mobile-summary-sheet.tsx +109 -0
  18. package/app-template/src/views/checkout/variants/one-page/one-page-summary.tsx +237 -0
  19. package/app-template/src/views/checkout/variants/one-page/payment-options-grid.tsx +201 -0
  20. package/app-template/src/views/checkout/variants/one-page/sections/address-section.tsx +318 -0
  21. package/app-template/src/views/checkout/variants/one-page/sections/contact-section.tsx +62 -0
  22. package/app-template/src/views/checkout/variants/one-page/sections/payment-section.tsx +71 -0
  23. package/app-template/src/views/checkout/variants/one-page/sections/shipping-section.tsx +298 -0
  24. package/app-template/src/views/checkout/variants/one-page/skeletons/address-skeleton.tsx +25 -0
  25. package/app-template/src/views/checkout/variants/one-page/skeletons/checkout-skeleton.tsx +27 -0
  26. package/app-template/src/views/checkout/variants/one-page/skeletons/index.ts +6 -0
  27. package/app-template/src/views/checkout/variants/one-page/skeletons/payment-skeleton.tsx +23 -0
  28. package/app-template/src/views/checkout/variants/one-page/skeletons/section-skeleton.tsx +47 -0
  29. package/app-template/src/views/checkout/variants/one-page/skeletons/shipping-skeleton.tsx +20 -0
  30. package/app-template/src/views/checkout/variants/one-page/skeletons/summary-skeleton.tsx +38 -0
  31. package/app-template/src/views/checkout/variants/one-page/trust-badges.tsx +30 -0
  32. package/app-template/src/views/checkout/variants/registry.ts +32 -0
  33. package/app-template/src/views/checkout/variants/types.ts +30 -0
  34. package/app-template/src/views/checkout/variants/use-resolved-config.ts +89 -0
  35. package/package.json +1 -1
@@ -0,0 +1,298 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import clsx from 'clsx';
3
+ import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
4
+ import {
5
+ useSetShippingOptionMutation,
6
+ useSetAttributeBasedShippingOptionsMutation,
7
+ useSetDataSourceShippingOptionsMutation
8
+ } from '@akinon/next/data/client/checkout';
9
+ import { setSelectedShippingOptions } from '@akinon/next/redux/reducers/checkout';
10
+ import { useLocalization } from '@akinon/next/hooks';
11
+ import { Button, Price, Radio } from '@theme/components';
12
+ import type { RootState } from '@theme/redux/store';
13
+ import AccordionSection, { SectionStatus } from '../accordion-section';
14
+ import { ShippingOptionsSkeleton } from '../skeletons/shipping-skeleton';
15
+
16
+ interface ShippingSectionProps {
17
+ status: SectionStatus;
18
+ onEdit: () => void;
19
+ onComplete: () => void;
20
+ editable?: boolean;
21
+ }
22
+
23
+ const ShippingSection = ({
24
+ status,
25
+ onEdit,
26
+ onComplete,
27
+ editable = true
28
+ }: ShippingSectionProps) => {
29
+ const { t } = useLocalization();
30
+ const dispatch = useAppDispatch();
31
+ const {
32
+ shippingOptions,
33
+ attributeBasedShippingOptions,
34
+ dataSourceShippingOptions,
35
+ preOrder,
36
+ selectedShippingOptions
37
+ } = useAppSelector((state: RootState) => state.checkout);
38
+ const {
39
+ shipping_option,
40
+ attribute_based_shipping_options,
41
+ data_source_shipping_options
42
+ } = preOrder ?? {};
43
+
44
+ const [setShippingOption, { isLoading: isSettingPlain }] =
45
+ useSetShippingOptionMutation();
46
+ const [setAttributeBasedShippingOptions, { isLoading: isSettingAttribute }] =
47
+ useSetAttributeBasedShippingOptionsMutation();
48
+ const [setDataSourceShippingOption, { isLoading: isSettingDataSource }] =
49
+ useSetDataSourceShippingOptionsMutation();
50
+ const isSetting = isSettingPlain || isSettingAttribute || isSettingDataSource;
51
+
52
+ const prevAttributeBasedRef = useRef(attributeBasedShippingOptions);
53
+ const [selectedDataSourcePks, setSelectedDataSourcePks] = useState<
54
+ { dataSourcePk: number; optionPk: number }[] | null
55
+ >(null);
56
+
57
+ const initializeAttributeOptions = useCallback(() => {
58
+ if (!attribute_based_shipping_options) return;
59
+ const next = { ...selectedShippingOptions };
60
+ let changed = false;
61
+ Object.entries(attributeBasedShippingOptions).forEach(([key, options]) => {
62
+ if (!next[key] || !options.some((opt) => opt.pk === next[key])) {
63
+ next[key] = options[0].pk;
64
+ changed = true;
65
+ }
66
+ });
67
+ if (changed) {
68
+ dispatch(setSelectedShippingOptions(next));
69
+ setAttributeBasedShippingOptions(next);
70
+ }
71
+ }, [
72
+ attributeBasedShippingOptions,
73
+ selectedShippingOptions,
74
+ attribute_based_shipping_options,
75
+ dispatch,
76
+ setAttributeBasedShippingOptions
77
+ ]);
78
+
79
+ useEffect(() => {
80
+ if (!data_source_shipping_options) return;
81
+ setSelectedDataSourcePks(
82
+ data_source_shipping_options.map((option) => ({
83
+ dataSourcePk: option.data_source.pk,
84
+ optionPk: option.pk
85
+ }))
86
+ );
87
+ }, [data_source_shipping_options]);
88
+
89
+ useEffect(() => {
90
+ if (
91
+ JSON.stringify(prevAttributeBasedRef.current) !==
92
+ JSON.stringify(attributeBasedShippingOptions) ||
93
+ Object.keys(selectedShippingOptions).length === 0
94
+ ) {
95
+ initializeAttributeOptions();
96
+ prevAttributeBasedRef.current = attributeBasedShippingOptions;
97
+ }
98
+ }, [
99
+ attributeBasedShippingOptions,
100
+ selectedShippingOptions,
101
+ initializeAttributeOptions
102
+ ]);
103
+
104
+ const handlePlainOption = (pk: number) => {
105
+ setShippingOption(pk);
106
+ };
107
+
108
+ const handleAttributeOption = (key: string, pk: number) => {
109
+ const next = { ...selectedShippingOptions, [key]: pk };
110
+ dispatch(setSelectedShippingOptions(next));
111
+ setAttributeBasedShippingOptions(next);
112
+ };
113
+
114
+ const handleDataSourceOption = (dataSourcePk: number, optionPk: number) => {
115
+ const updated = selectedDataSourcePks?.map((item) =>
116
+ item.dataSourcePk === dataSourcePk ? { ...item, optionPk } : item
117
+ );
118
+ if (!updated) return;
119
+ setSelectedDataSourcePks(updated);
120
+ setDataSourceShippingOption(updated.map((item) => item.optionPk));
121
+ };
122
+
123
+ const summary = shipping_option ? (
124
+ <div className="flex flex-col">
125
+ <span className="font-medium">{shipping_option.name}</span>
126
+ {shipping_option.shipping_amount && (
127
+ <span className="text-xs text-black-700">
128
+ <Price value={shipping_option.shipping_amount} />
129
+ </span>
130
+ )}
131
+ </div>
132
+ ) : null;
133
+
134
+ const hasAttributeOptions = useMemo(
135
+ () =>
136
+ attributeBasedShippingOptions &&
137
+ Object.keys(attributeBasedShippingOptions).length > 0,
138
+ [attributeBasedShippingOptions]
139
+ );
140
+
141
+ const hasDataSourceOptions = useMemo(
142
+ () => dataSourceShippingOptions && dataSourceShippingOptions.length > 0,
143
+ [dataSourceShippingOptions]
144
+ );
145
+
146
+ const noOptionsLoaded =
147
+ shippingOptions.length === 0 &&
148
+ !hasAttributeOptions &&
149
+ !hasDataSourceOptions;
150
+
151
+ return (
152
+ <AccordionSection
153
+ step={3}
154
+ title={t('checkout.one_page.shipping.title')}
155
+ status={status}
156
+ onEdit={onEdit}
157
+ editable={editable}
158
+ summary={summary}
159
+ dataTestId="one-page-shipping"
160
+ >
161
+ {noOptionsLoaded ? (
162
+ <ShippingOptionsSkeleton />
163
+ ) : (
164
+ <div
165
+ className={clsx('flex flex-col gap-4', {
166
+ 'opacity-60 pointer-events-none': isSetting
167
+ })}
168
+ >
169
+ {shippingOptions.length > 0 && (
170
+ <div
171
+ className="flex flex-col gap-2"
172
+ role="radiogroup"
173
+ aria-label={t('checkout.one_page.shipping.title')}
174
+ >
175
+ {shippingOptions.map((option) => (
176
+ <label
177
+ key={option.pk}
178
+ className={clsx(
179
+ 'flex cursor-pointer items-center justify-between rounded-sm border px-4 py-3 transition-all',
180
+ 'focus-within:ring-2 focus-within:ring-secondary focus-within:ring-offset-1',
181
+ shipping_option?.pk === option.pk
182
+ ? 'border-secondary-400 bg-secondary-50/50'
183
+ : 'border-gray-300 hover:border-secondary-400'
184
+ )}
185
+ >
186
+ <Radio
187
+ name="one-page-shipping"
188
+ checked={option.pk === shipping_option?.pk}
189
+ onChange={() => handlePlainOption(option.pk)}
190
+ data-testid={`one-page-shipping-option-${option.pk}`}
191
+ >
192
+ <span className="ml-1 text-sm">{option.name}</span>
193
+ </Radio>
194
+ <span className="text-sm font-medium">
195
+ <Price value={option.shipping_amount} />
196
+ </span>
197
+ </label>
198
+ ))}
199
+ </div>
200
+ )}
201
+
202
+ {hasAttributeOptions &&
203
+ Object.entries(attributeBasedShippingOptions).map(
204
+ ([key, options]) => (
205
+ <div key={key}>
206
+ <h4 className="mb-2 text-sm font-medium text-black-800">
207
+ {key}
208
+ </h4>
209
+ <div
210
+ className="flex flex-col gap-2"
211
+ role="radiogroup"
212
+ aria-label={key}
213
+ >
214
+ {options.map((option) => (
215
+ <label
216
+ key={option.pk}
217
+ className="flex cursor-pointer items-center justify-between rounded-sm border border-gray-300 px-4 py-3 hover:border-secondary-400"
218
+ >
219
+ <Radio
220
+ name={`one-page-attr-${key}`}
221
+ checked={selectedShippingOptions[key] === option.pk}
222
+ onChange={() =>
223
+ handleAttributeOption(key, option.pk)
224
+ }
225
+ data-testid={`one-page-attr-shipping-${option.pk}`}
226
+ >
227
+ <span className="ml-1 text-sm">
228
+ {option.shipping_option_name}
229
+ </span>
230
+ </Radio>
231
+ <span className="text-sm font-medium">
232
+ <Price value={option.shipping_amount} />
233
+ </span>
234
+ </label>
235
+ ))}
236
+ </div>
237
+ </div>
238
+ )
239
+ )}
240
+
241
+ {hasDataSourceOptions &&
242
+ dataSourceShippingOptions.map((source) => (
243
+ <div key={source.pk}>
244
+ <h4 className="mb-2 text-sm font-medium text-black-800">
245
+ {source.name}
246
+ </h4>
247
+ <div
248
+ className="flex flex-col gap-2"
249
+ role="radiogroup"
250
+ aria-label={source.name}
251
+ >
252
+ {source.data_source_shipping_options.map((opt) => (
253
+ <label
254
+ key={opt.pk}
255
+ className="flex cursor-pointer items-center justify-between rounded-sm border border-gray-300 px-4 py-3 hover:border-secondary-400"
256
+ >
257
+ <Radio
258
+ name={`one-page-ds-${source.pk}`}
259
+ checked={
260
+ selectedDataSourcePks?.some(
261
+ (item) => item.optionPk === opt.pk
262
+ ) || false
263
+ }
264
+ onChange={() =>
265
+ handleDataSourceOption(source.pk, opt.pk)
266
+ }
267
+ data-testid={`one-page-ds-shipping-${opt.pk}`}
268
+ >
269
+ <span className="ml-1 text-sm">
270
+ {opt.shipping_option_name}
271
+ </span>
272
+ </Radio>
273
+ <span className="text-sm font-medium">
274
+ <Price value={opt.shipping_amount} />
275
+ </span>
276
+ </label>
277
+ ))}
278
+ </div>
279
+ </div>
280
+ ))}
281
+
282
+ {shipping_option && (
283
+ <div className="flex justify-end">
284
+ <Button
285
+ onClick={onComplete}
286
+ data-testid="one-page-shipping-continue"
287
+ >
288
+ {t('checkout.one_page.continue_to_payment')}
289
+ </Button>
290
+ </div>
291
+ )}
292
+ </div>
293
+ )}
294
+ </AccordionSection>
295
+ );
296
+ };
297
+
298
+ export default ShippingSection;
@@ -0,0 +1,25 @@
1
+ import { Skeleton } from '@theme/components';
2
+
3
+ export const AddressListSkeleton = () => (
4
+ <div className="flex flex-col gap-4" aria-busy="true">
5
+ <div className="flex items-center justify-between">
6
+ <Skeleton className="h-4 w-32" />
7
+ <Skeleton className="h-4 w-48" />
8
+ </div>
9
+ <div className="grid grid-cols-1 gap-2 md:grid-cols-2">
10
+ {Array.from({ length: 3 }).map((_, i) => (
11
+ <div
12
+ key={i}
13
+ className="rounded-sm border border-gray-300 p-3 shadow-sm"
14
+ >
15
+ <Skeleton className="mb-2 h-4 w-4 rounded-full" />
16
+ <Skeleton className="mb-1.5 h-3 w-3/4" />
17
+ <Skeleton className="mb-1.5 h-3 w-1/2" />
18
+ <Skeleton className="h-3 w-full" />
19
+ </div>
20
+ ))}
21
+ </div>
22
+ </div>
23
+ );
24
+
25
+ export default AddressListSkeleton;
@@ -0,0 +1,27 @@
1
+ import { SectionSkeleton } from './section-skeleton';
2
+ import { AddressListSkeleton } from './address-skeleton';
3
+ import { SummarySkeleton } from './summary-skeleton';
4
+
5
+ export const CheckoutOnePageSkeleton = () => (
6
+ <div className="min-h-screen bg-[#fafafa] pb-20 md:pb-16">
7
+ <div className="container w-full px-4 pt-6 md:px-6 md:pt-10 lg:px-8">
8
+ <div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:gap-8">
9
+ <div className="w-full lg:w-2/3">
10
+ <SectionSkeleton />
11
+ <SectionSkeleton active>
12
+ <AddressListSkeleton />
13
+ </SectionSkeleton>
14
+ <SectionSkeleton />
15
+ <SectionSkeleton />
16
+ </div>
17
+ <aside className="hidden w-full lg:block lg:w-1/3">
18
+ <div className="sticky top-6">
19
+ <SummarySkeleton />
20
+ </div>
21
+ </aside>
22
+ </div>
23
+ </div>
24
+ </div>
25
+ );
26
+
27
+ export default CheckoutOnePageSkeleton;
@@ -0,0 +1,6 @@
1
+ export { SectionSkeleton } from './section-skeleton';
2
+ export { AddressListSkeleton } from './address-skeleton';
3
+ export { ShippingOptionsSkeleton } from './shipping-skeleton';
4
+ export { PaymentGridSkeleton } from './payment-skeleton';
5
+ export { SummarySkeleton } from './summary-skeleton';
6
+ export { CheckoutOnePageSkeleton } from './checkout-skeleton';
@@ -0,0 +1,23 @@
1
+ import { Skeleton } from '@theme/components';
2
+
3
+ export const PaymentGridSkeleton = () => (
4
+ <div
5
+ className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"
6
+ aria-busy="true"
7
+ >
8
+ {Array.from({ length: 6 }).map((_, i) => (
9
+ <div
10
+ key={i}
11
+ className="flex min-h-[5rem] flex-col justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-3"
12
+ >
13
+ <div className="flex items-center justify-between">
14
+ <Skeleton className="h-5 w-5" />
15
+ <Skeleton className="h-4 w-4 rounded-full" />
16
+ </div>
17
+ <Skeleton className="h-4 w-3/4" />
18
+ </div>
19
+ ))}
20
+ </div>
21
+ );
22
+
23
+ export default PaymentGridSkeleton;
@@ -0,0 +1,47 @@
1
+ import { ReactNode } from 'react';
2
+ import clsx from 'clsx';
3
+ import { Skeleton } from '@theme/components';
4
+
5
+ interface SectionSkeletonProps {
6
+ active?: boolean;
7
+ children?: ReactNode;
8
+ contentClassName?: string;
9
+ }
10
+
11
+ export const SectionSkeleton = ({
12
+ active = false,
13
+ children,
14
+ contentClassName
15
+ }: SectionSkeletonProps) => {
16
+ return (
17
+ <div
18
+ className={clsx(
19
+ 'mb-4 overflow-hidden rounded-2xl bg-white last:mb-0',
20
+ active
21
+ ? 'shadow-[0_12px_40px_-16px_rgba(0,0,0,0.16)] ring-1 ring-black-800/10'
22
+ : 'shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]'
23
+ )}
24
+ aria-busy="true"
25
+ aria-label="Loading section"
26
+ >
27
+ <header className="flex items-center justify-between px-6 py-5 sm:px-7">
28
+ <div className="flex items-center gap-3.5">
29
+ <Skeleton className="h-8 w-8 rounded-full bg-gray-200" />
30
+ <Skeleton className="h-5 w-44 bg-gray-200" />
31
+ </div>
32
+ </header>
33
+ {(active || children) && (
34
+ <div
35
+ className={clsx(
36
+ 'border-t border-gray-100 px-6 py-6 sm:px-7',
37
+ contentClassName
38
+ )}
39
+ >
40
+ {children ?? <Skeleton className="h-14 w-full bg-gray-200" />}
41
+ </div>
42
+ )}
43
+ </div>
44
+ );
45
+ };
46
+
47
+ export default SectionSkeleton;
@@ -0,0 +1,20 @@
1
+ import { Skeleton } from '@theme/components';
2
+
3
+ export const ShippingOptionsSkeleton = () => (
4
+ <div className="flex flex-col gap-2" aria-busy="true">
5
+ {Array.from({ length: 3 }).map((_, i) => (
6
+ <div
7
+ key={i}
8
+ className="flex items-center justify-between rounded-sm border border-gray-300 px-4 py-3"
9
+ >
10
+ <div className="flex items-center gap-3">
11
+ <Skeleton className="h-4 w-4 rounded-full" />
12
+ <Skeleton className="h-4 w-32" />
13
+ </div>
14
+ <Skeleton className="h-4 w-16" />
15
+ </div>
16
+ ))}
17
+ </div>
18
+ );
19
+
20
+ export default ShippingOptionsSkeleton;
@@ -0,0 +1,38 @@
1
+ import { Skeleton } from '@theme/components';
2
+
3
+ export const SummarySkeleton = () => (
4
+ <div className="flex flex-col gap-4" aria-busy="true">
5
+ <section className="overflow-hidden rounded-2xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]">
6
+ <div className="flex items-baseline justify-between px-6 py-5">
7
+ <Skeleton className="h-5 w-24 bg-gray-200" />
8
+ <Skeleton className="h-3 w-14 bg-gray-200" />
9
+ </div>
10
+ <div className="divide-y divide-gray-100 border-t border-gray-100">
11
+ {Array.from({ length: 2 }).map((_, i) => (
12
+ <div key={i} className="flex gap-3 px-6 py-4">
13
+ <Skeleton className="h-20 w-14 flex-shrink-0 rounded-md bg-gray-200" />
14
+ <div className="flex flex-1 flex-col gap-2">
15
+ <Skeleton className="h-3 w-3/4 bg-gray-200" />
16
+ <Skeleton className="h-3 w-1/2 bg-gray-200" />
17
+ <div className="mt-auto flex justify-between">
18
+ <Skeleton className="h-3 w-16 bg-gray-200" />
19
+ <Skeleton className="h-3 w-12 bg-gray-200" />
20
+ </div>
21
+ </div>
22
+ </div>
23
+ ))}
24
+ </div>
25
+ <div className="space-y-2.5 border-t border-gray-100 px-6 py-5">
26
+ <Skeleton className="h-3 w-full bg-gray-200" />
27
+ <Skeleton className="h-3 w-full bg-gray-200" />
28
+ <Skeleton className="h-3 w-full bg-gray-200" />
29
+ </div>
30
+ <div className="flex items-baseline justify-between border-t border-gray-100 px-6 py-5">
31
+ <Skeleton className="h-4 w-16 bg-gray-200" />
32
+ <Skeleton className="h-7 w-28 bg-gray-200" />
33
+ </div>
34
+ </section>
35
+ </div>
36
+ );
37
+
38
+ export default SummarySkeleton;
@@ -0,0 +1,30 @@
1
+ import { useLocalization } from '@akinon/next/hooks';
2
+ import { Icon } from '@theme/components';
3
+ import { Image } from '@akinon/next/components/image';
4
+
5
+ const TrustBadges = () => {
6
+ const { t } = useLocalization();
7
+
8
+ return (
9
+ <div className="mt-6 flex flex-col gap-3 px-2 sm:flex-row sm:items-center sm:justify-between">
10
+ <div className="flex items-center gap-2 text-xs text-gray-600">
11
+ <Icon name="info" size={14} className="fill-gray-500" aria-hidden="true" />
12
+ <span>{t('checkout.one_page.trust.secure_note')}</span>
13
+ </div>
14
+ <div className="flex items-center gap-2 opacity-70" aria-hidden="true">
15
+ <Image src="/visa.png" alt="Visa" width={36} height={12} />
16
+ <Image src="/mastercard.png" alt="Mastercard" width={20} height={12} />
17
+ <Image src="/vbv.png" alt="Verified by Visa" width={26} height={12} />
18
+ <Image
19
+ src="/mastersecure.png"
20
+ alt="Mastercard Secure"
21
+ width={32}
22
+ height={12}
23
+ />
24
+ <Image src="/ssl-secure.png" alt="SSL Secure" width={12} height={12} />
25
+ </div>
26
+ </div>
27
+ );
28
+ };
29
+
30
+ export default TrustBadges;
@@ -0,0 +1,32 @@
1
+ import dynamic from 'next/dynamic';
2
+ import type { ComponentType } from 'react';
3
+ import type { CheckoutVariantId, CheckoutVariantProps } from './types';
4
+
5
+ type VariantLoader = () => Promise<{
6
+ default: ComponentType<CheckoutVariantProps>;
7
+ }>;
8
+
9
+ const variantLoaders: Record<CheckoutVariantId, VariantLoader> = {
10
+ 'multi-step': () => import('./multi-step'),
11
+ 'one-page': () => import('./one-page'),
12
+ accordion: () => import('./one-page'),
13
+ 'basket-and-checkout': () => import('./basket-and-checkout')
14
+ };
15
+
16
+ /**
17
+ * Variants that embed the basket inside the checkout page. In these modes the
18
+ * basket page redirects to checkout, so checkout must never redirect back to
19
+ * the basket page — that would create an infinite redirect loop.
20
+ */
21
+ export const UNIFIED_VARIANTS: ReadonlySet<string> = new Set([
22
+ 'basket-and-checkout'
23
+ ]);
24
+
25
+ export const getCheckoutVariant = (variant: CheckoutVariantId | undefined) => {
26
+ const loader = variantLoaders[variant ?? 'multi-step'] ?? variantLoaders['multi-step'];
27
+
28
+ return dynamic(loader, { ssr: false });
29
+ };
30
+
31
+ export const isKnownVariant = (value: unknown): value is CheckoutVariantId =>
32
+ typeof value === 'string' && value in variantLoaders;
@@ -0,0 +1,30 @@
1
+ import { ComponentType } from 'react';
2
+
3
+ export type CheckoutVariantId =
4
+ | 'multi-step'
5
+ | 'one-page'
6
+ | 'accordion'
7
+ | 'basket-and-checkout';
8
+
9
+ export interface CheckoutVariantOptions {
10
+ /** Show "Edit" button on completed sections so user can revisit them. Default: true */
11
+ enableEditMode?: boolean;
12
+ /** Auto-open next incomplete section when the current one becomes complete. Default: true */
13
+ autoAdvance?: boolean;
14
+ /** Render the sticky bottom summary drawer on mobile. Default: true */
15
+ stickyMobileCta?: boolean;
16
+ /** Show "Order details" review card (address + shipping + payment) in summary. Default: true */
17
+ showOrderReview?: boolean;
18
+ /** Show SSL/security badges + trust note under the payment section. Default: true */
19
+ showTrustBadges?: boolean;
20
+ /** Fire GA4 funnel events for each section transition. Default: true */
21
+ gtmEnabled?: boolean;
22
+ /** Compact spacing/typography for power-user mode. Default: false */
23
+ compactMode?: boolean;
24
+ }
25
+
26
+ export interface CheckoutVariantProps {
27
+ options?: CheckoutVariantOptions;
28
+ }
29
+
30
+ export type CheckoutVariant = ComponentType<CheckoutVariantProps>;
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import settings from '@theme/settings';
5
+ import { isKnownVariant } from './registry';
6
+ import type { CheckoutVariantId, CheckoutVariantOptions } from './types';
7
+
8
+ export interface ResolvedCheckoutConfig {
9
+ variantId: CheckoutVariantId;
10
+ options: CheckoutVariantOptions;
11
+ }
12
+
13
+ interface RawCheckoutConfig {
14
+ variant?: string;
15
+ options?: Record<string, unknown>;
16
+ }
17
+
18
+ const FALLBACK_VARIANT: CheckoutVariantId = 'multi-step';
19
+
20
+ const coerceOptions = (
21
+ raw: Record<string, unknown> | undefined
22
+ ): CheckoutVariantOptions => {
23
+ if (!raw) return {};
24
+ const cast = raw as Partial<CheckoutVariantOptions>;
25
+ const out: CheckoutVariantOptions = {};
26
+ if (typeof cast.enableEditMode === 'boolean') out.enableEditMode = cast.enableEditMode;
27
+ if (typeof cast.autoAdvance === 'boolean') out.autoAdvance = cast.autoAdvance;
28
+ if (typeof cast.stickyMobileCta === 'boolean') out.stickyMobileCta = cast.stickyMobileCta;
29
+ if (typeof cast.showOrderReview === 'boolean') out.showOrderReview = cast.showOrderReview;
30
+ if (typeof cast.showTrustBadges === 'boolean') out.showTrustBadges = cast.showTrustBadges;
31
+ if (typeof cast.gtmEnabled === 'boolean') out.gtmEnabled = cast.gtmEnabled;
32
+ if (typeof cast.compactMode === 'boolean') out.compactMode = cast.compactMode;
33
+ return out;
34
+ };
35
+
36
+ const readBaseFromSettings = (): ResolvedCheckoutConfig => {
37
+ const fromSettings = (settings as { checkout?: RawCheckoutConfig })?.checkout;
38
+ const variantId = isKnownVariant(fromSettings?.variant)
39
+ ? fromSettings.variant
40
+ : FALLBACK_VARIANT;
41
+ return {
42
+ variantId,
43
+ options: coerceOptions(fromSettings?.options)
44
+ };
45
+ };
46
+
47
+ const mergeOverride = (
48
+ base: ResolvedCheckoutConfig,
49
+ override: RawCheckoutConfig | undefined
50
+ ): ResolvedCheckoutConfig => {
51
+ if (!override) return base;
52
+ return {
53
+ variantId: isKnownVariant(override.variant) ? override.variant : base.variantId,
54
+ options: { ...base.options, ...coerceOptions(override.options) }
55
+ };
56
+ };
57
+
58
+ /**
59
+ * Resolves checkout config from two layers (each optional):
60
+ *
61
+ * 1. settings.js (sync, always available — baseline / fallback)
62
+ * 2. /api/theme-settings (backend theme-config widget — persistent override)
63
+ *
64
+ * Layer 2 falls back silently if backend is unreachable or no config is saved.
65
+ * The editor is OPTIONAL — when no theme-config is saved, the storefront keeps
66
+ * using its built-in settings.js.
67
+ */
68
+ export const useResolvedCheckoutConfig = (): ResolvedCheckoutConfig => {
69
+ const [config, setConfig] = useState<ResolvedCheckoutConfig>(readBaseFromSettings);
70
+
71
+ useEffect(() => {
72
+ let cancelled = false;
73
+ fetch('/api/theme-settings', { cache: 'no-store' })
74
+ .then((res) => (res.ok ? res.json() : null))
75
+ .then((data: { checkout?: RawCheckoutConfig } | null) => {
76
+ if (cancelled) return;
77
+ if (!data?.checkout) return;
78
+ setConfig(() => mergeOverride(readBaseFromSettings(), data.checkout));
79
+ })
80
+ .catch(() => {
81
+ // Silent: backend not reachable or widget missing — keep settings.js baseline
82
+ });
83
+ return () => {
84
+ cancelled = true;
85
+ };
86
+ }, []);
87
+
88
+ return config;
89
+ };