@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,125 @@
|
|
|
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 './sections/contact-section';
|
|
7
|
+
import AddressSection from './sections/address-section';
|
|
8
|
+
import ShippingSection from './sections/shipping-section';
|
|
9
|
+
import PaymentSection from './sections/payment-section';
|
|
10
|
+
import OnePageSummary from './one-page-summary';
|
|
11
|
+
import MobileSummarySheet from './mobile-summary-sheet';
|
|
12
|
+
import TrustBadges from './trust-badges';
|
|
13
|
+
import { useSectionState, type SectionId } from './hooks/use-section-state';
|
|
14
|
+
import { useCheckoutFunnelGtm } from './hooks/use-checkout-funnel-gtm';
|
|
15
|
+
import type { CheckoutVariantProps } from '../types';
|
|
16
|
+
|
|
17
|
+
const OnePageCheckout = ({ options }: CheckoutVariantProps) => {
|
|
18
|
+
const autoAdvance = options?.autoAdvance ?? true;
|
|
19
|
+
const stickyMobileCta = options?.stickyMobileCta ?? true;
|
|
20
|
+
const enableEditMode = options?.enableEditMode ?? true;
|
|
21
|
+
const showOrderReview = options?.showOrderReview ?? true;
|
|
22
|
+
const showTrustBadges = options?.showTrustBadges ?? true;
|
|
23
|
+
const gtmEnabled = options?.gtmEnabled ?? true;
|
|
24
|
+
const compactMode = options?.compactMode ?? false;
|
|
25
|
+
|
|
26
|
+
const { sections, activeSection, openSection } = useSectionState({
|
|
27
|
+
autoAdvance
|
|
28
|
+
});
|
|
29
|
+
useCheckoutFunnelGtm(activeSection, { enabled: gtmEnabled });
|
|
30
|
+
|
|
31
|
+
const sectionStatus = useCallback(
|
|
32
|
+
(id: SectionId) =>
|
|
33
|
+
sections.find((section) => section.id === id)?.status ?? 'locked',
|
|
34
|
+
[sections]
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const goTo = useCallback(
|
|
38
|
+
(id: SectionId) => () => openSection(id),
|
|
39
|
+
[openSection]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<PluginModule component={Component.MasterpassProvider}>
|
|
44
|
+
<PluginModule component={Component.MasterpassDeleteConfirmationModal} />
|
|
45
|
+
<PluginModule component={Component.MasterpassOtpModal} />
|
|
46
|
+
<PluginModule component={Component.MasterpassLinkModal} />
|
|
47
|
+
|
|
48
|
+
<div
|
|
49
|
+
className={clsx(
|
|
50
|
+
'min-h-screen bg-[#fafafa]',
|
|
51
|
+
compactMode ? 'pb-12 md:pb-8' : 'pb-20 md:pb-16'
|
|
52
|
+
)}
|
|
53
|
+
data-compact={compactMode || undefined}
|
|
54
|
+
>
|
|
55
|
+
<div
|
|
56
|
+
className={clsx(
|
|
57
|
+
'container w-full px-4 md:px-6 lg:px-8',
|
|
58
|
+
compactMode ? 'pt-4' : 'pt-6 md:pt-10'
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
<div
|
|
62
|
+
className={clsx(
|
|
63
|
+
'flex flex-col lg:flex-row lg:items-start',
|
|
64
|
+
compactMode ? 'gap-4 lg:gap-6' : 'gap-6 lg:gap-8'
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<div
|
|
68
|
+
className="w-full lg:w-2/3"
|
|
69
|
+
data-testid="one-page-sections"
|
|
70
|
+
>
|
|
71
|
+
<ContactSection
|
|
72
|
+
status={sectionStatus('contact')}
|
|
73
|
+
onEdit={goTo('contact')}
|
|
74
|
+
/>
|
|
75
|
+
<AddressSection
|
|
76
|
+
status={sectionStatus('address')}
|
|
77
|
+
onEdit={goTo('address')}
|
|
78
|
+
onComplete={goTo('shipping')}
|
|
79
|
+
editable={enableEditMode}
|
|
80
|
+
/>
|
|
81
|
+
<ShippingSection
|
|
82
|
+
status={sectionStatus('shipping')}
|
|
83
|
+
onEdit={goTo('shipping')}
|
|
84
|
+
onComplete={goTo('payment')}
|
|
85
|
+
editable={enableEditMode}
|
|
86
|
+
/>
|
|
87
|
+
<PaymentSection
|
|
88
|
+
status={sectionStatus('payment')}
|
|
89
|
+
onEdit={goTo('payment')}
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
{showTrustBadges && <TrustBadges />}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<aside
|
|
96
|
+
className="hidden w-full lg:block lg:w-1/3"
|
|
97
|
+
data-testid="one-page-summary"
|
|
98
|
+
>
|
|
99
|
+
<div className="sticky top-6">
|
|
100
|
+
<OnePageSummary
|
|
101
|
+
onEditSection={openSection}
|
|
102
|
+
showReview={showOrderReview}
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
</aside>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{stickyMobileCta && (
|
|
110
|
+
<div
|
|
111
|
+
className="fixed inset-x-0 bottom-0 z-30 lg:hidden"
|
|
112
|
+
data-testid="one-page-mobile-cta"
|
|
113
|
+
>
|
|
114
|
+
<MobileSummarySheet
|
|
115
|
+
onEditSection={openSection}
|
|
116
|
+
showReview={showOrderReview}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
</PluginModule>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export default OnePageCheckout;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useAppSelector } from '@akinon/next/redux/hooks';
|
|
4
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
5
|
+
import { Price, Icon } from '@theme/components';
|
|
6
|
+
import type { RootState } from '@theme/redux/store';
|
|
7
|
+
import OnePageSummary from './one-page-summary';
|
|
8
|
+
import type { SectionId } from './hooks/use-section-state';
|
|
9
|
+
|
|
10
|
+
interface MobileSummarySheetProps {
|
|
11
|
+
onEditSection: (section: SectionId) => void;
|
|
12
|
+
showReview?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MobileSummarySheet = ({
|
|
16
|
+
onEditSection,
|
|
17
|
+
showReview = true
|
|
18
|
+
}: MobileSummarySheetProps) => {
|
|
19
|
+
const { t } = useLocalization();
|
|
20
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
21
|
+
const preOrder = useAppSelector(
|
|
22
|
+
(state: RootState) => state.checkout.preOrder
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!isOpen) return undefined;
|
|
27
|
+
const onKey = (event: KeyboardEvent) => {
|
|
28
|
+
if (event.key === 'Escape') setIsOpen(false);
|
|
29
|
+
};
|
|
30
|
+
document.addEventListener('keydown', onKey);
|
|
31
|
+
const previousOverflow = document.body.style.overflow;
|
|
32
|
+
document.body.style.overflow = 'hidden';
|
|
33
|
+
return () => {
|
|
34
|
+
document.removeEventListener('keydown', onKey);
|
|
35
|
+
document.body.style.overflow = previousOverflow;
|
|
36
|
+
};
|
|
37
|
+
}, [isOpen]);
|
|
38
|
+
|
|
39
|
+
if (!preOrder) return null;
|
|
40
|
+
const paymentName = preOrder.payment_option?.name;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
{isOpen && (
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
aria-label={t('checkout.one_page.summary.hide_summary')}
|
|
48
|
+
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
|
|
49
|
+
onClick={() => setIsOpen(false)}
|
|
50
|
+
data-testid="one-page-mobile-summary-backdrop"
|
|
51
|
+
/>
|
|
52
|
+
)}
|
|
53
|
+
|
|
54
|
+
<div className="relative z-50">
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
onClick={() => setIsOpen((prev) => !prev)}
|
|
58
|
+
aria-expanded={isOpen}
|
|
59
|
+
aria-controls="mobile-summary-content"
|
|
60
|
+
className="flex w-full items-center justify-between gap-3 border-t border-gray-200 bg-white px-5 py-4 shadow-[0_-8px_24px_-12px_rgba(0,0,0,0.12)] focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-secondary"
|
|
61
|
+
data-testid="one-page-mobile-summary-toggle"
|
|
62
|
+
>
|
|
63
|
+
<span className="flex flex-col items-start gap-1">
|
|
64
|
+
<span className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-gray-600">
|
|
65
|
+
<Icon name="basket" size={12} aria-hidden="true" />
|
|
66
|
+
{isOpen
|
|
67
|
+
? t('checkout.one_page.summary.hide_summary')
|
|
68
|
+
: t('checkout.one_page.summary.show_summary')}
|
|
69
|
+
<Icon
|
|
70
|
+
name="chevron-end"
|
|
71
|
+
size={10}
|
|
72
|
+
aria-hidden="true"
|
|
73
|
+
className={clsx('transition-transform duration-200', {
|
|
74
|
+
'rotate-90': isOpen,
|
|
75
|
+
'-rotate-90': !isOpen
|
|
76
|
+
})}
|
|
77
|
+
/>
|
|
78
|
+
</span>
|
|
79
|
+
{paymentName && (
|
|
80
|
+
<span className="text-xs font-medium text-black-800">
|
|
81
|
+
{paymentName}
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
</span>
|
|
85
|
+
<span className="text-xl font-bold tabular-nums text-black-800">
|
|
86
|
+
<Price value={preOrder.unpaid_amount} />
|
|
87
|
+
</span>
|
|
88
|
+
</button>
|
|
89
|
+
|
|
90
|
+
{isOpen && (
|
|
91
|
+
<div
|
|
92
|
+
id="mobile-summary-content"
|
|
93
|
+
role="dialog"
|
|
94
|
+
aria-modal="true"
|
|
95
|
+
aria-label={t('checkout.summary.title')}
|
|
96
|
+
className="max-h-[75vh] overflow-y-auto border-t border-gray-200 bg-[#fafafa] px-4 py-5"
|
|
97
|
+
>
|
|
98
|
+
<OnePageSummary
|
|
99
|
+
onEditSection={onEditSection}
|
|
100
|
+
showReview={showReview}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
</>
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export default MobileSummarySheet;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { useAppSelector } from '@akinon/next/redux/hooks';
|
|
2
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
3
|
+
import PluginModule, { Component } from '@akinon/next/components/plugin-module';
|
|
4
|
+
import { Price, Link } from '@theme/components';
|
|
5
|
+
import { Image } from '@akinon/next/components/image';
|
|
6
|
+
import { StoreCredits } from '@theme/views/checkout/steps/payment/options/store-credit';
|
|
7
|
+
import type { RootState } from '@theme/redux/store';
|
|
8
|
+
import type { SectionId } from './hooks/use-section-state';
|
|
9
|
+
|
|
10
|
+
interface OnePageSummaryProps {
|
|
11
|
+
onEditSection: (section: SectionId) => void;
|
|
12
|
+
showReview?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const OnePageSummary = ({
|
|
16
|
+
onEditSection,
|
|
17
|
+
showReview = true
|
|
18
|
+
}: OnePageSummaryProps) => {
|
|
19
|
+
const { t } = useLocalization();
|
|
20
|
+
const preOrder = useAppSelector(
|
|
21
|
+
(state: RootState) => state.checkout.preOrder
|
|
22
|
+
);
|
|
23
|
+
const { isMobileApp } = useAppSelector((state: RootState) => state.root);
|
|
24
|
+
|
|
25
|
+
if (!preOrder?.basket) return null;
|
|
26
|
+
|
|
27
|
+
const itemCount = preOrder.basket.basketitem_set.length;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex flex-col gap-4">
|
|
31
|
+
<PluginModule
|
|
32
|
+
component={Component.CheckoutGiftPack}
|
|
33
|
+
props={{
|
|
34
|
+
className:
|
|
35
|
+
'flex flex-col w-full rounded-2xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]'
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
<StoreCredits />
|
|
39
|
+
|
|
40
|
+
<section className="overflow-hidden rounded-2xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]">
|
|
41
|
+
<header className="flex items-baseline justify-between px-6 py-5">
|
|
42
|
+
<span className="text-lg font-semibold tracking-tight text-black-800">
|
|
43
|
+
{t('checkout.summary.title')}
|
|
44
|
+
</span>
|
|
45
|
+
<span className="text-[11px] font-medium uppercase tracking-wider text-gray-500">
|
|
46
|
+
{itemCount} {t('checkout.summary.items')}
|
|
47
|
+
</span>
|
|
48
|
+
</header>
|
|
49
|
+
|
|
50
|
+
<ul className="max-h-72 divide-y divide-gray-100 overflow-y-auto border-t border-gray-100">
|
|
51
|
+
{preOrder.basket.basketitem_set.map((item, index) => (
|
|
52
|
+
<li
|
|
53
|
+
key={`one-page-summary-item-${index}`}
|
|
54
|
+
className="flex gap-3 px-6 py-4"
|
|
55
|
+
>
|
|
56
|
+
<Link
|
|
57
|
+
href={item.product.absolute_url || '#'}
|
|
58
|
+
passHref
|
|
59
|
+
className={
|
|
60
|
+
isMobileApp
|
|
61
|
+
? 'pointer-events-none flex-shrink-0'
|
|
62
|
+
: 'flex-shrink-0 overflow-hidden rounded-md'
|
|
63
|
+
}
|
|
64
|
+
>
|
|
65
|
+
<Image
|
|
66
|
+
src={item.product.productimage_set[0]?.image}
|
|
67
|
+
alt={item.product.name}
|
|
68
|
+
width={56}
|
|
69
|
+
height={84}
|
|
70
|
+
/>
|
|
71
|
+
</Link>
|
|
72
|
+
<div className="flex flex-1 flex-col justify-between text-xs">
|
|
73
|
+
<Link
|
|
74
|
+
href={item.product.absolute_url || '#'}
|
|
75
|
+
className="line-clamp-2 text-sm font-medium text-black-800 transition-colors hover:text-secondary"
|
|
76
|
+
>
|
|
77
|
+
{item.product.name}
|
|
78
|
+
</Link>
|
|
79
|
+
<div className="flex items-end justify-between gap-2">
|
|
80
|
+
<span className="text-gray-500">
|
|
81
|
+
{t('checkout.summary.quantity')}:{' '}
|
|
82
|
+
<span className="font-medium tabular-nums text-black-800">
|
|
83
|
+
{item.quantity}
|
|
84
|
+
</span>
|
|
85
|
+
</span>
|
|
86
|
+
<div className="flex flex-col items-end gap-0.5">
|
|
87
|
+
{item.product.retail_price !== item.product.price && (
|
|
88
|
+
<span className="text-[10px] text-gray-400 line-through">
|
|
89
|
+
<Price value={item.product.retail_price} />
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
<span className="text-sm font-semibold text-black-800 tabular-nums">
|
|
93
|
+
<Price value={item.product.price} />
|
|
94
|
+
</span>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</li>
|
|
99
|
+
))}
|
|
100
|
+
</ul>
|
|
101
|
+
|
|
102
|
+
<dl className="space-y-2.5 border-t border-gray-100 px-6 py-5 text-sm text-gray-600">
|
|
103
|
+
<div className="flex items-center justify-between">
|
|
104
|
+
<dt>{t('checkout.summary.subtotal')}</dt>
|
|
105
|
+
<dd className="font-medium tabular-nums text-black-800">
|
|
106
|
+
<Price value={preOrder.basket.total_amount} />
|
|
107
|
+
</dd>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="flex items-center justify-between">
|
|
110
|
+
<dt>{t('checkout.summary.shipping')}</dt>
|
|
111
|
+
<dd className="font-medium tabular-nums text-black-800">
|
|
112
|
+
<Price value={preOrder.shipping_amount} />
|
|
113
|
+
</dd>
|
|
114
|
+
</div>
|
|
115
|
+
{parseFloat(preOrder.loyalty_money || '0') > 0 && (
|
|
116
|
+
<div className="flex items-center justify-between">
|
|
117
|
+
<dt>{t('checkout.summary.loyalty_money_total')}</dt>
|
|
118
|
+
<dd className="font-medium tabular-nums text-black-800">
|
|
119
|
+
<Price value={preOrder.loyalty_money} />
|
|
120
|
+
</dd>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
{parseFloat(preOrder.basket.total_discount_amount || '0') > 0 && (
|
|
124
|
+
<div className="flex items-center justify-between">
|
|
125
|
+
<dt>{t('checkout.summary.discounts_total')}</dt>
|
|
126
|
+
<dd className="font-medium tabular-nums text-black-800">
|
|
127
|
+
<Price
|
|
128
|
+
value={preOrder.basket.total_discount_amount}
|
|
129
|
+
useNegative
|
|
130
|
+
/>
|
|
131
|
+
</dd>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</dl>
|
|
135
|
+
|
|
136
|
+
<div className="flex items-baseline justify-between border-t border-gray-100 px-6 py-5">
|
|
137
|
+
<span className="text-sm font-medium uppercase tracking-wider text-gray-700">
|
|
138
|
+
{t('checkout.summary.total')}
|
|
139
|
+
</span>
|
|
140
|
+
<span className="text-2xl font-bold tracking-tight text-black-800 tabular-nums">
|
|
141
|
+
<Price value={preOrder.unpaid_amount} />
|
|
142
|
+
</span>
|
|
143
|
+
</div>
|
|
144
|
+
</section>
|
|
145
|
+
|
|
146
|
+
{showReview &&
|
|
147
|
+
(preOrder.shipping_address ||
|
|
148
|
+
preOrder.shipping_option ||
|
|
149
|
+
preOrder.payment_option) && (
|
|
150
|
+
<section className="overflow-hidden rounded-2xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]">
|
|
151
|
+
<header className="px-6 py-5">
|
|
152
|
+
<span className="text-base font-semibold tracking-tight text-black-800">
|
|
153
|
+
{t('checkout.one_page.summary.review_title')}
|
|
154
|
+
</span>
|
|
155
|
+
</header>
|
|
156
|
+
|
|
157
|
+
<ul className="divide-y divide-gray-100 border-t border-gray-100 text-sm text-black-800">
|
|
158
|
+
{preOrder.shipping_address && (
|
|
159
|
+
<li className="flex items-start justify-between gap-3 px-6 py-4">
|
|
160
|
+
<div className="flex-1">
|
|
161
|
+
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
|
162
|
+
{t('checkout.one_page.summary.deliver_to')}
|
|
163
|
+
</div>
|
|
164
|
+
<div className="font-medium">
|
|
165
|
+
{preOrder.shipping_address.title}
|
|
166
|
+
</div>
|
|
167
|
+
<div className="text-xs text-gray-600">
|
|
168
|
+
{preOrder.shipping_address.line},{' '}
|
|
169
|
+
{preOrder.shipping_address.township?.name},{' '}
|
|
170
|
+
{preOrder.shipping_address.city?.name}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
onClick={() => onEditSection('address')}
|
|
176
|
+
className="rounded-md px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-black-800"
|
|
177
|
+
data-testid="one-page-summary-edit-address"
|
|
178
|
+
>
|
|
179
|
+
{t('checkout.one_page.edit')}
|
|
180
|
+
</button>
|
|
181
|
+
</li>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{preOrder.shipping_option && (
|
|
185
|
+
<li className="flex items-start justify-between gap-3 px-6 py-4">
|
|
186
|
+
<div className="flex-1">
|
|
187
|
+
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
|
188
|
+
{t('checkout.one_page.summary.shipping_method')}
|
|
189
|
+
</div>
|
|
190
|
+
<div className="font-medium">
|
|
191
|
+
{preOrder.shipping_option.name}
|
|
192
|
+
</div>
|
|
193
|
+
{preOrder.shipping_option.shipping_amount && (
|
|
194
|
+
<div className="text-xs tabular-nums text-gray-600">
|
|
195
|
+
<Price value={preOrder.shipping_option.shipping_amount} />
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
onClick={() => onEditSection('shipping')}
|
|
202
|
+
className="rounded-md px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-black-800"
|
|
203
|
+
data-testid="one-page-summary-edit-shipping"
|
|
204
|
+
>
|
|
205
|
+
{t('checkout.one_page.edit')}
|
|
206
|
+
</button>
|
|
207
|
+
</li>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{preOrder.payment_option && (
|
|
211
|
+
<li className="flex items-start justify-between gap-3 px-6 py-4">
|
|
212
|
+
<div className="flex-1">
|
|
213
|
+
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
|
214
|
+
{t('checkout.one_page.summary.payment_method')}
|
|
215
|
+
</div>
|
|
216
|
+
<div className="font-medium">
|
|
217
|
+
{preOrder.payment_option.name}
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
onClick={() => onEditSection('payment')}
|
|
223
|
+
className="rounded-md px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-black-800"
|
|
224
|
+
data-testid="one-page-summary-edit-payment"
|
|
225
|
+
>
|
|
226
|
+
{t('checkout.one_page.edit')}
|
|
227
|
+
</button>
|
|
228
|
+
</li>
|
|
229
|
+
)}
|
|
230
|
+
</ul>
|
|
231
|
+
</section>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export default OnePageSummary;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useAppSelector } from '@akinon/next/redux/hooks';
|
|
4
|
+
import { useSetPaymentOptionMutation } from '@akinon/next/data/client/checkout';
|
|
5
|
+
import { usePaymentOptions } from '@akinon/next/hooks/use-payment-options';
|
|
6
|
+
import { useLocalization } from '@akinon/next/hooks';
|
|
7
|
+
import { Icon } from '@theme/components';
|
|
8
|
+
import type { RootState } from '@theme/redux/store';
|
|
9
|
+
import type { CheckoutPaymentOption } from '@akinon/next/types';
|
|
10
|
+
|
|
11
|
+
const PAYMENT_TYPE_ICON: Record<string, string> = {
|
|
12
|
+
credit_card: 'cvc',
|
|
13
|
+
funds_transfer: 'file',
|
|
14
|
+
pay_on_delivery: 'address',
|
|
15
|
+
bkm_express: 'akinon',
|
|
16
|
+
masterpass: 'cvc',
|
|
17
|
+
masterpass_rest: 'cvc',
|
|
18
|
+
saved_card: 'cvc',
|
|
19
|
+
gpay: 'google',
|
|
20
|
+
google_pay: 'google',
|
|
21
|
+
apple_pay: 'apple',
|
|
22
|
+
wallet: 'akinon',
|
|
23
|
+
redirection: 'globe',
|
|
24
|
+
credit_payment: 'installment',
|
|
25
|
+
loyalty: 'heart-full',
|
|
26
|
+
hepsipay: 'akinon',
|
|
27
|
+
tabby: 'installment',
|
|
28
|
+
tamara: 'installment',
|
|
29
|
+
iyzico: 'cvc',
|
|
30
|
+
cybersource_uc: 'cvc',
|
|
31
|
+
flow_payment: 'installment'
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const SLUG_KEYWORD_ICON: Array<[string, string]> = [
|
|
35
|
+
['apple', 'apple'],
|
|
36
|
+
['google', 'google'],
|
|
37
|
+
['gpay', 'google'],
|
|
38
|
+
['hepsi', 'akinon'],
|
|
39
|
+
['tabby', 'installment'],
|
|
40
|
+
['tamara', 'installment'],
|
|
41
|
+
['flow', 'installment'],
|
|
42
|
+
['saved', 'cvc'],
|
|
43
|
+
['credit', 'cvc'],
|
|
44
|
+
['transfer', 'file'],
|
|
45
|
+
['delivery', 'address'],
|
|
46
|
+
['wallet', 'akinon']
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const getOptionIcon = (option: CheckoutPaymentOption): string => {
|
|
50
|
+
if (PAYMENT_TYPE_ICON[option.payment_type]) {
|
|
51
|
+
return PAYMENT_TYPE_ICON[option.payment_type];
|
|
52
|
+
}
|
|
53
|
+
const slug = option.slug?.toLowerCase() || '';
|
|
54
|
+
for (const [keyword, icon] of SLUG_KEYWORD_ICON) {
|
|
55
|
+
if (slug.includes(keyword)) return icon;
|
|
56
|
+
}
|
|
57
|
+
return 'default';
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const PaymentOptionsGrid = () => {
|
|
61
|
+
const { t } = useLocalization();
|
|
62
|
+
const { preOrder, attributeBasedShippingOptions } = useAppSelector(
|
|
63
|
+
(state: RootState) => state.checkout
|
|
64
|
+
);
|
|
65
|
+
const [setPaymentOption, { isLoading }] = useSetPaymentOptionMutation();
|
|
66
|
+
const { filteredPaymentOptions } = usePaymentOptions();
|
|
67
|
+
|
|
68
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
69
|
+
|
|
70
|
+
const visibleOptions = useMemo(() => {
|
|
71
|
+
if (
|
|
72
|
+
attributeBasedShippingOptions &&
|
|
73
|
+
Object.keys(attributeBasedShippingOptions).length > 0
|
|
74
|
+
) {
|
|
75
|
+
return filteredPaymentOptions.filter(
|
|
76
|
+
(option) => option.slug?.toLowerCase() !== 'pay-on-delivery'
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return filteredPaymentOptions;
|
|
80
|
+
}, [filteredPaymentOptions, attributeBasedShippingOptions]);
|
|
81
|
+
|
|
82
|
+
const handleKeyDown = useCallback(
|
|
83
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
84
|
+
if (
|
|
85
|
+
!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'].includes(
|
|
86
|
+
event.key
|
|
87
|
+
)
|
|
88
|
+
) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
const buttons = Array.from(
|
|
93
|
+
containerRef.current?.querySelectorAll<HTMLButtonElement>(
|
|
94
|
+
'button[role="radio"]'
|
|
95
|
+
) ?? []
|
|
96
|
+
);
|
|
97
|
+
if (buttons.length === 0) return;
|
|
98
|
+
const currentIndex = buttons.findIndex(
|
|
99
|
+
(btn) => btn === document.activeElement
|
|
100
|
+
);
|
|
101
|
+
let nextIndex = currentIndex;
|
|
102
|
+
switch (event.key) {
|
|
103
|
+
case 'ArrowRight':
|
|
104
|
+
case 'ArrowDown':
|
|
105
|
+
nextIndex = (currentIndex + 1 + buttons.length) % buttons.length;
|
|
106
|
+
break;
|
|
107
|
+
case 'ArrowLeft':
|
|
108
|
+
case 'ArrowUp':
|
|
109
|
+
nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
|
|
110
|
+
break;
|
|
111
|
+
case 'Home':
|
|
112
|
+
nextIndex = 0;
|
|
113
|
+
break;
|
|
114
|
+
case 'End':
|
|
115
|
+
nextIndex = buttons.length - 1;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
buttons[nextIndex]?.focus();
|
|
119
|
+
},
|
|
120
|
+
[]
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (visibleOptions.length === 0) {
|
|
124
|
+
return (
|
|
125
|
+
<p className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-5 py-4 text-sm italic text-gray-600">
|
|
126
|
+
{t('checkout.one_page.payment.no_options')}
|
|
127
|
+
</p>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
ref={containerRef}
|
|
134
|
+
className={clsx(
|
|
135
|
+
'grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3',
|
|
136
|
+
{ 'pointer-events-none opacity-60': isLoading }
|
|
137
|
+
)}
|
|
138
|
+
role="radiogroup"
|
|
139
|
+
aria-label={t('checkout.one_page.payment.title')}
|
|
140
|
+
onKeyDown={handleKeyDown}
|
|
141
|
+
>
|
|
142
|
+
{visibleOptions.map((option, index) => {
|
|
143
|
+
const selected = preOrder?.payment_option?.pk === option.pk;
|
|
144
|
+
return (
|
|
145
|
+
<button
|
|
146
|
+
key={option.pk}
|
|
147
|
+
type="button"
|
|
148
|
+
role="radio"
|
|
149
|
+
aria-checked={selected}
|
|
150
|
+
tabIndex={selected || (!preOrder?.payment_option && index === 0) ? 0 : -1}
|
|
151
|
+
onClick={() => setPaymentOption(option.pk)}
|
|
152
|
+
className={clsx(
|
|
153
|
+
'group relative flex min-h-[5rem] items-center gap-3',
|
|
154
|
+
'rounded-xl border px-4 py-3 text-left',
|
|
155
|
+
'transition-all duration-200 ease-out',
|
|
156
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-black-800 focus-visible:ring-offset-2',
|
|
157
|
+
selected
|
|
158
|
+
? 'border-black-800 bg-black-800/[0.03] shadow-[inset_0_0_0_1px_rgb(31,31,31)]'
|
|
159
|
+
: 'border-gray-200 bg-white hover:border-gray-400'
|
|
160
|
+
)}
|
|
161
|
+
data-testid={`one-page-payment-card-${option.pk}`}
|
|
162
|
+
data-payment-type={option.payment_type}
|
|
163
|
+
>
|
|
164
|
+
<Icon
|
|
165
|
+
name={getOptionIcon(option)}
|
|
166
|
+
size={20}
|
|
167
|
+
className={clsx(
|
|
168
|
+
'flex-shrink-0 transition-colors',
|
|
169
|
+
selected ? 'fill-black-800' : 'fill-gray-600'
|
|
170
|
+
)}
|
|
171
|
+
aria-hidden="true"
|
|
172
|
+
/>
|
|
173
|
+
<span
|
|
174
|
+
className={clsx(
|
|
175
|
+
'flex-1 text-sm font-medium leading-tight',
|
|
176
|
+
selected ? 'text-black-800' : 'text-gray-700'
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
{option.name}
|
|
180
|
+
</span>
|
|
181
|
+
<span
|
|
182
|
+
className={clsx(
|
|
183
|
+
'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 transition-all',
|
|
184
|
+
selected
|
|
185
|
+
? 'border-black-800 bg-black-800'
|
|
186
|
+
: 'border-gray-300 bg-white group-hover:border-gray-400'
|
|
187
|
+
)}
|
|
188
|
+
aria-hidden="true"
|
|
189
|
+
>
|
|
190
|
+
{selected && (
|
|
191
|
+
<span className="block h-1.5 w-1.5 rounded-full bg-white" />
|
|
192
|
+
)}
|
|
193
|
+
</span>
|
|
194
|
+
</button>
|
|
195
|
+
);
|
|
196
|
+
})}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export default PaymentOptionsGrid;
|