@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.
- package/README.md +37 -0
- package/api/collections.ts +93 -0
- package/api/orders.ts +23 -0
- package/apps/analytics-events.ts +32 -0
- package/apps/apps.css +613 -0
- package/apps/follow-card.tsx +192 -0
- package/apps/gtag.ts +19 -0
- package/apps/subscribe-form.tsx +124 -0
- package/client.ts +52 -0
- package/components/auth/auth-email-login-step.tsx +46 -0
- package/components/auth/auth-email-otp-step.tsx +54 -0
- package/components/auth/auth-email-otp-verify-step.tsx +82 -0
- package/components/auth/auth-email-register-step.tsx +59 -0
- package/components/auth/auth-flow.tsx +26 -0
- package/components/auth/auth-signup-step.tsx +53 -0
- package/components/blog/category-filter.tsx +92 -0
- package/components/blog/index.ts +12 -0
- package/components/blog/pagination.tsx +73 -0
- package/components/blog/post-categories.tsx +42 -0
- package/components/blog/post-meta.tsx +49 -0
- package/components/blog/related-posts.tsx +54 -0
- package/components/blog/share-buttons.tsx +89 -0
- package/components/cart/cart-panel-controller.tsx +67 -0
- package/components/cart/default-cart-shell.tsx +207 -0
- package/components/checkout/checkout-contact.tsx +84 -0
- package/components/checkout/default-checkout-shell.tsx +177 -0
- package/components/checkout/policy-links.tsx +98 -0
- package/components/image.tsx +149 -0
- package/components/markdown.tsx +54 -0
- package/components/product-media-frame.tsx +56 -0
- package/components/variant-picker.css +84 -0
- package/components/variant-picker.tsx +107 -0
- package/design-token-preview.tsx +81 -0
- package/edit-mode-active.tsx +324 -0
- package/edit-mode.tsx +80 -0
- package/hooks/use-address-autocomplete.ts +62 -0
- package/hooks/use-auth-flow.tsx +346 -0
- package/hooks/use-auth.ts +17 -0
- package/hooks/use-cart.ts +14 -0
- package/hooks/use-categories.ts +112 -0
- package/hooks/use-collection-params.ts +67 -0
- package/hooks/use-href.ts +15 -0
- package/hooks/use-posts.ts +75 -0
- package/hooks/use-product-detail.ts +69 -0
- package/hooks/use-product-questions.ts +77 -0
- package/hooks/use-product-sections.ts +165 -0
- package/hooks/use-products.ts +216 -0
- package/hooks/use-related-products.ts +56 -0
- package/hooks/use-resolve-coordinates.ts +48 -0
- package/hooks/use-reviews.ts +167 -0
- package/hooks/use-search.ts +55 -0
- package/hooks/use-shop-cart.ts +31 -0
- package/hooks/use-shop-params.ts +68 -0
- package/hooks/use-shop.ts +186 -0
- package/hooks/use-variant-selection.ts +98 -0
- package/index.ts +49 -0
- package/package.json +41 -0
- package/page-renderer.tsx +365 -0
- package/provider.tsx +91 -0
- package/sdk/client.ts +221 -0
- package/sdk/error-handler.ts +37 -0
- package/sdk/platform.ts +78 -0
- package/sdk/queek-client.ts +104 -0
- package/sdk/reauth-guard.ts +27 -0
- package/sdk/server-store-client.ts +165 -0
- package/shared-blocks/button.tsx +41 -0
- package/shared-blocks/callout.tsx +35 -0
- package/shared-blocks/content-default.tsx +23 -0
- package/shared-blocks/core-blocks.css +1792 -0
- package/shared-blocks/divider.tsx +17 -0
- package/shared-blocks/embed.tsx +40 -0
- package/shared-blocks/faq.tsx +44 -0
- package/shared-blocks/image.tsx +29 -0
- package/shared-blocks/index.ts +24 -0
- package/shared-blocks/manifest.ts +76 -0
- package/shared-blocks/product-qa.tsx +203 -0
- package/shared-blocks/quote.tsx +20 -0
- package/shared-blocks/reviews.tsx +436 -0
- package/shared-blocks/table.tsx +45 -0
- package/shared-blocks/video.tsx +60 -0
- package/stores/address-modal-store.ts +15 -0
- package/stores/auth-modal-store.ts +17 -0
- package/stores/cart-panel-store.ts +17 -0
- package/stores/cart-store.ts +172 -0
- package/stores/checkout-store.ts +167 -0
- package/stores/edit-preview-store.ts +61 -0
- package/stores/location-store.ts +41 -0
- package/stores/orders-sheet-store.ts +21 -0
- package/stores/policy-modal-store.ts +15 -0
- package/stores/product-modal-store.ts +18 -0
- package/stores/search-modal-store.ts +17 -0
- package/stores/toast-store.ts +46 -0
- package/stores/user-store.ts +88 -0
- package/theme-context.tsx +28 -0
- package/theme-variant-resolver.ts +44 -0
- package/types/block.ts +292 -0
- package/types/cart.ts +21 -0
- package/types/media.ts +55 -0
- package/types/menu.ts +27 -0
- package/types/order.ts +119 -0
- package/types/page.ts +86 -0
- package/types/product.ts +279 -0
- package/types/theme.ts +380 -0
- package/types/vendor.ts +243 -0
- package/utils/addon-selection.ts +31 -0
- package/utils/benefit-icon.tsx +145 -0
- package/utils/brand-palette.ts +64 -0
- package/utils/brand.ts +518 -0
- package/utils/contact-location.ts +44 -0
- package/utils/format.ts +64 -0
- package/utils/hero-content.ts +35 -0
- package/utils/image-srcset.ts +157 -0
- package/utils/markdown.tsx +237 -0
- package/utils/match-variant.ts +31 -0
- package/utils/menu-link.ts +41 -0
- package/utils/placeholder-resolver.ts +184 -0
- package/utils/product-media.ts +77 -0
- package/utils/product-normalizer.ts +323 -0
- package/utils/vendor-host.ts +173 -0
- 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'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
|
+
}
|