@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.
- package/CHANGELOG.md +2 -0
- package/app-template/.env.example +6 -0
- package/app-template/CHANGELOG.md +40 -0
- package/app-template/package.json +29 -28
- package/app-template/public/locales/en/checkout.json +50 -0
- package/app-template/public/locales/tr/checkout.json +50 -0
- package/app-template/src/app/[pz]/account/(dashboard)/layout.tsx +20 -0
- package/app-template/src/app/[pz]/account/account-page-slot.tsx +35 -0
- package/app-template/src/app/[pz]/account/address/layout.tsx +18 -0
- package/app-template/src/app/[pz]/account/change-email/layout.tsx +18 -0
- package/app-template/src/app/[pz]/account/change-password/layout.tsx +18 -0
- package/app-template/src/app/[pz]/account/contact/layout.tsx +18 -0
- package/app-template/src/app/[pz]/account/coupons/layout.tsx +18 -0
- package/app-template/src/app/[pz]/account/faq/layout.tsx +14 -0
- package/app-template/src/app/[pz]/account/favourite-products/layout.tsx +21 -0
- package/app-template/src/app/[pz]/account/layout.tsx +36 -3
- package/app-template/src/app/[pz]/account/orders/(list)/layout.tsx +20 -0
- package/app-template/src/app/[pz]/account/orders/[id]/(detail)/layout.tsx +40 -0
- package/app-template/src/app/[pz]/account/profile/layout.tsx +18 -0
- package/app-template/src/app/[pz]/auth/(login)/layout.tsx +37 -0
- package/app-template/src/app/[pz]/basket/layout.tsx +25 -0
- package/app-template/src/app/[pz]/client-root.tsx +2 -0
- package/app-template/src/app/[pz]/configure-page-context.ts +17 -0
- package/app-template/src/app/[pz]/layout.tsx +22 -3
- package/app-template/src/app/[pz]/orders/checkout/layout.tsx +47 -0
- package/app-template/src/app/[pz]/orders/checkout/page.tsx +75 -97
- package/app-template/src/app/[pz]/orders/completed/[token]/layout.tsx +24 -2
- package/app-template/src/app/[pz]/users/password/reset/layout.tsx +21 -0
- package/app-template/src/app/[pz]/users/reset/[[...id]]/layout.tsx +30 -0
- package/app-template/src/components/checkbox.tsx +22 -3
- package/app-template/src/components/route-handler.tsx +48 -0
- package/app-template/src/routes/index.ts +6 -6
- package/app-template/src/views/checkout/__tests__/part-style-reachability.test.ts +119 -0
- package/app-template/src/views/checkout/step-button.tsx +13 -2
- package/app-template/src/views/checkout/step-list.tsx +5 -1
- package/app-template/src/views/checkout/steps/payment/agreements.tsx +9 -1
- package/app-template/src/views/checkout/steps/payment/index.tsx +10 -2
- package/app-template/src/views/checkout/steps/payment/options/credit-card/index.tsx +2 -0
- package/app-template/src/views/checkout/steps/payment/options/funds-transfer.tsx +2 -0
- package/app-template/src/views/checkout/steps/payment/payment-header.tsx +5 -1
- package/app-template/src/views/checkout/steps/payment/payment-option-buttons.tsx +16 -4
- package/app-template/src/views/checkout/steps/shipping/addresses.tsx +2 -1
- package/app-template/src/views/checkout/steps/shipping/index.tsx +2 -0
- package/app-template/src/views/checkout/steps/shipping/shipping-options.tsx +2 -0
- package/app-template/src/views/checkout/summary.tsx +56 -18
- package/app-template/src/views/checkout/variants/__tests__/registry.test.tsx +82 -0
- package/app-template/src/views/checkout/variants/__tests__/use-resolved-config.test.tsx +208 -0
- package/app-template/src/views/checkout/variants/basket-and-checkout/basket-section.tsx +150 -0
- package/app-template/src/views/checkout/variants/basket-and-checkout/index.tsx +135 -0
- package/app-template/src/views/checkout/variants/multi-step/index.tsx +78 -0
- package/app-template/src/views/checkout/variants/one-page/accordion-section.tsx +165 -0
- package/app-template/src/views/checkout/variants/one-page/hooks/use-checkout-funnel-gtm.ts +60 -0
- package/app-template/src/views/checkout/variants/one-page/hooks/use-section-state.ts +103 -0
- package/app-template/src/views/checkout/variants/one-page/index.tsx +125 -0
- package/app-template/src/views/checkout/variants/one-page/mobile-summary-sheet.tsx +109 -0
- package/app-template/src/views/checkout/variants/one-page/one-page-summary.tsx +301 -0
- package/app-template/src/views/checkout/variants/one-page/payment-options-grid.tsx +220 -0
- package/app-template/src/views/checkout/variants/one-page/sections/address-section.tsx +320 -0
- package/app-template/src/views/checkout/variants/one-page/sections/contact-section.tsx +62 -0
- package/app-template/src/views/checkout/variants/one-page/sections/payment-section.tsx +73 -0
- package/app-template/src/views/checkout/variants/one-page/sections/shipping-section.tsx +328 -0
- package/app-template/src/views/checkout/variants/one-page/skeletons/address-skeleton.tsx +25 -0
- package/app-template/src/views/checkout/variants/one-page/skeletons/checkout-skeleton.tsx +27 -0
- package/app-template/src/views/checkout/variants/one-page/skeletons/index.ts +6 -0
- package/app-template/src/views/checkout/variants/one-page/skeletons/payment-skeleton.tsx +23 -0
- package/app-template/src/views/checkout/variants/one-page/skeletons/section-skeleton.tsx +47 -0
- package/app-template/src/views/checkout/variants/one-page/skeletons/shipping-skeleton.tsx +20 -0
- package/app-template/src/views/checkout/variants/one-page/skeletons/summary-skeleton.tsx +38 -0
- package/app-template/src/views/checkout/variants/one-page/trust-badges.tsx +35 -0
- package/app-template/src/views/checkout/variants/registry.ts +43 -0
- package/app-template/src/views/checkout/variants/types.ts +30 -0
- package/app-template/src/views/checkout/variants/use-resolved-config.ts +168 -0
- package/app-template/tailwind.config.js +3 -1
- package/dist/commands/plugins.js +23 -2
- package/package.json +1 -1
- package/app-template/src/app/[pz]/account/orders/[id]/layout.tsx +0 -5
- /package/app-template/src/app/[pz]/account/{page.tsx → (dashboard)/page.tsx} +0 -0
- /package/app-template/src/app/[pz]/account/orders/{page.tsx → (list)/page.tsx} +0 -0
- /package/app-template/src/app/[pz]/account/orders/[id]/{page.tsx → (detail)/page.tsx} +0 -0
- /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>;
|