@fayz-ai/storefront 0.8.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.
@@ -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
@@ -59,6 +59,9 @@ export const TID = {
59
59
  pdpAddToCart: 'pdp-add-to-cart',
60
60
  pdpRoot: 'pdp-root',
61
61
  pdpGallery: 'pdp-gallery',
62
+ pdpGalleryImage: 'pdp-gallery-image',
63
+ pdpGalleryThumbs: 'pdp-gallery-thumbs',
64
+ pdpGalleryThumb: 'pdp-gallery-thumb',
62
65
  pdpActions: 'pdp-actions',
63
66
  pdpEnquiryButton: 'pdp-enquiry-button',
64
67
  enquiryForm: 'enquiry-form',
@@ -87,6 +90,15 @@ export const TID = {
87
90
  cartTotal: 'cart-total',
88
91
  goCheckout: 'go-checkout',
89
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',
90
102
  // checkout
91
103
  checkoutEmail: 'checkout-email',
92
104
  checkoutName: 'checkout-name',
@@ -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,