@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,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">&#8220;</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
+ }));