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 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.55.0",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.55.0",
3
+ "version": "1.58.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -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(intent.clientSecret)) {
387
+ if (!isAllowedPaymentUrl(paymentUrl)) {
373
388
  setError(t('paymentRedirectBlocked'));
374
389
  return;
375
390
  }
376
- safePaymentRedirect(intent.clientSecret);
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(intent.clientSecret)) {
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(intent.clientSecret).origin;
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={paymentIntent.clientSecret}
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={paymentIntent.clientSecret}
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={paymentIntent.clientSecret} className="text-primary hover:underline">
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 gem orbiting a ring band (Atelier's "polishing" mark).
354
- Static ring + gem under reduced motion; the orbit/breathe are additive. */
355
- .loader-orbit {
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.25);
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-orbit-dot {
367
- position: absolute;
368
- inset: 0;
369
- }
370
- .loader-orbit-dot::before {
366
+ .loader-ring::before {
371
367
  content: '';
372
368
  position: absolute;
373
- top: -3px;
374
- inset-inline-start: 50%;
375
- margin-inline-start: -3.5px;
376
- height: 7px;
377
- width: 7px;
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.55);
379
+ box-shadow: 0 0 12px hsl(var(--primary) / 0.4);
381
380
  }
382
381
  @media (prefers-reduced-motion: no-preference) {
383
- .loader-orbit-dot {
384
- animation: orbit 1.5s cubic-bezier(0.55, 0.12, 0.45, 0.88) infinite;
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 orbit {
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 bg-foreground/35 backdrop-blur-[2px] transition-opacity duration-300 motion-reduce:transition-none',
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 bg-background shadow-2xl transition-transform duration-300 ease-out motion-reduce:transition-none sm:rounded-s-3xl sm:border-s',
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 text-foreground">{t('title')}</h2>
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 bg-primary px-1.5 text-xs font-bold text-primary-foreground">
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 text-foreground transition-colors hover:bg-secondary"
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 bg-secondary text-muted-foreground">
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 text-foreground">{t('emptyTitle')}</p>
117
- <p className="max-w-xs text-sm text-muted-foreground">{t('emptySubtitle')}</p>
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="border-t bg-secondary/40 px-5 py-4">
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-base text-foreground">
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 text-primary">
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 text-muted-foreground">{t('shippingAtCheckout')}</p>
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, IconGem, IconReturn } from '@/ui/shared/icons';
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: IconGem, titleKey: 'benefit3Title', descKey: 'benefit3Desc' },
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 bg-secondary text-primary">
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 text-foreground">{t(titleKey)}</p>
27
- <p className="mt-0.5 text-[13px] leading-5 text-muted-foreground">{t(descKey)}</p>
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
- IconGem,
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 data-pdp-gallery className="relative aspect-square overflow-hidden rounded-3xl border bg-secondary shadow-[0_28px_60px_-30px_hsl(var(--primary)/0.3)]">
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 bg-secondary transition-colors',
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-transparent hover:border-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 text-muted-foreground">
226
+ <p className="text-muted-foreground mb-1 text-sm">
224
227
  {t('by')}{' '}
225
- <span className="font-medium text-foreground">
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 text-muted-foreground">
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 bg-secondary/60 p-4">
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 text-muted-foreground">
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 text-destructive">
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 text-foreground transition-colors hover:bg-secondary disabled:opacity-40"
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 text-foreground transition-colors hover:bg-secondary"
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={() => { flyToCart(firstVisible('[data-pdp-gallery]')); handleAddToCart(); }}
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 text-muted-foreground">
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
- <IconGem size={20} />
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 text-muted-foreground">{t('downloadAfterPurchase')}</p>
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 text-muted-foreground"
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 { IconGem } from '@/ui/shared/icons';
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 bg-secondary text-primary">
24
- <IconGem size={28} />
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-xl text-foreground">{t('emptyTitle')}</p>
27
- <p className="text-sm text-muted-foreground">{t('emptyBody')}</p>
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 IconGem(p: IconProps) {
135
- return (
136
- <Base {...p}>
137
- <path d="M7 4h10l3.5 5L12 20.5 1.5 9 5 4Z M1.5 9h21M9.5 4 12 9l2.5-5M12 20.5 9.5 9M12 20.5 14.5 9" strokeWidth={1.4} />
138
- </Base>
139
- );
140
- }
141
-
142
- export function IconReturn(p: IconProps) {
143
- return (
144
- <Base {...p}>
145
- <path d="M4 9h11a5 5 0 0 1 0 10H8" />
146
- <path d="M8 5 4 9l4 4" />
147
- </Base>
148
- );
149
- }
150
-
151
- export function IconChevronDown(p: IconProps) {
152
- return (
153
- <Base {...p}>
154
- <path d="m6 9 6 6 6-6" />
155
- </Base>
156
- );
157
- }
158
-
159
- /** Points toward the inline-end (reading direction). Add className="rtl-flip". */
160
- export function IconArrowEnd(p: IconProps) {
161
- return (
162
- <Base {...p}>
163
- <path d="M4 12h16M14 6l6 6-6 6" />
164
- </Base>
165
- );
166
- }
167
-
168
- export function IconSparkle(p: IconProps) {
169
- return (
170
- <Base {...p}>
171
- <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" />
172
- <path 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" strokeWidth={1.4} />
173
- </Base>
174
- );
175
- }
176
-
177
- export function IconQuote(p: IconProps) {
178
- return (
179
- <Base {...p} fill="currentColor" strokeWidth={0}>
180
- <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" />
181
- </Base>
182
- );
183
- }
184
-
185
- export function IconMail(p: IconProps) {
186
- return (
187
- <Base {...p}>
188
- <rect x="3" y="5.5" width="18" height="13" rx="2" />
189
- <path d="m4 7.5 8 6 8-6" />
190
- </Base>
191
- );
192
- }
193
-
194
- export function IconClock(p: IconProps) {
195
- return (
196
- <Base {...p}>
197
- <circle cx="12" cy="12" r="8.5" />
198
- <path d="M12 7.5V12l3 2" />
199
- </Base>
200
- );
201
- }
202
-
203
- export function IconTag(p: IconProps) {
204
- return (
205
- <Base {...p}>
206
- <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" />
207
- <circle cx="15.5" cy="8.5" r="1.4" />
208
- </Base>
209
- );
210
- }
211
-
212
- export function IconDownload(p: IconProps) {
213
- return (
214
- <Base {...p}>
215
- <path d="M12 4v11M7 10.5 12 15.5l5-5M5 19.5h14" />
216
- </Base>
217
- );
218
- }
219
-
220
- /** Social glyphs for the footer (filled, single-color). */
221
- export function IconSocial({ platform, ...p }: IconProps & { platform: string }) {
222
- const key = platform.toLowerCase();
223
- if (key.includes('instagram')) {
224
- return (
225
- <Base {...p} strokeWidth={1.6}>
226
- <rect x="3.5" y="3.5" width="17" height="17" rx="4.5" />
227
- <circle cx="12" cy="12" r="4" />
228
- <circle cx="17.2" cy="6.8" r="0.6" fill="currentColor" stroke="none" />
229
- </Base>
230
- );
231
- }
232
- if (key.includes('facebook')) {
233
- return (
234
- <Base {...p} fill="currentColor" strokeWidth={0}>
235
- <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" />
236
- </Base>
237
- );
238
- }
239
- if (key.includes('tiktok')) {
240
- return (
241
- <Base {...p} fill="currentColor" strokeWidth={0}>
242
- <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" />
243
- </Base>
244
- );
245
- }
246
- if (key.includes('whatsapp')) {
247
- return (
248
- <Base {...p} strokeWidth={1.6}>
249
- <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" />
250
- <path 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" strokeWidth={1.3} />
251
- </Base>
252
- );
253
- }
254
- if (key.includes('youtube')) {
255
- return (
256
- <Base {...p} strokeWidth={1.6}>
257
- <rect x="2.5" y="6" width="19" height="12" rx="3" />
258
- <path d="m10 9.5 5 2.5-5 2.5v-5Z" fill="currentColor" stroke="none" />
259
- </Base>
260
- );
261
- }
262
- // Fallback: globe
263
- return (
264
- <Base {...p} strokeWidth={1.6}>
265
- <circle cx="12" cy="12" r="8.5" />
266
- <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" />
267
- </Base>
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 "polishing" mark —
20
- * a gem resting inside a ring band with a light orbiting around it, plus the
21
- * visible loading line. Static under prefers-reduced-motion.
22
- * Keeps `role="status"` and the sm/md/lg `size` contract.
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-orbit">
38
- <span className="loader-orbit-dot" />
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 text-muted-foreground">
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 inline-block rounded-full border-primary/25 border-t-primary',
56
+ 'spin border-primary/25 border-t-primary inline-block rounded-full',
58
57
  RING_SIZE[size]
59
58
  )}
60
59
  />