@akinon/projectzero 2.0.32 → 2.0.33-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/app-template/CHANGELOG.md +34 -0
  3. package/app-template/package.json +29 -29
  4. package/app-template/public/locales/en/checkout.json +50 -0
  5. package/app-template/public/locales/tr/checkout.json +50 -0
  6. package/app-template/src/app/[pz]/basket/page.tsx +33 -6
  7. package/app-template/src/app/[pz]/orders/checkout/page.tsx +57 -92
  8. package/app-template/src/app/api/theme-settings/route.ts +21 -2
  9. package/app-template/src/data/server/theme.ts +6 -25
  10. package/app-template/src/views/checkout/variants/basket-and-checkout/basket-section.tsx +149 -0
  11. package/app-template/src/views/checkout/variants/basket-and-checkout/index.tsx +132 -0
  12. package/app-template/src/views/checkout/variants/multi-step/index.tsx +73 -0
  13. package/app-template/src/views/checkout/variants/one-page/accordion-section.tsx +140 -0
  14. package/app-template/src/views/checkout/variants/one-page/hooks/use-checkout-funnel-gtm.ts +60 -0
  15. package/app-template/src/views/checkout/variants/one-page/hooks/use-section-state.ts +99 -0
  16. package/app-template/src/views/checkout/variants/one-page/index.tsx +125 -0
  17. package/app-template/src/views/checkout/variants/one-page/mobile-summary-sheet.tsx +109 -0
  18. package/app-template/src/views/checkout/variants/one-page/one-page-summary.tsx +237 -0
  19. package/app-template/src/views/checkout/variants/one-page/payment-options-grid.tsx +201 -0
  20. package/app-template/src/views/checkout/variants/one-page/sections/address-section.tsx +318 -0
  21. package/app-template/src/views/checkout/variants/one-page/sections/contact-section.tsx +62 -0
  22. package/app-template/src/views/checkout/variants/one-page/sections/payment-section.tsx +71 -0
  23. package/app-template/src/views/checkout/variants/one-page/sections/shipping-section.tsx +298 -0
  24. package/app-template/src/views/checkout/variants/one-page/skeletons/address-skeleton.tsx +25 -0
  25. package/app-template/src/views/checkout/variants/one-page/skeletons/checkout-skeleton.tsx +27 -0
  26. package/app-template/src/views/checkout/variants/one-page/skeletons/index.ts +6 -0
  27. package/app-template/src/views/checkout/variants/one-page/skeletons/payment-skeleton.tsx +23 -0
  28. package/app-template/src/views/checkout/variants/one-page/skeletons/section-skeleton.tsx +47 -0
  29. package/app-template/src/views/checkout/variants/one-page/skeletons/shipping-skeleton.tsx +20 -0
  30. package/app-template/src/views/checkout/variants/one-page/skeletons/summary-skeleton.tsx +38 -0
  31. package/app-template/src/views/checkout/variants/one-page/trust-badges.tsx +30 -0
  32. package/app-template/src/views/checkout/variants/registry.ts +32 -0
  33. package/app-template/src/views/checkout/variants/types.ts +30 -0
  34. package/app-template/src/views/checkout/variants/use-resolved-config.ts +89 -0
  35. package/package.json +1 -1
@@ -0,0 +1,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;