@usequeek/theme-kit 0.1.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 (120) hide show
  1. package/README.md +37 -0
  2. package/api/collections.ts +93 -0
  3. package/api/orders.ts +23 -0
  4. package/apps/analytics-events.ts +32 -0
  5. package/apps/apps.css +613 -0
  6. package/apps/follow-card.tsx +192 -0
  7. package/apps/gtag.ts +19 -0
  8. package/apps/subscribe-form.tsx +124 -0
  9. package/client.ts +52 -0
  10. package/components/auth/auth-email-login-step.tsx +46 -0
  11. package/components/auth/auth-email-otp-step.tsx +54 -0
  12. package/components/auth/auth-email-otp-verify-step.tsx +82 -0
  13. package/components/auth/auth-email-register-step.tsx +59 -0
  14. package/components/auth/auth-flow.tsx +26 -0
  15. package/components/auth/auth-signup-step.tsx +53 -0
  16. package/components/blog/category-filter.tsx +92 -0
  17. package/components/blog/index.ts +12 -0
  18. package/components/blog/pagination.tsx +73 -0
  19. package/components/blog/post-categories.tsx +42 -0
  20. package/components/blog/post-meta.tsx +49 -0
  21. package/components/blog/related-posts.tsx +54 -0
  22. package/components/blog/share-buttons.tsx +89 -0
  23. package/components/cart/cart-panel-controller.tsx +67 -0
  24. package/components/cart/default-cart-shell.tsx +207 -0
  25. package/components/checkout/checkout-contact.tsx +84 -0
  26. package/components/checkout/default-checkout-shell.tsx +177 -0
  27. package/components/checkout/policy-links.tsx +98 -0
  28. package/components/image.tsx +149 -0
  29. package/components/markdown.tsx +54 -0
  30. package/components/product-media-frame.tsx +56 -0
  31. package/components/variant-picker.css +84 -0
  32. package/components/variant-picker.tsx +107 -0
  33. package/design-token-preview.tsx +81 -0
  34. package/edit-mode-active.tsx +324 -0
  35. package/edit-mode.tsx +80 -0
  36. package/hooks/use-address-autocomplete.ts +62 -0
  37. package/hooks/use-auth-flow.tsx +346 -0
  38. package/hooks/use-auth.ts +17 -0
  39. package/hooks/use-cart.ts +14 -0
  40. package/hooks/use-categories.ts +112 -0
  41. package/hooks/use-collection-params.ts +67 -0
  42. package/hooks/use-href.ts +15 -0
  43. package/hooks/use-posts.ts +75 -0
  44. package/hooks/use-product-detail.ts +69 -0
  45. package/hooks/use-product-questions.ts +77 -0
  46. package/hooks/use-product-sections.ts +165 -0
  47. package/hooks/use-products.ts +216 -0
  48. package/hooks/use-related-products.ts +56 -0
  49. package/hooks/use-resolve-coordinates.ts +48 -0
  50. package/hooks/use-reviews.ts +167 -0
  51. package/hooks/use-search.ts +55 -0
  52. package/hooks/use-shop-cart.ts +31 -0
  53. package/hooks/use-shop-params.ts +68 -0
  54. package/hooks/use-shop.ts +186 -0
  55. package/hooks/use-variant-selection.ts +98 -0
  56. package/index.ts +49 -0
  57. package/package.json +41 -0
  58. package/page-renderer.tsx +365 -0
  59. package/provider.tsx +91 -0
  60. package/sdk/client.ts +221 -0
  61. package/sdk/error-handler.ts +37 -0
  62. package/sdk/platform.ts +78 -0
  63. package/sdk/queek-client.ts +104 -0
  64. package/sdk/reauth-guard.ts +27 -0
  65. package/sdk/server-store-client.ts +165 -0
  66. package/shared-blocks/button.tsx +41 -0
  67. package/shared-blocks/callout.tsx +35 -0
  68. package/shared-blocks/content-default.tsx +23 -0
  69. package/shared-blocks/core-blocks.css +1792 -0
  70. package/shared-blocks/divider.tsx +17 -0
  71. package/shared-blocks/embed.tsx +40 -0
  72. package/shared-blocks/faq.tsx +44 -0
  73. package/shared-blocks/image.tsx +29 -0
  74. package/shared-blocks/index.ts +24 -0
  75. package/shared-blocks/manifest.ts +76 -0
  76. package/shared-blocks/product-qa.tsx +203 -0
  77. package/shared-blocks/quote.tsx +20 -0
  78. package/shared-blocks/reviews.tsx +436 -0
  79. package/shared-blocks/table.tsx +45 -0
  80. package/shared-blocks/video.tsx +60 -0
  81. package/stores/address-modal-store.ts +15 -0
  82. package/stores/auth-modal-store.ts +17 -0
  83. package/stores/cart-panel-store.ts +17 -0
  84. package/stores/cart-store.ts +172 -0
  85. package/stores/checkout-store.ts +167 -0
  86. package/stores/edit-preview-store.ts +61 -0
  87. package/stores/location-store.ts +41 -0
  88. package/stores/orders-sheet-store.ts +21 -0
  89. package/stores/policy-modal-store.ts +15 -0
  90. package/stores/product-modal-store.ts +18 -0
  91. package/stores/search-modal-store.ts +17 -0
  92. package/stores/toast-store.ts +46 -0
  93. package/stores/user-store.ts +88 -0
  94. package/theme-context.tsx +28 -0
  95. package/theme-variant-resolver.ts +44 -0
  96. package/types/block.ts +292 -0
  97. package/types/cart.ts +21 -0
  98. package/types/media.ts +55 -0
  99. package/types/menu.ts +27 -0
  100. package/types/order.ts +119 -0
  101. package/types/page.ts +86 -0
  102. package/types/product.ts +279 -0
  103. package/types/theme.ts +380 -0
  104. package/types/vendor.ts +243 -0
  105. package/utils/addon-selection.ts +31 -0
  106. package/utils/benefit-icon.tsx +145 -0
  107. package/utils/brand-palette.ts +64 -0
  108. package/utils/brand.ts +518 -0
  109. package/utils/contact-location.ts +44 -0
  110. package/utils/format.ts +64 -0
  111. package/utils/hero-content.ts +35 -0
  112. package/utils/image-srcset.ts +157 -0
  113. package/utils/markdown.tsx +237 -0
  114. package/utils/match-variant.ts +31 -0
  115. package/utils/menu-link.ts +41 -0
  116. package/utils/placeholder-resolver.ts +184 -0
  117. package/utils/product-media.ts +77 -0
  118. package/utils/product-normalizer.ts +323 -0
  119. package/utils/vendor-host.ts +173 -0
  120. package/utils/video-embed.ts +38 -0
@@ -0,0 +1,207 @@
1
+ 'use client';
2
+
3
+ import { useRef, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import type { CartShellProps } from '../../types/theme';
6
+ import { useHref } from '../../hooks/use-href';
7
+ import { useCheckoutStore } from '../../stores/checkout-store';
8
+ import { useRelatedProducts } from '../../hooks/use-related-products';
9
+ import { useCart } from '../../hooks/use-cart';
10
+ import { formatMoney } from '../../utils/format';
11
+ import { productRequiresVariant } from '../../utils/match-variant';
12
+ import { Image } from '../image';
13
+
14
+ /**
15
+ * The cart page's shared, root design — every theme's shells/cart-shell.tsx
16
+ * delegates to this (all thin passthroughs today) unless one chooses to
17
+ * build its own; the override mechanism stays real, it's just unused today.
18
+ */
19
+ export function DefaultCartShell({
20
+ items,
21
+ currency,
22
+ total,
23
+ empty,
24
+ onIncrease,
25
+ onDecrease,
26
+ onRemove,
27
+ }: CartShellProps): JSX.Element {
28
+ const href = useHref();
29
+ const { addProduct } = useCart();
30
+ const vendorNote = useCheckoutStore((s) => s.vendorNote);
31
+ const setVendorNote = useCheckoutStore((s) => s.setVendorNote);
32
+ const [noteOpen, setNoteOpen] = useState(false);
33
+ const [agreed, setAgreed] = useState(false);
34
+ const relatedRef = useRef<HTMLDivElement>(null);
35
+
36
+ const firstSlug = items.find((item) => item.slug)?.slug ?? '';
37
+ const { products: related } = useRelatedProducts(firstSlug, 6);
38
+
39
+ const scrollRelated = (dir: number): void => {
40
+ const el = relatedRef.current;
41
+ if (!el) return;
42
+ const card = el.querySelector<HTMLElement>('.core-cart-page__related-card');
43
+ const amount = card ? card.offsetWidth + 16 : el.clientWidth * 0.8;
44
+ el.scrollBy({ left: dir * amount, behavior: 'smooth' });
45
+ };
46
+
47
+ if (empty) {
48
+ return (
49
+ <div className="core-cart-page">
50
+ <h1 className="core-cart-page__title">Your cart</h1>
51
+ <div className="core-cart-page__empty">
52
+ <p>Your cart is empty.</p>
53
+ <Link href={href('/')} className="core-checkout__back-link">Browse products</Link>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <div className="core-cart-page">
61
+ <h1 className="core-cart-page__title">Your cart</h1>
62
+
63
+ <div className="core-cart-page__grid">
64
+ <div className="core-cart-page__main">
65
+ <div className="core-cart-page__items-card">
66
+ <ul className="core-cart-page__items">
67
+ {items.map((item) => (
68
+ <li key={`${item.shop_id}-${item.id}`} className="core-cart-page__item">
69
+ {item.image ? (
70
+ <Image src={item.image} alt={item.title} className="core-cart-page__item-img" />
71
+ ) : (
72
+ <div className="core-cart-page__item-img core-cart-page__item-img--placeholder" />
73
+ )}
74
+ <div className="core-cart-page__item-info">
75
+ <span className="core-cart-page__item-title">{item.title}</span>
76
+ <span className="core-cart-page__item-price">{formatMoney(item.unit_price, currency)} each</span>
77
+ </div>
78
+ <div className="core-cart-page__item-qty">
79
+ <button type="button" onClick={() => onDecrease(item.id, item.shop_id)} aria-label={`Decrease ${item.title}`}>
80
+ <svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2 6h8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
81
+ </button>
82
+ <span>{item.quantity}</span>
83
+ <button type="button" onClick={() => onIncrease(item.id, item.shop_id)} aria-label={`Increase ${item.title}`}>
84
+ <svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M6 2v8M2 6h8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
85
+ </button>
86
+ </div>
87
+ <span className="core-cart-page__item-total">{formatMoney(item.unit_price * item.quantity, currency)}</span>
88
+ <button type="button" className="core-cart-page__item-remove" onClick={() => onRemove(item.id, item.shop_id)} aria-label={`Remove ${item.title}`}>
89
+ <svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
90
+ <path d="M4 6h12M8 6V4.5A1.5 1.5 0 019.5 3h1A1.5 1.5 0 0112 4.5V6m-6 0v9.5A1.5 1.5 0 007.5 17h5a1.5 1.5 0 001.5-1.5V6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
91
+ </svg>
92
+ </button>
93
+ </li>
94
+ ))}
95
+ </ul>
96
+ </div>
97
+
98
+ {related.length > 0 ? (
99
+ <section className="core-cart-page__related">
100
+ <div className="core-cart-page__related-head">
101
+ <h2>You may also like</h2>
102
+ <div className="core-cart-page__related-nav">
103
+ <button type="button" onClick={() => scrollRelated(-1)} aria-label="Previous">
104
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M10 13L5 8l5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
105
+ </button>
106
+ <button type="button" onClick={() => scrollRelated(1)} aria-label="Next">
107
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
108
+ </button>
109
+ </div>
110
+ </div>
111
+ <div className="core-cart-page__related-row" ref={relatedRef}>
112
+ {related.map((product) => (
113
+ <div key={product.id} className="core-cart-page__related-card">
114
+ {product.media.thumbnail ? (
115
+ <Image
116
+ src={product.media.thumbnail}
117
+ alt={product.title}
118
+ className="core-cart-page__related-img"
119
+ variants={product.media.image_variants}
120
+ intent="avatar"
121
+ />
122
+ ) : (
123
+ <div className="core-cart-page__related-img core-cart-page__related-img--placeholder" />
124
+ )}
125
+ <div className="core-cart-page__related-info">
126
+ <span className="core-cart-page__related-title">{product.title}</span>
127
+ <span className="core-cart-page__related-price">{product.pricing.is_price_from ? 'From ' : ''}{formatMoney(product.pricing.sale_amount, currency)}</span>
128
+ </div>
129
+ {productRequiresVariant(product) ? (
130
+ <Link
131
+ className="core-cart-page__related-add"
132
+ href={href(`/products/${product.slug}`)}
133
+ aria-label={`Choose options for ${product.title}`}
134
+ >
135
+ <svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
136
+ <path d="M7 4l6 6-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
137
+ </svg>
138
+ </Link>
139
+ ) : (
140
+ <button
141
+ type="button"
142
+ className="core-cart-page__related-add"
143
+ aria-label={`Add ${product.title} to cart`}
144
+ onClick={() => addProduct(product)}
145
+ >
146
+ <svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
147
+ <path d="M2 3h2l2.4 9.6a1 1 0 001 .4H15a1 1 0 00.97-.76L17 7H5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
148
+ <circle cx="8" cy="17" r="1" fill="currentColor" />
149
+ <circle cx="14" cy="17" r="1" fill="currentColor" />
150
+ </svg>
151
+ </button>
152
+ )}
153
+ </div>
154
+ ))}
155
+ </div>
156
+ </section>
157
+ ) : null}
158
+ </div>
159
+
160
+ <div className="core-cart-page__summary">
161
+ <div className="core-cart-page__summary-card">
162
+ <h2 className="core-cart-page__summary-title">Order summary</h2>
163
+
164
+ <button type="button" className="core-cart-page__note-toggle" onClick={() => setNoteOpen((v) => !v)}>
165
+ Add order note
166
+ <span aria-hidden="true">{noteOpen ? '−' : '+'}</span>
167
+ </button>
168
+ {noteOpen ? (
169
+ <textarea
170
+ className="core-textarea core-cart-page__note-input"
171
+ rows={3}
172
+ value={vendorNote}
173
+ onChange={(event) => setVendorNote(event.target.value)}
174
+ placeholder="Order instructions (optional)"
175
+ />
176
+ ) : null}
177
+
178
+ <div className="core-cart-page__subtotal">
179
+ <span>Subtotal:</span>
180
+ <strong>{formatMoney(total, currency)} {currency}</strong>
181
+ </div>
182
+
183
+ <label className="core-cart-page__terms">
184
+ <input type="checkbox" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} />
185
+ <span>I agree to the <Link href={href('/policies')}>terms and refund policy</Link></span>
186
+ </label>
187
+
188
+ <Link
189
+ href={href('/checkout')}
190
+ className="core-cart-page__checkout-btn"
191
+ aria-disabled={!agreed}
192
+ onClick={(event) => { if (!agreed) event.preventDefault(); }}
193
+ >
194
+ <svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
195
+ <path d="M2 3h2l2.4 9.6a1 1 0 001 .4H15a1 1 0 00.97-.76L17 7H5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
196
+ <circle cx="8" cy="17" r="1" fill="currentColor" />
197
+ <circle cx="14" cy="17" r="1" fill="currentColor" />
198
+ </svg>
199
+ Checkout
200
+ </Link>
201
+ <Link href={href('/')} className="core-cart-page__continue-link">Continue shopping</Link>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { useAuth } from '../../hooks/use-auth';
5
+ import { Image } from '../image';
6
+
7
+ /**
8
+ * First section of the checkout form — shows who's checking out. Ordering
9
+ * requires auth (`canCheckout` in checkout-controller.tsx), so unlike
10
+ * Shopify's guest-friendly email field this is purely an identity
11
+ * confirmation: avatar + email when signed in, a sign-in prompt otherwise.
12
+ */
13
+ export function CheckoutContactSection(): JSX.Element {
14
+ const { user, isAuthenticated, openAuthModal, logout } = useAuth();
15
+ const [menuOpen, setMenuOpen] = useState(false);
16
+ const menuRef = useRef<HTMLDivElement>(null);
17
+
18
+ useEffect(() => {
19
+ if (!menuOpen) return;
20
+ const onClick = (event: MouseEvent): void => {
21
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
22
+ setMenuOpen(false);
23
+ }
24
+ };
25
+ document.addEventListener('mousedown', onClick);
26
+ return () => document.removeEventListener('mousedown', onClick);
27
+ }, [menuOpen]);
28
+
29
+ if (!isAuthenticated || !user) {
30
+ return (
31
+ <section className="core-checkout__section core-checkout-contact">
32
+ <div className="core-checkout-contact__row">
33
+ <span className="core-checkout-contact__label">Contact</span>
34
+ <button
35
+ type="button"
36
+ className="core-checkout-contact__signin"
37
+ onClick={() => openAuthModal('login')}
38
+ >
39
+ Sign in
40
+ </button>
41
+ </div>
42
+ </section>
43
+ );
44
+ }
45
+
46
+ const initial = (user.first_name || user.name || '?').charAt(0).toUpperCase();
47
+ const contact = user.email || user.phone || '';
48
+
49
+ return (
50
+ <section className="core-checkout__section core-checkout-contact">
51
+ <div className="core-checkout-contact__row" ref={menuRef}>
52
+ <div className="core-checkout-contact__identity">
53
+ {user.avatar ? (
54
+ <Image src={user.avatar} alt={user.name} className="core-checkout-contact__avatar" />
55
+ ) : (
56
+ <span className="core-checkout-contact__avatar core-checkout-contact__avatar--initial">{initial}</span>
57
+ )}
58
+ <span className="core-checkout-contact__email">{contact}</span>
59
+ </div>
60
+ <div className="core-checkout-contact__menu-wrap">
61
+ <button
62
+ type="button"
63
+ className="core-checkout-contact__menu-btn"
64
+ aria-label="Account options"
65
+ onClick={() => setMenuOpen((v) => !v)}
66
+ >
67
+ <svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
68
+ <circle cx="10" cy="4" r="1.5" />
69
+ <circle cx="10" cy="10" r="1.5" />
70
+ <circle cx="10" cy="16" r="1.5" />
71
+ </svg>
72
+ </button>
73
+ {menuOpen ? (
74
+ <div className="core-checkout-contact__menu">
75
+ <button type="button" onClick={() => { setMenuOpen(false); logout(); }}>
76
+ Sign out
77
+ </button>
78
+ </div>
79
+ ) : null}
80
+ </div>
81
+ </div>
82
+ </section>
83
+ );
84
+ }
@@ -0,0 +1,177 @@
1
+ 'use client';
2
+ import Link from 'next/link';
3
+ import type { CheckoutShellProps } from '../../types/theme';
4
+ import { formatMoney } from '../../utils/format';
5
+ import { useHref } from '../../hooks/use-href';
6
+ import { CheckoutContactSection } from './checkout-contact';
7
+ import { PolicyLinks } from './policy-links';
8
+
9
+ export function DefaultCheckoutShell({
10
+ cartItems,
11
+ deliveryModes,
12
+ addressPicker,
13
+ deliverySourcePicker,
14
+ shippingZonePicker,
15
+ schedulePicker,
16
+ paymentPicker,
17
+ promoInput,
18
+ noteInputs,
19
+ feesBreakdown,
20
+ deliveryMessage,
21
+ submitButton,
22
+ activeTab,
23
+ onTabChange,
24
+ totalCount,
25
+ subtotal,
26
+ currency,
27
+ mode = 'panel',
28
+ policies,
29
+ }: CheckoutShellProps): JSX.Element {
30
+ const href = useHref();
31
+
32
+ if (mode === 'page') {
33
+ if (totalCount === 0) {
34
+ return (
35
+ <div className="core-checkout core-checkout--page">
36
+ <div className="core-checkout__empty">
37
+ <p>Your cart is empty.</p>
38
+ <Link href={href('/')} className="core-checkout__back-link">Browse products</Link>
39
+ </div>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ return (
45
+ <div className="core-checkout core-checkout--page">
46
+ <div className="core-checkout__page-grid">
47
+ {/* Left pane: white — form */}
48
+ <div className="core-checkout__page-form">
49
+ <div className="core-checkout__page-header">
50
+ <Link href={href('/')} className="core-checkout__back">
51
+ <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" width="14" height="14">
52
+ <path d="M10 13L5 8l5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
53
+ </svg>
54
+ Continue shopping
55
+ </Link>
56
+ </div>
57
+ <CheckoutContactSection />
58
+ <section className="core-checkout__section">
59
+ <h3 className="core-checkout__section-title">How you&apos;ll get it</h3>
60
+ {deliveryModes}
61
+ </section>
62
+ {shippingZonePicker ? (
63
+ <section className="core-checkout__section">
64
+ <h3 className="core-checkout__section-title">Shipping zone</h3>
65
+ {shippingZonePicker}
66
+ </section>
67
+ ) : null}
68
+ {addressPicker ? (
69
+ <section className="core-checkout__section">
70
+ <h3 className="core-checkout__section-title">Delivery address</h3>
71
+ {addressPicker}
72
+ </section>
73
+ ) : null}
74
+ {deliverySourcePicker ? (
75
+ <section className="core-checkout__section">
76
+ {deliverySourcePicker}
77
+ </section>
78
+ ) : null}
79
+ {schedulePicker ? (
80
+ <section className="core-checkout__section">
81
+ <h3 className="core-checkout__section-title">Schedule</h3>
82
+ {schedulePicker}
83
+ </section>
84
+ ) : null}
85
+ <section className="core-checkout__section">
86
+ <h3 className="core-checkout__payment-title">Payment</h3>
87
+ <p className="core-checkout__payment-subtitle">All transactions are secure and encrypted.</p>
88
+ {paymentPicker}
89
+ </section>
90
+ <section className="core-checkout__section">
91
+ {noteInputs}
92
+ </section>
93
+ <PolicyLinks policies={policies} />
94
+ </div>
95
+
96
+ {/* Right pane: off-white — order summary */}
97
+ <div className="core-checkout__page-summary">
98
+ <div className="core-checkout__summary-card">
99
+ <h3 className="core-checkout__section-title">Order summary</h3>
100
+ {cartItems}
101
+ <div className="core-checkout__summary-promo">{promoInput}</div>
102
+ <div className="core-checkout__summary-fees">{feesBreakdown}</div>
103
+ {deliveryMessage ? (
104
+ <div className={`core-checkout__delivery-message core-checkout__delivery-message--${deliveryMessage.type}`}>
105
+ {deliveryMessage.message}
106
+ </div>
107
+ ) : null}
108
+ <div className="core-checkout__actions">{submitButton}</div>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ // Panel / modal mode (original tab behavior)
117
+ return (
118
+ <div className="core-checkout">
119
+ <div className="core-checkout__tabs">
120
+ <button
121
+ type="button"
122
+ className={`core-checkout__tab${activeTab === 'summary' ? ' is-active' : ''}`}
123
+ onClick={() => onTabChange('summary')}
124
+ >
125
+ Summary {totalCount > 0 ? `(${totalCount})` : ''}
126
+ </button>
127
+ <button
128
+ type="button"
129
+ className={`core-checkout__tab${activeTab === 'checkout' ? ' is-active' : ''}`}
130
+ onClick={() => onTabChange('checkout')}
131
+ disabled={totalCount === 0}
132
+ >
133
+ Checkout
134
+ </button>
135
+ </div>
136
+
137
+ {activeTab === 'summary' ? (
138
+ <div className="core-checkout__summary">
139
+ {totalCount === 0 ? (
140
+ <div className="core-checkout__empty"><p>Your cart is empty</p></div>
141
+ ) : (
142
+ <>
143
+ {cartItems}
144
+ <div className="core-checkout__summary-footer">
145
+ <div className="core-checkout__subtotal">
146
+ <span>Subtotal</span>
147
+ <span>{formatMoney(subtotal, currency)}</span>
148
+ </div>
149
+ <button type="button" className="core-submit-btn" onClick={() => onTabChange('checkout')}>
150
+ Proceed to checkout
151
+ </button>
152
+ </div>
153
+ </>
154
+ )}
155
+ </div>
156
+ ) : (
157
+ <div className="core-checkout__form">
158
+ <section className="core-checkout__section">{deliveryModes}</section>
159
+ {shippingZonePicker ? <section className="core-checkout__section">{shippingZonePicker}</section> : null}
160
+ {addressPicker ? <section className="core-checkout__section">{addressPicker}</section> : null}
161
+ {deliverySourcePicker ? <section className="core-checkout__section">{deliverySourcePicker}</section> : null}
162
+ {schedulePicker ? <section className="core-checkout__section">{schedulePicker}</section> : null}
163
+ <section className="core-checkout__section">{paymentPicker}</section>
164
+ <section className="core-checkout__section">{noteInputs}</section>
165
+ <section className="core-checkout__section">{promoInput}</section>
166
+ <section className="core-checkout__section">{feesBreakdown}</section>
167
+ {deliveryMessage ? (
168
+ <div className={`core-checkout__delivery-message core-checkout__delivery-message--${deliveryMessage.type}`}>
169
+ {deliveryMessage.message}
170
+ </div>
171
+ ) : null}
172
+ <div className="core-checkout__actions">{submitButton}</div>
173
+ </div>
174
+ )}
175
+ </div>
176
+ );
177
+ }
@@ -0,0 +1,98 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useSyncExternalStore } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import Link from 'next/link';
6
+ import type { Page } from '../../types/page';
7
+ import { useHref } from '../../hooks/use-href';
8
+ import { usePolicyModalStore } from '../../stores/policy-modal-store';
9
+ import { PageRenderer } from '../../page-renderer';
10
+
11
+ /**
12
+ * Checkout-footer policy links (refund, shipping, privacy, terms — whatever
13
+ * the vendor has published). Clicking opens the policy content inline in a
14
+ * modal instead of navigating away from checkout; the modal still links to
15
+ * the real `/[vendor]/[slug]` page for the full read.
16
+ */
17
+ // SSR-safe "has this hydrated on the client yet" check for the portal target
18
+ // (document.body doesn't exist during SSR) — no subscription needed, the
19
+ // snapshot just flips once from the server render to the client render.
20
+ function subscribeNoop(): () => void {
21
+ return () => {};
22
+ }
23
+
24
+ export function PolicyLinks({ policies }: { policies: Page[] }): JSX.Element | null {
25
+ const activeSlug = usePolicyModalStore((s) => s.activeSlug);
26
+ const open = usePolicyModalStore((s) => s.open);
27
+ const close = usePolicyModalStore((s) => s.close);
28
+ const href = useHref();
29
+ const mounted = useSyncExternalStore(subscribeNoop, () => true, () => false);
30
+
31
+ const active = policies.find((p) => p.slug === activeSlug) ?? null;
32
+
33
+ useEffect(() => {
34
+ if (!active) return;
35
+ const onKey = (event: KeyboardEvent): void => {
36
+ if (event.key === 'Escape') close();
37
+ };
38
+ document.addEventListener('keydown', onKey);
39
+ return () => document.removeEventListener('keydown', onKey);
40
+ }, [active, close]);
41
+
42
+ if (policies.length === 0) return null;
43
+
44
+ // Portaled to document.body — this component renders inline among the
45
+ // checkout form's sections (width-capped by `.core-checkout__page-form > *`),
46
+ // and a plain nested overlay would inherit that constraint instead of
47
+ // covering the viewport. A portal makes the modal immune to whatever
48
+ // ancestor layout the page around it does, now or later.
49
+ const modal = active && mounted
50
+ ? createPortal(
51
+ <div className="core-policy-modal__overlay" role="presentation" onClick={close}>
52
+ <div
53
+ className="core-policy-modal"
54
+ role="dialog"
55
+ aria-modal="true"
56
+ aria-label={active.title}
57
+ onClick={(event) => event.stopPropagation()}
58
+ >
59
+ <div className="core-policy-modal__head">
60
+ <h2 className="core-policy-modal__title">{active.title}</h2>
61
+ <button type="button" className="core-policy-modal__close" aria-label="Close" onClick={close}>
62
+ <svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
63
+ <path d="M5 5l10 10M15 5L5 15" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
64
+ </svg>
65
+ </button>
66
+ </div>
67
+ <div className="core-policy-modal__body">
68
+ <PageRenderer blocks={active.content} />
69
+ </div>
70
+ <div className="core-policy-modal__foot">
71
+ <Link href={href(`/${active.slug}`)} className="core-policy-modal__full-link" onClick={close}>
72
+ View full page
73
+ </Link>
74
+ </div>
75
+ </div>
76
+ </div>,
77
+ document.body,
78
+ )
79
+ : null;
80
+
81
+ return (
82
+ <>
83
+ <nav className="core-checkout-policies" aria-label="Store policies">
84
+ {policies.map((policy) => (
85
+ <button
86
+ key={policy.id}
87
+ type="button"
88
+ className="core-checkout-policies__link"
89
+ onClick={() => open(policy.slug)}
90
+ >
91
+ {policy.title}
92
+ </button>
93
+ ))}
94
+ </nav>
95
+ {modal}
96
+ </>
97
+ );
98
+ }