@akinon/projectzero 1.126.19 → 1.126.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/app-template/.env.example +6 -0
  3. package/app-template/CHANGELOG.md +40 -0
  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,165 @@
1
+ import { ReactNode, useEffect, useRef, useId } from 'react';
2
+ import clsx from 'clsx';
3
+ import { useLocalization } from '@akinon/next/hooks';
4
+ import { partAttrs } from '@akinon/pz-theme/src/utils/part-styles';
5
+ import { Icon } from '@theme/components';
6
+
7
+ export type SectionStatus = 'locked' | 'active' | 'completed';
8
+
9
+ interface AccordionSectionProps {
10
+ step: number;
11
+ title: string;
12
+ status: SectionStatus;
13
+ summary?: ReactNode;
14
+ onEdit?: () => void;
15
+ editable?: boolean;
16
+ children: ReactNode;
17
+ dataTestId?: string;
18
+ padded?: boolean;
19
+ }
20
+
21
+ export const AccordionSection = ({
22
+ step,
23
+ title,
24
+ status,
25
+ summary,
26
+ onEdit,
27
+ editable = true,
28
+ children,
29
+ dataTestId,
30
+ padded = true
31
+ }: AccordionSectionProps) => {
32
+ const { t } = useLocalization();
33
+ const isOpen = status === 'active';
34
+ const isCompleted = status === 'completed';
35
+ const isLocked = status === 'locked';
36
+
37
+ const contentRef = useRef<HTMLDivElement>(null);
38
+ const headerId = useId();
39
+ const panelId = useId();
40
+ const wasOpenRef = useRef(isOpen);
41
+
42
+ useEffect(() => {
43
+ if (isOpen && !wasOpenRef.current && contentRef.current) {
44
+ const firstFocusable = contentRef.current.querySelector<HTMLElement>(
45
+ 'input:not([disabled]), button:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
46
+ );
47
+ firstFocusable?.focus({ preventScroll: true });
48
+ }
49
+ wasOpenRef.current = isOpen;
50
+ }, [isOpen]);
51
+
52
+ return (
53
+ <section
54
+ className={clsx(
55
+ 'mb-4 overflow-hidden rounded-2xl bg-white last:mb-0',
56
+ 'transition-all duration-300 ease-out',
57
+ isOpen
58
+ ? 'shadow-[0_12px_40px_-16px_rgba(0,0,0,0.16)] ring-1 ring-black-800/10'
59
+ : 'shadow-[0_1px_3px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.04]',
60
+ isLocked && 'opacity-50'
61
+ )}
62
+ data-testid={dataTestId}
63
+ data-status={status}
64
+ aria-labelledby={headerId}
65
+ {...partAttrs('section', {
66
+ open: isOpen,
67
+ completed: isCompleted,
68
+ disabled: isLocked
69
+ })}
70
+ >
71
+ <header
72
+ id={headerId}
73
+ className={clsx(
74
+ 'flex items-center justify-between gap-3 px-6 py-5 sm:px-7',
75
+ isOpen && 'border-b border-gray-100'
76
+ )}
77
+ {...partAttrs('section-header', {
78
+ open: isOpen,
79
+ completed: isCompleted,
80
+ disabled: isLocked
81
+ })}
82
+ >
83
+ <div className="flex items-center gap-3.5">
84
+ <span
85
+ className={clsx(
86
+ 'flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold transition-all',
87
+ isOpen && 'bg-black-800 text-white',
88
+ isCompleted && !isOpen && 'bg-black-800 text-white',
89
+ isLocked && 'bg-gray-100 text-gray-400'
90
+ )}
91
+ aria-hidden="true"
92
+ {...partAttrs('section-number', {
93
+ open: isOpen,
94
+ completed: isCompleted,
95
+ disabled: isLocked
96
+ })}
97
+ >
98
+ {isCompleted ? (
99
+ <Icon name="check" size={14} className="fill-white" />
100
+ ) : (
101
+ step
102
+ )}
103
+ </span>
104
+ <h2
105
+ className={clsx(
106
+ 'text-lg font-semibold leading-tight tracking-tight',
107
+ isOpen || isCompleted ? 'text-black-800' : 'text-gray-500'
108
+ )}
109
+ {...partAttrs('section-title', {
110
+ open: isOpen,
111
+ completed: isCompleted,
112
+ disabled: isLocked
113
+ })}
114
+ >
115
+ {title}
116
+ </h2>
117
+ </div>
118
+
119
+ {isCompleted && editable && onEdit && (
120
+ <button
121
+ type="button"
122
+ onClick={onEdit}
123
+ className={clsx(
124
+ 'group flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium',
125
+ 'text-gray-700 transition-all duration-150',
126
+ 'hover:bg-gray-100 hover:text-black-800',
127
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-black-800 focus-visible:ring-offset-1'
128
+ )}
129
+ data-testid={`${dataTestId}-edit`}
130
+ aria-label={`${t('checkout.one_page.edit')} ${title}`}
131
+ {...partAttrs('edit-button')}
132
+ >
133
+ <span>{t('checkout.one_page.edit')}</span>
134
+ </button>
135
+ )}
136
+ </header>
137
+
138
+ <div
139
+ id={panelId}
140
+ role="region"
141
+ aria-labelledby={headerId}
142
+ hidden={!isOpen}
143
+ className={clsx(
144
+ 'transition-opacity duration-300 ease-out',
145
+ isOpen ? 'opacity-100' : 'opacity-0'
146
+ )}
147
+ >
148
+ <div
149
+ ref={contentRef}
150
+ className={padded ? 'px-6 py-6 sm:px-7' : undefined}
151
+ >
152
+ {children}
153
+ </div>
154
+ </div>
155
+
156
+ {isCompleted && summary && (
157
+ <div className="border-t border-gray-100 px-6 py-4 text-sm text-gray-700 sm:px-7">
158
+ {summary}
159
+ </div>
160
+ )}
161
+ </section>
162
+ );
163
+ };
164
+
165
+ 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,103 @@
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 = ({
20
+ autoAdvance = true
21
+ }: UseSectionStateOptions = {}) => {
22
+ const { data: session } = useSession();
23
+ const preOrder = useAppSelector(
24
+ (state: RootState) => state.checkout.preOrder
25
+ );
26
+
27
+ const isAuthenticated = Boolean(session?.user);
28
+ const contactComplete = isAuthenticated || Boolean(preOrder?.is_guest);
29
+ const addressComplete = Boolean(
30
+ preOrder?.shipping_address?.pk && preOrder?.billing_address?.pk
31
+ );
32
+ const shippingComplete = Boolean(preOrder?.shipping_option?.pk);
33
+
34
+ const completion = useMemo(
35
+ () => ({
36
+ contact: contactComplete,
37
+ address: addressComplete,
38
+ shipping: shippingComplete,
39
+ payment: false
40
+ }),
41
+ [contactComplete, addressComplete, shippingComplete]
42
+ );
43
+
44
+ const firstIncomplete = useMemo<SectionId>(() => {
45
+ if (!completion.contact) return 'contact';
46
+ if (!completion.address) return 'address';
47
+ if (!completion.shipping) return 'shipping';
48
+ return 'payment';
49
+ }, [completion]);
50
+
51
+ const [activeSection, setActiveSection] =
52
+ useState<SectionId>(firstIncomplete);
53
+ const [touchedSections, setTouchedSections] = useState<Set<SectionId>>(
54
+ () => new Set()
55
+ );
56
+
57
+ useEffect(() => {
58
+ if (!autoAdvance) return;
59
+ if (touchedSections.has(activeSection)) return;
60
+ if (firstIncomplete !== activeSection) {
61
+ setActiveSection(firstIncomplete);
62
+ }
63
+ }, [firstIncomplete, autoAdvance, activeSection, touchedSections]);
64
+
65
+ const openSection = useCallback((id: SectionId) => {
66
+ setActiveSection(id);
67
+ setTouchedSections((prev) => {
68
+ const next = new Set(prev);
69
+ next.add(id);
70
+ return next;
71
+ });
72
+ }, []);
73
+
74
+ const sections = useMemo<SectionState[]>(() => {
75
+ const order: SectionId[] = ['contact', 'address', 'shipping', 'payment'];
76
+ return order.map((id) => {
77
+ const isComplete = completion[id];
78
+ let status: SectionStatus;
79
+ if (id === activeSection) {
80
+ status = 'active';
81
+ } else if (isComplete) {
82
+ status = 'completed';
83
+ } else {
84
+ const indexCurrent = order.indexOf(activeSection);
85
+ const indexThis = order.indexOf(id);
86
+ status = indexThis < indexCurrent ? 'completed' : 'locked';
87
+ }
88
+ return { id, status, isComplete };
89
+ });
90
+ }, [completion, activeSection]);
91
+
92
+ const allComplete =
93
+ completion.contact && completion.address && completion.shipping;
94
+
95
+ return {
96
+ sections,
97
+ activeSection,
98
+ openSection,
99
+ allComplete,
100
+ completion,
101
+ isAuthenticated
102
+ };
103
+ };
@@ -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 { CheckoutPiece } from '@akinon/pz-theme/src/chrome/checkout-piece';
7
+ import ContactSection from './sections/contact-section';
8
+ import AddressSection from './sections/address-section';
9
+ import ShippingSection from './sections/shipping-section';
10
+ import PaymentSection from './sections/payment-section';
11
+ import OnePageSummary from './one-page-summary';
12
+ import MobileSummarySheet from './mobile-summary-sheet';
13
+ import TrustBadges from './trust-badges';
14
+ import { useSectionState, type SectionId } from './hooks/use-section-state';
15
+ import { useCheckoutFunnelGtm } from './hooks/use-checkout-funnel-gtm';
16
+ import type { CheckoutVariantProps } from '../types';
17
+
18
+ const OnePageCheckout = ({ options }: CheckoutVariantProps) => {
19
+ const autoAdvance = options?.autoAdvance ?? true;
20
+ const stickyMobileCta = options?.stickyMobileCta ?? true;
21
+ const enableEditMode = options?.enableEditMode ?? true;
22
+ const showOrderReview = options?.showOrderReview ?? true;
23
+ const showTrustBadges = options?.showTrustBadges ?? true;
24
+ const gtmEnabled = options?.gtmEnabled ?? true;
25
+ const compactMode = options?.compactMode ?? false;
26
+
27
+ const { sections, activeSection, openSection } = useSectionState({
28
+ autoAdvance
29
+ });
30
+ useCheckoutFunnelGtm(activeSection, { enabled: gtmEnabled });
31
+
32
+ const sectionStatus = useCallback(
33
+ (id: SectionId) =>
34
+ sections.find((section) => section.id === id)?.status ?? 'locked',
35
+ [sections]
36
+ );
37
+
38
+ const goTo = useCallback(
39
+ (id: SectionId) => () => openSection(id),
40
+ [openSection]
41
+ );
42
+
43
+ return (
44
+ <PluginModule component={Component.MasterpassProvider}>
45
+ <PluginModule component={Component.MasterpassDeleteConfirmationModal} />
46
+ <PluginModule component={Component.MasterpassOtpModal} />
47
+ <PluginModule component={Component.MasterpassLinkModal} />
48
+
49
+ <div
50
+ className={clsx(
51
+ 'min-h-screen bg-[#fafafa]',
52
+ compactMode ? 'pb-12 md:pb-8' : 'pb-20 md:pb-16'
53
+ )}
54
+ data-compact={compactMode || undefined}
55
+ >
56
+ <div
57
+ className={clsx(
58
+ 'container w-full px-4 md:px-6 lg:px-8',
59
+ compactMode ? 'pt-4' : 'pt-6 md:pt-10'
60
+ )}
61
+ >
62
+ <div
63
+ className={clsx(
64
+ 'flex flex-col lg:flex-row lg:items-start',
65
+ compactMode ? 'gap-4 lg:gap-6' : 'gap-6 lg:gap-8'
66
+ )}
67
+ >
68
+ <div className="w-full lg:w-2/3" data-testid="one-page-sections">
69
+ <ContactSection
70
+ status={sectionStatus('contact')}
71
+ onEdit={goTo('contact')}
72
+ />
73
+ <AddressSection
74
+ status={sectionStatus('address')}
75
+ onEdit={goTo('address')}
76
+ onComplete={goTo('shipping')}
77
+ editable={enableEditMode}
78
+ />
79
+ <ShippingSection
80
+ status={sectionStatus('shipping')}
81
+ onEdit={goTo('shipping')}
82
+ onComplete={goTo('payment')}
83
+ editable={enableEditMode}
84
+ />
85
+ <PaymentSection
86
+ status={sectionStatus('payment')}
87
+ onEdit={goTo('payment')}
88
+ />
89
+
90
+ {showTrustBadges && <TrustBadges />}
91
+ </div>
92
+
93
+ <aside
94
+ className="hidden w-full lg:block lg:w-1/3"
95
+ data-testid="one-page-summary"
96
+ >
97
+ <div className="sticky top-6">
98
+ <CheckoutPiece piece="summary">
99
+ <OnePageSummary
100
+ onEditSection={openSection}
101
+ showReview={showOrderReview}
102
+ />
103
+ </CheckoutPiece>
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;