create-brainerce-store 1.50.1 → 1.51.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/dist/index.js +171 -66
- package/messages/en.json +4 -1
- package/messages/he.json +4 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +51 -0
- package/templates/nextjs/base/.env.local.ejs +5 -0
- package/templates/nextjs/base/.eslintrc.json +57 -3
- package/templates/nextjs/base/AGENTS.md +41 -0
- package/templates/nextjs/base/AI-GUIDE.md +94 -0
- package/templates/nextjs/base/CLAUDE.md +42 -0
- package/templates/nextjs/base/TRANSLATIONS.md +1 -1
- package/templates/nextjs/base/next.config.ts +18 -10
- package/templates/nextjs/base/package.json.ejs +5 -1
- package/templates/nextjs/base/src/app/account/page.tsx +5 -5
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
- package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
- package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
- package/templates/nextjs/base/src/app/layout.tsx.ejs +16 -14
- package/templates/nextjs/base/src/app/login/page.tsx +4 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
- package/templates/nextjs/base/src/app/page.tsx +4 -4
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
- package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
- package/templates/nextjs/base/src/app/products/page.tsx +4 -623
- package/templates/nextjs/base/src/app/register/page.tsx +4 -4
- package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
- package/templates/nextjs/base/src/app/sitemap.ts +1 -1
- package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
- package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
- package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
- package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
- package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
- package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
- package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
- package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
- package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +6 -6
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -0
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
- package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
- package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -0
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
- package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +12 -0
- package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
- package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
- package/templates/nextjs/base/src/middleware.ts.ejs +2 -2
- package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +5 -5
- package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +6 -6
- package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +1 -1
- package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +5 -5
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +149 -0
- package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +2 -2
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +29 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/faq-section.tsx.ejs +2 -2
- package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +3 -3
- package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
- package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +1 -1
- package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +18 -51
- package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +1 -1
- package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +6 -6
- package/templates/nextjs/base/src/{components/products → ui/product}/modifier-group-selector.tsx +10 -77
- package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +2 -2
- package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +10 -10
- package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +45 -206
- package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +429 -0
- package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +4 -4
- package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +2 -2
- package/templates/nextjs/base/src/{components/reviews → ui/product}/reviews-section.tsx +1 -1
- package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +2 -2
- package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
- package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -0
- package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
- package/templates/nextjs/base/tailwind.config.ts +12 -0
- package/templates/nextjs/designs/passepartout/app-overlay/layout.tsx.ejs +253 -0
- package/templates/nextjs/designs/passepartout/globals.css +412 -0
- package/templates/nextjs/designs/passepartout/messages-patch/en.json +87 -0
- package/templates/nextjs/designs/passepartout/messages-patch/he.json +87 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-bundle-offer.tsx +111 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +39 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-upgrade-banner.tsx +141 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +149 -0
- package/templates/nextjs/designs/passepartout/ui/cart/free-shipping-bar.tsx +62 -0
- package/templates/nextjs/designs/passepartout/ui/cart/reservation-countdown.tsx +103 -0
- package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +401 -0
- package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +139 -0
- package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +45 -0
- package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +103 -0
- package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +159 -0
- package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +155 -0
- package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +60 -0
- package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +34 -0
- package/templates/nextjs/designs/passepartout/ui/product/discount-badge.tsx +22 -0
- package/templates/nextjs/designs/passepartout/ui/product/frequently-bought-together.tsx +205 -0
- package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +111 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +265 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +667 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +35 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +427 -0
- package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +113 -0
- package/templates/nextjs/designs/passepartout/ui/product/review-form.tsx +294 -0
- package/templates/nextjs/designs/passepartout/ui/product/reviews-section.tsx.ejs +96 -0
- package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +52 -0
- package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +307 -0
- package/templates/nextjs/designs/passepartout/ui/shared/fly-to-cart.ts +85 -0
- package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +33 -0
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +87 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +121 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +25 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +97 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +103 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +117 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +52 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +79 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +74 -0
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +34 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +443 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +17 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +166 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +163 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +50 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +95 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +269 -0
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx +78 -0
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +43 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +210 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +29 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +25 -0
- package/templates/nextjs/base/src/app/home-client.tsx +0 -98
- package/templates/nextjs/base/src/components/products/allergen-chips.tsx +0 -78
- /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
- /package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{components → ui}/layout/language-switcher.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +0 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
5
|
+
import { checkAuthStatus } from '@/core/lib/auth';
|
|
6
|
+
import type { MyProductReview, ProductReview } from 'brainerce';
|
|
7
|
+
|
|
8
|
+
interface ReviewFormProps {
|
|
9
|
+
productId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Stage =
|
|
13
|
+
| { kind: 'loading' }
|
|
14
|
+
| { kind: 'signed_out' }
|
|
15
|
+
| { kind: 'not_eligible'; reason: MyProductReview['reason'] }
|
|
16
|
+
| { kind: 'submit' }
|
|
17
|
+
| { kind: 'edit'; review: ProductReview };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Single component that decides which UI to render for a customer on a PDP:
|
|
21
|
+
* - Loading while we check auth state and eligibility
|
|
22
|
+
* - Signed out -> sign-in CTA
|
|
23
|
+
* - Signed in but not eligible -> "purchase this product to review"
|
|
24
|
+
* - Eligible without review -> submit form
|
|
25
|
+
* - Eligible with existing review -> edit/delete form (rating + body prefilled)
|
|
26
|
+
*
|
|
27
|
+
* DESIGN ME — review submit/edit form; auth via checkAuthStatus(), eligibility
|
|
28
|
+
* + existing review via getClient().getMyProductReview(productId).
|
|
29
|
+
*/
|
|
30
|
+
export function ReviewForm({ productId }: ReviewFormProps) {
|
|
31
|
+
const [stage, setStage] = useState<Stage>({ kind: 'loading' });
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
async function load() {
|
|
36
|
+
const auth = await checkAuthStatus();
|
|
37
|
+
if (cancelled) return;
|
|
38
|
+
if (!auth.isLoggedIn) {
|
|
39
|
+
setStage({ kind: 'signed_out' });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const me = await getClient().getMyProductReview(productId);
|
|
44
|
+
if (cancelled) return;
|
|
45
|
+
if (!me.eligible) {
|
|
46
|
+
setStage({ kind: 'not_eligible', reason: me.reason });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (me.myReview) {
|
|
50
|
+
setStage({ kind: 'edit', review: me.myReview });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
setStage({ kind: 'submit' });
|
|
54
|
+
} catch {
|
|
55
|
+
if (!cancelled) setStage({ kind: 'not_eligible', reason: 'product_not_found' });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
void load();
|
|
59
|
+
return () => {
|
|
60
|
+
cancelled = true;
|
|
61
|
+
};
|
|
62
|
+
}, [productId]);
|
|
63
|
+
|
|
64
|
+
if (stage.kind === 'loading') {
|
|
65
|
+
return <p role="status">Loading…</p>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (stage.kind === 'signed_out') {
|
|
69
|
+
return (
|
|
70
|
+
<p>
|
|
71
|
+
<a href="/account/login">Sign in</a> to leave a review for this product.
|
|
72
|
+
</p>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (stage.kind === 'not_eligible') {
|
|
77
|
+
return <NotEligibleMessage reason={stage.reason} />;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (stage.kind === 'edit') {
|
|
81
|
+
return (
|
|
82
|
+
<EditReviewBlock
|
|
83
|
+
productId={productId}
|
|
84
|
+
initial={stage.review}
|
|
85
|
+
onDeleted={() => setStage({ kind: 'submit' })}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return <SubmitReviewBlock productId={productId} />;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function NotEligibleMessage({ reason }: { reason: MyProductReview['reason'] }) {
|
|
94
|
+
let message: string;
|
|
95
|
+
switch (reason) {
|
|
96
|
+
case 'no_eligible_order':
|
|
97
|
+
message = 'Only customers who purchased this product can leave a review.';
|
|
98
|
+
break;
|
|
99
|
+
case 'reviews_disabled':
|
|
100
|
+
message = 'Reviews are not enabled for this store.';
|
|
101
|
+
break;
|
|
102
|
+
default:
|
|
103
|
+
message = 'You cannot review this product right now.';
|
|
104
|
+
}
|
|
105
|
+
return <p>{message}</p>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function SubmitReviewBlock({ productId }: { productId: string }) {
|
|
109
|
+
return <ReviewEditor productId={productId} mode="create" initialRating={5} initialBody="" />;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function EditReviewBlock({
|
|
113
|
+
productId,
|
|
114
|
+
initial,
|
|
115
|
+
onDeleted,
|
|
116
|
+
}: {
|
|
117
|
+
productId: string;
|
|
118
|
+
initial: ProductReview;
|
|
119
|
+
onDeleted: () => void;
|
|
120
|
+
}) {
|
|
121
|
+
const [deleting, setDeleting] = useState(false);
|
|
122
|
+
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
123
|
+
|
|
124
|
+
async function handleDelete() {
|
|
125
|
+
if (!window.confirm('Delete your review for this product?')) return;
|
|
126
|
+
setDeleting(true);
|
|
127
|
+
setDeleteError(null);
|
|
128
|
+
try {
|
|
129
|
+
await getClient().deleteMyProductReview(productId);
|
|
130
|
+
onDeleted();
|
|
131
|
+
window.location.reload();
|
|
132
|
+
} catch {
|
|
133
|
+
setDeleteError('Could not delete your review. Please try again.');
|
|
134
|
+
} finally {
|
|
135
|
+
setDeleting(false);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div>
|
|
141
|
+
<ReviewEditor
|
|
142
|
+
productId={productId}
|
|
143
|
+
mode="edit"
|
|
144
|
+
initialRating={initial.rating}
|
|
145
|
+
initialBody={initial.body ?? ''}
|
|
146
|
+
/>
|
|
147
|
+
<div className="flex items-center gap-2">
|
|
148
|
+
<p>Want to start over? You can delete your review.</p>
|
|
149
|
+
<button type="button" onClick={handleDelete} disabled={deleting}>
|
|
150
|
+
{deleting ? 'Deleting…' : 'Delete review'}
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
{deleteError && <p role="alert">{deleteError}</p>}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The actual form. Same shape for create and edit — only the API method
|
|
160
|
+
* and the heading differ. Optimistic UX: success state + page reload to
|
|
161
|
+
* let the server-rendered reviews list pick up the change.
|
|
162
|
+
*/
|
|
163
|
+
function ReviewEditor({
|
|
164
|
+
productId,
|
|
165
|
+
mode,
|
|
166
|
+
initialRating,
|
|
167
|
+
initialBody,
|
|
168
|
+
}: {
|
|
169
|
+
productId: string;
|
|
170
|
+
mode: 'create' | 'edit';
|
|
171
|
+
initialRating: number;
|
|
172
|
+
initialBody: string;
|
|
173
|
+
}) {
|
|
174
|
+
const [rating, setRating] = useState(initialRating);
|
|
175
|
+
const [body, setBody] = useState(initialBody);
|
|
176
|
+
const [submitting, setSubmitting] = useState(false);
|
|
177
|
+
const [error, setError] = useState<string | null>(null);
|
|
178
|
+
const [success, setSuccess] = useState(false);
|
|
179
|
+
|
|
180
|
+
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
setError(null);
|
|
183
|
+
setSubmitting(true);
|
|
184
|
+
try {
|
|
185
|
+
const client = getClient();
|
|
186
|
+
const input = { rating, body: body.trim() || undefined };
|
|
187
|
+
if (mode === 'edit') {
|
|
188
|
+
await client.updateMyProductReview(productId, input);
|
|
189
|
+
} else {
|
|
190
|
+
await client.submitProductReview(productId, input);
|
|
191
|
+
}
|
|
192
|
+
setSuccess(true);
|
|
193
|
+
setTimeout(() => window.location.reload(), 1200);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const status = (err as { status?: number })?.status;
|
|
196
|
+
if (status === 409) {
|
|
197
|
+
setError("You've already submitted a review for this product.");
|
|
198
|
+
} else if (status === 403) {
|
|
199
|
+
setError('Only customers who purchased this product can leave a review.');
|
|
200
|
+
} else if (status === 429) {
|
|
201
|
+
setError('Too many submissions. Please try again in a few minutes.');
|
|
202
|
+
} else {
|
|
203
|
+
setError(
|
|
204
|
+
mode === 'edit'
|
|
205
|
+
? 'Could not update your review. Please try again.'
|
|
206
|
+
: 'Could not submit your review. Please try again.'
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
setSubmitting(false);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (success) {
|
|
215
|
+
return (
|
|
216
|
+
<p role="status">
|
|
217
|
+
{mode === 'edit'
|
|
218
|
+
? 'Thanks! Your review has been updated.'
|
|
219
|
+
: 'Thanks! Your review has been submitted.'}
|
|
220
|
+
</p>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<form onSubmit={handleSubmit}>
|
|
226
|
+
<h3>{mode === 'edit' ? 'Edit your review' : 'Write a review'}</h3>
|
|
227
|
+
|
|
228
|
+
<fieldset>
|
|
229
|
+
<legend>Your rating</legend>
|
|
230
|
+
<div className="inline-flex gap-1" role="radiogroup" aria-label="Star rating">
|
|
231
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
232
|
+
<button
|
|
233
|
+
key={n}
|
|
234
|
+
type="button"
|
|
235
|
+
role="radio"
|
|
236
|
+
aria-checked={rating === n}
|
|
237
|
+
onClick={() => setRating(n)}
|
|
238
|
+
>
|
|
239
|
+
{n <= rating ? '★' : '☆'}
|
|
240
|
+
</button>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
</fieldset>
|
|
244
|
+
|
|
245
|
+
<label className="block">
|
|
246
|
+
Your review <span>(optional)</span>
|
|
247
|
+
<textarea
|
|
248
|
+
value={body}
|
|
249
|
+
onChange={(event) => setBody(event.target.value)}
|
|
250
|
+
maxLength={5000}
|
|
251
|
+
rows={4}
|
|
252
|
+
className="block w-full"
|
|
253
|
+
/>
|
|
254
|
+
</label>
|
|
255
|
+
|
|
256
|
+
{error && <p role="alert">{error}</p>}
|
|
257
|
+
|
|
258
|
+
<button type="submit" disabled={submitting}>
|
|
259
|
+
{submitting
|
|
260
|
+
? mode === 'edit'
|
|
261
|
+
? 'Saving…'
|
|
262
|
+
: 'Submitting…'
|
|
263
|
+
: mode === 'edit'
|
|
264
|
+
? 'Save changes'
|
|
265
|
+
: 'Submit review'}
|
|
266
|
+
</button>
|
|
267
|
+
</form>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ProductReview } from 'brainerce';
|
|
2
|
+
import { getServerClient } from '@/core/lib/brainerce';
|
|
3
|
+
import { ReviewForm } from './review-form';
|
|
4
|
+
|
|
5
|
+
interface ReviewsSectionProps {
|
|
6
|
+
productId: string;
|
|
7
|
+
initialReviews?: ProductReview[];
|
|
8
|
+
/** Where to send users back to after submit (defaults to the current product URL). */
|
|
9
|
+
returnUrl?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Renders the visible reviews for a product plus a submit form.
|
|
14
|
+
*
|
|
15
|
+
* Server component — fetches with the SDK on the server so reviews are in
|
|
16
|
+
* the initial HTML payload (good for SEO; JSON-LD on the same page already
|
|
17
|
+
* carries `aggregateRating` when reviewCount > 0).
|
|
18
|
+
*/
|
|
19
|
+
export async function ReviewsSection({ productId, initialReviews }: ReviewsSectionProps) {
|
|
20
|
+
let reviews: ProductReview[] = initialReviews ?? [];
|
|
21
|
+
|
|
22
|
+
if (!initialReviews) {
|
|
23
|
+
try {
|
|
24
|
+
const result = await getServerClient().listProductReviews(productId, { page: 1, limit: 20 });
|
|
25
|
+
reviews = result.data;
|
|
26
|
+
} catch {
|
|
27
|
+
// Reviews disabled at store level or network error — render the form anyway.
|
|
28
|
+
reviews = [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<section aria-labelledby="reviews-heading">
|
|
34
|
+
{/* DESIGN ME — product reviews: server-fetched review list (SDK listProductReviews) + eligibility-aware submit form */}
|
|
35
|
+
<h2 id="reviews-heading">Reviews</h2>
|
|
36
|
+
|
|
37
|
+
{reviews.length === 0 ? (
|
|
38
|
+
<p>No reviews yet — be the first to share your experience.</p>
|
|
39
|
+
) : (
|
|
40
|
+
<ul>
|
|
41
|
+
{reviews.map((review) => (
|
|
42
|
+
<ReviewCard key={review.id} review={review} />
|
|
43
|
+
))}
|
|
44
|
+
</ul>
|
|
45
|
+
)}
|
|
46
|
+
|
|
47
|
+
<ReviewForm productId={productId} />
|
|
48
|
+
</section>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ReviewCard({ review }: { review: ProductReview }) {
|
|
53
|
+
return (
|
|
54
|
+
<li>
|
|
55
|
+
<article>
|
|
56
|
+
<header className="flex flex-wrap items-center gap-2">
|
|
57
|
+
<Stars rating={review.rating} />
|
|
58
|
+
<span>{review.authorName}</span>
|
|
59
|
+
{review.verifiedPurchase && <span>Verified purchase</span>}
|
|
60
|
+
<time dateTime={review.createdAt}>{new Date(review.createdAt).toLocaleDateString()}</time>
|
|
61
|
+
</header>
|
|
62
|
+
{review.body && <p className="whitespace-pre-line break-words">{review.body}</p>}
|
|
63
|
+
</article>
|
|
64
|
+
</li>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function Stars({ rating }: { rating: number }) {
|
|
69
|
+
return (
|
|
70
|
+
<span aria-label={`${rating} of 5 stars`}>
|
|
71
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
72
|
+
<span key={n} aria-hidden="true">
|
|
73
|
+
{n <= rating ? '★' : '☆'}
|
|
74
|
+
</span>
|
|
75
|
+
))}
|
|
76
|
+
</span>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { InventoryInfo } from 'brainerce';
|
|
4
|
+
import { cn } from '@/core/lib/utils';
|
|
5
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
+
|
|
7
|
+
interface StockBadgeProps {
|
|
8
|
+
inventory: InventoryInfo | null | undefined;
|
|
9
|
+
lowStockThreshold?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* DESIGN ME — stock status badge (in stock / low stock / out of stock);
|
|
15
|
+
* derives the label from the product/variant `inventory` prop. Keep the
|
|
16
|
+
* label logic; add your own tones per state.
|
|
17
|
+
*/
|
|
18
|
+
export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
|
|
19
|
+
const t = useTranslations('productDetail');
|
|
20
|
+
const label = getStockLabel(inventory, lowStockThreshold, t);
|
|
21
|
+
|
|
22
|
+
return <span className={cn(className)}>{label}</span>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getStockLabel(
|
|
26
|
+
inventory: InventoryInfo | null | undefined,
|
|
27
|
+
lowStockThreshold: number,
|
|
28
|
+
t: (key: string) => string
|
|
29
|
+
): string {
|
|
30
|
+
if (!inventory) return t('outOfStock');
|
|
31
|
+
|
|
32
|
+
const { trackingMode, inStock, available } = inventory;
|
|
33
|
+
|
|
34
|
+
if (trackingMode === 'DISABLED') return t('unavailable');
|
|
35
|
+
if (!inStock) return t('outOfStock');
|
|
36
|
+
if (trackingMode === 'UNLIMITED') return t('inStock');
|
|
37
|
+
|
|
38
|
+
// TRACKED — show actual quantity
|
|
39
|
+
if (available <= lowStockThreshold) {
|
|
40
|
+
return t('onlyLeft').replace('{available}', String(available));
|
|
41
|
+
}
|
|
42
|
+
return t('availableInStock').replace('{available}', String(available));
|
|
43
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import type { Product, ProductVariant } from 'brainerce';
|
|
5
|
+
import { getVariantOptions, getProductSwatches, formatPrice } from 'brainerce';
|
|
6
|
+
import type { InventoryInfo } from 'brainerce';
|
|
7
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
8
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
9
|
+
import { cn } from '@/core/lib/utils';
|
|
10
|
+
|
|
11
|
+
interface VariantSelectorProps {
|
|
12
|
+
product: Product;
|
|
13
|
+
selectedVariant: ProductVariant | null;
|
|
14
|
+
onVariantChange: (variant: ProductVariant) => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface AttributeGroup {
|
|
19
|
+
name: string;
|
|
20
|
+
displayType: string;
|
|
21
|
+
values: Array<{
|
|
22
|
+
value: string;
|
|
23
|
+
swatchColor?: string | null;
|
|
24
|
+
swatchColor2?: string | null;
|
|
25
|
+
swatchImageUrl?: string | null;
|
|
26
|
+
variants: ProductVariant[];
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* DESIGN ME — variant selector on the product page: one group per attribute
|
|
32
|
+
* (Size, Color, …) with selectable values. Selection state and the matching
|
|
33
|
+
* logic below are the contract; the merchant's swatch metadata
|
|
34
|
+
* (`displayType`, `swatchColor`, `swatchColor2`, `swatchImageUrl`) is
|
|
35
|
+
* computed per value — the skeleton renders plain buttons, use the swatch
|
|
36
|
+
* data to build color/image swatches in your design.
|
|
37
|
+
*/
|
|
38
|
+
export function VariantSelector({
|
|
39
|
+
product,
|
|
40
|
+
selectedVariant,
|
|
41
|
+
onVariantChange,
|
|
42
|
+
className,
|
|
43
|
+
}: VariantSelectorProps) {
|
|
44
|
+
const t = useTranslations('productDetail');
|
|
45
|
+
const currency = useCurrency();
|
|
46
|
+
const variants = useMemo(() => product.variants || [], [product.variants]);
|
|
47
|
+
|
|
48
|
+
// Get swatch metadata from product attribute options
|
|
49
|
+
const swatchData = useMemo(() => getProductSwatches(product), [product]);
|
|
50
|
+
const swatchMap = useMemo(() => {
|
|
51
|
+
const map = new Map<
|
|
52
|
+
string,
|
|
53
|
+
{
|
|
54
|
+
displayType: string;
|
|
55
|
+
options: Map<
|
|
56
|
+
string,
|
|
57
|
+
{
|
|
58
|
+
swatchColor?: string | null;
|
|
59
|
+
swatchColor2?: string | null;
|
|
60
|
+
swatchImageUrl?: string | null;
|
|
61
|
+
}
|
|
62
|
+
>;
|
|
63
|
+
}
|
|
64
|
+
>();
|
|
65
|
+
for (const attr of swatchData) {
|
|
66
|
+
const optMap = new Map<
|
|
67
|
+
string,
|
|
68
|
+
{
|
|
69
|
+
swatchColor?: string | null;
|
|
70
|
+
swatchColor2?: string | null;
|
|
71
|
+
swatchImageUrl?: string | null;
|
|
72
|
+
}
|
|
73
|
+
>();
|
|
74
|
+
for (const opt of attr.options) {
|
|
75
|
+
optMap.set(opt.name, {
|
|
76
|
+
swatchColor: opt.swatchColor,
|
|
77
|
+
swatchColor2: opt.swatchColor2,
|
|
78
|
+
swatchImageUrl: opt.swatchImageUrl,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
map.set(attr.attributeName, { displayType: attr.displayType, options: optMap });
|
|
82
|
+
}
|
|
83
|
+
return map;
|
|
84
|
+
}, [swatchData]);
|
|
85
|
+
|
|
86
|
+
// Build attribute groups from variant data, enriched with swatch info
|
|
87
|
+
const attributeGroups = useMemo<AttributeGroup[]>(() => {
|
|
88
|
+
const groups = new Map<string, Map<string, ProductVariant[]>>();
|
|
89
|
+
|
|
90
|
+
for (const variant of variants) {
|
|
91
|
+
const options = getVariantOptions(variant);
|
|
92
|
+
for (const { name, value } of options) {
|
|
93
|
+
if (!groups.has(name)) {
|
|
94
|
+
groups.set(name, new Map());
|
|
95
|
+
}
|
|
96
|
+
const valuesMap = groups.get(name)!;
|
|
97
|
+
if (!valuesMap.has(value)) {
|
|
98
|
+
valuesMap.set(value, []);
|
|
99
|
+
}
|
|
100
|
+
valuesMap.get(value)!.push(variant);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return Array.from(groups.entries()).map(([name, valuesMap]) => {
|
|
105
|
+
const attrSwatch = swatchMap.get(name);
|
|
106
|
+
return {
|
|
107
|
+
name,
|
|
108
|
+
displayType: attrSwatch?.displayType || 'DROPDOWN',
|
|
109
|
+
values: Array.from(valuesMap.entries()).map(([value, variantList]) => {
|
|
110
|
+
const optSwatch = attrSwatch?.options.get(value);
|
|
111
|
+
return {
|
|
112
|
+
value,
|
|
113
|
+
swatchColor: optSwatch?.swatchColor,
|
|
114
|
+
swatchColor2: optSwatch?.swatchColor2,
|
|
115
|
+
swatchImageUrl: optSwatch?.swatchImageUrl,
|
|
116
|
+
variants: variantList,
|
|
117
|
+
};
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}, [variants, swatchMap]);
|
|
122
|
+
|
|
123
|
+
// Get currently selected attribute values
|
|
124
|
+
const selectedOptions = useMemo(() => {
|
|
125
|
+
if (!selectedVariant) return new Map<string, string>();
|
|
126
|
+
const opts = getVariantOptions(selectedVariant);
|
|
127
|
+
return new Map(opts.map(({ name, value }) => [name, value]));
|
|
128
|
+
}, [selectedVariant]);
|
|
129
|
+
|
|
130
|
+
// Find the variant that matches all selected attributes
|
|
131
|
+
function findMatchingVariant(
|
|
132
|
+
attributeName: string,
|
|
133
|
+
newValue: string
|
|
134
|
+
): ProductVariant | undefined {
|
|
135
|
+
const nextSelection = new Map(selectedOptions);
|
|
136
|
+
nextSelection.set(attributeName, newValue);
|
|
137
|
+
|
|
138
|
+
return variants.find((v) => {
|
|
139
|
+
const opts = getVariantOptions(v);
|
|
140
|
+
return Array.from(nextSelection.entries()).every(([name, value]) =>
|
|
141
|
+
opts.some((o) => o.name === name && o.value === value)
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (attributeGroups.length === 0) return null;
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className={cn(className)}>
|
|
150
|
+
{attributeGroups.map((group) => (
|
|
151
|
+
<fieldset key={group.name} data-display-type={group.displayType}>
|
|
152
|
+
<legend>
|
|
153
|
+
{group.name}
|
|
154
|
+
{selectedOptions.get(group.name) && <span>: {selectedOptions.get(group.name)}</span>}
|
|
155
|
+
</legend>
|
|
156
|
+
<div className="flex flex-wrap gap-2">
|
|
157
|
+
{group.values.map(({ value, variants: matchingVariants }) => {
|
|
158
|
+
const isSelected = selectedOptions.get(group.name) === value;
|
|
159
|
+
const matchedVariant = findMatchingVariant(group.name, value);
|
|
160
|
+
const isAvailable = matchedVariant?.inventory?.canPurchase !== false;
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<button
|
|
164
|
+
key={value}
|
|
165
|
+
type="button"
|
|
166
|
+
disabled={!isAvailable}
|
|
167
|
+
aria-pressed={isSelected}
|
|
168
|
+
onClick={() => {
|
|
169
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
170
|
+
if (variant) onVariantChange(variant);
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
{value}
|
|
174
|
+
</button>
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
</div>
|
|
178
|
+
</fieldset>
|
|
179
|
+
))}
|
|
180
|
+
|
|
181
|
+
{/* Variant-specific info */}
|
|
182
|
+
{selectedVariant && (
|
|
183
|
+
<p>
|
|
184
|
+
{selectedVariant.price && (
|
|
185
|
+
<span>
|
|
186
|
+
{
|
|
187
|
+
formatPrice(selectedVariant.salePrice || selectedVariant.price, {
|
|
188
|
+
currency,
|
|
189
|
+
}) as string
|
|
190
|
+
}
|
|
191
|
+
</span>
|
|
192
|
+
)}{' '}
|
|
193
|
+
<span>{getTranslatedStockStatus(selectedVariant.inventory, t)}</span>
|
|
194
|
+
</p>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getTranslatedStockStatus(
|
|
201
|
+
inventory: InventoryInfo | null | undefined,
|
|
202
|
+
t: (key: string) => string
|
|
203
|
+
): string {
|
|
204
|
+
if (!inventory) return t('outOfStock');
|
|
205
|
+
const { trackingMode, inStock, available } = inventory;
|
|
206
|
+
if (trackingMode === 'DISABLED') return t('unavailable');
|
|
207
|
+
if (!inStock) return t('outOfStock');
|
|
208
|
+
if (trackingMode === 'UNLIMITED') return t('inStock');
|
|
209
|
+
return t('availableInStock').replace('{available}', String(available));
|
|
210
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import Image, { type ImageProps } from 'next/image';
|
|
2
|
+
import { OPTIMIZED_IMAGE_HOSTS } from '@/core/lib/image-hosts';
|
|
3
|
+
|
|
4
|
+
const OPTIMIZED_HOSTS = new Set(OPTIMIZED_IMAGE_HOSTS);
|
|
5
|
+
|
|
6
|
+
function isOptimizedHost(src: ImageProps['src']): boolean {
|
|
7
|
+
if (typeof src !== 'string') return true; // StaticImageData — always local
|
|
8
|
+
try {
|
|
9
|
+
return OPTIMIZED_HOSTS.has(new URL(src).hostname);
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Drop-in `next/image` replacement for Brainerce-sourced images (product
|
|
17
|
+
* photos, blog covers). Uploaded images get re-hosted onto cdn.brainerce.com
|
|
18
|
+
* by the backend's async image-import pipeline, but a product/variant can
|
|
19
|
+
* still carry a raw upstream-merchant URL while mid-import, or if that job
|
|
20
|
+
* failed. next/image throws if `src`'s host isn't in `images.remotePatterns`,
|
|
21
|
+
* so any host outside our known CDN falls back to `unoptimized` — an
|
|
22
|
+
* unresized, direct-from-origin fetch — instead of crashing the page.
|
|
23
|
+
*
|
|
24
|
+
* DESIGN ME — shared image wrapper; render ALL catalog images through this
|
|
25
|
+
* component (not raw next/image) so unknown hosts never crash the page.
|
|
26
|
+
*/
|
|
27
|
+
export function CdnImage({ unoptimized, ...props }: ImageProps) {
|
|
28
|
+
return <Image {...props} unoptimized={unoptimized ?? !isOptimizedHost(props.src)} />;
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
4
|
+
import { cn } from '@/core/lib/utils';
|
|
5
|
+
|
|
6
|
+
interface LoadingSpinnerProps {
|
|
7
|
+
size?: 'sm' | 'md' | 'lg';
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* DESIGN ME — loading indicator used by every page and async button. Bare
|
|
13
|
+
* text placeholder; replace with your own spinner/skeleton. Keep the
|
|
14
|
+
* `role="status"` + sr-only label, and keep the `size` prop (callers across
|
|
15
|
+
* the app pass sm/md/lg).
|
|
16
|
+
*/
|
|
17
|
+
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
|
18
|
+
const t = useTranslations('common');
|
|
19
|
+
return (
|
|
20
|
+
<span role="status" data-size={size} className={cn(className)}>
|
|
21
|
+
<span aria-hidden="true">…</span>
|
|
22
|
+
<span className="sr-only">{t('loading')}</span>
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
25
|
+
}
|