@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,436 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { ReviewItem, ReviewsBlockData, ReviewsSummary } from '../types/block';
|
|
5
|
+
import { Image } from '../components/image';
|
|
6
|
+
import { useReviews } from '../hooks/use-reviews';
|
|
7
|
+
import { useStorefront } from '../provider';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Framework-owned reviews block. Renders verified, platform-generated reviews
|
|
11
|
+
* fetched from `GET /client/store/reviews`. Themes style via `.core-block-reviews`
|
|
12
|
+
* and `.core-block-reviews--{layout}`.
|
|
13
|
+
*/
|
|
14
|
+
export function CoreReviewsBlock(props: ReviewsBlockData): JSX.Element {
|
|
15
|
+
const layout = props.layout ?? 'grid';
|
|
16
|
+
const { reviews, summary, isLoading } = useReviews(props);
|
|
17
|
+
const title = props.title;
|
|
18
|
+
|
|
19
|
+
const hasReviews = reviews.length > 0;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<section
|
|
23
|
+
className={`core-block core-block-reviews core-block-reviews--${layout}`}
|
|
24
|
+
data-loading={isLoading ? 'true' : undefined}
|
|
25
|
+
>
|
|
26
|
+
{(title || summary.total_reviews > 0) && (
|
|
27
|
+
<header className="core-block-reviews__header">
|
|
28
|
+
{title ? <h2 className="core-block-reviews__title">{title}</h2> : null}
|
|
29
|
+
{summary.total_reviews > 0 && (
|
|
30
|
+
<div className="core-block-reviews__summary" aria-label="Average rating">
|
|
31
|
+
<Stars rating={summary.average_rating} />
|
|
32
|
+
<span className="core-block-reviews__summary-text">
|
|
33
|
+
<strong>{summary.average_rating.toFixed(1)}</strong>
|
|
34
|
+
<span>·</span>
|
|
35
|
+
<span>{summary.total_reviews} review{summary.total_reviews === 1 ? '' : 's'}</span>
|
|
36
|
+
</span>
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
</header>
|
|
40
|
+
)}
|
|
41
|
+
|
|
42
|
+
{!hasReviews && !isLoading && (
|
|
43
|
+
<p className="core-block-reviews__empty">No reviews yet.</p>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
{hasReviews && layout === 'compact' && <CompactList reviews={reviews} summary={summary} />}
|
|
47
|
+
{hasReviews && layout === 'carousel' && <ReviewsCarousel reviews={reviews} />}
|
|
48
|
+
{hasReviews && layout === 'spotlight' && <SpotlightReviews reviews={reviews} />}
|
|
49
|
+
{hasReviews && layout === 'minimal' && <MinimalList reviews={reviews} />}
|
|
50
|
+
{hasReviews && layout !== 'compact' && layout !== 'carousel' && layout !== 'spotlight' && layout !== 'minimal' && (
|
|
51
|
+
<div className={`core-block-reviews__list core-block-reviews__list--${layout}`}>
|
|
52
|
+
{reviews.map((review) => (
|
|
53
|
+
<ReviewCard key={review.id} review={review} />
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
</section>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Carousel layout with prev/next controls. The list keeps its native
|
|
63
|
+
* scroll-snap (touch swipe still works); the arrows scroll by one card width
|
|
64
|
+
* for pointer users. Arrows are hidden on small screens (swipe instead) AND
|
|
65
|
+
* whenever the list doesn't actually overflow its container — a handful of
|
|
66
|
+
* reviews that already fit in full has nothing to scroll to, so showing
|
|
67
|
+
* "scroll" affordances there is just decorative noise.
|
|
68
|
+
*/
|
|
69
|
+
function ReviewsCarousel({ reviews }: { reviews: ReviewItem[] }): JSX.Element {
|
|
70
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
71
|
+
const [canScroll, setCanScroll] = useState({ prev: false, next: false });
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const el = listRef.current;
|
|
75
|
+
if (!el) return;
|
|
76
|
+
|
|
77
|
+
const update = (): void => {
|
|
78
|
+
const overflows = el.scrollWidth - el.clientWidth > 4;
|
|
79
|
+
setCanScroll({
|
|
80
|
+
prev: overflows && el.scrollLeft > 4,
|
|
81
|
+
next: overflows && el.scrollLeft < el.scrollWidth - el.clientWidth - 4,
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
update();
|
|
86
|
+
el.addEventListener('scroll', update, { passive: true });
|
|
87
|
+
|
|
88
|
+
const ro = new ResizeObserver(update);
|
|
89
|
+
ro.observe(el);
|
|
90
|
+
|
|
91
|
+
return () => {
|
|
92
|
+
el.removeEventListener('scroll', update);
|
|
93
|
+
ro.disconnect();
|
|
94
|
+
};
|
|
95
|
+
}, [reviews]);
|
|
96
|
+
|
|
97
|
+
const scrollByCards = (dir: number): void => {
|
|
98
|
+
const el = listRef.current;
|
|
99
|
+
if (!el) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const card = el.querySelector<HTMLElement>('.core-block-reviews__card');
|
|
103
|
+
const amount = card ? card.offsetWidth + 16 : el.clientWidth * 0.8;
|
|
104
|
+
el.scrollBy({ left: dir * amount, behavior: 'smooth' });
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="core-block-reviews__carousel">
|
|
109
|
+
{canScroll.prev ? (
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
className="core-block-reviews__nav core-block-reviews__nav--prev"
|
|
113
|
+
onClick={() => scrollByCards(-1)}
|
|
114
|
+
aria-label="Previous reviews"
|
|
115
|
+
>
|
|
116
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
117
|
+
<path d="M15 18l-6-6 6-6" />
|
|
118
|
+
</svg>
|
|
119
|
+
</button>
|
|
120
|
+
) : null}
|
|
121
|
+
<div ref={listRef} className="core-block-reviews__list core-block-reviews__list--carousel">
|
|
122
|
+
{reviews.map((review) => (
|
|
123
|
+
<ReviewCard key={review.id} review={review} />
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
{canScroll.next ? (
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className="core-block-reviews__nav core-block-reviews__nav--next"
|
|
130
|
+
onClick={() => scrollByCards(1)}
|
|
131
|
+
aria-label="Next reviews"
|
|
132
|
+
>
|
|
133
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
134
|
+
<path d="M9 18l6-6-6-6" />
|
|
135
|
+
</svg>
|
|
136
|
+
</button>
|
|
137
|
+
) : null}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Spotlight layout — one quote at a time, large and editorial. A genuinely
|
|
144
|
+
* different visual language from the card grid (no card chrome, one voice
|
|
145
|
+
* at a time), not just the same card rearranged.
|
|
146
|
+
*/
|
|
147
|
+
function SpotlightReviews({ reviews }: { reviews: ReviewItem[] }): JSX.Element {
|
|
148
|
+
const [index, setIndex] = useState(0);
|
|
149
|
+
const active = reviews[index];
|
|
150
|
+
const canNavigate = reviews.length > 1;
|
|
151
|
+
|
|
152
|
+
const go = (dir: number): void => {
|
|
153
|
+
setIndex((i) => (i + dir + reviews.length) % reviews.length);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div className="core-block-reviews__spotlight">
|
|
158
|
+
<div className="core-block-reviews__spotlight-row">
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
className="core-block-reviews__spotlight-arrow core-block-reviews__spotlight-arrow--prev"
|
|
162
|
+
onClick={() => go(-1)}
|
|
163
|
+
aria-label="Previous review"
|
|
164
|
+
hidden={!canNavigate}
|
|
165
|
+
>
|
|
166
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
167
|
+
<path d="M15 18l-6-6 6-6" />
|
|
168
|
+
</svg>
|
|
169
|
+
</button>
|
|
170
|
+
|
|
171
|
+
<div className="core-block-reviews__spotlight-stage">
|
|
172
|
+
<blockquote key={active.id} className="core-block-reviews__spotlight-slide">
|
|
173
|
+
<span className="core-block-reviews__spotlight-glyph" aria-hidden="true">“</span>
|
|
174
|
+
<Stars rating={active.rating} />
|
|
175
|
+
{active.title ? <p className="core-block-reviews__spotlight-title">{active.title}</p> : null}
|
|
176
|
+
{active.description ? (
|
|
177
|
+
<p className="core-block-reviews__spotlight-body">{active.description}</p>
|
|
178
|
+
) : null}
|
|
179
|
+
<footer className="core-block-reviews__spotlight-footer">
|
|
180
|
+
<div className="core-block-reviews__avatar">
|
|
181
|
+
{active.user.avatar ? (
|
|
182
|
+
<Image src={active.user.avatar} alt={active.user.name} className="core-block-reviews__avatar-img" />
|
|
183
|
+
) : (
|
|
184
|
+
<span className="core-block-reviews__avatar-initials" aria-hidden="true">
|
|
185
|
+
{initialsFor(active.user.name)}
|
|
186
|
+
</span>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
<div className="core-block-reviews__spotlight-meta">
|
|
190
|
+
<p className="core-block-reviews__author">{active.user.name}</p>
|
|
191
|
+
{active.is_verified_purchase ? (
|
|
192
|
+
<span className="core-block-reviews__pill core-block-reviews__pill--verified">
|
|
193
|
+
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
|
194
|
+
<path d="M5 8.5 2.5 6l-.7.7L5 9.9l6.7-6.7-.7-.7z" fill="currentColor" />
|
|
195
|
+
</svg>
|
|
196
|
+
Verified buyer
|
|
197
|
+
</span>
|
|
198
|
+
) : null}
|
|
199
|
+
</div>
|
|
200
|
+
</footer>
|
|
201
|
+
</blockquote>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
className="core-block-reviews__spotlight-arrow core-block-reviews__spotlight-arrow--next"
|
|
207
|
+
onClick={() => go(1)}
|
|
208
|
+
aria-label="Next review"
|
|
209
|
+
hidden={!canNavigate}
|
|
210
|
+
>
|
|
211
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
212
|
+
<path d="M9 18l6-6-6-6" />
|
|
213
|
+
</svg>
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{canNavigate ? (
|
|
218
|
+
<div className="core-block-reviews__spotlight-dots" role="tablist" aria-label="Choose a review">
|
|
219
|
+
{reviews.map((r, i) => (
|
|
220
|
+
<button
|
|
221
|
+
key={r.id}
|
|
222
|
+
type="button"
|
|
223
|
+
role="tab"
|
|
224
|
+
aria-selected={i === index}
|
|
225
|
+
aria-label={`Review ${i + 1} of ${reviews.length}`}
|
|
226
|
+
className={`core-block-reviews__spotlight-dot${i === index ? ' is-active' : ''}`}
|
|
227
|
+
onClick={() => setIndex(i)}
|
|
228
|
+
/>
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
) : null}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Minimal layout — no card chrome at all, a tight text-forward list divided
|
|
238
|
+
* by hairlines. For brands whose whole language is restraint (industrial,
|
|
239
|
+
* quiet-luxury) where card backgrounds/shadows would read as noise.
|
|
240
|
+
*/
|
|
241
|
+
function MinimalList({ reviews }: { reviews: ReviewItem[] }): JSX.Element {
|
|
242
|
+
return (
|
|
243
|
+
<ul className="core-block-reviews__minimal">
|
|
244
|
+
{reviews.map((review) => (
|
|
245
|
+
<li key={review.id} className="core-block-reviews__minimal-item">
|
|
246
|
+
<div className="core-block-reviews__minimal-head">
|
|
247
|
+
<Stars rating={review.rating} small />
|
|
248
|
+
<p className="core-block-reviews__author">{review.user.name}</p>
|
|
249
|
+
<p className="core-block-reviews__date">{formatDate(review.created_at)}</p>
|
|
250
|
+
{review.is_verified_purchase ? (
|
|
251
|
+
<span className="core-block-reviews__pill core-block-reviews__pill--verified">Verified buyer</span>
|
|
252
|
+
) : null}
|
|
253
|
+
</div>
|
|
254
|
+
{review.title ? <p className="core-block-reviews__minimal-title">{review.title}</p> : null}
|
|
255
|
+
{review.description ? (
|
|
256
|
+
<p className="core-block-reviews__minimal-body">{review.description}</p>
|
|
257
|
+
) : null}
|
|
258
|
+
</li>
|
|
259
|
+
))}
|
|
260
|
+
</ul>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function ReviewCard({ review }: { review: ReviewItem }): JSX.Element {
|
|
265
|
+
return (
|
|
266
|
+
<article className="core-block-reviews__card">
|
|
267
|
+
<header className="core-block-reviews__card-head">
|
|
268
|
+
<div className="core-block-reviews__avatar">
|
|
269
|
+
{review.user.avatar ? (
|
|
270
|
+
<Image src={review.user.avatar} alt={review.user.name} className="core-block-reviews__avatar-img" />
|
|
271
|
+
) : (
|
|
272
|
+
<span className="core-block-reviews__avatar-initials" aria-hidden="true">
|
|
273
|
+
{initialsFor(review.user.name)}
|
|
274
|
+
</span>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
<div className="core-block-reviews__card-meta">
|
|
278
|
+
<p className="core-block-reviews__author">{review.user.name}</p>
|
|
279
|
+
<p className="core-block-reviews__date">{formatDate(review.created_at)}</p>
|
|
280
|
+
</div>
|
|
281
|
+
<Stars rating={review.rating} />
|
|
282
|
+
</header>
|
|
283
|
+
|
|
284
|
+
{review.title ? <h3 className="core-block-reviews__card-title">{review.title}</h3> : null}
|
|
285
|
+
{review.description ? <p className="core-block-reviews__card-body">{review.description}</p> : null}
|
|
286
|
+
|
|
287
|
+
{review.media && review.media.length > 0 && (
|
|
288
|
+
<div className="core-block-reviews__media">
|
|
289
|
+
{review.media.map((m, i) =>
|
|
290
|
+
m.type === 'image' ? (
|
|
291
|
+
<Image
|
|
292
|
+
key={i}
|
|
293
|
+
src={m.url}
|
|
294
|
+
alt={review.title ?? review.user.name}
|
|
295
|
+
className="core-block-reviews__media-img"
|
|
296
|
+
/>
|
|
297
|
+
) : (
|
|
298
|
+
<video key={i} src={m.url} className="core-block-reviews__media-video" controls />
|
|
299
|
+
),
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
|
|
304
|
+
{review.vendor_response ? <VendorReply review={review} /> : null}
|
|
305
|
+
|
|
306
|
+
<footer className="core-block-reviews__card-foot">
|
|
307
|
+
{review.is_verified_purchase ? (
|
|
308
|
+
<span className="core-block-reviews__pill core-block-reviews__pill--verified">
|
|
309
|
+
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
|
310
|
+
<path
|
|
311
|
+
d="M5 8.5 2.5 6l-.7.7L5 9.9l6.7-6.7-.7-.7z"
|
|
312
|
+
fill="currentColor"
|
|
313
|
+
/>
|
|
314
|
+
</svg>
|
|
315
|
+
Verified buyer
|
|
316
|
+
</span>
|
|
317
|
+
) : null}
|
|
318
|
+
{review.product ? (
|
|
319
|
+
<span className="core-block-reviews__product">
|
|
320
|
+
{review.product.thumbnail ? (
|
|
321
|
+
<Image
|
|
322
|
+
src={review.product.thumbnail}
|
|
323
|
+
alt={review.product.title}
|
|
324
|
+
className="core-block-reviews__product-thumb"
|
|
325
|
+
/>
|
|
326
|
+
) : null}
|
|
327
|
+
<span className="core-block-reviews__product-title">{review.product.title}</span>
|
|
328
|
+
</span>
|
|
329
|
+
) : null}
|
|
330
|
+
</footer>
|
|
331
|
+
</article>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function VendorReply({ review }: { review: ReviewItem }): JSX.Element {
|
|
336
|
+
const { vendor } = useStorefront();
|
|
337
|
+
return (
|
|
338
|
+
<div className="core-block-reviews__reply">
|
|
339
|
+
<p className="core-block-reviews__reply-label">Reply from {vendor.name ?? 'the vendor'}</p>
|
|
340
|
+
<p className="core-block-reviews__reply-body">{review.vendor_response}</p>
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function CompactList({
|
|
346
|
+
reviews,
|
|
347
|
+
summary,
|
|
348
|
+
}: {
|
|
349
|
+
reviews: ReviewItem[];
|
|
350
|
+
summary: ReviewsSummary;
|
|
351
|
+
}): JSX.Element {
|
|
352
|
+
const total = summary.total_reviews || reviews.length;
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<div className="core-block-reviews__compact">
|
|
356
|
+
<div className="core-block-reviews__compact-breakdown">
|
|
357
|
+
{[5, 4, 3, 2, 1].map((star) => {
|
|
358
|
+
const count = summary.rating_breakdown?.[String(star)] ?? 0;
|
|
359
|
+
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
|
360
|
+
return (
|
|
361
|
+
<div key={star} className="core-block-reviews__compact-row">
|
|
362
|
+
<span className="core-block-reviews__compact-label">{star}★</span>
|
|
363
|
+
<span className="core-block-reviews__compact-bar">
|
|
364
|
+
<span className="core-block-reviews__compact-fill" style={{ width: `${pct}%` }} />
|
|
365
|
+
</span>
|
|
366
|
+
<span className="core-block-reviews__compact-count">{count}</span>
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
})}
|
|
370
|
+
</div>
|
|
371
|
+
<ul className="core-block-reviews__compact-list">
|
|
372
|
+
{reviews.map((review) => (
|
|
373
|
+
<li key={review.id} className="core-block-reviews__compact-item">
|
|
374
|
+
<div className="core-block-reviews__compact-head">
|
|
375
|
+
<Stars rating={review.rating} small />
|
|
376
|
+
<span className="core-block-reviews__compact-author">{review.user.name}</span>
|
|
377
|
+
{review.is_verified_purchase ? (
|
|
378
|
+
<span className="core-block-reviews__pill core-block-reviews__pill--verified">Verified</span>
|
|
379
|
+
) : null}
|
|
380
|
+
</div>
|
|
381
|
+
{review.title ? <p className="core-block-reviews__compact-title">{review.title}</p> : null}
|
|
382
|
+
{review.description ? (
|
|
383
|
+
<p className="core-block-reviews__compact-body">{review.description}</p>
|
|
384
|
+
) : null}
|
|
385
|
+
{review.vendor_response ? <VendorReply review={review} /> : null}
|
|
386
|
+
</li>
|
|
387
|
+
))}
|
|
388
|
+
</ul>
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function Stars({ rating, small }: { rating: number; small?: boolean }): JSX.Element {
|
|
394
|
+
const rounded = Math.round(rating);
|
|
395
|
+
return (
|
|
396
|
+
<span
|
|
397
|
+
className={`core-block-reviews__stars${small ? ' core-block-reviews__stars--sm' : ''}`}
|
|
398
|
+
aria-label={`${rating} out of 5 stars`}
|
|
399
|
+
role="img"
|
|
400
|
+
>
|
|
401
|
+
{[1, 2, 3, 4, 5].map((i) => (
|
|
402
|
+
<svg
|
|
403
|
+
key={i}
|
|
404
|
+
viewBox="0 0 20 20"
|
|
405
|
+
className="core-block-reviews__star"
|
|
406
|
+
data-filled={i <= rounded ? 'true' : 'false'}
|
|
407
|
+
aria-hidden="true"
|
|
408
|
+
>
|
|
409
|
+
<path d="M10 1.5l2.6 5.4 5.9.8-4.3 4.1 1 5.9L10 14.9 4.8 17.7l1-5.9L1.5 7.7l5.9-.8z" />
|
|
410
|
+
</svg>
|
|
411
|
+
))}
|
|
412
|
+
</span>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function initialsFor(name: string): string {
|
|
417
|
+
return name
|
|
418
|
+
.split(/\s+/)
|
|
419
|
+
.map((part) => part.charAt(0))
|
|
420
|
+
.filter(Boolean)
|
|
421
|
+
.slice(0, 2)
|
|
422
|
+
.join('')
|
|
423
|
+
.toUpperCase();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function formatDate(iso: string): string {
|
|
427
|
+
try {
|
|
428
|
+
return new Date(iso).toLocaleDateString(undefined, {
|
|
429
|
+
month: 'short',
|
|
430
|
+
day: 'numeric',
|
|
431
|
+
year: 'numeric',
|
|
432
|
+
});
|
|
433
|
+
} catch {
|
|
434
|
+
return '';
|
|
435
|
+
}
|
|
436
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { TableBlockData } from '../types/block';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Framework-owned table block. Semantic HTML table with theme-scoped styling.
|
|
7
|
+
* Themes style via `.core-block-table` + sub-elements.
|
|
8
|
+
*/
|
|
9
|
+
export function CoreTableBlock({ headers, rows }: TableBlockData): JSX.Element | null {
|
|
10
|
+
const safeHeaders = Array.isArray(headers) ? headers : [];
|
|
11
|
+
const safeRows = Array.isArray(rows) ? rows : [];
|
|
12
|
+
|
|
13
|
+
if (safeHeaders.length === 0 && safeRows.length === 0) return null;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<section className="core-block core-block-table">
|
|
17
|
+
<div className="core-block-table__wrap">
|
|
18
|
+
<table className="core-block-table__table">
|
|
19
|
+
{safeHeaders.length > 0 ? (
|
|
20
|
+
<thead className="core-block-table__head">
|
|
21
|
+
<tr>
|
|
22
|
+
{safeHeaders.map((header, i) => (
|
|
23
|
+
<th key={i} className="core-block-table__th">
|
|
24
|
+
{header}
|
|
25
|
+
</th>
|
|
26
|
+
))}
|
|
27
|
+
</tr>
|
|
28
|
+
</thead>
|
|
29
|
+
) : null}
|
|
30
|
+
<tbody className="core-block-table__body">
|
|
31
|
+
{safeRows.map((row, rowIndex) => (
|
|
32
|
+
<tr key={rowIndex} className="core-block-table__row">
|
|
33
|
+
{(Array.isArray(row) ? row : []).map((cell, cellIndex) => (
|
|
34
|
+
<td key={cellIndex} className="core-block-table__cell">
|
|
35
|
+
{cell}
|
|
36
|
+
</td>
|
|
37
|
+
))}
|
|
38
|
+
</tr>
|
|
39
|
+
))}
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
</div>
|
|
43
|
+
</section>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { VideoBlockData } from '../types/block';
|
|
4
|
+
import { resolveVideoEmbedUrl } from '../utils/video-embed';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Framework-owned video block. Detects YouTube, Vimeo, or direct video file
|
|
8
|
+
* URLs and renders the appropriate embed. Themes style via `.core-block-video`.
|
|
9
|
+
*/
|
|
10
|
+
export function CoreVideoBlock({ url }: VideoBlockData): JSX.Element {
|
|
11
|
+
if (!url) {
|
|
12
|
+
return (
|
|
13
|
+
<section className="core-block core-block-video">
|
|
14
|
+
<div className="core-block-video__wrap core-block-video__wrap--empty">
|
|
15
|
+
<div className="core-block-video__placeholder">
|
|
16
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
17
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
18
|
+
</svg>
|
|
19
|
+
<span>Add a video URL</span>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</section>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const embedUrl = resolveVideoEmbedUrl(url);
|
|
27
|
+
|
|
28
|
+
if (embedUrl) {
|
|
29
|
+
return (
|
|
30
|
+
<section className="core-block core-block-video">
|
|
31
|
+
<div className="core-block-video__wrap">
|
|
32
|
+
<iframe
|
|
33
|
+
className="core-block-video__frame"
|
|
34
|
+
src={embedUrl}
|
|
35
|
+
title="Video"
|
|
36
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
37
|
+
allowFullScreen
|
|
38
|
+
loading="lazy"
|
|
39
|
+
referrerPolicy="strict-origin-when-cross-origin"
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
</section>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Direct video file — use HTML5 <video>
|
|
47
|
+
return (
|
|
48
|
+
<section className="core-block core-block-video">
|
|
49
|
+
<div className="core-block-video__wrap">
|
|
50
|
+
<video
|
|
51
|
+
className="core-block-video__el"
|
|
52
|
+
src={url}
|
|
53
|
+
controls
|
|
54
|
+
preload="metadata"
|
|
55
|
+
playsInline
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
</section>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
|
|
5
|
+
interface AddressModalState {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
open: () => void;
|
|
8
|
+
close: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const useAddressModalStore = create<AddressModalState>()((set) => ({
|
|
12
|
+
isOpen: false,
|
|
13
|
+
open: () => set({ isOpen: true }),
|
|
14
|
+
close: () => set({ isOpen: false }),
|
|
15
|
+
}));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
|
|
5
|
+
interface AuthModalState {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
mode: 'login' | 'signup';
|
|
8
|
+
open: (mode?: 'login' | 'signup') => void;
|
|
9
|
+
close: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const useAuthModalStore = create<AuthModalState>()((set) => ({
|
|
13
|
+
isOpen: false,
|
|
14
|
+
mode: 'login',
|
|
15
|
+
open: (mode = 'login') => set({ isOpen: true, mode }),
|
|
16
|
+
close: () => set({ isOpen: false }),
|
|
17
|
+
}));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
|
|
5
|
+
interface CartPanelState {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
open: () => void;
|
|
8
|
+
close: () => void;
|
|
9
|
+
toggle: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const useCartPanelStore = create<CartPanelState>()((set) => ({
|
|
13
|
+
isOpen: false,
|
|
14
|
+
open: () => set({ isOpen: true }),
|
|
15
|
+
close: () => set({ isOpen: false }),
|
|
16
|
+
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
|
17
|
+
}));
|