create-brainerce-store 1.84.0 → 1.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +130 -92
  2. package/dist/index.js +39 -17
  3. package/messages/en.json +9 -2
  4. package/messages/he.json +9 -2
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.env.local.ejs +10 -1
  7. package/templates/nextjs/base/AGENTS.md.ejs +229 -207
  8. package/templates/nextjs/base/AI-GUIDE.md +4 -0
  9. package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
  10. package/templates/nextjs/base/scripts/connect.mjs +115 -18
  11. package/templates/nextjs/base/scripts/env-file.mjs +93 -0
  12. package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
  14. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +2 -0
  15. package/templates/nextjs/base/src/app/checkout/page.tsx +1325 -1302
  16. package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
  17. package/templates/nextjs/base/src/app/icon.svg +11 -0
  18. package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
  19. package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
  20. package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
  21. package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
  22. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +17 -2
  23. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
  24. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +130 -12
  25. package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
  26. package/templates/nextjs/base/src/core/lib/tracking.ts +25 -14
  27. package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
  28. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  29. package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
  30. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
  31. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
  32. package/templates/nextjs/base/src/ui/product/price-display.tsx +85 -62
  33. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +539 -537
  34. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
  35. package/templates/nextjs/designs/atelier/globals.css +408 -402
  36. package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
  37. package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
  38. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
  39. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
  40. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
  41. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
  42. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
  43. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
  44. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
  45. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
  46. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +90 -69
  47. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
  48. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +560 -553
  49. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
  50. package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
  51. package/templates/nextjs/ui-canvas/product/price-display.tsx +73 -52
  52. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +428 -426
@@ -1,110 +1,113 @@
1
- 'use client';
2
-
3
- import { Link } from '@/core/lib/navigation';
4
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
- import type { ProductRecommendation } from 'brainerce';
6
- import { PriceDisplay } from '@/ui/product/price-display';
7
- import { cn } from '@/core/lib/utils';
8
-
9
- interface RecommendationCardProps {
10
- item: ProductRecommendation;
11
- className?: string;
12
- }
13
-
14
- function RecommendationCard({ item, className }: RecommendationCardProps) {
15
- const firstImage = item.images?.[0];
16
- const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
17
- const slug = item.slug || item.id;
18
- // ⛔ NOT region-converted, and it cannot be. `ProductRecommendation` is a
19
- // trimmed shape that carries `basePrice` / `salePrice` only: the backend
20
- // attaches no displayPrice/displayCurrency to it, so there is no converted
21
- // amount to render and converting here would mean inventing an FX rate.
22
- // These tiles stay in the store currency on a multi-region store. Do not
23
- // "fix" it with a client-side conversion.
24
- const basePrice = parseFloat(item.basePrice);
25
- const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
26
- const isOnSale = salePrice != null && salePrice < basePrice;
27
- // ⛔ No price on a KIT tile. The recommendations endpoint does not resolve
28
- // kits, so `item.basePrice` here is the kit's stored PLACEHOLDER — right only
29
- // for FIXED pricing and wrong for every SUM / SUM_MINUS_PERCENT kit. Showing
30
- // it would quote a price the product page then contradicts. The tile still
31
- // links through, and the product page reads the resolved price from the API.
32
- // Do NOT recompute a kit price on the client.
33
- const priceIsUnresolved = item.type === 'KIT';
34
-
35
- return (
36
- <Link
37
- href={`/products/${slug}`}
38
- className={cn('group card card-hover block overflow-hidden', className)}
39
- >
40
- {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
41
- <span className="relative block aspect-square overflow-hidden bg-secondary">
42
- {imageUrl ? (
43
- <Image
44
- src={imageUrl}
45
- alt={item.name}
46
- fill
47
- sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
48
- className="img-zoom object-cover"
49
- />
50
- ) : null}
51
- </span>
52
- <span className="block space-y-1 p-4">
53
- <span className="block text-sm font-semibold leading-snug text-foreground group-hover:text-primary">
54
- {item.name}
55
- </span>
56
- {priceIsUnresolved ? null : (
57
- <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
58
- )}
59
- </span>
60
- </Link>
61
- );
62
- }
63
-
64
- interface RecommendationSectionProps {
65
- title: string;
66
- items: ProductRecommendation[];
67
- className?: string;
68
- }
69
-
70
- /** Recommendation strip on the product page (upsells / related). */
71
- export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
72
- if (items.length === 0) return null;
73
-
74
- return (
75
- <section className={cn('border-t pt-10', className)}>
76
- <h2 className="mb-6 text-2xl">{title}</h2>
77
- <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
78
- {items.slice(0, 4).map((item) => (
79
- <RecommendationCard key={item.id} item={item} />
80
- ))}
81
- </div>
82
- </section>
83
- );
84
- }
85
-
86
- interface CartRecommendationSectionProps {
87
- title: string;
88
- items: ProductRecommendation[];
89
- className?: string;
90
- }
91
-
92
- /** Cross-sell strip on the cart page ("you might also need"). */
93
- export function CartRecommendationSection({
94
- title,
95
- items,
96
- className,
97
- }: CartRecommendationSectionProps) {
98
- if (items.length === 0) return null;
99
-
100
- return (
101
- <section className={cn('border-t pt-10', className)}>
102
- <h2 className="mb-6 text-2xl">{title}</h2>
103
- <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
104
- {items.slice(0, 4).map((item) => (
105
- <RecommendationCard key={item.id} item={item} />
106
- ))}
107
- </div>
108
- </section>
109
- );
110
- }
1
+ 'use client';
2
+
3
+ import { Link } from '@/core/lib/navigation';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import { NoPhotoTile } from '@/ui/shared/no-photo-tile';
6
+ import type { ProductRecommendation } from 'brainerce';
7
+ import { PriceDisplay } from '@/ui/product/price-display';
8
+ import { cn } from '@/core/lib/utils';
9
+
10
+ interface RecommendationCardProps {
11
+ item: ProductRecommendation;
12
+ className?: string;
13
+ }
14
+
15
+ function RecommendationCard({ item, className }: RecommendationCardProps) {
16
+ const firstImage = item.images?.[0];
17
+ const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
18
+ const slug = item.slug || item.id;
19
+ // ⛔ NOT region-converted, and it cannot be. `ProductRecommendation` is a
20
+ // trimmed shape that carries `basePrice` / `salePrice` only: the backend
21
+ // attaches no displayPrice/displayCurrency to it, so there is no converted
22
+ // amount to render and converting here would mean inventing an FX rate.
23
+ // These tiles stay in the store currency on a multi-region store. Do not
24
+ // "fix" it with a client-side conversion.
25
+ const basePrice = parseFloat(item.basePrice);
26
+ const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
27
+ const isOnSale = salePrice != null && salePrice < basePrice;
28
+ // ⛔ No price on a KIT tile. The recommendations endpoint does not resolve
29
+ // kits, so `item.basePrice` here is the kit's stored PLACEHOLDER — right only
30
+ // for FIXED pricing and wrong for every SUM / SUM_MINUS_PERCENT kit. Showing
31
+ // it would quote a price the product page then contradicts. The tile still
32
+ // links through, and the product page reads the resolved price from the API.
33
+ // Do NOT recompute a kit price on the client.
34
+ const priceIsUnresolved = item.type === 'KIT';
35
+
36
+ return (
37
+ <Link
38
+ href={`/products/${slug}`}
39
+ className={cn('group card card-hover block overflow-hidden', className)}
40
+ >
41
+ {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
42
+ <span className="relative block aspect-square overflow-hidden bg-secondary">
43
+ {imageUrl ? (
44
+ <Image
45
+ src={imageUrl}
46
+ alt={item.name}
47
+ fill
48
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
49
+ className="img-zoom object-cover"
50
+ />
51
+ ) : (
52
+ <NoPhotoTile name={item.name} />
53
+ )}
54
+ </span>
55
+ <span className="block space-y-1 p-4">
56
+ <span className="block text-sm font-semibold leading-snug text-foreground group-hover:text-primary">
57
+ {item.name}
58
+ </span>
59
+ {priceIsUnresolved ? null : (
60
+ <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
61
+ )}
62
+ </span>
63
+ </Link>
64
+ );
65
+ }
66
+
67
+ interface RecommendationSectionProps {
68
+ title: string;
69
+ items: ProductRecommendation[];
70
+ className?: string;
71
+ }
72
+
73
+ /** Recommendation strip on the product page (upsells / related). */
74
+ export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
75
+ if (items.length === 0) return null;
76
+
77
+ return (
78
+ <section className={cn('border-t pt-10', className)}>
79
+ <h2 className="mb-6 text-2xl">{title}</h2>
80
+ <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
81
+ {items.slice(0, 4).map((item) => (
82
+ <RecommendationCard key={item.id} item={item} />
83
+ ))}
84
+ </div>
85
+ </section>
86
+ );
87
+ }
88
+
89
+ interface CartRecommendationSectionProps {
90
+ title: string;
91
+ items: ProductRecommendation[];
92
+ className?: string;
93
+ }
94
+
95
+ /** Cross-sell strip on the cart page ("you might also need"). */
96
+ export function CartRecommendationSection({
97
+ title,
98
+ items,
99
+ className,
100
+ }: CartRecommendationSectionProps) {
101
+ if (items.length === 0) return null;
102
+
103
+ return (
104
+ <section className={cn('border-t pt-10', className)}>
105
+ <h2 className="mb-6 text-2xl">{title}</h2>
106
+ <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
107
+ {items.slice(0, 4).map((item) => (
108
+ <RecommendationCard key={item.id} item={item} />
109
+ ))}
110
+ </div>
111
+ </section>
112
+ );
113
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Deliberate no-photo state for any image well in the pack.
3
+ *
4
+ * A brand-new store is seeded with names and prices only, so every product
5
+ * opens without a photo. Leaving the well as a bare `bg-secondary` square
6
+ * makes the grid look broken; this fills it with the same atmosphere the rest
7
+ * of the design uses (sand / forest washes, display serif) so the state reads
8
+ * as designed. The first uploaded image replaces it automatically.
9
+ *
10
+ * Fills its `relative` parent like a `fill` image would. Always decorative:
11
+ * every caller already names the product accessibly beside the well (card
12
+ * heading, cart line, page title), so the tile is `aria-hidden`.
13
+ */
14
+ import { cn } from '@/core/lib/utils';
15
+
16
+ /** First user-perceived character of the name, safe for surrogate pairs. */
17
+ function initialOf(name: string): string {
18
+ return Array.from(name.trim())[0] ?? '';
19
+ }
20
+
21
+ const INITIAL_SIZE = {
22
+ sm: 'text-lg',
23
+ md: 'text-4xl sm:text-5xl',
24
+ lg: 'text-6xl sm:text-7xl',
25
+ } as const;
26
+
27
+ export function NoPhotoTile({
28
+ name,
29
+ tone = 'sand',
30
+ size = 'md',
31
+ showName = false,
32
+ className,
33
+ }: {
34
+ name: string;
35
+ /** `sand` on light wells, `forest` for the hero's lead card. */
36
+ tone?: 'sand' | 'forest';
37
+ size?: 'sm' | 'md' | 'lg';
38
+ /** Print the full name under the initial (hero / category tiles). */
39
+ showName?: boolean;
40
+ className?: string;
41
+ }) {
42
+ const forest = tone === 'forest';
43
+ return (
44
+ <span
45
+ aria-hidden="true"
46
+ className={cn(
47
+ 'absolute inset-0 flex flex-col items-center justify-center gap-2 overflow-hidden p-4 text-center',
48
+ forest ? 'wash-forest text-primary-foreground' : 'wash-sand text-primary',
49
+ className
50
+ )}
51
+ >
52
+ <span
53
+ className={cn(
54
+ 'font-display select-none leading-none',
55
+ INITIAL_SIZE[size],
56
+ forest ? 'text-primary-foreground/70' : 'text-primary/45'
57
+ )}
58
+ >
59
+ {initialOf(name)}
60
+ </span>
61
+ {showName && (
62
+ <span
63
+ className={cn(
64
+ 'line-clamp-2 max-w-full text-sm font-semibold leading-snug',
65
+ forest ? 'text-primary-foreground/85' : 'text-foreground/80'
66
+ )}
67
+ >
68
+ {name}
69
+ </span>
70
+ )}
71
+ </span>
72
+ );
73
+ }
@@ -1,52 +1,73 @@
1
- 'use client';
2
-
3
- import { formatPrice } from 'brainerce';
4
- import { useCurrency } from '@/core/lib/use-currency';
5
- import { cn } from '@/core/lib/utils';
6
-
7
- interface PriceDisplayProps {
8
- price: string | number;
9
- salePrice?: string | number | null;
10
- currency?: string;
11
- className?: string;
12
- size?: 'sm' | 'md' | 'lg';
13
- }
14
-
15
- /**
16
- * DESIGN ME — price renderer used by cards, product page and recommendations;
17
- * computes sale state + discount % from props (currency via useCurrency()).
18
- * Never hardcode currency symbols — formatPrice handles them.
19
- *
20
- * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
21
- */
22
- export function PriceDisplay({
23
- price,
24
- salePrice,
25
- currency,
26
- className,
27
- size = 'md',
28
- }: PriceDisplayProps) {
29
- const currencyCode = useCurrency(currency);
30
-
31
- const basePrice = typeof price === 'string' ? parseFloat(price) : price;
32
- const sale =
33
- salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
34
- const isOnSale = sale !== null && sale < basePrice;
35
-
36
- const discountPercent =
37
- isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
38
-
39
- return (
40
- <span data-size={size} className={cn('inline-flex items-center gap-2', className)}>
41
- {isOnSale ? (
42
- <>
43
- <span>{formatPrice(sale!, { currency: currencyCode }) as string}</span>
44
- <s>{formatPrice(basePrice, { currency: currencyCode }) as string}</s>
45
- {discountPercent > 0 && <span>-{discountPercent}%</span>}
46
- </>
47
- ) : (
48
- <span>{formatPrice(basePrice, { currency: currencyCode }) as string}</span>
49
- )}
50
- </span>
51
- );
52
- }
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { useCurrency } from '@/core/lib/use-currency';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+ import { useStoreInfo } from '@/core/providers/store-provider';
7
+ import { cn } from '@/core/lib/utils';
8
+
9
+ interface PriceDisplayProps {
10
+ price: string | number;
11
+ salePrice?: string | number | null;
12
+ currency?: string;
13
+ className?: string;
14
+ size?: 'sm' | 'md' | 'lg';
15
+ }
16
+
17
+ /**
18
+ * DESIGN ME — price renderer used by cards, product page and recommendations;
19
+ * computes sale state + discount % from props (currency via useCurrency()).
20
+ * Never hardcode currency symbols — formatPrice handles them.
21
+ *
22
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
23
+ */
24
+ export function PriceDisplay({
25
+ price,
26
+ salePrice,
27
+ currency,
28
+ className,
29
+ size = 'md',
30
+ }: PriceDisplayProps) {
31
+ const currencyCode = useCurrency(currency);
32
+ const { storeInfo } = useStoreInfo();
33
+ const t = useTranslations('common');
34
+
35
+ const basePrice = typeof price === 'string' ? parseFloat(price) : price;
36
+ const sale =
37
+ salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
38
+ const isOnSale = sale !== null && sale < basePrice;
39
+
40
+ const discountPercent =
41
+ isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
42
+
43
+ // FX overlay disclosure (see core/lib/display-price.ts): `currency` is the
44
+ // region-converted DISPLAY currency when it differs from the store's own
45
+ // base currency. The shopper is still charged in the base currency at
46
+ // checkout, so every converted price needs a visible "estimated" cue.
47
+ const isConverted = !!currency && !!storeInfo?.currency && currency !== storeInfo.currency;
48
+
49
+ return (
50
+ <span data-size={size} className={cn('inline-flex items-center gap-2', className)}>
51
+ {isOnSale ? (
52
+ <>
53
+ <span>{formatPrice(sale!, { currency: currencyCode }) as string}</span>
54
+ <s>{formatPrice(basePrice, { currency: currencyCode }) as string}</s>
55
+ {discountPercent > 0 && <span>-{discountPercent}%</span>}
56
+ </>
57
+ ) : (
58
+ <span>{formatPrice(basePrice, { currency: currencyCode }) as string}</span>
59
+ )}
60
+ {isConverted && (
61
+ <span
62
+ className="text-muted-foreground cursor-help text-xs"
63
+ title={t('estimatedPriceTooltip', {
64
+ currency: currencyCode,
65
+ baseCurrency: storeInfo!.currency,
66
+ })}
67
+ >
68
+ {t('estimatedPrice')}
69
+ </span>
70
+ )}
71
+ </span>
72
+ );
73
+ }