@akinon/projectzero 1.126.19 → 1.126.20

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 (80) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/app-template/.env.example +6 -0
  3. package/app-template/CHANGELOG.md +40 -0
  4. package/app-template/package.json +29 -28
  5. package/app-template/public/locales/en/checkout.json +50 -0
  6. package/app-template/public/locales/tr/checkout.json +50 -0
  7. package/app-template/src/app/[pz]/account/(dashboard)/layout.tsx +20 -0
  8. package/app-template/src/app/[pz]/account/account-page-slot.tsx +35 -0
  9. package/app-template/src/app/[pz]/account/address/layout.tsx +18 -0
  10. package/app-template/src/app/[pz]/account/change-email/layout.tsx +18 -0
  11. package/app-template/src/app/[pz]/account/change-password/layout.tsx +18 -0
  12. package/app-template/src/app/[pz]/account/contact/layout.tsx +18 -0
  13. package/app-template/src/app/[pz]/account/coupons/layout.tsx +18 -0
  14. package/app-template/src/app/[pz]/account/faq/layout.tsx +14 -0
  15. package/app-template/src/app/[pz]/account/favourite-products/layout.tsx +21 -0
  16. package/app-template/src/app/[pz]/account/layout.tsx +36 -3
  17. package/app-template/src/app/[pz]/account/orders/(list)/layout.tsx +20 -0
  18. package/app-template/src/app/[pz]/account/orders/[id]/(detail)/layout.tsx +40 -0
  19. package/app-template/src/app/[pz]/account/profile/layout.tsx +18 -0
  20. package/app-template/src/app/[pz]/auth/(login)/layout.tsx +37 -0
  21. package/app-template/src/app/[pz]/basket/layout.tsx +25 -0
  22. package/app-template/src/app/[pz]/client-root.tsx +2 -0
  23. package/app-template/src/app/[pz]/configure-page-context.ts +17 -0
  24. package/app-template/src/app/[pz]/layout.tsx +22 -3
  25. package/app-template/src/app/[pz]/orders/checkout/layout.tsx +47 -0
  26. package/app-template/src/app/[pz]/orders/checkout/page.tsx +75 -97
  27. package/app-template/src/app/[pz]/orders/completed/[token]/layout.tsx +24 -2
  28. package/app-template/src/app/[pz]/users/password/reset/layout.tsx +21 -0
  29. package/app-template/src/app/[pz]/users/reset/[[...id]]/layout.tsx +30 -0
  30. package/app-template/src/components/checkbox.tsx +22 -3
  31. package/app-template/src/components/route-handler.tsx +48 -0
  32. package/app-template/src/routes/index.ts +6 -6
  33. package/app-template/src/views/checkout/__tests__/part-style-reachability.test.ts +119 -0
  34. package/app-template/src/views/checkout/step-button.tsx +13 -2
  35. package/app-template/src/views/checkout/step-list.tsx +5 -1
  36. package/app-template/src/views/checkout/steps/payment/agreements.tsx +9 -1
  37. package/app-template/src/views/checkout/steps/payment/index.tsx +10 -2
  38. package/app-template/src/views/checkout/steps/payment/options/credit-card/index.tsx +2 -0
  39. package/app-template/src/views/checkout/steps/payment/options/funds-transfer.tsx +2 -0
  40. package/app-template/src/views/checkout/steps/payment/payment-header.tsx +5 -1
  41. package/app-template/src/views/checkout/steps/payment/payment-option-buttons.tsx +16 -4
  42. package/app-template/src/views/checkout/steps/shipping/addresses.tsx +2 -1
  43. package/app-template/src/views/checkout/steps/shipping/index.tsx +2 -0
  44. package/app-template/src/views/checkout/steps/shipping/shipping-options.tsx +2 -0
  45. package/app-template/src/views/checkout/summary.tsx +56 -18
  46. package/app-template/src/views/checkout/variants/__tests__/registry.test.tsx +82 -0
  47. package/app-template/src/views/checkout/variants/__tests__/use-resolved-config.test.tsx +208 -0
  48. package/app-template/src/views/checkout/variants/basket-and-checkout/basket-section.tsx +150 -0
  49. package/app-template/src/views/checkout/variants/basket-and-checkout/index.tsx +135 -0
  50. package/app-template/src/views/checkout/variants/multi-step/index.tsx +78 -0
  51. package/app-template/src/views/checkout/variants/one-page/accordion-section.tsx +165 -0
  52. package/app-template/src/views/checkout/variants/one-page/hooks/use-checkout-funnel-gtm.ts +60 -0
  53. package/app-template/src/views/checkout/variants/one-page/hooks/use-section-state.ts +103 -0
  54. package/app-template/src/views/checkout/variants/one-page/index.tsx +125 -0
  55. package/app-template/src/views/checkout/variants/one-page/mobile-summary-sheet.tsx +109 -0
  56. package/app-template/src/views/checkout/variants/one-page/one-page-summary.tsx +301 -0
  57. package/app-template/src/views/checkout/variants/one-page/payment-options-grid.tsx +220 -0
  58. package/app-template/src/views/checkout/variants/one-page/sections/address-section.tsx +320 -0
  59. package/app-template/src/views/checkout/variants/one-page/sections/contact-section.tsx +62 -0
  60. package/app-template/src/views/checkout/variants/one-page/sections/payment-section.tsx +73 -0
  61. package/app-template/src/views/checkout/variants/one-page/sections/shipping-section.tsx +328 -0
  62. package/app-template/src/views/checkout/variants/one-page/skeletons/address-skeleton.tsx +25 -0
  63. package/app-template/src/views/checkout/variants/one-page/skeletons/checkout-skeleton.tsx +27 -0
  64. package/app-template/src/views/checkout/variants/one-page/skeletons/index.ts +6 -0
  65. package/app-template/src/views/checkout/variants/one-page/skeletons/payment-skeleton.tsx +23 -0
  66. package/app-template/src/views/checkout/variants/one-page/skeletons/section-skeleton.tsx +47 -0
  67. package/app-template/src/views/checkout/variants/one-page/skeletons/shipping-skeleton.tsx +20 -0
  68. package/app-template/src/views/checkout/variants/one-page/skeletons/summary-skeleton.tsx +38 -0
  69. package/app-template/src/views/checkout/variants/one-page/trust-badges.tsx +35 -0
  70. package/app-template/src/views/checkout/variants/registry.ts +43 -0
  71. package/app-template/src/views/checkout/variants/types.ts +30 -0
  72. package/app-template/src/views/checkout/variants/use-resolved-config.ts +168 -0
  73. package/app-template/tailwind.config.js +3 -1
  74. package/dist/commands/plugins.js +23 -2
  75. package/package.json +1 -1
  76. package/app-template/src/app/[pz]/account/orders/[id]/layout.tsx +0 -5
  77. /package/app-template/src/app/[pz]/account/{page.tsx → (dashboard)/page.tsx} +0 -0
  78. /package/app-template/src/app/[pz]/account/orders/{page.tsx → (list)/page.tsx} +0 -0
  79. /package/app-template/src/app/[pz]/account/orders/[id]/{page.tsx → (detail)/page.tsx} +0 -0
  80. /package/app-template/src/app/[pz]/auth/{page.tsx → (login)/page.tsx} +0 -0
@@ -0,0 +1,328 @@
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 type { AttributeBasedShippingOption } from '@akinon/next/types';
12
+ import { Button, Price, Radio } from '@theme/components';
13
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
14
+ import type { RootState } from '@theme/redux/store';
15
+ import AccordionSection, { SectionStatus } from '../accordion-section';
16
+ import { ShippingOptionsSkeleton } from '../skeletons/shipping-skeleton';
17
+
18
+ interface ShippingSectionProps {
19
+ status: SectionStatus;
20
+ onEdit: () => void;
21
+ onComplete: () => void;
22
+ editable?: boolean;
23
+ }
24
+
25
+ const ShippingSection = ({
26
+ status,
27
+ onEdit,
28
+ onComplete,
29
+ editable = true
30
+ }: ShippingSectionProps) => {
31
+ const { t } = useLocalization();
32
+ const dispatch = useAppDispatch();
33
+ const {
34
+ shippingOptions,
35
+ attributeBasedShippingOptions,
36
+ dataSourceShippingOptions,
37
+ preOrder,
38
+ selectedShippingOptions
39
+ } = useAppSelector((state: RootState) => state.checkout);
40
+ const {
41
+ shipping_option,
42
+ attribute_based_shipping_options,
43
+ data_source_shipping_options
44
+ } = preOrder ?? {};
45
+
46
+ const [setShippingOption, { isLoading: isSettingPlain }] =
47
+ useSetShippingOptionMutation();
48
+ const [setAttributeBasedShippingOptions, { isLoading: isSettingAttribute }] =
49
+ useSetAttributeBasedShippingOptionsMutation();
50
+ const [setDataSourceShippingOption, { isLoading: isSettingDataSource }] =
51
+ useSetDataSourceShippingOptionsMutation();
52
+ const isSetting = isSettingPlain || isSettingAttribute || isSettingDataSource;
53
+
54
+ const prevAttributeBasedRef = useRef(attributeBasedShippingOptions);
55
+ const [selectedDataSourcePks, setSelectedDataSourcePks] = useState<
56
+ { dataSourcePk: number; optionPk: number }[] | null
57
+ >(null);
58
+
59
+ const initializeAttributeOptions = useCallback(() => {
60
+ if (!attribute_based_shipping_options) return;
61
+ const next: Record<string, number> = {};
62
+ let changed = false;
63
+ Object.entries(attributeBasedShippingOptions).forEach(([key, group]) => {
64
+ // The step arrives as a dictionary of groups, each carrying its own
65
+ // option list. Both the local and the remote page use this shape.
66
+ const options: AttributeBasedShippingOption[] =
67
+ group?.attribute_based_shipping_options ?? [];
68
+
69
+ if (options.length === 0) return;
70
+
71
+ const previous = selectedShippingOptions[key];
72
+ const keepsPrevious = options.some((opt) => opt.pk === previous);
73
+
74
+ next[key] = keepsPrevious ? previous : options[0].pk;
75
+
76
+ if (!keepsPrevious) {
77
+ changed = true;
78
+ }
79
+ });
80
+ // A group the backend no longer reports must not stay in the selection:
81
+ // it rejects the whole payload when the group count does not match.
82
+ if (
83
+ Object.keys(next).length !== Object.keys(selectedShippingOptions).length
84
+ ) {
85
+ changed = true;
86
+ }
87
+ if (changed && Object.keys(next).length > 0) {
88
+ dispatch(setSelectedShippingOptions(next));
89
+ setAttributeBasedShippingOptions(next);
90
+ }
91
+ }, [
92
+ attributeBasedShippingOptions,
93
+ selectedShippingOptions,
94
+ attribute_based_shipping_options,
95
+ dispatch,
96
+ setAttributeBasedShippingOptions
97
+ ]);
98
+
99
+ useEffect(() => {
100
+ if (!data_source_shipping_options) return;
101
+ setSelectedDataSourcePks(
102
+ data_source_shipping_options.map((option) => ({
103
+ dataSourcePk: option.data_source.pk,
104
+ optionPk: option.pk
105
+ }))
106
+ );
107
+ }, [data_source_shipping_options]);
108
+
109
+ useEffect(() => {
110
+ if (
111
+ JSON.stringify(prevAttributeBasedRef.current) !==
112
+ JSON.stringify(attributeBasedShippingOptions) ||
113
+ Object.keys(selectedShippingOptions).length === 0
114
+ ) {
115
+ initializeAttributeOptions();
116
+ prevAttributeBasedRef.current = attributeBasedShippingOptions;
117
+ }
118
+ }, [
119
+ attributeBasedShippingOptions,
120
+ selectedShippingOptions,
121
+ initializeAttributeOptions
122
+ ]);
123
+
124
+ const handlePlainOption = (pk: number) => {
125
+ setShippingOption(pk);
126
+ };
127
+
128
+ const handleAttributeOption = (key: string, pk: number) => {
129
+ const next = { ...selectedShippingOptions, [key]: pk };
130
+ dispatch(setSelectedShippingOptions(next));
131
+ setAttributeBasedShippingOptions(next);
132
+ };
133
+
134
+ const handleDataSourceOption = (dataSourcePk: number, optionPk: number) => {
135
+ const updated = selectedDataSourcePks?.map((item) =>
136
+ item.dataSourcePk === dataSourcePk ? { ...item, optionPk } : item
137
+ );
138
+ if (!updated) return;
139
+ setSelectedDataSourcePks(updated);
140
+ setDataSourceShippingOption(updated.map((item) => item.optionPk));
141
+ };
142
+
143
+ const summary = shipping_option ? (
144
+ <div className="flex flex-col">
145
+ <span className="font-medium">{shipping_option.name}</span>
146
+ {shipping_option.shipping_amount && (
147
+ <span className="text-xs text-black-700">
148
+ <Price value={shipping_option.shipping_amount} />
149
+ </span>
150
+ )}
151
+ </div>
152
+ ) : null;
153
+
154
+ const hasAttributeOptions = useMemo(
155
+ () =>
156
+ attributeBasedShippingOptions &&
157
+ Object.keys(attributeBasedShippingOptions).length > 0,
158
+ [attributeBasedShippingOptions]
159
+ );
160
+
161
+ const hasDataSourceOptions = useMemo(
162
+ () => dataSourceShippingOptions && dataSourceShippingOptions.length > 0,
163
+ [dataSourceShippingOptions]
164
+ );
165
+
166
+ const noOptionsLoaded =
167
+ shippingOptions.length === 0 &&
168
+ !hasAttributeOptions &&
169
+ !hasDataSourceOptions;
170
+
171
+ return (
172
+ <AccordionSection
173
+ step={3}
174
+ title={t('checkout.one_page.shipping.title')}
175
+ status={status}
176
+ onEdit={onEdit}
177
+ editable={editable}
178
+ summary={summary}
179
+ dataTestId="one-page-shipping"
180
+ >
181
+ {noOptionsLoaded ? (
182
+ <ShippingOptionsSkeleton />
183
+ ) : (
184
+ <div
185
+ className={clsx('flex flex-col gap-4', {
186
+ 'opacity-60 pointer-events-none': isSetting
187
+ })}
188
+ >
189
+ {shippingOptions.length > 0 && (
190
+ <div
191
+ className="flex flex-col gap-2"
192
+ role="radiogroup"
193
+ aria-label={t('checkout.one_page.shipping.title')}
194
+ >
195
+ {shippingOptions.map((option) => (
196
+ <label
197
+ key={option.pk}
198
+ className={clsx(
199
+ 'flex cursor-pointer items-center justify-between rounded-sm border px-4 py-3 transition-all',
200
+ 'focus-within:ring-2 focus-within:ring-secondary focus-within:ring-offset-1',
201
+ shipping_option?.pk === option.pk
202
+ ? 'border-secondary-400 bg-secondary-50/50'
203
+ : 'border-gray-300 hover:border-secondary-400'
204
+ )}
205
+ >
206
+ <Radio
207
+ name="one-page-shipping"
208
+ checked={option.pk === shipping_option?.pk}
209
+ onChange={() => handlePlainOption(option.pk)}
210
+ data-testid={`one-page-shipping-option-${option.pk}`}
211
+ >
212
+ <span className="ml-1 text-sm">{option.name}</span>
213
+ </Radio>
214
+ <span className="text-sm font-medium">
215
+ <Price value={option.shipping_amount} />
216
+ </span>
217
+ </label>
218
+ ))}
219
+ </div>
220
+ )}
221
+
222
+ {hasAttributeOptions &&
223
+ Object.entries(attributeBasedShippingOptions).map(
224
+ ([key, group]) => {
225
+ const options: AttributeBasedShippingOption[] =
226
+ group?.attribute_based_shipping_options ?? [];
227
+
228
+ if (options.length === 0) {
229
+ return null;
230
+ }
231
+
232
+ return (
233
+ <div key={key}>
234
+ <h4 className="mb-2 text-sm font-medium text-black-800">
235
+ {key}
236
+ </h4>
237
+ <div
238
+ className="flex flex-col gap-2"
239
+ role="radiogroup"
240
+ aria-label={key}
241
+ >
242
+ {options.map((option) => (
243
+ <label
244
+ key={option.pk}
245
+ className="flex cursor-pointer items-center justify-between rounded-sm border border-gray-300 px-4 py-3 hover:border-secondary-400"
246
+ >
247
+ <Radio
248
+ name={`one-page-attr-${key}`}
249
+ checked={selectedShippingOptions[key] === option.pk}
250
+ onChange={() =>
251
+ handleAttributeOption(key, option.pk)
252
+ }
253
+ data-testid={`one-page-attr-shipping-${option.pk}`}
254
+ >
255
+ <span className="ml-1 text-sm">
256
+ {option.shipping_option_name}
257
+ </span>
258
+ </Radio>
259
+ <span className="text-sm font-medium">
260
+ <Price value={option.shipping_amount} />
261
+ </span>
262
+ </label>
263
+ ))}
264
+ </div>
265
+ </div>
266
+ );
267
+ }
268
+ )}
269
+
270
+ {hasDataSourceOptions &&
271
+ dataSourceShippingOptions.map((source) => (
272
+ <div key={source.pk}>
273
+ <h4 className="mb-2 text-sm font-medium text-black-800">
274
+ {source.name}
275
+ </h4>
276
+ <div
277
+ className="flex flex-col gap-2"
278
+ role="radiogroup"
279
+ aria-label={source.name}
280
+ >
281
+ {source.data_source_shipping_options.map((opt) => (
282
+ <label
283
+ key={opt.pk}
284
+ className="flex cursor-pointer items-center justify-between rounded-sm border border-gray-300 px-4 py-3 hover:border-secondary-400"
285
+ >
286
+ <Radio
287
+ name={`one-page-ds-${source.pk}`}
288
+ checked={
289
+ selectedDataSourcePks?.some(
290
+ (item) => item.optionPk === opt.pk
291
+ ) || false
292
+ }
293
+ onChange={() =>
294
+ handleDataSourceOption(source.pk, opt.pk)
295
+ }
296
+ data-testid={`one-page-ds-shipping-${opt.pk}`}
297
+ >
298
+ <span className="ml-1 text-sm">
299
+ {opt.shipping_option_name}
300
+ </span>
301
+ </Radio>
302
+ <span className="text-sm font-medium">
303
+ <Price value={opt.shipping_amount} />
304
+ </span>
305
+ </label>
306
+ ))}
307
+ </div>
308
+ </div>
309
+ ))}
310
+
311
+ {shipping_option && (
312
+ <div className="flex justify-end">
313
+ <Button
314
+ onClick={onComplete}
315
+ data-testid="one-page-shipping-continue"
316
+ {...partAttrs('continue-button')}
317
+ >
318
+ {t('checkout.one_page.continue_to_payment')}
319
+ </Button>
320
+ </div>
321
+ )}
322
+ </div>
323
+ )}
324
+ </AccordionSection>
325
+ );
326
+ };
327
+
328
+ 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,35 @@
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
12
+ name="info"
13
+ size={14}
14
+ className="fill-gray-500"
15
+ aria-hidden="true"
16
+ />
17
+ <span>{t('checkout.one_page.trust.secure_note')}</span>
18
+ </div>
19
+ <div className="flex items-center gap-2 opacity-70" aria-hidden="true">
20
+ <Image src="/visa.png" alt="Visa" width={36} height={12} />
21
+ <Image src="/mastercard.png" alt="Mastercard" width={20} height={12} />
22
+ <Image src="/vbv.png" alt="Verified by Visa" width={26} height={12} />
23
+ <Image
24
+ src="/mastersecure.png"
25
+ alt="Mastercard Secure"
26
+ width={32}
27
+ height={12}
28
+ />
29
+ <Image src="/ssl-secure.png" alt="SSL Secure" width={12} height={12} />
30
+ </div>
31
+ </div>
32
+ );
33
+ };
34
+
35
+ export default TrustBadges;
@@ -0,0 +1,43 @@
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
+ // 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'];
31
+
32
+ return dynamic(loader, { ssr: false });
33
+ };
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
+ */
41
+ export const isKnownVariant = (value: unknown): value is CheckoutVariantId =>
42
+ typeof value === 'string' &&
43
+ Object.prototype.hasOwnProperty.call(variantLoaders, value);
@@ -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>;