@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.
- package/CHANGELOG.md +2 -0
- package/app-template/CHANGELOG.md +34 -0
- package/app-template/package.json +29 -29
- 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]/basket/page.tsx +33 -6
- package/app-template/src/app/[pz]/orders/checkout/page.tsx +57 -92
- package/app-template/src/app/api/theme-settings/route.ts +21 -2
- package/app-template/src/data/server/theme.ts +6 -25
- package/app-template/src/views/checkout/variants/basket-and-checkout/basket-section.tsx +149 -0
- package/app-template/src/views/checkout/variants/basket-and-checkout/index.tsx +132 -0
- package/app-template/src/views/checkout/variants/multi-step/index.tsx +73 -0
- package/app-template/src/views/checkout/variants/one-page/accordion-section.tsx +140 -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 +99 -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 +237 -0
- package/app-template/src/views/checkout/variants/one-page/payment-options-grid.tsx +201 -0
- package/app-template/src/views/checkout/variants/one-page/sections/address-section.tsx +318 -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 +71 -0
- package/app-template/src/views/checkout/variants/one-page/sections/shipping-section.tsx +298 -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 +30 -0
- package/app-template/src/views/checkout/variants/registry.ts +32 -0
- package/app-template/src/views/checkout/variants/types.ts +30 -0
- package/app-template/src/views/checkout/variants/use-resolved-config.ts +89 -0
- package/package.json +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useGetBasketQuery } from '@akinon/next/data/client/basket';
|
|
3
|
+
import { checkoutApi } from '@akinon/next/data/client/checkout';
|
|
4
|
+
import { useAppDispatch } from '@akinon/next/redux/hooks';
|
|
5
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
6
|
+
import PluginModule, { Component } from '@akinon/next/components/plugin-module';
|
|
7
|
+
import { BasketItem } from '@theme/views/basket';
|
|
8
|
+
import { Icon, Link, Button, LoaderSpinner } from '@theme/components';
|
|
9
|
+
import { ROUTES } from '@theme/routes';
|
|
10
|
+
import settings from '@theme/settings';
|
|
11
|
+
|
|
12
|
+
const BasketSection = () => {
|
|
13
|
+
const { t } = useLocalization();
|
|
14
|
+
const dispatch = useAppDispatch();
|
|
15
|
+
const { data: basket, isLoading, isSuccess } = useGetBasketQuery();
|
|
16
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
17
|
+
|
|
18
|
+
const multiBasket = (settings as { plugins?: { multiBasket?: boolean } })
|
|
19
|
+
?.plugins?.multiBasket ?? false;
|
|
20
|
+
|
|
21
|
+
const totalQuantity = basket?.total_quantity ?? 0;
|
|
22
|
+
const totalAmount = basket?.total_amount ?? '0';
|
|
23
|
+
const initialSnapshotRef = useRef<{
|
|
24
|
+
qty: number;
|
|
25
|
+
amount: string;
|
|
26
|
+
} | null>(null);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!isSuccess) return;
|
|
30
|
+
if (initialSnapshotRef.current === null) {
|
|
31
|
+
initialSnapshotRef.current = { qty: totalQuantity, amount: totalAmount };
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const prev = initialSnapshotRef.current;
|
|
35
|
+
if (prev.qty !== totalQuantity || prev.amount !== totalAmount) {
|
|
36
|
+
initialSnapshotRef.current = { qty: totalQuantity, amount: totalAmount };
|
|
37
|
+
dispatch(checkoutApi.util.invalidateTags(['Checkout']));
|
|
38
|
+
}
|
|
39
|
+
}, [isSuccess, totalQuantity, totalAmount, dispatch]);
|
|
40
|
+
|
|
41
|
+
if (isLoading) {
|
|
42
|
+
return (
|
|
43
|
+
<section
|
|
44
|
+
className="mb-5 rounded-2xl bg-white py-10 shadow-[0_2px_8px_-4px_rgba(0,0,0,0.06)] ring-1 ring-black/[0.04]"
|
|
45
|
+
data-testid="basket-section-loading"
|
|
46
|
+
>
|
|
47
|
+
<div className="flex justify-center">
|
|
48
|
+
<LoaderSpinner />
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
isSuccess &&
|
|
56
|
+
(!basket?.basketitem_set || basket.basketitem_set.length === 0)
|
|
57
|
+
) {
|
|
58
|
+
return (
|
|
59
|
+
<section
|
|
60
|
+
className="mb-5 rounded-2xl border-2 border-dashed border-gray-200 bg-white px-6 py-12 text-center"
|
|
61
|
+
data-testid="basket-section-empty"
|
|
62
|
+
>
|
|
63
|
+
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-gray-100">
|
|
64
|
+
<Icon
|
|
65
|
+
name="basket"
|
|
66
|
+
size={24}
|
|
67
|
+
className="fill-gray-500"
|
|
68
|
+
aria-hidden="true"
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
<h2 className="mb-2 text-lg font-semibold text-black-800">
|
|
72
|
+
{t('basket.empty.title')}
|
|
73
|
+
</h2>
|
|
74
|
+
<p className="mb-5 text-sm text-gray-600">
|
|
75
|
+
{t('basket.empty.content_first')}
|
|
76
|
+
</p>
|
|
77
|
+
<Link href={ROUTES.HOME} passHref>
|
|
78
|
+
<Button
|
|
79
|
+
appearance="filled"
|
|
80
|
+
className="px-6"
|
|
81
|
+
data-testid="basket-section-empty-cta"
|
|
82
|
+
>
|
|
83
|
+
{t('basket.empty.button')}
|
|
84
|
+
</Button>
|
|
85
|
+
</Link>
|
|
86
|
+
</section>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!basket) return null;
|
|
91
|
+
|
|
92
|
+
const itemCount = basket.basketitem_set.length;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<section
|
|
96
|
+
className="mb-5 overflow-hidden rounded-2xl bg-white shadow-[0_2px_8px_-4px_rgba(0,0,0,0.06)] ring-1 ring-black/[0.04]"
|
|
97
|
+
data-testid="basket-section"
|
|
98
|
+
>
|
|
99
|
+
<header className="flex items-center justify-between gap-3 px-6 py-5 sm:px-7">
|
|
100
|
+
<div className="flex items-baseline gap-3">
|
|
101
|
+
<h2 className="text-base font-semibold leading-tight text-black-800 sm:text-lg">
|
|
102
|
+
{t('checkout.one_page.basket.title')}
|
|
103
|
+
</h2>
|
|
104
|
+
<span className="text-[11px] font-medium uppercase tracking-wider text-gray-500">
|
|
105
|
+
{itemCount} {t('checkout.summary.items')}
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={() => setCollapsed((prev) => !prev)}
|
|
111
|
+
aria-expanded={!collapsed}
|
|
112
|
+
aria-controls="basket-section-content"
|
|
113
|
+
className="rounded-lg px-3 py-1.5 text-xs font-medium text-gray-700 transition-all hover:bg-gray-100 hover:text-black-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary"
|
|
114
|
+
data-testid="basket-section-toggle"
|
|
115
|
+
>
|
|
116
|
+
{collapsed
|
|
117
|
+
? t('checkout.one_page.basket.show')
|
|
118
|
+
: t('checkout.one_page.basket.hide')}
|
|
119
|
+
</button>
|
|
120
|
+
</header>
|
|
121
|
+
|
|
122
|
+
{!collapsed && (
|
|
123
|
+
<div
|
|
124
|
+
id="basket-section-content"
|
|
125
|
+
className="max-h-[28rem] overflow-y-auto border-t border-gray-100 px-6 sm:px-7"
|
|
126
|
+
>
|
|
127
|
+
<ul>
|
|
128
|
+
{multiBasket ? (
|
|
129
|
+
<PluginModule
|
|
130
|
+
component={Component.MultiBasket}
|
|
131
|
+
props={{ BasketItem }}
|
|
132
|
+
/>
|
|
133
|
+
) : (
|
|
134
|
+
basket.basketitem_set.map((basketItem, index) => (
|
|
135
|
+
<BasketItem
|
|
136
|
+
basketItem={basketItem}
|
|
137
|
+
namespace={basket.namespace}
|
|
138
|
+
key={`${basketItem.id}-${index}`}
|
|
139
|
+
/>
|
|
140
|
+
))
|
|
141
|
+
)}
|
|
142
|
+
</ul>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
</section>
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export default BasketSection;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react';
|
|
4
|
+
import clsx from 'clsx';
|
|
5
|
+
import PluginModule, { Component } from '@akinon/next/components/plugin-module';
|
|
6
|
+
import ContactSection from '../one-page/sections/contact-section';
|
|
7
|
+
import AddressSection from '../one-page/sections/address-section';
|
|
8
|
+
import ShippingSection from '../one-page/sections/shipping-section';
|
|
9
|
+
import PaymentSection from '../one-page/sections/payment-section';
|
|
10
|
+
import OnePageSummary from '../one-page/one-page-summary';
|
|
11
|
+
import MobileSummarySheet from '../one-page/mobile-summary-sheet';
|
|
12
|
+
import TrustBadges from '../one-page/trust-badges';
|
|
13
|
+
import {
|
|
14
|
+
useSectionState,
|
|
15
|
+
type SectionId
|
|
16
|
+
} from '../one-page/hooks/use-section-state';
|
|
17
|
+
import { useCheckoutFunnelGtm } from '../one-page/hooks/use-checkout-funnel-gtm';
|
|
18
|
+
import BasketSection from './basket-section';
|
|
19
|
+
import type { CheckoutVariantProps } from '../types';
|
|
20
|
+
|
|
21
|
+
const BasketAndCheckout = ({ options }: CheckoutVariantProps) => {
|
|
22
|
+
const autoAdvance = options?.autoAdvance ?? true;
|
|
23
|
+
const stickyMobileCta = options?.stickyMobileCta ?? true;
|
|
24
|
+
const enableEditMode = options?.enableEditMode ?? true;
|
|
25
|
+
const showOrderReview = options?.showOrderReview ?? true;
|
|
26
|
+
const showTrustBadges = options?.showTrustBadges ?? true;
|
|
27
|
+
const gtmEnabled = options?.gtmEnabled ?? true;
|
|
28
|
+
const compactMode = options?.compactMode ?? false;
|
|
29
|
+
|
|
30
|
+
const { sections, activeSection, openSection } = useSectionState({
|
|
31
|
+
autoAdvance
|
|
32
|
+
});
|
|
33
|
+
useCheckoutFunnelGtm(activeSection, { enabled: gtmEnabled });
|
|
34
|
+
|
|
35
|
+
const sectionStatus = useCallback(
|
|
36
|
+
(id: SectionId) =>
|
|
37
|
+
sections.find((section) => section.id === id)?.status ?? 'locked',
|
|
38
|
+
[sections]
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const goTo = useCallback(
|
|
42
|
+
(id: SectionId) => () => openSection(id),
|
|
43
|
+
[openSection]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<PluginModule component={Component.MasterpassProvider}>
|
|
48
|
+
<PluginModule component={Component.MasterpassDeleteConfirmationModal} />
|
|
49
|
+
<PluginModule component={Component.MasterpassOtpModal} />
|
|
50
|
+
<PluginModule component={Component.MasterpassLinkModal} />
|
|
51
|
+
|
|
52
|
+
<div
|
|
53
|
+
className={clsx(
|
|
54
|
+
'min-h-screen bg-[#fafafa]',
|
|
55
|
+
compactMode ? 'pb-12 md:pb-8' : 'pb-20 md:pb-16'
|
|
56
|
+
)}
|
|
57
|
+
data-variant="basket-and-checkout"
|
|
58
|
+
data-compact={compactMode || undefined}
|
|
59
|
+
>
|
|
60
|
+
<div
|
|
61
|
+
className={clsx(
|
|
62
|
+
'container w-full px-4 md:px-6 lg:px-8',
|
|
63
|
+
compactMode ? 'pt-4' : 'pt-6 md:pt-10'
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
<div
|
|
67
|
+
className={clsx(
|
|
68
|
+
'flex flex-col lg:flex-row lg:items-start',
|
|
69
|
+
compactMode ? 'gap-4 lg:gap-6' : 'gap-6 lg:gap-8'
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
<div
|
|
73
|
+
className="w-full lg:w-2/3"
|
|
74
|
+
data-testid="basket-and-checkout-sections"
|
|
75
|
+
>
|
|
76
|
+
<BasketSection />
|
|
77
|
+
|
|
78
|
+
<ContactSection
|
|
79
|
+
status={sectionStatus('contact')}
|
|
80
|
+
onEdit={goTo('contact')}
|
|
81
|
+
/>
|
|
82
|
+
<AddressSection
|
|
83
|
+
status={sectionStatus('address')}
|
|
84
|
+
onEdit={goTo('address')}
|
|
85
|
+
onComplete={goTo('shipping')}
|
|
86
|
+
editable={enableEditMode}
|
|
87
|
+
/>
|
|
88
|
+
<ShippingSection
|
|
89
|
+
status={sectionStatus('shipping')}
|
|
90
|
+
onEdit={goTo('shipping')}
|
|
91
|
+
onComplete={goTo('payment')}
|
|
92
|
+
editable={enableEditMode}
|
|
93
|
+
/>
|
|
94
|
+
<PaymentSection
|
|
95
|
+
status={sectionStatus('payment')}
|
|
96
|
+
onEdit={goTo('payment')}
|
|
97
|
+
/>
|
|
98
|
+
|
|
99
|
+
{showTrustBadges && <TrustBadges />}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<aside
|
|
103
|
+
className="hidden w-full lg:block lg:w-1/3"
|
|
104
|
+
data-testid="basket-and-checkout-summary"
|
|
105
|
+
>
|
|
106
|
+
<div className="sticky top-6">
|
|
107
|
+
<OnePageSummary
|
|
108
|
+
onEditSection={openSection}
|
|
109
|
+
showReview={showOrderReview}
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
</aside>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{stickyMobileCta && (
|
|
117
|
+
<div
|
|
118
|
+
className="fixed inset-x-0 bottom-0 z-30 lg:hidden"
|
|
119
|
+
data-testid="basket-and-checkout-mobile-cta"
|
|
120
|
+
>
|
|
121
|
+
<MobileSummarySheet
|
|
122
|
+
onEditSection={openSection}
|
|
123
|
+
showReview={showOrderReview}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
</PluginModule>
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export default BasketAndCheckout;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { useAppDispatch, useAppSelector } from '@akinon/next/redux/hooks';
|
|
5
|
+
import { setCurrentStep } from '@akinon/next/redux/reducers/checkout';
|
|
6
|
+
import { CheckoutStep } from '@akinon/next/types';
|
|
7
|
+
import { RootState } from '@theme/redux/store';
|
|
8
|
+
import PluginModule, { Component } from '@akinon/next/components/plugin-module';
|
|
9
|
+
import { CheckoutStepList } from '@theme/views/checkout/step-list';
|
|
10
|
+
import { Summary } from '@theme/views/checkout/summary';
|
|
11
|
+
import ShippingStep from '@theme/views/checkout/steps/shipping';
|
|
12
|
+
import PaymentStep from '@theme/views/checkout/steps/payment';
|
|
13
|
+
import { pushAddPaymentInfo, pushAddShippingInfo } from '@theme/utils/gtm';
|
|
14
|
+
import type { CheckoutVariantProps } from '../types';
|
|
15
|
+
|
|
16
|
+
const MultiStepCheckout = (_props: CheckoutVariantProps) => {
|
|
17
|
+
const { steps, preOrder } = useAppSelector(
|
|
18
|
+
(state: RootState) => state.checkout
|
|
19
|
+
);
|
|
20
|
+
const dispatch = useAppDispatch();
|
|
21
|
+
const initialStepChanged = useRef<boolean>(false);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (steps.shipping.completed && !initialStepChanged.current) {
|
|
25
|
+
dispatch(setCurrentStep(CheckoutStep.Payment));
|
|
26
|
+
initialStepChanged.current = true;
|
|
27
|
+
}
|
|
28
|
+
}, [steps.shipping.completed]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (preOrder && !preOrder.shipping_option) {
|
|
32
|
+
initialStepChanged.current = true;
|
|
33
|
+
}
|
|
34
|
+
}, [preOrder]);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!preOrder?.basket?.basketitem_set) return;
|
|
38
|
+
const products = preOrder.basket.basketitem_set.map((item) => ({
|
|
39
|
+
...item.product
|
|
40
|
+
}));
|
|
41
|
+
if (steps.current === CheckoutStep.Shipping) {
|
|
42
|
+
pushAddShippingInfo(products);
|
|
43
|
+
}
|
|
44
|
+
if (steps.current === CheckoutStep.Payment) {
|
|
45
|
+
pushAddPaymentInfo(products, String(preOrder?.payment_option?.name));
|
|
46
|
+
}
|
|
47
|
+
}, [steps.current, preOrder?.payment_option?.name]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<PluginModule component={Component.MasterpassProvider}>
|
|
51
|
+
<PluginModule component={Component.MasterpassDeleteConfirmationModal} />
|
|
52
|
+
<PluginModule component={Component.MasterpassOtpModal} />
|
|
53
|
+
<PluginModule component={Component.MasterpassLinkModal} />
|
|
54
|
+
|
|
55
|
+
<div className="container flex flex-col flex-wrap w-full px-4 md:px-0">
|
|
56
|
+
<CheckoutStepList />
|
|
57
|
+
|
|
58
|
+
<div className="w-full flex flex-wrap">
|
|
59
|
+
<div className="w-full h-fit-content lg:w-2/3">
|
|
60
|
+
{steps.current === CheckoutStep.Shipping && <ShippingStep />}
|
|
61
|
+
{steps.current === CheckoutStep.Payment && <PaymentStep />}
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div className="w-full h-fit-content mt-6 lg:w-1/3 lg:pl-8 lg:mt-0">
|
|
65
|
+
<Summary />
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</PluginModule>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export default MultiStepCheckout;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ReactNode, useEffect, useRef, useId } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
4
|
+
import { Icon } from '@theme/components';
|
|
5
|
+
|
|
6
|
+
export type SectionStatus = 'locked' | 'active' | 'completed';
|
|
7
|
+
|
|
8
|
+
interface AccordionSectionProps {
|
|
9
|
+
step: number;
|
|
10
|
+
title: string;
|
|
11
|
+
status: SectionStatus;
|
|
12
|
+
summary?: ReactNode;
|
|
13
|
+
onEdit?: () => void;
|
|
14
|
+
editable?: boolean;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
dataTestId?: string;
|
|
17
|
+
padded?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const AccordionSection = ({
|
|
21
|
+
step,
|
|
22
|
+
title,
|
|
23
|
+
status,
|
|
24
|
+
summary,
|
|
25
|
+
onEdit,
|
|
26
|
+
editable = true,
|
|
27
|
+
children,
|
|
28
|
+
dataTestId,
|
|
29
|
+
padded = true
|
|
30
|
+
}: AccordionSectionProps) => {
|
|
31
|
+
const { t } = useLocalization();
|
|
32
|
+
const isOpen = status === 'active';
|
|
33
|
+
const isCompleted = status === 'completed';
|
|
34
|
+
const isLocked = status === 'locked';
|
|
35
|
+
|
|
36
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
const headerId = useId();
|
|
38
|
+
const panelId = useId();
|
|
39
|
+
const wasOpenRef = useRef(isOpen);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (isOpen && !wasOpenRef.current && contentRef.current) {
|
|
43
|
+
const firstFocusable = contentRef.current.querySelector<HTMLElement>(
|
|
44
|
+
'input:not([disabled]), button:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
45
|
+
);
|
|
46
|
+
firstFocusable?.focus({ preventScroll: true });
|
|
47
|
+
}
|
|
48
|
+
wasOpenRef.current = isOpen;
|
|
49
|
+
}, [isOpen]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<section
|
|
53
|
+
className={clsx(
|
|
54
|
+
'mb-4 overflow-hidden rounded-2xl bg-white last:mb-0',
|
|
55
|
+
'transition-all duration-300 ease-out',
|
|
56
|
+
isOpen
|
|
57
|
+
? 'shadow-[0_12px_40px_-16px_rgba(0,0,0,0.16)] ring-1 ring-black-800/10'
|
|
58
|
+
: 'shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]',
|
|
59
|
+
isLocked && 'opacity-50'
|
|
60
|
+
)}
|
|
61
|
+
data-testid={dataTestId}
|
|
62
|
+
data-status={status}
|
|
63
|
+
aria-labelledby={headerId}
|
|
64
|
+
>
|
|
65
|
+
<header
|
|
66
|
+
id={headerId}
|
|
67
|
+
className={clsx(
|
|
68
|
+
'flex items-center justify-between gap-3 px-6 py-5 sm:px-7',
|
|
69
|
+
isOpen && 'border-b border-gray-100'
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
<div className="flex items-center gap-3.5">
|
|
73
|
+
<span
|
|
74
|
+
className={clsx(
|
|
75
|
+
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold transition-all',
|
|
76
|
+
isOpen && 'bg-black-800 text-white',
|
|
77
|
+
isCompleted && !isOpen && 'bg-black-800 text-white',
|
|
78
|
+
isLocked && 'bg-gray-100 text-gray-400'
|
|
79
|
+
)}
|
|
80
|
+
aria-hidden="true"
|
|
81
|
+
>
|
|
82
|
+
{isCompleted ? (
|
|
83
|
+
<Icon name="check" size={14} className="fill-white" />
|
|
84
|
+
) : (
|
|
85
|
+
step
|
|
86
|
+
)}
|
|
87
|
+
</span>
|
|
88
|
+
<h2
|
|
89
|
+
className={clsx(
|
|
90
|
+
'text-lg font-semibold leading-tight tracking-tight',
|
|
91
|
+
isOpen || isCompleted ? 'text-black-800' : 'text-gray-500'
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{title}
|
|
95
|
+
</h2>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{isCompleted && editable && onEdit && (
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={onEdit}
|
|
102
|
+
className={clsx(
|
|
103
|
+
'group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium',
|
|
104
|
+
'text-gray-700 transition-all duration-150',
|
|
105
|
+
'hover:bg-gray-100 hover:text-black-800',
|
|
106
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-black-800 focus-visible:ring-offset-1'
|
|
107
|
+
)}
|
|
108
|
+
data-testid={`${dataTestId}-edit`}
|
|
109
|
+
aria-label={`${t('checkout.one_page.edit')} ${title}`}
|
|
110
|
+
>
|
|
111
|
+
<span>{t('checkout.one_page.edit')}</span>
|
|
112
|
+
</button>
|
|
113
|
+
)}
|
|
114
|
+
</header>
|
|
115
|
+
|
|
116
|
+
<div
|
|
117
|
+
id={panelId}
|
|
118
|
+
role="region"
|
|
119
|
+
aria-labelledby={headerId}
|
|
120
|
+
hidden={!isOpen}
|
|
121
|
+
className={clsx(
|
|
122
|
+
'transition-opacity duration-300 ease-out',
|
|
123
|
+
isOpen ? 'opacity-100' : 'opacity-0'
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
<div ref={contentRef} className={padded ? 'px-6 py-6 sm:px-7' : undefined}>
|
|
127
|
+
{children}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{isCompleted && summary && (
|
|
132
|
+
<div className="border-t border-gray-100 px-6 py-4 text-sm text-gray-700 sm:px-7">
|
|
133
|
+
{summary}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</section>
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export default AccordionSection;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useAppSelector } from '@akinon/next/redux/hooks';
|
|
3
|
+
import type { RootState } from '@theme/redux/store';
|
|
4
|
+
import {
|
|
5
|
+
pushAddShippingInfo,
|
|
6
|
+
pushAddPaymentInfo,
|
|
7
|
+
pushEventGA4
|
|
8
|
+
} from '@theme/utils/gtm';
|
|
9
|
+
import type { SectionId } from './use-section-state';
|
|
10
|
+
|
|
11
|
+
const SECTION_EVENT: Record<SectionId, string> = {
|
|
12
|
+
contact: 'one_page_contact_complete',
|
|
13
|
+
address: 'one_page_address_complete',
|
|
14
|
+
shipping: 'one_page_shipping_complete',
|
|
15
|
+
payment: 'one_page_payment_open'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
interface UseCheckoutFunnelGtmOptions {
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const useCheckoutFunnelGtm = (
|
|
23
|
+
activeSection: SectionId,
|
|
24
|
+
{ enabled = true }: UseCheckoutFunnelGtmOptions = {}
|
|
25
|
+
) => {
|
|
26
|
+
const preOrder = useAppSelector(
|
|
27
|
+
(state: RootState) => state.checkout.preOrder
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const fired = useRef<Set<string>>(new Set());
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!enabled) return;
|
|
34
|
+
if (!preOrder?.basket?.basketitem_set) return;
|
|
35
|
+
|
|
36
|
+
// GTM dataLayer may not be initialised (no GTM tag, ad blocker, etc.).
|
|
37
|
+
// Any failure must NEVER crash the checkout flow — wrap in try/catch.
|
|
38
|
+
try {
|
|
39
|
+
const products = preOrder.basket.basketitem_set.map((item) => ({
|
|
40
|
+
...item.product
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
if (activeSection === 'shipping' && !fired.current.has('shipping')) {
|
|
44
|
+
pushAddShippingInfo(products);
|
|
45
|
+
fired.current.add('shipping');
|
|
46
|
+
}
|
|
47
|
+
if (activeSection === 'payment' && !fired.current.has('payment')) {
|
|
48
|
+
pushAddPaymentInfo(products, String(preOrder?.payment_option?.name));
|
|
49
|
+
fired.current.add('payment');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pushEventGA4(SECTION_EVENT[activeSection], {
|
|
53
|
+
section: activeSection,
|
|
54
|
+
items_count: products.length
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.warn('Checkout funnel GTM error (suppressed):', error);
|
|
58
|
+
}
|
|
59
|
+
}, [activeSection, enabled, preOrder?.payment_option?.name]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
60
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useAppSelector } from '@akinon/next/redux/hooks';
|
|
3
|
+
import { useSession } from 'next-auth/react';
|
|
4
|
+
import type { RootState } from '@theme/redux/store';
|
|
5
|
+
import type { SectionStatus } from '../accordion-section';
|
|
6
|
+
|
|
7
|
+
export type SectionId = 'contact' | 'address' | 'shipping' | 'payment';
|
|
8
|
+
|
|
9
|
+
export interface SectionState {
|
|
10
|
+
id: SectionId;
|
|
11
|
+
status: SectionStatus;
|
|
12
|
+
isComplete: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface UseSectionStateOptions {
|
|
16
|
+
autoAdvance?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const useSectionState = ({ autoAdvance = true }: UseSectionStateOptions = {}) => {
|
|
20
|
+
const { data: session } = useSession();
|
|
21
|
+
const preOrder = useAppSelector(
|
|
22
|
+
(state: RootState) => state.checkout.preOrder
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const isAuthenticated = Boolean(session?.user);
|
|
26
|
+
const contactComplete = isAuthenticated || Boolean(preOrder?.is_guest);
|
|
27
|
+
const addressComplete = Boolean(
|
|
28
|
+
preOrder?.shipping_address?.pk && preOrder?.billing_address?.pk
|
|
29
|
+
);
|
|
30
|
+
const shippingComplete = Boolean(preOrder?.shipping_option?.pk);
|
|
31
|
+
|
|
32
|
+
const completion = useMemo(
|
|
33
|
+
() => ({
|
|
34
|
+
contact: contactComplete,
|
|
35
|
+
address: addressComplete,
|
|
36
|
+
shipping: shippingComplete,
|
|
37
|
+
payment: false
|
|
38
|
+
}),
|
|
39
|
+
[contactComplete, addressComplete, shippingComplete]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const firstIncomplete = useMemo<SectionId>(() => {
|
|
43
|
+
if (!completion.contact) return 'contact';
|
|
44
|
+
if (!completion.address) return 'address';
|
|
45
|
+
if (!completion.shipping) return 'shipping';
|
|
46
|
+
return 'payment';
|
|
47
|
+
}, [completion]);
|
|
48
|
+
|
|
49
|
+
const [activeSection, setActiveSection] = useState<SectionId>(firstIncomplete);
|
|
50
|
+
const [touchedSections, setTouchedSections] = useState<Set<SectionId>>(
|
|
51
|
+
() => new Set()
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!autoAdvance) return;
|
|
56
|
+
if (touchedSections.has(activeSection)) return;
|
|
57
|
+
if (firstIncomplete !== activeSection) {
|
|
58
|
+
setActiveSection(firstIncomplete);
|
|
59
|
+
}
|
|
60
|
+
}, [firstIncomplete, autoAdvance, activeSection, touchedSections]);
|
|
61
|
+
|
|
62
|
+
const openSection = useCallback((id: SectionId) => {
|
|
63
|
+
setActiveSection(id);
|
|
64
|
+
setTouchedSections((prev) => {
|
|
65
|
+
const next = new Set(prev);
|
|
66
|
+
next.add(id);
|
|
67
|
+
return next;
|
|
68
|
+
});
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const sections = useMemo<SectionState[]>(() => {
|
|
72
|
+
const order: SectionId[] = ['contact', 'address', 'shipping', 'payment'];
|
|
73
|
+
return order.map((id) => {
|
|
74
|
+
const isComplete = completion[id];
|
|
75
|
+
let status: SectionStatus;
|
|
76
|
+
if (id === activeSection) {
|
|
77
|
+
status = 'active';
|
|
78
|
+
} else if (isComplete) {
|
|
79
|
+
status = 'completed';
|
|
80
|
+
} else {
|
|
81
|
+
const indexCurrent = order.indexOf(activeSection);
|
|
82
|
+
const indexThis = order.indexOf(id);
|
|
83
|
+
status = indexThis < indexCurrent ? 'completed' : 'locked';
|
|
84
|
+
}
|
|
85
|
+
return { id, status, isComplete };
|
|
86
|
+
});
|
|
87
|
+
}, [completion, activeSection]);
|
|
88
|
+
|
|
89
|
+
const allComplete = completion.contact && completion.address && completion.shipping;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
sections,
|
|
93
|
+
activeSection,
|
|
94
|
+
openSection,
|
|
95
|
+
allComplete,
|
|
96
|
+
completion,
|
|
97
|
+
isAuthenticated
|
|
98
|
+
};
|
|
99
|
+
};
|