@akinon/projectzero 1.126.20-v1-rc.17 → 1.126.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +1 -5
  2. package/app-template/.env.example +6 -0
  3. package/app-template/CHANGELOG.md +39 -35
  4. package/app-template/package.json +29 -28
  5. package/app-template/public/locales/en/checkout.json +50 -0
  6. package/app-template/public/locales/tr/checkout.json +50 -0
  7. package/app-template/src/app/[pz]/account/(dashboard)/layout.tsx +20 -0
  8. package/app-template/src/app/[pz]/account/account-page-slot.tsx +35 -0
  9. package/app-template/src/app/[pz]/account/address/layout.tsx +18 -0
  10. package/app-template/src/app/[pz]/account/change-email/layout.tsx +18 -0
  11. package/app-template/src/app/[pz]/account/change-password/layout.tsx +18 -0
  12. package/app-template/src/app/[pz]/account/contact/layout.tsx +18 -0
  13. package/app-template/src/app/[pz]/account/coupons/layout.tsx +18 -0
  14. package/app-template/src/app/[pz]/account/faq/layout.tsx +14 -0
  15. package/app-template/src/app/[pz]/account/favourite-products/layout.tsx +21 -0
  16. package/app-template/src/app/[pz]/account/layout.tsx +36 -3
  17. package/app-template/src/app/[pz]/account/orders/(list)/layout.tsx +20 -0
  18. package/app-template/src/app/[pz]/account/orders/[id]/(detail)/layout.tsx +40 -0
  19. package/app-template/src/app/[pz]/account/profile/layout.tsx +18 -0
  20. package/app-template/src/app/[pz]/auth/(login)/layout.tsx +37 -0
  21. package/app-template/src/app/[pz]/basket/layout.tsx +25 -0
  22. package/app-template/src/app/[pz]/client-root.tsx +2 -0
  23. package/app-template/src/app/[pz]/configure-page-context.ts +17 -0
  24. package/app-template/src/app/[pz]/layout.tsx +22 -3
  25. package/app-template/src/app/[pz]/orders/checkout/layout.tsx +47 -0
  26. package/app-template/src/app/[pz]/orders/checkout/page.tsx +75 -97
  27. package/app-template/src/app/[pz]/orders/completed/[token]/layout.tsx +24 -2
  28. package/app-template/src/app/[pz]/users/password/reset/layout.tsx +21 -0
  29. package/app-template/src/app/[pz]/users/reset/[[...id]]/layout.tsx +30 -0
  30. package/app-template/src/components/checkbox.tsx +22 -3
  31. package/app-template/src/components/route-handler.tsx +48 -0
  32. package/app-template/src/routes/index.ts +6 -6
  33. package/app-template/src/views/checkout/__tests__/part-style-reachability.test.ts +119 -0
  34. package/app-template/src/views/checkout/step-button.tsx +13 -2
  35. package/app-template/src/views/checkout/step-list.tsx +5 -1
  36. package/app-template/src/views/checkout/steps/payment/agreements.tsx +9 -1
  37. package/app-template/src/views/checkout/steps/payment/index.tsx +10 -2
  38. package/app-template/src/views/checkout/steps/payment/options/credit-card/index.tsx +2 -0
  39. package/app-template/src/views/checkout/steps/payment/options/funds-transfer.tsx +2 -0
  40. package/app-template/src/views/checkout/steps/payment/payment-header.tsx +5 -1
  41. package/app-template/src/views/checkout/steps/payment/payment-option-buttons.tsx +16 -4
  42. package/app-template/src/views/checkout/steps/shipping/addresses.tsx +2 -1
  43. package/app-template/src/views/checkout/steps/shipping/index.tsx +2 -0
  44. package/app-template/src/views/checkout/steps/shipping/shipping-options.tsx +2 -0
  45. package/app-template/src/views/checkout/summary.tsx +56 -18
  46. package/app-template/src/views/checkout/variants/__tests__/registry.test.tsx +82 -0
  47. package/app-template/src/views/checkout/variants/__tests__/use-resolved-config.test.tsx +208 -0
  48. package/app-template/src/views/checkout/variants/basket-and-checkout/basket-section.tsx +150 -0
  49. package/app-template/src/views/checkout/variants/basket-and-checkout/index.tsx +135 -0
  50. package/app-template/src/views/checkout/variants/multi-step/index.tsx +78 -0
  51. package/app-template/src/views/checkout/variants/one-page/accordion-section.tsx +165 -0
  52. package/app-template/src/views/checkout/variants/one-page/hooks/use-checkout-funnel-gtm.ts +60 -0
  53. package/app-template/src/views/checkout/variants/one-page/hooks/use-section-state.ts +103 -0
  54. package/app-template/src/views/checkout/variants/one-page/index.tsx +125 -0
  55. package/app-template/src/views/checkout/variants/one-page/mobile-summary-sheet.tsx +109 -0
  56. package/app-template/src/views/checkout/variants/one-page/one-page-summary.tsx +301 -0
  57. package/app-template/src/views/checkout/variants/one-page/payment-options-grid.tsx +220 -0
  58. package/app-template/src/views/checkout/variants/one-page/sections/address-section.tsx +320 -0
  59. package/app-template/src/views/checkout/variants/one-page/sections/contact-section.tsx +62 -0
  60. package/app-template/src/views/checkout/variants/one-page/sections/payment-section.tsx +73 -0
  61. package/app-template/src/views/checkout/variants/one-page/sections/shipping-section.tsx +328 -0
  62. package/app-template/src/views/checkout/variants/one-page/skeletons/address-skeleton.tsx +25 -0
  63. package/app-template/src/views/checkout/variants/one-page/skeletons/checkout-skeleton.tsx +27 -0
  64. package/app-template/src/views/checkout/variants/one-page/skeletons/index.ts +6 -0
  65. package/app-template/src/views/checkout/variants/one-page/skeletons/payment-skeleton.tsx +23 -0
  66. package/app-template/src/views/checkout/variants/one-page/skeletons/section-skeleton.tsx +47 -0
  67. package/app-template/src/views/checkout/variants/one-page/skeletons/shipping-skeleton.tsx +20 -0
  68. package/app-template/src/views/checkout/variants/one-page/skeletons/summary-skeleton.tsx +38 -0
  69. package/app-template/src/views/checkout/variants/one-page/trust-badges.tsx +35 -0
  70. package/app-template/src/views/checkout/variants/registry.ts +43 -0
  71. package/app-template/src/views/checkout/variants/types.ts +30 -0
  72. package/app-template/src/views/checkout/variants/use-resolved-config.ts +168 -0
  73. package/app-template/tailwind.config.js +3 -1
  74. package/dist/commands/plugins.js +23 -2
  75. package/package.json +1 -1
  76. package/app-template/src/app/[pz]/account/orders/[id]/layout.tsx +0 -5
  77. /package/app-template/src/app/[pz]/account/{page.tsx → (dashboard)/page.tsx} +0 -0
  78. /package/app-template/src/app/[pz]/account/orders/{page.tsx → (list)/page.tsx} +0 -0
  79. /package/app-template/src/app/[pz]/account/orders/[id]/{page.tsx → (detail)/page.tsx} +0 -0
  80. /package/app-template/src/app/[pz]/auth/{page.tsx → (login)/page.tsx} +0 -0
@@ -0,0 +1,301 @@
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 { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
6
+ import { Image } from '@akinon/next/components/image';
7
+ import { StoreCredits } from '@theme/views/checkout/steps/payment/options/store-credit';
8
+ import type { RootState } from '@theme/redux/store';
9
+ import type { SectionId } from './hooks/use-section-state';
10
+
11
+ interface OnePageSummaryProps {
12
+ onEditSection: (section: SectionId) => void;
13
+ showReview?: boolean;
14
+ }
15
+
16
+ const OnePageSummary = ({
17
+ onEditSection,
18
+ showReview = true
19
+ }: OnePageSummaryProps) => {
20
+ const { t } = useLocalization();
21
+ const preOrder = useAppSelector(
22
+ (state: RootState) => state.checkout.preOrder
23
+ );
24
+ const { isMobileApp } = useAppSelector((state: RootState) => state.root);
25
+
26
+ if (!preOrder?.basket) return null;
27
+
28
+ const itemCount = preOrder.basket.basketitem_set.length;
29
+
30
+ return (
31
+ <div className="flex flex-col gap-4">
32
+ <PluginModule
33
+ component={Component.CheckoutGiftPack}
34
+ props={{
35
+ className:
36
+ '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]'
37
+ }}
38
+ />
39
+ <StoreCredits />
40
+
41
+ <section
42
+ className="overflow-hidden rounded-2xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]"
43
+ {...partAttrs('summary')}
44
+ >
45
+ <header
46
+ className="flex items-baseline justify-between px-6 py-5"
47
+ {...partAttrs('summary-header')}
48
+ >
49
+ <span
50
+ className="text-lg font-semibold tracking-tight text-black-800"
51
+ {...partAttrs('summary-title')}
52
+ >
53
+ {t('checkout.summary.title')}
54
+ </span>
55
+ <span className="text-[11px] font-medium uppercase tracking-wider text-gray-500">
56
+ {itemCount} {t('checkout.summary.items')}
57
+ </span>
58
+ </header>
59
+
60
+ <ul className="max-h-72 divide-y divide-gray-100 overflow-y-auto border-t border-gray-100">
61
+ {preOrder.basket.basketitem_set.map((item, index) => (
62
+ <li
63
+ key={`one-page-summary-item-${index}`}
64
+ className="flex gap-3 px-6 py-4"
65
+ {...partAttrs('summary-item')}
66
+ >
67
+ <Link
68
+ href={item.product.absolute_url || '#'}
69
+ passHref
70
+ className={
71
+ isMobileApp
72
+ ? 'pointer-events-none flex-shrink-0'
73
+ : 'flex-shrink-0 overflow-hidden rounded-md'
74
+ }
75
+ >
76
+ <Image
77
+ src={item.product.productimage_set[0]?.image}
78
+ alt={item.product.name}
79
+ width={56}
80
+ height={84}
81
+ />
82
+ </Link>
83
+ <div className="flex flex-1 flex-col justify-between text-xs">
84
+ <Link
85
+ href={item.product.absolute_url || '#'}
86
+ className="line-clamp-2 text-sm font-medium text-black-800 transition-colors hover:text-secondary"
87
+ >
88
+ {item.product.name}
89
+ </Link>
90
+ <div className="flex items-end justify-between gap-2">
91
+ <span className="text-gray-500">
92
+ {t('checkout.summary.quantity')}:{' '}
93
+ <span className="font-medium tabular-nums text-black-800">
94
+ {item.quantity}
95
+ </span>
96
+ </span>
97
+ <div className="flex flex-col items-end gap-0.5">
98
+ {item.product.retail_price !== item.product.price && (
99
+ <span className="text-[10px] text-gray-400 line-through">
100
+ <Price value={item.product.retail_price} />
101
+ </span>
102
+ )}
103
+ <span className="text-sm font-semibold text-black-800 tabular-nums">
104
+ <Price value={item.product.price} />
105
+ </span>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </li>
110
+ ))}
111
+ </ul>
112
+
113
+ <dl className="space-y-2.5 border-t border-gray-100 px-6 py-5 text-sm text-gray-600">
114
+ <div
115
+ className="flex items-center justify-between"
116
+ {...partAttrs('summary-row')}
117
+ >
118
+ <dt {...partAttrs('summary-row-label')}>
119
+ {t('checkout.summary.subtotal')}
120
+ </dt>
121
+ {/*
122
+ The amount declares its own weight and colour, so a rule on the
123
+ row can never reach it by inheritance — it gets its own part
124
+ instead of leaving the row's Text Color a dead control.
125
+ */}
126
+ <dd
127
+ className="font-medium tabular-nums text-black-800"
128
+ {...partAttrs('summary-row-value')}
129
+ >
130
+ <Price value={preOrder.basket.total_amount} />
131
+ </dd>
132
+ </div>
133
+ <div
134
+ className="flex items-center justify-between"
135
+ {...partAttrs('summary-row')}
136
+ >
137
+ <dt {...partAttrs('summary-row-label')}>
138
+ {t('checkout.summary.shipping')}
139
+ </dt>
140
+ <dd
141
+ className="font-medium tabular-nums text-black-800"
142
+ {...partAttrs('summary-row-value')}
143
+ >
144
+ <Price value={preOrder.shipping_amount} />
145
+ </dd>
146
+ </div>
147
+ {parseFloat(preOrder.loyalty_money || '0') > 0 && (
148
+ <div
149
+ className="flex items-center justify-between"
150
+ {...partAttrs('summary-row')}
151
+ >
152
+ <dt {...partAttrs('summary-row-label')}>
153
+ {t('checkout.summary.loyalty_money_total')}
154
+ </dt>
155
+ <dd
156
+ className="font-medium tabular-nums text-black-800"
157
+ {...partAttrs('summary-row-value')}
158
+ >
159
+ <Price value={preOrder.loyalty_money} />
160
+ </dd>
161
+ </div>
162
+ )}
163
+ {parseFloat(preOrder.basket.total_discount_amount || '0') > 0 && (
164
+ <div
165
+ className="flex items-center justify-between"
166
+ {...partAttrs('summary-row')}
167
+ >
168
+ <dt {...partAttrs('summary-row-label')}>
169
+ {t('checkout.summary.discounts_total')}
170
+ </dt>
171
+ <dd
172
+ className="font-medium tabular-nums text-black-800"
173
+ {...partAttrs('summary-row-value')}
174
+ >
175
+ <Price
176
+ value={preOrder.basket.total_discount_amount}
177
+ useNegative
178
+ />
179
+ </dd>
180
+ </div>
181
+ )}
182
+ </dl>
183
+
184
+ {/*
185
+ The row carries the baseline typography that used to live on the
186
+ label span, so the row's own controls have something to change; the
187
+ amount keeps its distinct treatment and answers to its own part.
188
+ */}
189
+ <div
190
+ className="flex items-baseline justify-between border-t border-gray-100 px-6 py-5 text-sm font-medium uppercase tracking-wider text-gray-700"
191
+ {...partAttrs('summary-total')}
192
+ >
193
+ <span {...partAttrs('summary-total-label')}>
194
+ {t('checkout.summary.total')}
195
+ </span>
196
+ <span
197
+ className="text-2xl font-bold tracking-tight text-black-800 tabular-nums"
198
+ {...partAttrs('summary-total-value')}
199
+ >
200
+ <Price value={preOrder.unpaid_amount} />
201
+ </span>
202
+ </div>
203
+ </section>
204
+
205
+ {showReview &&
206
+ (preOrder.shipping_address ||
207
+ preOrder.shipping_option ||
208
+ preOrder.payment_option) && (
209
+ <section className="overflow-hidden rounded-2xl bg-white shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]">
210
+ <header className="px-6 py-5">
211
+ <span className="text-base font-semibold tracking-tight text-black-800">
212
+ {t('checkout.one_page.summary.review_title')}
213
+ </span>
214
+ </header>
215
+
216
+ <ul className="divide-y divide-gray-100 border-t border-gray-100 text-sm text-black-800">
217
+ {preOrder.shipping_address && (
218
+ <li className="flex items-start justify-between gap-3 px-6 py-4">
219
+ <div className="flex-1">
220
+ <div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
221
+ {t('checkout.one_page.summary.deliver_to')}
222
+ </div>
223
+ <div className="font-medium">
224
+ {preOrder.shipping_address.title}
225
+ </div>
226
+ <div className="text-xs text-gray-600">
227
+ {preOrder.shipping_address.line},{' '}
228
+ {preOrder.shipping_address.township?.name},{' '}
229
+ {preOrder.shipping_address.city?.name}
230
+ </div>
231
+ </div>
232
+ <button
233
+ type="button"
234
+ onClick={() => onEditSection('address')}
235
+ className="rounded-md px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-black-800"
236
+ data-testid="one-page-summary-edit-address"
237
+ {...partAttrs('summary-edit')}
238
+ >
239
+ {t('checkout.one_page.edit')}
240
+ </button>
241
+ </li>
242
+ )}
243
+
244
+ {preOrder.shipping_option && (
245
+ <li className="flex items-start justify-between gap-3 px-6 py-4">
246
+ <div className="flex-1">
247
+ <div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
248
+ {t('checkout.one_page.summary.shipping_method')}
249
+ </div>
250
+ <div className="font-medium">
251
+ {preOrder.shipping_option.name}
252
+ </div>
253
+ {preOrder.shipping_option.shipping_amount && (
254
+ <div className="text-xs tabular-nums text-gray-600">
255
+ <Price
256
+ value={preOrder.shipping_option.shipping_amount}
257
+ />
258
+ </div>
259
+ )}
260
+ </div>
261
+ <button
262
+ type="button"
263
+ onClick={() => onEditSection('shipping')}
264
+ className="rounded-md px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-black-800"
265
+ data-testid="one-page-summary-edit-shipping"
266
+ {...partAttrs('summary-edit')}
267
+ >
268
+ {t('checkout.one_page.edit')}
269
+ </button>
270
+ </li>
271
+ )}
272
+
273
+ {preOrder.payment_option && (
274
+ <li className="flex items-start justify-between gap-3 px-6 py-4">
275
+ <div className="flex-1">
276
+ <div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
277
+ {t('checkout.one_page.summary.payment_method')}
278
+ </div>
279
+ <div className="font-medium">
280
+ {preOrder.payment_option.name}
281
+ </div>
282
+ </div>
283
+ <button
284
+ type="button"
285
+ onClick={() => onEditSection('payment')}
286
+ className="rounded-md px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-black-800"
287
+ data-testid="one-page-summary-edit-payment"
288
+ {...partAttrs('summary-edit')}
289
+ >
290
+ {t('checkout.one_page.edit')}
291
+ </button>
292
+ </li>
293
+ )}
294
+ </ul>
295
+ </section>
296
+ )}
297
+ </div>
298
+ );
299
+ };
300
+
301
+ export default OnePageSummary;
@@ -0,0 +1,220 @@
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 { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
8
+ import { Icon } from '@theme/components';
9
+ import type { RootState } from '@theme/redux/store';
10
+ import type { CheckoutPaymentOption } from '@akinon/next/types';
11
+
12
+ const PAYMENT_TYPE_ICON: Record<string, string> = {
13
+ credit_card: 'cvc',
14
+ funds_transfer: 'file',
15
+ pay_on_delivery: 'address',
16
+ bkm_express: 'akinon',
17
+ masterpass: 'cvc',
18
+ masterpass_rest: 'cvc',
19
+ saved_card: 'cvc',
20
+ gpay: 'google',
21
+ google_pay: 'google',
22
+ apple_pay: 'apple',
23
+ wallet: 'akinon',
24
+ redirection: 'globe',
25
+ credit_payment: 'installment',
26
+ loyalty: 'heart-full',
27
+ hepsipay: 'akinon',
28
+ tabby: 'installment',
29
+ tamara: 'installment',
30
+ iyzico: 'cvc',
31
+ cybersource_uc: 'cvc',
32
+ flow_payment: 'installment'
33
+ };
34
+
35
+ const SLUG_KEYWORD_ICON: Array<[string, string]> = [
36
+ ['apple', 'apple'],
37
+ ['google', 'google'],
38
+ ['gpay', 'google'],
39
+ ['hepsi', 'akinon'],
40
+ ['tabby', 'installment'],
41
+ ['tamara', 'installment'],
42
+ ['flow', 'installment'],
43
+ ['saved', 'cvc'],
44
+ ['credit', 'cvc'],
45
+ ['transfer', 'file'],
46
+ ['delivery', 'address'],
47
+ ['wallet', 'akinon']
48
+ ];
49
+
50
+ const getOptionIcon = (option: CheckoutPaymentOption): string => {
51
+ if (PAYMENT_TYPE_ICON[option.payment_type]) {
52
+ return PAYMENT_TYPE_ICON[option.payment_type];
53
+ }
54
+ const slug = option.slug?.toLowerCase() || '';
55
+ for (const [keyword, icon] of SLUG_KEYWORD_ICON) {
56
+ if (slug.includes(keyword)) return icon;
57
+ }
58
+ return 'default';
59
+ };
60
+
61
+ const PaymentOptionsGrid = () => {
62
+ const { t } = useLocalization();
63
+ const { preOrder, attributeBasedShippingOptions } = useAppSelector(
64
+ (state: RootState) => state.checkout
65
+ );
66
+ const [setPaymentOption, { isLoading }] = useSetPaymentOptionMutation();
67
+ const { filteredPaymentOptions } = usePaymentOptions();
68
+
69
+ const containerRef = useRef<HTMLDivElement>(null);
70
+
71
+ const visibleOptions = useMemo(() => {
72
+ if (
73
+ attributeBasedShippingOptions &&
74
+ Object.keys(attributeBasedShippingOptions).length > 0
75
+ ) {
76
+ return filteredPaymentOptions.filter(
77
+ (option) => option.slug?.toLowerCase() !== 'pay-on-delivery'
78
+ );
79
+ }
80
+ return filteredPaymentOptions;
81
+ }, [filteredPaymentOptions, attributeBasedShippingOptions]);
82
+
83
+ const handleKeyDown = useCallback(
84
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
85
+ if (
86
+ ![
87
+ 'ArrowRight',
88
+ 'ArrowLeft',
89
+ 'ArrowDown',
90
+ 'ArrowUp',
91
+ 'Home',
92
+ 'End'
93
+ ].includes(event.key)
94
+ ) {
95
+ return;
96
+ }
97
+ event.preventDefault();
98
+ const buttons = Array.from(
99
+ containerRef.current?.querySelectorAll<HTMLButtonElement>(
100
+ 'button[role="radio"]'
101
+ ) ?? []
102
+ );
103
+ if (buttons.length === 0) return;
104
+ const currentIndex = buttons.findIndex(
105
+ (btn) => btn === document.activeElement
106
+ );
107
+ let nextIndex = currentIndex;
108
+ switch (event.key) {
109
+ case 'ArrowRight':
110
+ case 'ArrowDown':
111
+ nextIndex = (currentIndex + 1 + buttons.length) % buttons.length;
112
+ break;
113
+ case 'ArrowLeft':
114
+ case 'ArrowUp':
115
+ nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
116
+ break;
117
+ case 'Home':
118
+ nextIndex = 0;
119
+ break;
120
+ case 'End':
121
+ nextIndex = buttons.length - 1;
122
+ break;
123
+ }
124
+ buttons[nextIndex]?.focus();
125
+ },
126
+ []
127
+ );
128
+
129
+ if (visibleOptions.length === 0) {
130
+ return (
131
+ <p className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-5 py-4 text-sm italic text-gray-600">
132
+ {t('checkout.one_page.payment.no_options')}
133
+ </p>
134
+ );
135
+ }
136
+
137
+ return (
138
+ <div
139
+ ref={containerRef}
140
+ className={clsx(
141
+ 'grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3',
142
+ { 'pointer-events-none opacity-60': isLoading }
143
+ )}
144
+ role="radiogroup"
145
+ aria-label={t('checkout.one_page.payment.title')}
146
+ onKeyDown={handleKeyDown}
147
+ {...partAttrs('payment-options')}
148
+ >
149
+ {visibleOptions.map((option, index) => {
150
+ const selected = preOrder?.payment_option?.pk === option.pk;
151
+ return (
152
+ <button
153
+ key={option.pk}
154
+ type="button"
155
+ role="radio"
156
+ aria-checked={selected}
157
+ tabIndex={
158
+ selected || (!preOrder?.payment_option && index === 0) ? 0 : -1
159
+ }
160
+ onClick={() => setPaymentOption(option.pk)}
161
+ className={clsx(
162
+ 'group relative flex min-h-[5rem] items-center gap-3',
163
+ 'rounded-xl border px-4 py-3 text-left',
164
+ // Typography and colour sit on the button — the element the
165
+ // payment-option part is stamped on — instead of on the label
166
+ // span. A part rule can only reach the label by inheritance, and
167
+ // a utility class declared on the span outranks an inherited
168
+ // value however !important the rule is, so every Text Color /
169
+ // Font Size / Font Weight control in the panel was a silent
170
+ // no-op while these lived one level down.
171
+ 'text-sm font-medium leading-tight',
172
+ 'transition-all duration-200 ease-out',
173
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black-800 focus-visible:ring-offset-2',
174
+ selected
175
+ ? 'border-black-800 bg-black-800/[0.03] text-black-800 shadow-[inset_0_0_0_1px_rgb(31,31,31)]'
176
+ : 'border-gray-200 bg-white text-gray-700 hover:border-gray-400'
177
+ )}
178
+ data-testid={`one-page-payment-card-${option.pk}`}
179
+ data-payment-type={option.payment_type}
180
+ {...partAttrs('payment-option', { selected })}
181
+ >
182
+ {/*
183
+ `Icon` renders an icon FONT (<i class="pz-icon-…">), so the
184
+ `fill-*` utilities that used to sit here never coloured
185
+ anything — fill only applies to SVG. The glyph takes its colour
186
+ from `color`, which now comes from the button, so the icon
187
+ follows both the selected state and the panel's Text Color.
188
+ */}
189
+ <Icon
190
+ name={getOptionIcon(option)}
191
+ size={20}
192
+ className="flex-shrink-0 transition-colors"
193
+ aria-hidden="true"
194
+ {...partAttrs('payment-option-icon')}
195
+ />
196
+ <span className="flex-1">{option.name}</span>
197
+ {/*
198
+ A ring of `currentColor` with a transparent centre: the marker
199
+ follows whatever colour the composition sets on the button and
200
+ the middle shows the card's own background, so it stays legible
201
+ on any palette. The geometry is what the filled circle plus its
202
+ 6px inner dot drew before.
203
+ */}
204
+ <span
205
+ className={clsx(
206
+ 'flex h-4 w-4 flex-shrink-0 rounded-full transition-all',
207
+ selected
208
+ ? 'border-[5px] border-current'
209
+ : 'border-2 border-gray-300 group-hover:border-gray-400'
210
+ )}
211
+ aria-hidden="true"
212
+ />
213
+ </button>
214
+ );
215
+ })}
216
+ </div>
217
+ );
218
+ };
219
+
220
+ export default PaymentOptionsGrid;