create-brainerce-store 1.33.2 → 1.34.1

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.
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
 
3
- import { useState, useMemo } from 'react';
3
+ import { useState } from 'react';
4
4
  import Image from 'next/image';
5
5
  import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
6
- import { formatPrice, getVariantOptions } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
7
7
  import { useStoreInfo } from '@/providers/store-provider';
8
8
  import { useTranslations } from '@/lib/translations';
9
9
  import { cn } from '@/lib/utils';
@@ -20,117 +20,28 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
20
20
  const t = useTranslations('cart');
21
21
  const currency = storeInfo?.currency || 'USD';
22
22
  const [adding, setAdding] = useState(false);
23
- const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null);
24
23
 
25
- const product = offer.bundleProduct;
26
- const variants = product.variants;
27
- const requiresSelection = offer.requiresVariantSelection && variants && variants.length > 0;
24
+ const offered = offer.offeredProducts;
28
25
 
29
- // Build attribute groups from variants
30
- const attributeGroups = useMemo(() => {
31
- if (!requiresSelection || !variants) return [];
32
- const groups = new Map<string, Set<string>>();
33
- for (const v of variants) {
34
- const opts = getVariantOptions(v as any);
35
- for (const opt of opts) {
36
- if (!groups.has(opt.name)) groups.set(opt.name, new Set());
37
- groups.get(opt.name)!.add(opt.value);
38
- }
39
- }
40
- return Array.from(groups.entries()).map(([name, values]) => ({
41
- name,
42
- values: Array.from(values),
43
- }));
44
- }, [requiresSelection, variants]);
45
-
46
- const [selectedAttrs, setSelectedAttrs] = useState<Record<string, string>>({});
47
-
48
- const selectedVariant = useMemo(() => {
49
- if (!requiresSelection || !variants) return null;
50
- return (
51
- variants.find((v) => {
52
- const opts = getVariantOptions(v as any);
53
- return attributeGroups.every((group) => {
54
- const opt = opts.find((o) => o.name === group.name);
55
- return opt && selectedAttrs[group.name] === opt.value;
56
- });
57
- }) ?? null
58
- );
59
- }, [requiresSelection, variants, selectedAttrs, attributeGroups]);
60
-
61
- const effectiveVariantId = selectedVariant?.id ?? selectedVariantId;
62
-
63
- function handleAttrSelect(attrName: string, value: string) {
64
- const next = { ...selectedAttrs, [attrName]: value };
65
- setSelectedAttrs(next);
66
- if (variants) {
67
- const match = variants.find((v) => {
68
- const opts = getVariantOptions(v as any);
69
- return attributeGroups.every((group) => {
70
- const opt = opts.find((o) => o.name === group.name);
71
- return opt && next[group.name] === opt.value;
72
- });
73
- });
74
- setSelectedVariantId(match?.id ?? null);
75
- }
76
- }
77
-
78
- // Compute display prices
79
- const { displayOriginal, displayDiscounted, discountLabel } = useMemo(() => {
80
- let effectivePrice: number;
81
- if (selectedVariant) {
82
- const vSale = selectedVariant.salePrice ? parseFloat(selectedVariant.salePrice) : null;
83
- const vPrice = selectedVariant.price ? parseFloat(selectedVariant.price) : null;
84
- effectivePrice = vSale ?? vPrice ?? parseFloat(offer.originalPrice);
85
- } else {
86
- effectivePrice = parseFloat(offer.originalPrice);
87
- }
88
-
89
- let discounted: number;
90
- if (offer.discountType === 'PERCENTAGE') {
91
- discounted = effectivePrice * (1 - parseFloat(offer.discountValue) / 100);
92
- } else {
93
- discounted = Math.max(0, effectivePrice - parseFloat(offer.discountValue));
94
- }
95
-
96
- const label =
97
- offer.discountType === 'PERCENTAGE'
98
- ? `${offer.discountValue}%`
99
- : (formatPrice(parseFloat(offer.discountValue), { currency }) as string);
100
-
101
- return { displayOriginal: effectivePrice, displayDiscounted: discounted, discountLabel: label };
102
- }, [selectedVariant, offer.originalPrice, offer.discountType, offer.discountValue, currency]);
103
-
104
- const isOos =
105
- selectedVariant?.inventory?.trackingMode !== 'NOT_TRACKED' &&
106
- selectedVariant?.inventory?.available != null &&
107
- selectedVariant.inventory.available <= 0;
108
-
109
- const lockedLabel =
110
- offer.lockedVariant?.name ??
111
- (offer.lockedVariant?.attributes
112
- ? Object.values(offer.lockedVariant.attributes).join(' / ')
113
- : null);
114
-
115
- const firstImage = product.images?.[0];
116
- const imageUrl = firstImage
117
- ? typeof firstImage === 'string'
118
- ? firstImage
119
- : firstImage.url
120
- : null;
121
-
122
- const canAdd = !requiresSelection || !!effectiveVariantId;
26
+ const totalOriginal = parseFloat(offer.totalOriginalPrice);
27
+ const totalDiscounted = parseFloat(offer.totalDiscountedPrice);
28
+ const discountLabel =
29
+ offer.discountType === 'PERCENTAGE'
30
+ ? `${offer.discountValue}%`
31
+ : (formatPrice(parseFloat(offer.discountValue), { currency }) as string);
123
32
 
124
33
  async function handleAdd() {
125
- if (adding || !canAdd || isOos) return;
34
+ if (adding) return;
126
35
  try {
127
36
  setAdding(true);
128
37
  const { getClient } = await import('@/lib/brainerce');
129
38
  const client = getClient();
130
- await client.addBundleToCart(cartId, offer.id, effectiveVariantId ?? undefined);
39
+ // No variant selections in the default template — products with
40
+ // variants are best handled with a dedicated picker per offered item.
41
+ await client.addBundleToCart(cartId, offer.id);
131
42
  onAdd();
132
43
  } catch (err) {
133
- console.error('Failed to add bundle item:', err);
44
+ console.error('Failed to add bundle:', err);
134
45
  } finally {
135
46
  setAdding(false);
136
47
  }
@@ -138,110 +49,64 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
138
49
 
139
50
  return (
140
51
  <div className={cn('bg-background border-border rounded-lg border p-4', className)}>
141
- <div className="flex items-center gap-4">
142
- {/* Product image */}
143
- <div className="bg-muted relative h-16 w-16 flex-shrink-0 overflow-hidden rounded">
144
- {imageUrl ? (
145
- <Image src={imageUrl} alt={product.name} fill sizes="64px" className="object-cover" />
146
- ) : (
147
- <div className="text-muted-foreground flex h-full w-full items-center justify-center">
148
- <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
149
- <path
150
- strokeLinecap="round"
151
- strokeLinejoin="round"
152
- strokeWidth={1.5}
153
- d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
154
- />
155
- </svg>
156
- </div>
157
- )}
158
- </div>
52
+ <div className="mb-3">
53
+ <p className="text-foreground text-sm font-medium">{offer.name}</p>
54
+ {offer.description && (
55
+ <p className="text-muted-foreground mt-0.5 text-xs">{offer.description}</p>
56
+ )}
57
+ </div>
159
58
 
160
- {/* Details */}
161
- <div className="min-w-0 flex-1">
162
- <p className="text-foreground text-sm font-medium">{offer.name}</p>
163
- {offer.description && (
164
- <p className="text-muted-foreground mt-0.5 text-xs">{offer.description}</p>
165
- )}
166
- {lockedLabel && <p className="text-muted-foreground mt-0.5 text-xs">{lockedLabel}</p>}
167
- <div className="mt-1 flex items-center gap-2">
168
- <span className="text-muted-foreground text-sm line-through">
169
- {formatPrice(displayOriginal, { currency }) as string}
170
- </span>
171
- <span className="text-foreground text-sm font-semibold">
172
- {formatPrice(displayDiscounted, { currency }) as string}
173
- </span>
174
- <span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-xs font-medium">
175
- -{discountLabel}
176
- </span>
177
- </div>
59
+ <ul className="space-y-2">
60
+ {offered.map((p) => {
61
+ const firstImage = p.images?.[0];
62
+ const imageUrl = firstImage?.url ?? null;
63
+ return (
64
+ <li key={p.id} className="flex items-center gap-3">
65
+ <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
66
+ {imageUrl ? (
67
+ <Image src={imageUrl} alt={p.name} fill sizes="48px" className="object-cover" />
68
+ ) : null}
69
+ </div>
70
+ <div className="min-w-0 flex-1">
71
+ <p className="text-foreground truncate text-sm">{p.name}</p>
72
+ <div className="mt-0.5 flex items-center gap-2">
73
+ <span className="text-muted-foreground text-xs line-through">
74
+ {formatPrice(parseFloat(p.originalPrice), { currency }) as string}
75
+ </span>
76
+ <span className="text-foreground text-xs font-semibold">
77
+ {formatPrice(parseFloat(p.discountedPrice), { currency }) as string}
78
+ </span>
79
+ </div>
80
+ </div>
81
+ </li>
82
+ );
83
+ })}
84
+ </ul>
85
+
86
+ <div className="border-border mt-3 flex items-center justify-between border-t pt-3">
87
+ <div className="flex items-center gap-2">
88
+ <span className="text-muted-foreground text-sm line-through">
89
+ {formatPrice(totalOriginal, { currency }) as string}
90
+ </span>
91
+ <span className="text-foreground text-sm font-semibold">
92
+ {formatPrice(totalDiscounted, { currency }) as string}
93
+ </span>
94
+ <span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-xs font-medium">
95
+ -{discountLabel}
96
+ </span>
178
97
  </div>
179
-
180
- {/* Add button */}
181
98
  <button
182
99
  type="button"
183
100
  onClick={handleAdd}
184
- disabled={adding || !canAdd || isOos}
101
+ disabled={adding}
185
102
  className={cn(
186
103
  'bg-primary text-primary-foreground flex-shrink-0 rounded px-4 py-2 text-xs font-medium transition-opacity hover:opacity-90',
187
104
  'disabled:cursor-not-allowed disabled:opacity-50'
188
105
  )}
189
106
  >
190
- {adding
191
- ? t('addingBundle')
192
- : requiresSelection && !canAdd
193
- ? t('selectOptions') || 'Select options'
194
- : t('addBundleItem')}
107
+ {adding ? t('addingBundle') : t('addBundleItem')}
195
108
  </button>
196
109
  </div>
197
-
198
- {/* Compact variant selector */}
199
- {requiresSelection && (
200
- <div className="mt-3 space-y-1.5 ps-20">
201
- {attributeGroups.map((group) => (
202
- <div key={group.name} className="flex flex-wrap items-center gap-1.5">
203
- <span className="text-muted-foreground text-xs">{group.name}:</span>
204
- {group.values.map((value) => {
205
- const isSelected = selectedAttrs[group.name] === value;
206
- const variantForValue = variants?.find((v) => {
207
- const opts = getVariantOptions(v as any);
208
- const matchesValue = opts.some((o) => o.name === group.name && o.value === value);
209
- if (!matchesValue) return false;
210
- return Object.entries(selectedAttrs).every(([k, sv]) => {
211
- if (k === group.name) return true;
212
- return opts.some((o) => o.name === k && o.value === sv);
213
- });
214
- });
215
- const isVariantOos =
216
- variantForValue?.inventory?.trackingMode !== 'NOT_TRACKED' &&
217
- variantForValue?.inventory?.available != null &&
218
- variantForValue.inventory.available <= 0;
219
-
220
- return (
221
- <button
222
- key={value}
223
- type="button"
224
- onClick={() => handleAttrSelect(group.name, value)}
225
- disabled={isVariantOos}
226
- className={cn(
227
- 'rounded-full border px-2.5 py-0.5 text-xs transition-colors',
228
- isSelected
229
- ? 'border-primary bg-primary text-primary-foreground'
230
- : 'border-border text-foreground hover:border-primary/50',
231
- isVariantOos && 'cursor-not-allowed line-through opacity-40'
232
- )}
233
- >
234
- {value}
235
- </button>
236
- );
237
- })}
238
- </div>
239
- ))}
240
- {isOos && effectiveVariantId && (
241
- <p className="text-destructive text-xs">{t('outOfStock') || 'Out of stock'}</p>
242
- )}
243
- </div>
244
- )}
245
110
  </div>
246
111
  );
247
112
  }
@@ -1,60 +1,38 @@
1
- const ALLOWED_PAYMENT_HOSTS: readonly string[] = [
2
- 'checkout.stripe.com',
3
- 'js.stripe.com',
4
- 'hooks.stripe.com',
5
- 'www.paypal.com',
6
- 'www.sandbox.paypal.com',
7
- 'secure.cardcom.solutions',
8
- 'meshulam.co.il',
9
- 'grow.link',
10
- 'grow.security',
11
- 'creditguard.co.il',
12
- // Brainerce-hosted payment embeds (cardcom-payments /embed/:lpCode etc.).
13
- // These are platform-owned iframe shells that wrap provider-specific flows
14
- // and relay postMessage events back to the storefront.
15
- 'brainerce.com',
16
- ];
17
-
18
- export function isAllowedPaymentUrl(url: string): boolean {
19
- if (!url || typeof url !== 'string') return false;
20
-
21
- let parsed: URL;
22
- try {
23
- parsed = new URL(url);
24
- } catch {
25
- return false;
26
- }
27
-
28
- const hostname = parsed.hostname.toLowerCase();
29
-
30
- // Dev-only: allow http://localhost|127.0.0.1 so the local storefront can
31
- // iframe the local backend's embed proxy. Stripped in production builds.
32
- if (
33
- process.env.NODE_ENV !== 'production' &&
34
- parsed.protocol === 'http:' &&
35
- (hostname === 'localhost' || hostname === '127.0.0.1')
36
- ) {
37
- return true;
38
- }
39
-
40
- if (parsed.protocol !== 'https:') return false;
41
-
42
- return ALLOWED_PAYMENT_HOSTS.some((host) => hostname === host || hostname.endsWith('.' + host));
43
- }
44
-
45
- export function safePaymentRedirect(url: string): void {
46
- if (!isAllowedPaymentUrl(url)) {
47
- throw new Error('Payment redirect URL is not in the allowlist');
48
- }
49
- if (typeof window !== 'undefined') {
50
- window.location.href = url;
51
- }
52
- }
53
-
54
- // CUID format used by Prisma for Checkout.id — c + 24 lowercase alphanumeric chars.
55
- // Allow a small range to tolerate cuid2 (slightly different length).
56
- const CHECKOUT_ID_RE = /^c[a-z0-9]{20,30}$/;
57
-
58
- export function isValidCheckoutId(id: unknown): id is string {
59
- return typeof id === 'string' && CHECKOUT_ID_RE.test(id);
60
- }
1
+ // Re-export the platform-maintained payment URL allowlist from the SDK.
2
+ //
3
+ // Why a re-export and not a direct import everywhere: keeping a stable
4
+ // `@/lib/safe-redirect` import path means future SDK API changes (e.g. an
5
+ // `extraHosts` rename) only need editing here, and merchants don't have to
6
+ // touch every component that does payment redirects. It also leaves a clear
7
+ // place to plug in store-specific extras (custom self-hosted PSP) without
8
+ // scattering option objects across components.
9
+ //
10
+ // The allowlist itself now lives in the `brainerce` package — running
11
+ // `npm update brainerce` picks up new payment providers and platform
12
+ // embed domains automatically.
13
+
14
+ import {
15
+ isAllowedPaymentUrl as sdkIsAllowedPaymentUrl,
16
+ safePaymentRedirect as sdkSafePaymentRedirect,
17
+ } from 'brainerce';
18
+
19
+ // Dev-only: allow http://localhost|127.0.0.1 so the local storefront can
20
+ // iframe the local backend's embed proxy. Inlined to a literal at build time
21
+ // by Next.js, so the allow path is stripped from production bundles.
22
+ const allowLocalhost = process.env.NODE_ENV !== 'production';
23
+
24
+ export function isAllowedPaymentUrl(url: string): boolean {
25
+ return sdkIsAllowedPaymentUrl(url, { allowLocalhost });
26
+ }
27
+
28
+ export function safePaymentRedirect(url: string): void {
29
+ sdkSafePaymentRedirect(url, { allowLocalhost });
30
+ }
31
+
32
+ // CUID format used by Prisma for Checkout.id — c + 24 lowercase alphanumeric chars.
33
+ // Allow a small range to tolerate cuid2 (slightly different length).
34
+ const CHECKOUT_ID_RE = /^c[a-z0-9]{20,30}$/;
35
+
36
+ export function isValidCheckoutId(id: unknown): id is string {
37
+ return typeof id === 'string' && CHECKOUT_ID_RE.test(id);
38
+ }