@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.
- package/dist/auth.d.ts.map +1 -1
- package/dist/components/CartDrawer.d.ts.map +1 -1
- package/dist/components/DeliveryEstimator.d.ts +17 -0
- package/dist/components/DeliveryEstimator.d.ts.map +1 -0
- package/dist/components/ProductGallery.d.ts +22 -0
- package/dist/components/ProductGallery.d.ts.map +1 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/index.cjs +674 -275
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +576 -177
- package/dist/index.js.map +1 -1
- package/dist/pages/CheckoutPage.d.ts.map +1 -1
- package/dist/pages/ProductDetailPage.d.ts.map +1 -1
- package/dist/stores/cart.store.d.ts +16 -0
- package/dist/stores/cart.store.d.ts.map +1 -1
- package/dist/stores/delivery.store.d.ts +64 -0
- package/dist/stores/delivery.store.d.ts.map +1 -0
- package/dist/testids.d.ts +11 -0
- package/dist/testids.d.ts.map +1 -1
- package/dist/workflows/checkout.d.ts +5 -2
- package/dist/workflows/checkout.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/auth.ts +57 -1
- package/src/components/CartDrawer.tsx +23 -1
- package/src/components/DeliveryEstimator.tsx +157 -0
- package/src/components/ProductGallery.tsx +174 -0
- package/src/config.ts +15 -3
- package/src/pages/CheckoutPage.tsx +240 -134
- package/src/pages/ProductDetailPage.tsx +17 -13
- package/src/stores/cart.store.ts +21 -0
- package/src/stores/delivery.store.ts +152 -0
- package/src/testids.ts +12 -0
- package/src/workflows/checkout.ts +30 -5
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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,
|