create-brainerce-store 1.55.0 → 1.58.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 +1 -1
- package/package.json +1 -1
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +22 -7
- package/templates/nextjs/designs/atelier/globals.css +25 -19
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +14 -14
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +5 -5
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +23 -17
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +6 -11
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -269
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +9 -10
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.
|
|
34
|
+
version: "1.58.0",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
package/package.json
CHANGED
|
@@ -77,6 +77,13 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
77
77
|
const { storeInfo } = useStoreInfo();
|
|
78
78
|
|
|
79
79
|
const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
|
|
80
|
+
|
|
81
|
+
// The provider's payment URL. `clientSdk.renderArg` is the URL; `clientSecret`
|
|
82
|
+
// is only a fallback for providers that duplicate it there. Reading
|
|
83
|
+
// clientSecret alone breaks providers that return a real identifier (MAX,
|
|
84
|
+
// Takbull) — the iframe/link silently points at an id instead of a page.
|
|
85
|
+
const paymentIntentUrl =
|
|
86
|
+
paymentIntent?.clientSdk?.renderArg || paymentIntent?.clientSecret || '';
|
|
80
87
|
const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
|
|
81
88
|
const [loading, setLoading] = useState(true);
|
|
82
89
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -368,12 +375,20 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
368
375
|
// Sandbox mode — no SDK to load, UI handles it
|
|
369
376
|
if (sdk.renderType === 'sandbox') return;
|
|
370
377
|
|
|
378
|
+
// The URL to send the customer to is `renderArg`; `clientSecret` is only
|
|
379
|
+
// a fallback for providers that duplicate the URL into it. Reading
|
|
380
|
+
// clientSecret first silently breaks any provider that puts a real
|
|
381
|
+
// identifier there (MAX, Takbull) — the host check rejects the id and the
|
|
382
|
+
// customer never reaches the payment page. Mirrors the sdk-widget branch
|
|
383
|
+
// above, which already prefers renderArg.
|
|
384
|
+
const paymentUrl = sdk.renderArg || intent.clientSecret;
|
|
385
|
+
|
|
371
386
|
if (sdk.renderType === 'redirect') {
|
|
372
|
-
if (!isAllowedPaymentUrl(
|
|
387
|
+
if (!isAllowedPaymentUrl(paymentUrl)) {
|
|
373
388
|
setError(t('paymentRedirectBlocked'));
|
|
374
389
|
return;
|
|
375
390
|
}
|
|
376
|
-
safePaymentRedirect(
|
|
391
|
+
safePaymentRedirect(paymentUrl);
|
|
377
392
|
return;
|
|
378
393
|
}
|
|
379
394
|
|
|
@@ -383,13 +398,13 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
383
398
|
// (2) a Brainerce-hosted embed page on an allowlisted payment host
|
|
384
399
|
// that wraps provider-specific logic (e.g. Cardcom OpenFields).
|
|
385
400
|
if (sdk.renderType === 'iframe') {
|
|
386
|
-
if (!isAllowedPaymentUrl(
|
|
401
|
+
if (!isAllowedPaymentUrl(paymentUrl)) {
|
|
387
402
|
setError(t('paymentRedirectBlocked'));
|
|
388
403
|
return;
|
|
389
404
|
}
|
|
390
405
|
const iframeOrigin = (() => {
|
|
391
406
|
try {
|
|
392
|
-
return new URL(
|
|
407
|
+
return new URL(paymentUrl).origin;
|
|
393
408
|
} catch {
|
|
394
409
|
return '';
|
|
395
410
|
}
|
|
@@ -622,7 +637,7 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
622
637
|
return (
|
|
623
638
|
<div className={cn('w-full', className)}>
|
|
624
639
|
<iframe
|
|
625
|
-
src={
|
|
640
|
+
src={paymentIntentUrl}
|
|
626
641
|
className="block w-full border-0"
|
|
627
642
|
style={iframeStyle}
|
|
628
643
|
title={t('payment')}
|
|
@@ -681,7 +696,7 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
681
696
|
</div>
|
|
682
697
|
{/* Iframe body */}
|
|
683
698
|
<iframe
|
|
684
|
-
src={
|
|
699
|
+
src={paymentIntentUrl}
|
|
685
700
|
className="w-full border-0"
|
|
686
701
|
style={iframeStyle}
|
|
687
702
|
title={t('payment')}
|
|
@@ -724,7 +739,7 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
724
739
|
<p className="text-muted-foreground mt-4 text-sm">{t('redirectingToPayment')}</p>
|
|
725
740
|
<p className="text-muted-foreground mt-2 text-xs">
|
|
726
741
|
{t('redirectingHint')}
|
|
727
|
-
<a href={
|
|
742
|
+
<a href={paymentIntentUrl} className="text-primary hover:underline">
|
|
728
743
|
{t('clickHere')}
|
|
729
744
|
</a>
|
|
730
745
|
.
|
|
@@ -350,9 +350,10 @@
|
|
|
350
350
|
.cart-bump { animation: cart-bump 0.45s cubic-bezier(0.22, 1, 0.36, 1); }
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
-
/* Full-page loader — a
|
|
354
|
-
|
|
355
|
-
.
|
|
353
|
+
/* Full-page loader — a soft pulsing core inside a thin rotating ring
|
|
354
|
+
(Atelier's breathing mark). No iconography, so it fits any store niche.
|
|
355
|
+
Static ring + core under reduced motion; the spin/breathe are additive. */
|
|
356
|
+
.loader-ring {
|
|
356
357
|
position: relative;
|
|
357
358
|
display: flex;
|
|
358
359
|
height: 3.5rem;
|
|
@@ -360,35 +361,40 @@
|
|
|
360
361
|
align-items: center;
|
|
361
362
|
justify-content: center;
|
|
362
363
|
border-radius: 9999px;
|
|
363
|
-
border: 1px solid hsl(var(--primary) / 0.
|
|
364
|
-
box-shadow: inset 0 0 18px hsl(var(--primary) / 0.06);
|
|
364
|
+
border: 1px solid hsl(var(--primary) / 0.15);
|
|
365
365
|
}
|
|
366
|
-
.loader-
|
|
367
|
-
position: absolute;
|
|
368
|
-
inset: 0;
|
|
369
|
-
}
|
|
370
|
-
.loader-orbit-dot::before {
|
|
366
|
+
.loader-ring::before {
|
|
371
367
|
content: '';
|
|
372
368
|
position: absolute;
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
369
|
+
inset: -1px;
|
|
370
|
+
border-radius: 9999px;
|
|
371
|
+
border: 1.5px solid transparent;
|
|
372
|
+
border-top-color: hsl(var(--primary) / 0.9);
|
|
373
|
+
}
|
|
374
|
+
.loader-ring-core {
|
|
375
|
+
height: 0.6rem;
|
|
376
|
+
width: 0.6rem;
|
|
378
377
|
border-radius: 9999px;
|
|
379
378
|
background: hsl(var(--primary));
|
|
380
|
-
box-shadow: 0 0 12px hsl(var(--primary) / 0.
|
|
379
|
+
box-shadow: 0 0 12px hsl(var(--primary) / 0.4);
|
|
381
380
|
}
|
|
382
381
|
@media (prefers-reduced-motion: no-preference) {
|
|
383
|
-
.loader-
|
|
384
|
-
animation:
|
|
382
|
+
.loader-ring::before {
|
|
383
|
+
animation: ring-spin 1.4s linear infinite;
|
|
384
|
+
}
|
|
385
|
+
.loader-ring-core {
|
|
386
|
+
animation: ring-breathe 2s ease-in-out infinite;
|
|
385
387
|
}
|
|
386
388
|
.loader-text {
|
|
387
389
|
animation: loader-breathe 2.2s ease-in-out infinite;
|
|
388
390
|
}
|
|
389
|
-
@keyframes
|
|
391
|
+
@keyframes ring-spin {
|
|
390
392
|
to { transform: rotate(360deg); }
|
|
391
393
|
}
|
|
394
|
+
@keyframes ring-breathe {
|
|
395
|
+
0%, 100% { opacity: 0.45; transform: scale(0.85); }
|
|
396
|
+
50% { opacity: 1; transform: scale(1); }
|
|
397
|
+
}
|
|
392
398
|
@keyframes loader-breathe {
|
|
393
399
|
0%, 100% { opacity: 0.55; }
|
|
394
400
|
50% { opacity: 1; }
|
|
@@ -75,7 +75,7 @@ export function CartDrawer() {
|
|
|
75
75
|
onClick={close}
|
|
76
76
|
aria-hidden="true"
|
|
77
77
|
className={cn(
|
|
78
|
-
'absolute inset-0
|
|
78
|
+
'bg-foreground/35 absolute inset-0 backdrop-blur-[2px] transition-opacity duration-300 motion-reduce:transition-none',
|
|
79
79
|
open ? 'opacity-100' : 'opacity-0'
|
|
80
80
|
)}
|
|
81
81
|
/>
|
|
@@ -86,14 +86,14 @@ export function CartDrawer() {
|
|
|
86
86
|
aria-modal="true"
|
|
87
87
|
aria-label={t('title')}
|
|
88
88
|
className={cn(
|
|
89
|
-
'absolute inset-y-0 end-0 flex w-full max-w-md flex-col
|
|
89
|
+
'bg-background absolute inset-y-0 end-0 flex w-full max-w-md flex-col shadow-2xl transition-transform duration-300 ease-out motion-reduce:transition-none sm:rounded-s-3xl sm:border-s',
|
|
90
90
|
open ? 'translate-x-0' : 'ltr:translate-x-full rtl:-translate-x-full'
|
|
91
91
|
)}
|
|
92
92
|
>
|
|
93
93
|
<header className="flex items-center gap-3 border-b px-5 py-4">
|
|
94
|
-
<h2 className="font-display text-xl font-semibold
|
|
94
|
+
<h2 className="font-display text-foreground text-xl font-semibold">{t('title')}</h2>
|
|
95
95
|
{itemCount > 0 && (
|
|
96
|
-
<span className="flex h-6 min-w-6 items-center justify-center rounded-full
|
|
96
|
+
<span className="bg-primary text-primary-foreground flex h-6 min-w-6 items-center justify-center rounded-full px-1.5 text-xs font-bold">
|
|
97
97
|
{itemCount}
|
|
98
98
|
</span>
|
|
99
99
|
)}
|
|
@@ -102,7 +102,7 @@ export function CartDrawer() {
|
|
|
102
102
|
type="button"
|
|
103
103
|
onClick={close}
|
|
104
104
|
aria-label={tc('close')}
|
|
105
|
-
className="ms-auto flex h-10 w-10 items-center justify-center rounded-full
|
|
105
|
+
className="text-foreground hover:bg-secondary ms-auto flex h-10 w-10 items-center justify-center rounded-full transition-colors"
|
|
106
106
|
>
|
|
107
107
|
<IconX size={20} />
|
|
108
108
|
</button>
|
|
@@ -110,11 +110,11 @@ export function CartDrawer() {
|
|
|
110
110
|
|
|
111
111
|
{items.length === 0 ? (
|
|
112
112
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
|
113
|
-
<span className="flex h-16 w-16 items-center justify-center rounded-full
|
|
113
|
+
<span className="bg-secondary text-muted-foreground flex h-16 w-16 items-center justify-center rounded-full">
|
|
114
114
|
<IconBag size={24} />
|
|
115
115
|
</span>
|
|
116
|
-
<p className="font-display text-lg font-semibold
|
|
117
|
-
<p className="max-w-xs text-sm
|
|
116
|
+
<p className="font-display text-foreground text-lg font-semibold">{t('emptyTitle')}</p>
|
|
117
|
+
<p className="text-muted-foreground max-w-xs text-sm">{t('emptySubtitle')}</p>
|
|
118
118
|
<Link href="/products" onClick={close} className="btn-primary btn-sm mt-2">
|
|
119
119
|
{tc('continueShopping')}
|
|
120
120
|
</Link>
|
|
@@ -132,25 +132,25 @@ export function CartDrawer() {
|
|
|
132
132
|
</ul>
|
|
133
133
|
</div>
|
|
134
134
|
|
|
135
|
-
<footer className="
|
|
135
|
+
<footer className="bg-secondary/40 border-t px-5 py-4">
|
|
136
136
|
<div className="flex items-center justify-between text-sm">
|
|
137
137
|
<span className="text-muted-foreground">{tc('subtotal')}</span>
|
|
138
|
-
<strong className="text-
|
|
138
|
+
<strong className="text-foreground text-base">
|
|
139
139
|
{formatPrice(totals.subtotal, { currency }) as string}
|
|
140
140
|
</strong>
|
|
141
141
|
</div>
|
|
142
142
|
{totals.discount > 0 && (
|
|
143
|
-
<div className="mt-1 flex items-center justify-between text-sm
|
|
143
|
+
<div className="text-primary mt-1 flex items-center justify-between text-sm">
|
|
144
144
|
<span>{tc('discount')}</span>
|
|
145
145
|
<span>-{formatPrice(totals.discount, { currency }) as string}</span>
|
|
146
146
|
</div>
|
|
147
147
|
)}
|
|
148
|
-
<p className="mt-1 text-xs
|
|
148
|
+
<p className="text-muted-foreground mt-1 text-xs">{t('shippingAtCheckout')}</p>
|
|
149
149
|
<div className="mt-4 grid gap-2">
|
|
150
|
-
<Link href="/checkout" onClick={close} className="btn-primary w-full">
|
|
150
|
+
<Link href="/checkout" onClick={close} className="btn-primary btn-lg w-full">
|
|
151
151
|
{t('proceedToCheckout')}
|
|
152
152
|
</Link>
|
|
153
|
-
<Link href="/cart" onClick={close} className="btn-outline w-full">
|
|
153
|
+
<Link href="/cart" onClick={close} className="btn-outline btn-lg w-full">
|
|
154
154
|
{t('viewCart')}
|
|
155
155
|
</Link>
|
|
156
156
|
</div>
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useTranslations } from '@/core/lib/translations';
|
|
4
|
-
import { IconTruck, IconShield,
|
|
4
|
+
import { IconTruck, IconShield, IconBadgeCheck, IconReturn } from '@/ui/shared/icons';
|
|
5
5
|
|
|
6
6
|
const BENEFITS = [
|
|
7
7
|
{ icon: IconTruck, titleKey: 'benefit1Title', descKey: 'benefit1Desc' },
|
|
8
8
|
{ icon: IconShield, titleKey: 'benefit2Title', descKey: 'benefit2Desc' },
|
|
9
|
-
{ icon:
|
|
9
|
+
{ icon: IconBadgeCheck, titleKey: 'benefit3Title', descKey: 'benefit3Desc' },
|
|
10
10
|
{ icon: IconReturn, titleKey: 'benefit4Title', descKey: 'benefit4Desc' },
|
|
11
11
|
] as const;
|
|
12
12
|
|
|
@@ -19,12 +19,12 @@ export function BenefitsBand() {
|
|
|
19
19
|
<ul className="container-page grid grid-cols-2 gap-x-6 gap-y-8 pb-10 pt-16 lg:grid-cols-4">
|
|
20
20
|
{BENEFITS.map(({ icon: Icon, titleKey, descKey }) => (
|
|
21
21
|
<li key={titleKey} className="flex items-start gap-3.5">
|
|
22
|
-
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full
|
|
22
|
+
<span className="bg-secondary text-primary flex h-11 w-11 shrink-0 items-center justify-center rounded-full">
|
|
23
23
|
<Icon size={24} />
|
|
24
24
|
</span>
|
|
25
25
|
<div>
|
|
26
|
-
<p className="text-sm font-semibold
|
|
27
|
-
<p className="mt-0.5 text-[13px] leading-5
|
|
26
|
+
<p className="text-foreground text-sm font-semibold">{t(titleKey)}</p>
|
|
27
|
+
<p className="text-muted-foreground mt-0.5 text-[13px] leading-5">{t(descKey)}</p>
|
|
28
28
|
</div>
|
|
29
29
|
</li>
|
|
30
30
|
))}
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
IconMinus,
|
|
23
23
|
IconPlus,
|
|
24
24
|
IconTruck,
|
|
25
|
-
|
|
25
|
+
IconBadgeCheck,
|
|
26
26
|
IconDownload,
|
|
27
27
|
} from '@/ui/shared/icons';
|
|
28
28
|
|
|
@@ -159,7 +159,10 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
159
159
|
{/* Image Gallery */}
|
|
160
160
|
<figure className="lg:sticky lg:top-24 lg:self-start">
|
|
161
161
|
{/* `relative` + `aspect-square` are layout-critical for next/image fill */}
|
|
162
|
-
<div
|
|
162
|
+
<div
|
|
163
|
+
data-pdp-gallery
|
|
164
|
+
className="bg-secondary relative aspect-square overflow-hidden rounded-3xl border shadow-[0_28px_60px_-30px_hsl(var(--primary)/0.3)]"
|
|
165
|
+
>
|
|
163
166
|
{mainImageUrl ? (
|
|
164
167
|
<Image
|
|
165
168
|
src={mainImageUrl}
|
|
@@ -184,10 +187,10 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
184
187
|
onClick={() => setSelectedImageIndex(idx)}
|
|
185
188
|
aria-pressed={selectedImageIndex === idx}
|
|
186
189
|
className={cn(
|
|
187
|
-
'relative h-[4.5rem] w-[4.5rem] overflow-hidden rounded-lg border-2
|
|
190
|
+
'bg-secondary relative h-[4.5rem] w-[4.5rem] overflow-hidden rounded-lg border-2 transition-colors',
|
|
188
191
|
selectedImageIndex === idx
|
|
189
192
|
? 'border-primary'
|
|
190
|
-
: 'border-
|
|
193
|
+
: 'hover:border-border border-transparent'
|
|
191
194
|
)}
|
|
192
195
|
>
|
|
193
196
|
<Image
|
|
@@ -220,9 +223,9 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
220
223
|
{/* Brand */}
|
|
221
224
|
{(product as { brands?: Array<{ id: string; name: string }> }).brands &&
|
|
222
225
|
(product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
|
|
223
|
-
<p className="mb-1 text-sm
|
|
226
|
+
<p className="text-muted-foreground mb-1 text-sm">
|
|
224
227
|
{t('by')}{' '}
|
|
225
|
-
<span className="font-medium
|
|
228
|
+
<span className="text-foreground font-medium">
|
|
226
229
|
{(product as { brands: Array<{ id: string; name: string }> }).brands
|
|
227
230
|
.map((b) => b.name)
|
|
228
231
|
.join(', ')}
|
|
@@ -237,7 +240,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
237
240
|
{(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
|
|
238
241
|
(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
|
|
239
242
|
0 && (
|
|
240
|
-
<ul className="mt-2 flex flex-wrap gap-1.5 text-xs
|
|
243
|
+
<ul className="text-muted-foreground mt-2 flex flex-wrap gap-1.5 text-xs">
|
|
241
244
|
{(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
|
|
242
245
|
(tag) => (
|
|
243
246
|
<li key={tag.id}>#{tag.name}</li>
|
|
@@ -265,11 +268,11 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
265
268
|
|
|
266
269
|
{/* Downloadable files info */}
|
|
267
270
|
{product.isDownloadable && product.downloads && product.downloads.length > 0 && (
|
|
268
|
-
<section className="mt-6 rounded-xl border
|
|
271
|
+
<section className="bg-secondary/60 mt-6 rounded-xl border p-4">
|
|
269
272
|
<h2 className="text-sm font-semibold">
|
|
270
273
|
{t('filesIncluded').replace('{count}', String(product.downloads.length))}
|
|
271
274
|
</h2>
|
|
272
|
-
<ul className="mt-2 space-y-1.5 text-sm
|
|
275
|
+
<ul className="text-muted-foreground mt-2 space-y-1.5 text-sm">
|
|
273
276
|
{product.downloads.map((file: DownloadFile) => (
|
|
274
277
|
<li key={file.id} className="flex items-center gap-2">
|
|
275
278
|
<IconDownload size={16} />
|
|
@@ -322,7 +325,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
322
325
|
/>
|
|
323
326
|
))}
|
|
324
327
|
{modifierError && (
|
|
325
|
-
<p role="alert" className="text-sm font-medium
|
|
328
|
+
<p role="alert" className="text-destructive text-sm font-medium">
|
|
326
329
|
{modifierError}
|
|
327
330
|
</p>
|
|
328
331
|
)}
|
|
@@ -340,7 +343,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
340
343
|
type="button"
|
|
341
344
|
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
|
342
345
|
aria-label={t('decreaseQuantity')}
|
|
343
|
-
className="flex h-full w-11 items-center justify-center rounded-s-full
|
|
346
|
+
className="text-foreground hover:bg-secondary flex h-full w-11 items-center justify-center rounded-s-full transition-colors disabled:opacity-40"
|
|
344
347
|
disabled={quantity <= 1}
|
|
345
348
|
>
|
|
346
349
|
<IconMinus size={16} />
|
|
@@ -352,7 +355,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
352
355
|
type="button"
|
|
353
356
|
onClick={() => setQuantity((q) => q + 1)}
|
|
354
357
|
aria-label={t('increaseQuantity')}
|
|
355
|
-
className="flex h-full w-11 items-center justify-center rounded-e-full
|
|
358
|
+
className="text-foreground hover:bg-secondary flex h-full w-11 items-center justify-center rounded-e-full transition-colors"
|
|
356
359
|
>
|
|
357
360
|
<IconPlus size={16} />
|
|
358
361
|
</button>
|
|
@@ -360,7 +363,10 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
360
363
|
|
|
361
364
|
<button
|
|
362
365
|
type="button"
|
|
363
|
-
onClick={() => {
|
|
366
|
+
onClick={() => {
|
|
367
|
+
flyToCart(firstVisible('[data-pdp-gallery]'));
|
|
368
|
+
handleAddToCart();
|
|
369
|
+
}}
|
|
364
370
|
disabled={!canPurchase || addingToCart}
|
|
365
371
|
className={cn(
|
|
366
372
|
'btn-primary btn-lg flex-1 sm:min-w-64 sm:flex-none',
|
|
@@ -379,7 +385,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
379
385
|
</div>
|
|
380
386
|
|
|
381
387
|
{/* Reassurance row */}
|
|
382
|
-
<ul className="mt-6 space-y-2 border-t pt-5 text-sm
|
|
388
|
+
<ul className="text-muted-foreground mt-6 space-y-2 border-t pt-5 text-sm">
|
|
383
389
|
<li className="flex items-center gap-2.5">
|
|
384
390
|
<span className="text-primary">
|
|
385
391
|
<IconTruck size={20} />
|
|
@@ -388,7 +394,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
388
394
|
</li>
|
|
389
395
|
<li className="flex items-center gap-2.5">
|
|
390
396
|
<span className="text-primary">
|
|
391
|
-
<
|
|
397
|
+
<IconBadgeCheck size={20} />
|
|
392
398
|
</span>
|
|
393
399
|
{t('guaranteeNote')}
|
|
394
400
|
</li>
|
|
@@ -396,7 +402,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
396
402
|
|
|
397
403
|
{/* Download after purchase note */}
|
|
398
404
|
{product.isDownloadable && (
|
|
399
|
-
<p className="mt-4 text-sm
|
|
405
|
+
<p className="text-muted-foreground mt-4 text-sm">{t('downloadAfterPurchase')}</p>
|
|
400
406
|
)}
|
|
401
407
|
|
|
402
408
|
{/* Description */}
|
|
@@ -424,7 +430,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
424
430
|
<tr key={field.id} className="odd:bg-secondary/50">
|
|
425
431
|
<th
|
|
426
432
|
scope="row"
|
|
427
|
-
className="w-1/3 px-4 py-3 text-start font-medium
|
|
433
|
+
className="text-muted-foreground w-1/3 px-4 py-3 text-start font-medium"
|
|
428
434
|
>
|
|
429
435
|
{field.definitionName}
|
|
430
436
|
</th>
|
|
@@ -5,7 +5,7 @@ import { Link } from '@/core/lib/navigation';
|
|
|
5
5
|
import { useTranslations } from '@/core/lib/translations';
|
|
6
6
|
import { ProductCard } from '@/ui/product/product-card';
|
|
7
7
|
import { cn } from '@/core/lib/utils';
|
|
8
|
-
import {
|
|
8
|
+
import { IconSparkle } from '@/ui/shared/icons';
|
|
9
9
|
|
|
10
10
|
interface ProductGridProps {
|
|
11
11
|
products: Product[];
|
|
@@ -20,11 +20,11 @@ export function ProductGrid({ products, className }: ProductGridProps) {
|
|
|
20
20
|
if (products.length === 0) {
|
|
21
21
|
return (
|
|
22
22
|
<div className="card mx-auto flex max-w-md flex-col items-center gap-3 px-8 py-14 text-center">
|
|
23
|
-
<span className="flex h-14 w-14 items-center justify-center rounded-full
|
|
24
|
-
<
|
|
23
|
+
<span className="bg-secondary text-primary flex h-14 w-14 items-center justify-center rounded-full">
|
|
24
|
+
<IconSparkle size={28} />
|
|
25
25
|
</span>
|
|
26
|
-
<p className="font-display text-
|
|
27
|
-
<p className="text-
|
|
26
|
+
<p className="font-display text-foreground text-xl">{t('emptyTitle')}</p>
|
|
27
|
+
<p className="text-muted-foreground text-sm">{t('emptyBody')}</p>
|
|
28
28
|
<Link href="/products" className="btn-soft btn-sm mt-2">
|
|
29
29
|
{tc('viewAll')}
|
|
30
30
|
</Link>
|
|
@@ -33,12 +33,7 @@ export function ProductGrid({ products, className }: ProductGridProps) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
return (
|
|
36
|
-
<div
|
|
37
|
-
className={cn(
|
|
38
|
-
'grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4 lg:gap-6',
|
|
39
|
-
className
|
|
40
|
-
)}
|
|
41
|
-
>
|
|
36
|
+
<div className={cn('grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4 lg:gap-6', className)}>
|
|
42
37
|
{products.map((product) => (
|
|
43
38
|
<ProductCard key={product.id} product={product} />
|
|
44
39
|
))}
|
|
@@ -1,269 +1,276 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Inline icon set — minimal 24px-grid stroke icons (lucide-style).
|
|
3
|
-
* The project ships no icon dependency; every icon in the storefront comes
|
|
4
|
-
* from here so sizing (16/20/24) and stroke weight stay consistent.
|
|
5
|
-
* All icons are decorative by default (aria-hidden) — pair with sr-only text.
|
|
6
|
-
*/
|
|
7
|
-
import * as React from 'react';
|
|
8
|
-
|
|
9
|
-
export type IconProps = {
|
|
10
|
-
size?: 16 | 18 | 20 | 24 | 28 | 32 | 56;
|
|
11
|
-
className?: string;
|
|
12
|
-
/** stroke width — 1.75 default matches the light, boutique-grade look */
|
|
13
|
-
strokeWidth?: number;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
function Base({
|
|
17
|
-
size = 20,
|
|
18
|
-
className,
|
|
19
|
-
strokeWidth = 1.75,
|
|
20
|
-
children,
|
|
21
|
-
fill = 'none',
|
|
22
|
-
}: IconProps & { children: React.ReactNode; fill?: string }) {
|
|
23
|
-
return (
|
|
24
|
-
<svg
|
|
25
|
-
width={size}
|
|
26
|
-
height={size}
|
|
27
|
-
viewBox="0 0 24 24"
|
|
28
|
-
fill={fill}
|
|
29
|
-
stroke="currentColor"
|
|
30
|
-
strokeWidth={strokeWidth}
|
|
31
|
-
strokeLinecap="round"
|
|
32
|
-
strokeLinejoin="round"
|
|
33
|
-
aria-hidden="true"
|
|
34
|
-
className={className}
|
|
35
|
-
>
|
|
36
|
-
{children}
|
|
37
|
-
</svg>
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function IconBag(p: IconProps) {
|
|
42
|
-
return (
|
|
43
|
-
<Base {...p}>
|
|
44
|
-
<path d="M6 7h12l1.2 12.2a1.8 1.8 0 0 1-1.8 1.8H6.6a1.8 1.8 0 0 1-1.8-1.8L6 7Z" />
|
|
45
|
-
<path d="M9 10V6a3 3 0 0 1 6 0v4" />
|
|
46
|
-
</Base>
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function IconUser(p: IconProps) {
|
|
51
|
-
return (
|
|
52
|
-
<Base {...p}>
|
|
53
|
-
<circle cx="12" cy="8" r="3.5" />
|
|
54
|
-
<path d="M5 20c1.4-3 4-4.5 7-4.5s5.6 1.5 7 4.5" />
|
|
55
|
-
</Base>
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function IconMenu(p: IconProps) {
|
|
60
|
-
return (
|
|
61
|
-
<Base {...p}>
|
|
62
|
-
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
63
|
-
</Base>
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function IconX(p: IconProps) {
|
|
68
|
-
return (
|
|
69
|
-
<Base {...p}>
|
|
70
|
-
<path d="M6 6l12 12M18 6L6 18" />
|
|
71
|
-
</Base>
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function IconPlus(p: IconProps) {
|
|
76
|
-
return (
|
|
77
|
-
<Base {...p}>
|
|
78
|
-
<path d="M12 5v14M5 12h14" />
|
|
79
|
-
</Base>
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function IconMinus(p: IconProps) {
|
|
84
|
-
return (
|
|
85
|
-
<Base {...p}>
|
|
86
|
-
<path d="M5 12h14" />
|
|
87
|
-
</Base>
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function IconTrash(p: IconProps) {
|
|
92
|
-
return (
|
|
93
|
-
<Base {...p}>
|
|
94
|
-
<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13a1.5 1.5 0 0 0 1.5 1.3h7A1.5 1.5 0 0 0 17 20l1-13M9 7V5a1.5 1.5 0 0 1 1.5-1.5h3A1.5 1.5 0 0 1 15 5v2" />
|
|
95
|
-
</Base>
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function IconCheck(p: IconProps) {
|
|
100
|
-
return (
|
|
101
|
-
<Base {...p}>
|
|
102
|
-
<path d="M5 12.5 10 17.5 19 7" />
|
|
103
|
-
</Base>
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function IconStar({ filled = true, ...p }: IconProps & { filled?: boolean }) {
|
|
108
|
-
return (
|
|
109
|
-
<Base {...p} fill={filled ? 'currentColor' : 'none'} strokeWidth={filled ? 0 : 1.5}>
|
|
110
|
-
<path d="M12 3.2 14.7 8.7 20.8 9.6 16.4 13.9 17.4 20 12 17.1 6.6 20 7.6 13.9 3.2 9.6 9.3 8.7 12 3.2Z" />
|
|
111
|
-
</Base>
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function IconTruck(p: IconProps) {
|
|
116
|
-
return (
|
|
117
|
-
<Base {...p}>
|
|
118
|
-
<path d="M3 6.5h11v10H3zM14 10h3.5l3 3v3.5h-3" />
|
|
119
|
-
<circle cx="7" cy="17.5" r="1.8" />
|
|
120
|
-
<circle cx="17" cy="17.5" r="1.8" />
|
|
121
|
-
</Base>
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export function IconShield(p: IconProps) {
|
|
126
|
-
return (
|
|
127
|
-
<Base {...p}>
|
|
128
|
-
<path d="M12 3.5 19 6v5.5c0 4.5-3 7.7-7 9-4-1.3-7-4.5-7-9V6l7-2.5Z" />
|
|
129
|
-
<path d="m9 11.8 2.2 2.2L15.5 9.5" />
|
|
130
|
-
</Base>
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function
|
|
135
|
-
return (
|
|
136
|
-
<Base {...p}>
|
|
137
|
-
<path d="
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
<path d="
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
<path d="
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Inline icon set — minimal 24px-grid stroke icons (lucide-style).
|
|
3
|
+
* The project ships no icon dependency; every icon in the storefront comes
|
|
4
|
+
* from here so sizing (16/20/24) and stroke weight stay consistent.
|
|
5
|
+
* All icons are decorative by default (aria-hidden) — pair with sr-only text.
|
|
6
|
+
*/
|
|
7
|
+
import * as React from 'react';
|
|
8
|
+
|
|
9
|
+
export type IconProps = {
|
|
10
|
+
size?: 16 | 18 | 20 | 24 | 28 | 32 | 56;
|
|
11
|
+
className?: string;
|
|
12
|
+
/** stroke width — 1.75 default matches the light, boutique-grade look */
|
|
13
|
+
strokeWidth?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function Base({
|
|
17
|
+
size = 20,
|
|
18
|
+
className,
|
|
19
|
+
strokeWidth = 1.75,
|
|
20
|
+
children,
|
|
21
|
+
fill = 'none',
|
|
22
|
+
}: IconProps & { children: React.ReactNode; fill?: string }) {
|
|
23
|
+
return (
|
|
24
|
+
<svg
|
|
25
|
+
width={size}
|
|
26
|
+
height={size}
|
|
27
|
+
viewBox="0 0 24 24"
|
|
28
|
+
fill={fill}
|
|
29
|
+
stroke="currentColor"
|
|
30
|
+
strokeWidth={strokeWidth}
|
|
31
|
+
strokeLinecap="round"
|
|
32
|
+
strokeLinejoin="round"
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
className={className}
|
|
35
|
+
>
|
|
36
|
+
{children}
|
|
37
|
+
</svg>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function IconBag(p: IconProps) {
|
|
42
|
+
return (
|
|
43
|
+
<Base {...p}>
|
|
44
|
+
<path d="M6 7h12l1.2 12.2a1.8 1.8 0 0 1-1.8 1.8H6.6a1.8 1.8 0 0 1-1.8-1.8L6 7Z" />
|
|
45
|
+
<path d="M9 10V6a3 3 0 0 1 6 0v4" />
|
|
46
|
+
</Base>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function IconUser(p: IconProps) {
|
|
51
|
+
return (
|
|
52
|
+
<Base {...p}>
|
|
53
|
+
<circle cx="12" cy="8" r="3.5" />
|
|
54
|
+
<path d="M5 20c1.4-3 4-4.5 7-4.5s5.6 1.5 7 4.5" />
|
|
55
|
+
</Base>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function IconMenu(p: IconProps) {
|
|
60
|
+
return (
|
|
61
|
+
<Base {...p}>
|
|
62
|
+
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
63
|
+
</Base>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function IconX(p: IconProps) {
|
|
68
|
+
return (
|
|
69
|
+
<Base {...p}>
|
|
70
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
71
|
+
</Base>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function IconPlus(p: IconProps) {
|
|
76
|
+
return (
|
|
77
|
+
<Base {...p}>
|
|
78
|
+
<path d="M12 5v14M5 12h14" />
|
|
79
|
+
</Base>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function IconMinus(p: IconProps) {
|
|
84
|
+
return (
|
|
85
|
+
<Base {...p}>
|
|
86
|
+
<path d="M5 12h14" />
|
|
87
|
+
</Base>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function IconTrash(p: IconProps) {
|
|
92
|
+
return (
|
|
93
|
+
<Base {...p}>
|
|
94
|
+
<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13a1.5 1.5 0 0 0 1.5 1.3h7A1.5 1.5 0 0 0 17 20l1-13M9 7V5a1.5 1.5 0 0 1 1.5-1.5h3A1.5 1.5 0 0 1 15 5v2" />
|
|
95
|
+
</Base>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function IconCheck(p: IconProps) {
|
|
100
|
+
return (
|
|
101
|
+
<Base {...p}>
|
|
102
|
+
<path d="M5 12.5 10 17.5 19 7" />
|
|
103
|
+
</Base>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function IconStar({ filled = true, ...p }: IconProps & { filled?: boolean }) {
|
|
108
|
+
return (
|
|
109
|
+
<Base {...p} fill={filled ? 'currentColor' : 'none'} strokeWidth={filled ? 0 : 1.5}>
|
|
110
|
+
<path d="M12 3.2 14.7 8.7 20.8 9.6 16.4 13.9 17.4 20 12 17.1 6.6 20 7.6 13.9 3.2 9.6 9.3 8.7 12 3.2Z" />
|
|
111
|
+
</Base>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function IconTruck(p: IconProps) {
|
|
116
|
+
return (
|
|
117
|
+
<Base {...p}>
|
|
118
|
+
<path d="M3 6.5h11v10H3zM14 10h3.5l3 3v3.5h-3" />
|
|
119
|
+
<circle cx="7" cy="17.5" r="1.8" />
|
|
120
|
+
<circle cx="17" cy="17.5" r="1.8" />
|
|
121
|
+
</Base>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function IconShield(p: IconProps) {
|
|
126
|
+
return (
|
|
127
|
+
<Base {...p}>
|
|
128
|
+
<path d="M12 3.5 19 6v5.5c0 4.5-3 7.7-7 9-4-1.3-7-4.5-7-9V6l7-2.5Z" />
|
|
129
|
+
<path d="m9 11.8 2.2 2.2L15.5 9.5" />
|
|
130
|
+
</Base>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function IconBadgeCheck(p: IconProps) {
|
|
135
|
+
return (
|
|
136
|
+
<Base {...p}>
|
|
137
|
+
<path d="M8 3.5h8l4.5 4.5v8L16 20.5H8L3.5 16V8L8 3.5Z" />
|
|
138
|
+
<path d="m9 12 2.2 2.2L15.5 10" />
|
|
139
|
+
</Base>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function IconReturn(p: IconProps) {
|
|
144
|
+
return (
|
|
145
|
+
<Base {...p}>
|
|
146
|
+
<path d="M4 9h11a5 5 0 0 1 0 10H8" />
|
|
147
|
+
<path d="M8 5 4 9l4 4" />
|
|
148
|
+
</Base>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function IconChevronDown(p: IconProps) {
|
|
153
|
+
return (
|
|
154
|
+
<Base {...p}>
|
|
155
|
+
<path d="m6 9 6 6 6-6" />
|
|
156
|
+
</Base>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Points toward the inline-end (reading direction). Add className="rtl-flip". */
|
|
161
|
+
export function IconArrowEnd(p: IconProps) {
|
|
162
|
+
return (
|
|
163
|
+
<Base {...p}>
|
|
164
|
+
<path d="M4 12h16M14 6l6 6-6 6" />
|
|
165
|
+
</Base>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function IconSparkle(p: IconProps) {
|
|
170
|
+
return (
|
|
171
|
+
<Base {...p}>
|
|
172
|
+
<path d="M12 4c.7 3.6 2.4 5.3 6 6-3.6.7-5.3 2.4-6 6-.7-3.6-2.4-5.3-6-6 3.6-.7 5.3-2.4 6-6Z" />
|
|
173
|
+
<path
|
|
174
|
+
d="M19 15.5c.3 1.5 1 2.2 2.5 2.5-1.5.3-2.2 1-2.5 2.5-.3-1.5-1-2.2-2.5-2.5 1.5-.3 2.2-1 2.5-2.5Z"
|
|
175
|
+
strokeWidth={1.4}
|
|
176
|
+
/>
|
|
177
|
+
</Base>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function IconQuote(p: IconProps) {
|
|
182
|
+
return (
|
|
183
|
+
<Base {...p} fill="currentColor" strokeWidth={0}>
|
|
184
|
+
<path d="M5 6.5A4.5 4.5 0 0 0 3 11v6.5h6.5V11H6a3 3 0 0 1 2.5-3V6.5H5Zm10 0A4.5 4.5 0 0 0 13 11v6.5h6.5V11H16a3 3 0 0 1 2.5-3V6.5H15Z" />
|
|
185
|
+
</Base>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function IconMail(p: IconProps) {
|
|
190
|
+
return (
|
|
191
|
+
<Base {...p}>
|
|
192
|
+
<rect x="3" y="5.5" width="18" height="13" rx="2" />
|
|
193
|
+
<path d="m4 7.5 8 6 8-6" />
|
|
194
|
+
</Base>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function IconClock(p: IconProps) {
|
|
199
|
+
return (
|
|
200
|
+
<Base {...p}>
|
|
201
|
+
<circle cx="12" cy="12" r="8.5" />
|
|
202
|
+
<path d="M12 7.5V12l3 2" />
|
|
203
|
+
</Base>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function IconTag(p: IconProps) {
|
|
208
|
+
return (
|
|
209
|
+
<Base {...p}>
|
|
210
|
+
<path d="m3.5 12.5 8-8H20v8.5l-8 8a1.7 1.7 0 0 1-2.4 0l-6.1-6.1a1.7 1.7 0 0 1 0-2.4Z" />
|
|
211
|
+
<circle cx="15.5" cy="8.5" r="1.4" />
|
|
212
|
+
</Base>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function IconDownload(p: IconProps) {
|
|
217
|
+
return (
|
|
218
|
+
<Base {...p}>
|
|
219
|
+
<path d="M12 4v11M7 10.5 12 15.5l5-5M5 19.5h14" />
|
|
220
|
+
</Base>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Social glyphs for the footer (filled, single-color). */
|
|
225
|
+
export function IconSocial({ platform, ...p }: IconProps & { platform: string }) {
|
|
226
|
+
const key = platform.toLowerCase();
|
|
227
|
+
if (key.includes('instagram')) {
|
|
228
|
+
return (
|
|
229
|
+
<Base {...p} strokeWidth={1.6}>
|
|
230
|
+
<rect x="3.5" y="3.5" width="17" height="17" rx="4.5" />
|
|
231
|
+
<circle cx="12" cy="12" r="4" />
|
|
232
|
+
<circle cx="17.2" cy="6.8" r="0.6" fill="currentColor" stroke="none" />
|
|
233
|
+
</Base>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (key.includes('facebook')) {
|
|
237
|
+
return (
|
|
238
|
+
<Base {...p} fill="currentColor" strokeWidth={0}>
|
|
239
|
+
<path d="M13.5 21v-7h2.4l.4-3h-2.8V9.1c0-.9.3-1.5 1.6-1.5h1.3V4.9c-.2 0-1-.1-1.9-.1-1.9 0-3.3 1.2-3.3 3.4V11H8.5v3h2.7v7h2.3Z" />
|
|
240
|
+
</Base>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (key.includes('tiktok')) {
|
|
244
|
+
return (
|
|
245
|
+
<Base {...p} fill="currentColor" strokeWidth={0}>
|
|
246
|
+
<path d="M16.6 3c.3 2 1.5 3.3 3.4 3.6v2.8c-1.3 0-2.5-.4-3.4-1.1v5.9a5.6 5.6 0 1 1-5.6-5.6c.3 0 .6 0 .9.1v2.9a2.7 2.7 0 1 0 1.9 2.6V3h2.8Z" />
|
|
247
|
+
</Base>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (key.includes('whatsapp')) {
|
|
251
|
+
return (
|
|
252
|
+
<Base {...p} strokeWidth={1.6}>
|
|
253
|
+
<path d="M12 3.5a8.5 8.5 0 0 0-7.3 12.8L3.5 20.5l4.3-1.1A8.5 8.5 0 1 0 12 3.5Z" />
|
|
254
|
+
<path
|
|
255
|
+
d="M9 8.8c-.3 2.5 3.7 6.5 6.2 6.2l.6-1.5-2-1-.8.6c-.8-.4-1.7-1.3-2.1-2.1l.6-.8-1-2-1.5.6Z"
|
|
256
|
+
strokeWidth={1.3}
|
|
257
|
+
/>
|
|
258
|
+
</Base>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
if (key.includes('youtube')) {
|
|
262
|
+
return (
|
|
263
|
+
<Base {...p} strokeWidth={1.6}>
|
|
264
|
+
<rect x="2.5" y="6" width="19" height="12" rx="3" />
|
|
265
|
+
<path d="m10 9.5 5 2.5-5 2.5v-5Z" fill="currentColor" stroke="none" />
|
|
266
|
+
</Base>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
// Fallback: globe
|
|
270
|
+
return (
|
|
271
|
+
<Base {...p} strokeWidth={1.6}>
|
|
272
|
+
<circle cx="12" cy="12" r="8.5" />
|
|
273
|
+
<path d="M3.5 12h17M12 3.5c-4.7 4.7-4.7 12.3 0 17 4.7-4.7 4.7-12.3 0-17Z" />
|
|
274
|
+
</Base>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { useTranslations } from '@/core/lib/translations';
|
|
4
4
|
import { cn } from '@/core/lib/utils';
|
|
5
|
-
import { IconGem } from '@/ui/shared/icons';
|
|
6
5
|
|
|
7
6
|
interface LoadingSpinnerProps {
|
|
8
7
|
size?: 'sm' | 'md' | 'lg';
|
|
@@ -16,10 +15,11 @@ const RING_SIZE: Record<'sm' | 'md', string> = {
|
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Loading indicator. `sm`/`md` stay quiet ring spinners for buttons and
|
|
19
|
-
* inline waits; `lg` (the full-page state) is the Atelier
|
|
20
|
-
* a
|
|
21
|
-
* visible loading line. Static under
|
|
22
|
-
* Keeps `role="status"` and the sm/md/lg `size`
|
|
18
|
+
* inline waits; `lg` (the full-page state) is the Atelier breathing mark —
|
|
19
|
+
* a soft pulsing core inside a thin rotating ring band, no niche-specific
|
|
20
|
+
* iconography, plus the visible loading line. Static under
|
|
21
|
+
* prefers-reduced-motion. Keeps `role="status"` and the sm/md/lg `size`
|
|
22
|
+
* contract.
|
|
23
23
|
*/
|
|
24
24
|
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
|
25
25
|
const t = useTranslations('common');
|
|
@@ -34,11 +34,10 @@ export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps)
|
|
|
34
34
|
className
|
|
35
35
|
)}
|
|
36
36
|
>
|
|
37
|
-
<span aria-hidden="true" className="loader-
|
|
38
|
-
<span className="loader-
|
|
39
|
-
<IconGem size={20} className="text-primary/80" />
|
|
37
|
+
<span aria-hidden="true" className="loader-ring">
|
|
38
|
+
<span className="loader-ring-core" />
|
|
40
39
|
</span>
|
|
41
|
-
<span className="loader-text font-display text-sm italic
|
|
40
|
+
<span className="loader-text font-display text-muted-foreground text-sm italic">
|
|
42
41
|
{t('loading')}
|
|
43
42
|
</span>
|
|
44
43
|
</span>
|
|
@@ -54,7 +53,7 @@ export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps)
|
|
|
54
53
|
<span
|
|
55
54
|
aria-hidden="true"
|
|
56
55
|
className={cn(
|
|
57
|
-
'spin
|
|
56
|
+
'spin border-primary/25 border-t-primary inline-block rounded-full',
|
|
58
57
|
RING_SIZE[size]
|
|
59
58
|
)}
|
|
60
59
|
/>
|