@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.
- package/README.md +3 -1
- 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/components/StorefrontHeader.d.ts.map +1 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/createStorefrontApp.d.ts.map +1 -1
- package/dist/index.cjs +886 -370
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +790 -275
- 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/presets.d.ts +5 -0
- package/dist/presets.d.ts.map +1 -1
- package/dist/stores/cart.store.d.ts +21 -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 +12 -0
- package/dist/testids.d.ts.map +1 -1
- package/dist/theme.d.ts +18 -2
- package/dist/theme.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 +13 -7
- 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/components/StorefrontHeader.tsx +35 -8
- package/src/config.ts +15 -3
- package/src/createStorefrontApp.tsx +18 -1
- package/src/index.ts +1 -0
- package/src/pages/CheckoutPage.tsx +240 -134
- package/src/pages/ProductDetailPage.tsx +17 -13
- package/src/presets.ts +66 -0
- package/src/stores/cart.store.ts +37 -1
- package/src/stores/delivery.store.ts +152 -0
- package/src/testids.ts +13 -0
- package/src/theme.ts +33 -3
- package/src/workflows/checkout.ts +34 -6
package/src/stores/cart.store.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
@@ -84,7 +109,10 @@ export async function placeStorefrontOrder({
|
|
|
84
109
|
})
|
|
85
110
|
|
|
86
111
|
if (markPaid) {
|
|
87
|
-
|
|
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 ?? '' }
|