@fayz-ai/storefront 0.7.0 → 0.8.2

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 (48) hide show
  1. package/README.md +3 -1
  2. package/dist/auth.d.ts.map +1 -1
  3. package/dist/components/CartDrawer.d.ts.map +1 -1
  4. package/dist/components/DeliveryEstimator.d.ts +17 -0
  5. package/dist/components/DeliveryEstimator.d.ts.map +1 -0
  6. package/dist/components/ProductGallery.d.ts +22 -0
  7. package/dist/components/ProductGallery.d.ts.map +1 -0
  8. package/dist/components/StorefrontHeader.d.ts.map +1 -1
  9. package/dist/config.d.ts +8 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/createStorefrontApp.d.ts.map +1 -1
  12. package/dist/index.cjs +886 -370
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +790 -275
  17. package/dist/index.js.map +1 -1
  18. package/dist/pages/CheckoutPage.d.ts.map +1 -1
  19. package/dist/pages/ProductDetailPage.d.ts.map +1 -1
  20. package/dist/presets.d.ts +5 -0
  21. package/dist/presets.d.ts.map +1 -1
  22. package/dist/stores/cart.store.d.ts +21 -0
  23. package/dist/stores/cart.store.d.ts.map +1 -1
  24. package/dist/stores/delivery.store.d.ts +64 -0
  25. package/dist/stores/delivery.store.d.ts.map +1 -0
  26. package/dist/testids.d.ts +12 -0
  27. package/dist/testids.d.ts.map +1 -1
  28. package/dist/theme.d.ts +18 -2
  29. package/dist/theme.d.ts.map +1 -1
  30. package/dist/workflows/checkout.d.ts +5 -2
  31. package/dist/workflows/checkout.d.ts.map +1 -1
  32. package/package.json +13 -7
  33. package/src/auth.ts +57 -1
  34. package/src/components/CartDrawer.tsx +23 -1
  35. package/src/components/DeliveryEstimator.tsx +157 -0
  36. package/src/components/ProductGallery.tsx +174 -0
  37. package/src/components/StorefrontHeader.tsx +35 -8
  38. package/src/config.ts +15 -3
  39. package/src/createStorefrontApp.tsx +18 -1
  40. package/src/index.ts +1 -0
  41. package/src/pages/CheckoutPage.tsx +240 -134
  42. package/src/pages/ProductDetailPage.tsx +17 -13
  43. package/src/presets.ts +66 -0
  44. package/src/stores/cart.store.ts +37 -1
  45. package/src/stores/delivery.store.ts +152 -0
  46. package/src/testids.ts +13 -0
  47. package/src/theme.ts +33 -3
  48. package/src/workflows/checkout.ts +34 -6
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware'
3
3
  import type { Product } from '@fayz-ai/shop/types'
4
4
  import { roundCents } from '../format'
5
5
  import type { ResolvedStorefrontConfig } from '../config'
6
+ import { useDeliveryStore, selectQuotedShipping } from './delivery.store'
6
7
  import {
7
8
  formatProductOptionSelection,
8
9
  normalizeProductOptionSelection,
@@ -40,11 +41,16 @@ export interface CartState {
40
41
  clear(): void
41
42
  openDrawer(): void
42
43
  closeDrawer(): void
44
+ /** Drop persisted lines the active backend can no longer resolve (e.g. a cart
45
+ * built in an earlier mock-mode session, whose ids don't exist live). The
46
+ * resolver returns false only for a definitive "not found"; on error it must
47
+ * return true so a transient failure never discards a legit line. */
48
+ reconcile(resolve: (line: CartLine) => Promise<boolean>): Promise<void>
43
49
  }
44
50
 
45
51
  export const useCartStore = create<CartState>()(
46
52
  persist(
47
- (set) => ({
53
+ (set, get) => ({
48
54
  lines: [],
49
55
  discountCode: null,
50
56
  discountPercent: 0,
@@ -106,6 +112,16 @@ export const useCartStore = create<CartState>()(
106
112
  clear: () => set({ lines: [], discountCode: null, discountPercent: 0 }),
107
113
  openDrawer: () => set({ isOpen: true }),
108
114
  closeDrawer: () => set({ isOpen: false, justAddedLineId: null }),
115
+
116
+ reconcile: async (resolve) => {
117
+ const lines = get().lines
118
+ if (lines.length === 0) return
119
+ const keep = await Promise.all(
120
+ lines.map((line) => resolve(line).catch(() => true)),
121
+ )
122
+ const kept = lines.filter((_, i) => keep[i])
123
+ if (kept.length !== lines.length) set({ lines: kept })
124
+ },
109
125
  }),
110
126
  {
111
127
  name: 'fayz.storefront.cart.v1',
@@ -132,12 +148,32 @@ export const selectSubtotal = (s: Pick<CartState, 'lines'>): number =>
132
148
  export const selectDiscountTotal = (s: Pick<CartState, 'lines' | 'discountPercent'>): number =>
133
149
  roundCents(selectSubtotal(s) * (s.discountPercent / 100))
134
150
 
151
+ /**
152
+ * Freight to display.
153
+ *
154
+ * When the shopper has given a CEP and the store quoted it, that quote wins —
155
+ * it came from the same shipping_zones rows shop_place_order will charge from.
156
+ * Otherwise this falls back to the store-wide rate in config, which is what
157
+ * every storefront did before zones existed.
158
+ *
159
+ * The subtotal is PRE-discount on both sides of this. That is 0017's rule, and
160
+ * the quote is requested against the same number, so a coupon can never make
161
+ * the cart and the order disagree.
162
+ *
163
+ * Read through getState() rather than a hook because this is a plain selector
164
+ * called from several screens; components that render the value subscribe to
165
+ * useDeliveryStore themselves so a fresh quote re-renders them.
166
+ */
135
167
  export const selectShipping = (
136
168
  s: Pick<CartState, 'lines'>,
137
169
  cfg: ResolvedStorefrontConfig,
138
170
  ): number => {
139
171
  if (s.lines.length === 0) return 0
140
172
  const subtotal = selectSubtotal(s)
173
+
174
+ const quoted = selectQuotedShipping(useDeliveryStore.getState(), subtotal)
175
+ if (quoted != null) return quoted
176
+
141
177
  if (cfg.shipping.freeAbove != null && subtotal >= cfg.shipping.freeAbove) return 0
142
178
  return cfg.shipping.flatRate
143
179
  }
@@ -0,0 +1,152 @@
1
+ import { create } from 'zustand'
2
+ import { persist } from 'zustand/middleware'
3
+ import { lookupPostalCode, normalizePostalCode, type PostalAddress } from '@fayz-ai/core'
4
+ import { getShopProvider } from '@fayz-ai/shop/runtime'
5
+ import type { ShippingQuoteOption } from '@fayz-ai/shop/types'
6
+
7
+ /**
8
+ * Where the shopper wants it delivered, decided before the cart exists.
9
+ *
10
+ * Two different things live here and they are persisted differently on purpose:
11
+ *
12
+ * the ADDRESS is durable — a CEP maps to the same street for years, and
13
+ * carrying it to checkout is the whole point (the buyer types the number and
14
+ * nothing else);
15
+ *
16
+ * the QUOTE is not persisted at all. It is a price, and a price restored from
17
+ * localStorage is a price the shopper can edit with devtools. It is re-asked
18
+ * on demand, and resolved for the current cart through resolveOptionRate —
19
+ * the same rule shop_shipping_for applies — so it can never go stale. The
20
+ * order is charged from shop_place_order, which recomputes from the same
21
+ * zones and ignores anything the client sends.
22
+ */
23
+
24
+ export type DeliveryStatus = 'idle' | 'loading' | 'served' | 'unserved' | 'error'
25
+
26
+ export interface DeliveryState {
27
+ postalCode: string
28
+ address: PostalAddress | null
29
+ options: ShippingQuoteOption[]
30
+ selectedZoneId: string | null
31
+ status: DeliveryStatus
32
+ error: string | null
33
+
34
+ /** Look the CEP up and quote it. `subtotal` decides free-above thresholds. */
35
+ resolve: (postalCode: string, subtotal: number) => Promise<void>
36
+ selectZone: (zoneId: string) => void
37
+ clear: () => void
38
+ }
39
+
40
+ const EMPTY = {
41
+ postalCode: '',
42
+ address: null,
43
+ options: [] as ShippingQuoteOption[],
44
+ selectedZoneId: null,
45
+ status: 'idle' as DeliveryStatus,
46
+ error: null,
47
+ }
48
+
49
+ export const useDeliveryStore = create<DeliveryState>()(
50
+ persist(
51
+ (set, get) => ({
52
+ ...EMPTY,
53
+
54
+ async resolve(postalCode: string, subtotal: number) {
55
+ const code = normalizePostalCode(postalCode)
56
+ if (code.length !== 8) {
57
+ set({ ...EMPTY, postalCode, status: 'error', error: 'Digite os 8 dígitos do CEP.' })
58
+ return
59
+ }
60
+
61
+ set({ postalCode: code, status: 'loading', error: null })
62
+
63
+ // The QUOTE comes first and is the answer that matters. Whether we
64
+ // deliver to a postcode, and for how much, is decided entirely by our
65
+ // own zones against eight digits — it does not need to know the street.
66
+ //
67
+ // Doing it the other way round made the delivery answer depend on a
68
+ // third party: with ViaCEP slow or rate-limited, a perfectly serviceable
69
+ // address reported an error instead of a price.
70
+ let options: ShippingQuoteOption[] = []
71
+ try {
72
+ options = (await getShopProvider().quoteShipping?.(code, subtotal)) ?? []
73
+ } catch {
74
+ set({ ...EMPTY, postalCode: code, status: 'error',
75
+ error: 'Não foi possível calcular o frete agora. Tente de novo.' })
76
+ return
77
+ }
78
+
79
+ // The ADDRESS is the convenience layer: it saves the buyer typing, and
80
+ // losing it costs typing, not the sale. Failures here never change the
81
+ // delivery verdict already decided above.
82
+ let address: PostalAddress | null = null
83
+ let notFound = false
84
+ try {
85
+ address = await lookupPostalCode(code)
86
+ notFound = address === null
87
+ } catch {
88
+ address = null
89
+ }
90
+
91
+ set({
92
+ postalCode: code,
93
+ address,
94
+ options,
95
+ selectedZoneId: options[0]?.zoneId ?? null,
96
+ // A store with no zones configured quotes nothing, and that must NOT
97
+ // read as "we don't deliver here" — it falls back to the store-wide
98
+ // flat rate, which the cart already knows how to show.
99
+ status: options.length > 0 ? 'served' : 'unserved',
100
+ // Reported alongside the price, not instead of it: the CEP may be
101
+ // mistyped even though its range is covered.
102
+ error: notFound ? 'CEP não encontrado. Confira os números.' : null,
103
+ })
104
+ },
105
+
106
+ selectZone(zoneId: string) {
107
+ if (get().options.some((option) => option.zoneId === zoneId)) set({ selectedZoneId: zoneId })
108
+ },
109
+
110
+ clear() {
111
+ set({ ...EMPTY })
112
+ },
113
+ }),
114
+ {
115
+ name: 'fayz.storefront.delivery.v1',
116
+ // Deliberately NOT persisting `options`: a rehydrated
117
+ // price would be shown as current without anything having quoted it. The
118
+ // address survives, the money is asked for again.
119
+ partialize: (state) => ({ postalCode: state.postalCode, address: state.address }),
120
+ onRehydrateStorage: () => (state) => {
121
+ if (state?.address) state.status = 'idle'
122
+ },
123
+ },
124
+ ),
125
+ )
126
+
127
+ /**
128
+ * The freight for the chosen zone at THIS subtotal, or null when there is no
129
+ * zone in play (no CEP yet, or a store that configured none) and the caller
130
+ * should use the store-wide rate. Null never means free.
131
+ *
132
+ * The free-above rule is applied here rather than trusting the `rate` the
133
+ * server resolved, because that one was resolved for whatever the cart held at
134
+ * quote time. Re-deriving from `baseRate`/`freeAbove` — the same CASE
135
+ * shop_shipping_for runs — means adding an item can never leave a stale price
136
+ * on screen. An earlier version fell back to the config flat rate when the
137
+ * quote went stale, which showed R$ 8 (the flat rate) for an address in an
138
+ * R$ 18 zone: shown one number, charged another.
139
+ */
140
+ export function resolveOptionRate(option: ShippingQuoteOption, subtotal: number): number {
141
+ if (option.freeAbove != null && subtotal >= option.freeAbove) return 0
142
+ return option.baseRate
143
+ }
144
+
145
+ export const selectQuotedShipping = (
146
+ state: Pick<DeliveryState, 'options' | 'selectedZoneId' | 'status'>,
147
+ subtotal: number,
148
+ ): number | null => {
149
+ if (state.status !== 'served') return null
150
+ const option = state.options.find((o) => o.zoneId === state.selectedZoneId) ?? state.options[0]
151
+ return option ? resolveOptionRate(option, subtotal) : null
152
+ }
package/src/testids.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  export const TID = {
4
4
  // header
5
5
  headerSearch: 'header-search',
6
+ headerSearchToggle: 'header-search-toggle',
6
7
  cartButton: 'cart-button',
7
8
  cartCount: 'cart-count',
8
9
  accountLink: 'account-link',
@@ -58,6 +59,9 @@ export const TID = {
58
59
  pdpAddToCart: 'pdp-add-to-cart',
59
60
  pdpRoot: 'pdp-root',
60
61
  pdpGallery: 'pdp-gallery',
62
+ pdpGalleryImage: 'pdp-gallery-image',
63
+ pdpGalleryThumbs: 'pdp-gallery-thumbs',
64
+ pdpGalleryThumb: 'pdp-gallery-thumb',
61
65
  pdpActions: 'pdp-actions',
62
66
  pdpEnquiryButton: 'pdp-enquiry-button',
63
67
  enquiryForm: 'enquiry-form',
@@ -86,6 +90,15 @@ export const TID = {
86
90
  cartTotal: 'cart-total',
87
91
  goCheckout: 'go-checkout',
88
92
  cartEmpty: 'cart-empty',
93
+ // delivery estimate (product page + cart)
94
+ deliveryEstimator: 'delivery-estimator',
95
+ deliveryCepInput: 'delivery-cep-input',
96
+ deliveryCepSubmit: 'delivery-cep-submit',
97
+ deliveryOption: 'delivery-option',
98
+ deliveryUnserved: 'delivery-unserved',
99
+ deliveryError: 'delivery-error',
100
+ deliveryAddress: 'delivery-address',
101
+ deliveryClear: 'delivery-clear',
89
102
  // checkout
90
103
  checkoutEmail: 'checkout-email',
91
104
  checkoutName: 'checkout-name',
package/src/theme.ts CHANGED
@@ -33,6 +33,10 @@ export interface StorefrontThemeColors {
33
33
  /** Announcement bar */
34
34
  announcementBackground?: string
35
35
  announcementForeground?: string
36
+ /** Secondary brand accent (e.g. a gourmet gold beside the primary). Emitted
37
+ * as `--sf-accent` / `--sf-accent-foreground` and the `accent` tailwind token. */
38
+ accent?: string
39
+ accentForeground?: string
36
40
  }
37
41
 
38
42
  export interface StorefrontThemeFont {
@@ -40,7 +44,11 @@ export interface StorefrontThemeFont {
40
44
  heading: string
41
45
  /** Body font, e.g. 'Rubik' */
42
46
  body: string
43
- /** Google Fonts families to load (defaults to heading+body) */
47
+ /** Optional oversized display face for hero words (emitted as `--sf-font-display`). */
48
+ display?: string
49
+ /** Optional serif accent for quotes/editorial (emitted as `--sf-font-serif`). */
50
+ serif?: string
51
+ /** Google Fonts families to load (defaults to heading+body+display+serif) */
44
52
  googleFonts?: string[]
45
53
  /** Fallback stack — default sans-serif; use 'serif' for editorial themes */
46
54
  fallback?: 'sans-serif' | 'serif'
@@ -58,8 +66,11 @@ export interface StorefrontTheme {
58
66
  uppercaseNav?: boolean
59
67
  /** Show category shortcuts after primary nav links. Default true. */
60
68
  showCategories?: boolean
61
- /** Show the storefront search input in the header. Default true. */
69
+ /** Show the storefront search in the header. Default true. */
62
70
  showSearch?: boolean
71
+ /** Header search presentation: a full input ('full', default) or a compact
72
+ * icon button that expands into an input on click ('icon'). */
73
+ searchStyle?: 'full' | 'icon'
63
74
  }
64
75
  productCard?: {
65
76
  style?: CardStyle
@@ -67,6 +78,11 @@ export interface StorefrontTheme {
67
78
  }
68
79
  /** Uppercase CTA buttons with letter-spacing (Uyuni/Flex tone) */
69
80
  uppercaseButtons?: boolean
81
+ /** Brand-token passthrough. Each entry is emitted verbatim as a `--<key>` CSS
82
+ * custom property, so a store can add gradients, glows, extra colors, or
83
+ * letter-spacing the fixed schema doesn't enumerate — without editing the SDK.
84
+ * e.g. { 'grad-ember': 'linear-gradient(...)', 'glow-ember': '0 0 0 1px ...' }. */
85
+ tokens?: Record<string, string>
70
86
  }
71
87
 
72
88
  const RADIUS_MAP: Record<StorefrontRadius, { button: string; card: string; input: string }> = {
@@ -90,6 +106,8 @@ const COLOR_VAR_MAP: Record<keyof StorefrontThemeColors, string> = {
90
106
  headerForeground: '--sf-header-fg',
91
107
  announcementBackground: '--sf-announcement-bg',
92
108
  announcementForeground: '--sf-announcement-fg',
109
+ accent: '--sf-accent',
110
+ accentForeground: '--sf-accent-foreground',
93
111
  }
94
112
 
95
113
  export function themeToCss(theme: StorefrontTheme): string {
@@ -111,6 +129,14 @@ export function themeToCss(theme: StorefrontTheme): string {
111
129
  const fallback = theme.font.fallback ?? 'sans-serif'
112
130
  lines.push(`--font-family: '${theme.font.body}', ${fallback};`)
113
131
  lines.push(`--sf-font-heading: '${theme.font.heading}', ${fallback};`)
132
+ if (theme.font.display) lines.push(`--sf-font-display: '${theme.font.display}', ${fallback};`)
133
+ if (theme.font.serif) lines.push(`--sf-font-serif: '${theme.font.serif}', serif;`)
134
+ }
135
+
136
+ // Brand-token passthrough — emit each entry as a raw CSS custom property.
137
+ for (const [key, value] of Object.entries(theme.tokens ?? {})) {
138
+ const name = key.startsWith('--') ? key : `--${key}`
139
+ lines.push(`${name}: ${value};`)
114
140
  }
115
141
 
116
142
  const radius = RADIUS_MAP[theme.radius ?? 'soft']
@@ -135,7 +161,11 @@ export function themeToCss(theme: StorefrontTheme): string {
135
161
 
136
162
  function googleFontsHref(theme: StorefrontTheme): string | null {
137
163
  if (!theme.font) return null
138
- const families = theme.font.googleFonts ?? [theme.font.heading, theme.font.body]
164
+ const families =
165
+ theme.font.googleFonts ??
166
+ [theme.font.heading, theme.font.body, theme.font.display, theme.font.serif].filter(
167
+ (f): f is string => Boolean(f),
168
+ )
139
169
  const unique = [...new Set(families)]
140
170
  const params = unique
141
171
  .map((f) => `family=${encodeURIComponent(f).replace(/%20/g, '+')}:wght@300;400;500;600;700`)
@@ -1,5 +1,5 @@
1
1
  import { getShopProvider } from '@fayz-ai/shop/runtime'
2
- import type { Order } from '@fayz-ai/shop/types'
2
+ import type { Order, ShippingAddressInput, PaymentMethodKind } from '@fayz-ai/shop/types'
3
3
  import { establishCustomerSession } from '../auth'
4
4
  import type { ResolvedStorefrontConfig } from '../config'
5
5
  import type { CartState } from '../stores/cart.store'
@@ -24,6 +24,9 @@ export interface PlaceStorefrontOrderInput {
24
24
  session?: Pick<SessionState, 'customerId' | 'email'>
25
25
  customer: StorefrontCheckoutCustomer
26
26
  address?: StorefrontCheckoutAddress
27
+ /** Structured address; without it the order keeps only the free-text note. */
28
+ shippingAddress?: ShippingAddressInput
29
+ paymentMethod?: PaymentMethodKind
27
30
  markPaid?: boolean
28
31
  }
29
32
 
@@ -32,10 +35,25 @@ export interface PlaceStorefrontOrderResult {
32
35
  customerId: string
33
36
  }
34
37
 
35
- function formatDeliveryNotes(address: StorefrontCheckoutAddress | undefined): string | undefined {
36
- if (!address) return undefined
37
- if (address.notes?.trim()) return address.notes.trim()
38
+ /**
39
+ * `notes` is the buyer's free-text message to the store — nothing else.
40
+ *
41
+ * It used to double as the delivery address, from back when the order had
42
+ * nowhere structured to put one. Now that the address is a real snapshot plus a
43
+ * row in the address book, repeating it here only creates a second copy that
44
+ * can disagree with the first (it did: it dropped number and district). When a
45
+ * structured address is present, notes carries only what the customer wrote.
46
+ */
47
+ function formatDeliveryNotes(
48
+ address: StorefrontCheckoutAddress | undefined,
49
+ shippingAddress?: ShippingAddressInput,
50
+ ): string | undefined {
51
+ if (address?.notes?.trim()) return address.notes.trim()
38
52
 
53
+ // Structured address present → the order already knows where to deliver.
54
+ if (shippingAddress) return undefined
55
+
56
+ if (!address) return undefined
39
57
  const parts = [address.street, address.city, address.zip]
40
58
  .map((part) => part?.trim())
41
59
  .filter(Boolean)
@@ -54,6 +72,8 @@ export async function placeStorefrontOrder({
54
72
  session,
55
73
  customer,
56
74
  address,
75
+ shippingAddress,
76
+ paymentMethod,
57
77
  markPaid = false,
58
78
  }: PlaceStorefrontOrderInput): Promise<PlaceStorefrontOrderResult> {
59
79
  const email = customer.email.trim().toLowerCase()
@@ -73,9 +93,14 @@ export async function placeStorefrontOrder({
73
93
  customerId: customerId ?? undefined,
74
94
  customer: { name, email },
75
95
  currency: config.currency,
76
- notes: formatDeliveryNotes(address),
96
+ notes: formatDeliveryNotes(address, shippingAddress),
77
97
  discountCode: cart.discountCode ?? undefined,
78
98
  shippingTotal: selectShipping(cart, config),
99
+ // Structured address + method are what populate public.addresses and the
100
+ // transactions ledger. `notes` stays for backwards compatibility with
101
+ // checkouts that have not been updated yet.
102
+ shippingAddress,
103
+ paymentMethod,
79
104
  items: cart.lines.map((line) => ({
80
105
  productId: line.productId,
81
106
  quantity: line.quantity,
@@ -84,7 +109,10 @@ export async function placeStorefrontOrder({
84
109
  })
85
110
 
86
111
  if (markPaid) {
87
- await provider.updateOrder(order.id, { financialStatus: 'paid' })
112
+ // Prefer the RPC seam — anon storefronts have no UPDATE grant on the
113
+ // orders table, so a direct updateOrder 401s on pool backends.
114
+ if (provider.confirmPayment) await provider.confirmPayment(order.id)
115
+ else await provider.updateOrder(order.id, { financialStatus: 'paid' })
88
116
  }
89
117
 
90
118
  return { order, customerId: customerId ?? order.customerId ?? '' }