@fayz-ai/storefront 0.8.0 → 0.8.3
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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CheckoutPage.d.ts","sourceRoot":"","sources":["../../src/pages/CheckoutPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8B,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"CheckoutPage.d.ts","sourceRoot":"","sources":["../../src/pages/CheckoutPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8B,MAAM,OAAO,CAAA;AA0GlD,wBAAgB,YAAY,sBAkkB3B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProductDetailPage.d.ts","sourceRoot":"","sources":["../../src/pages/ProductDetailPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8B,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"ProductDetailPage.d.ts","sourceRoot":"","sources":["../../src/pages/ProductDetailPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8B,MAAM,OAAO,CAAA;AAqBlD,wBAAgB,iBAAiB,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,qBAkP3D"}
|
|
@@ -58,6 +58,22 @@ export declare const useCartStore: import("zustand").UseBoundStore<Omit<import("
|
|
|
58
58
|
export declare const selectCount: (s: Pick<CartState, "lines">) => number;
|
|
59
59
|
export declare const selectSubtotal: (s: Pick<CartState, "lines">) => number;
|
|
60
60
|
export declare const selectDiscountTotal: (s: Pick<CartState, "lines" | "discountPercent">) => number;
|
|
61
|
+
/**
|
|
62
|
+
* Freight to display.
|
|
63
|
+
*
|
|
64
|
+
* When the shopper has given a CEP and the store quoted it, that quote wins —
|
|
65
|
+
* it came from the same shipping_zones rows shop_place_order will charge from.
|
|
66
|
+
* Otherwise this falls back to the store-wide rate in config, which is what
|
|
67
|
+
* every storefront did before zones existed.
|
|
68
|
+
*
|
|
69
|
+
* The subtotal is PRE-discount on both sides of this. That is 0017's rule, and
|
|
70
|
+
* the quote is requested against the same number, so a coupon can never make
|
|
71
|
+
* the cart and the order disagree.
|
|
72
|
+
*
|
|
73
|
+
* Read through getState() rather than a hook because this is a plain selector
|
|
74
|
+
* called from several screens; components that render the value subscribe to
|
|
75
|
+
* useDeliveryStore themselves so a fresh quote re-renders them.
|
|
76
|
+
*/
|
|
61
77
|
export declare const selectShipping: (s: Pick<CartState, "lines">, cfg: ResolvedStorefrontConfig) => number;
|
|
62
78
|
export declare const selectTotal: (s: Pick<CartState, "lines" | "discountPercent">, cfg: ResolvedStorefrontConfig) => number;
|
|
63
79
|
//# sourceMappingURL=cart.store.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cart.store.d.ts","sourceRoot":"","sources":["../../src/stores/cart.store.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAA;
|
|
1
|
+
{"version":3,"file":"cart.store.d.ts","sourceRoot":"","sources":["../../src/stores/cart.store.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAA;AAEzD,OAAO,EAIL,KAAK,sBAAsB,EAC5B,MAAM,oBAAoB,CAAA;AAE3B,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,OAAO,EAAE,sBAAsB,CAAA;IAC/B,YAAY,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,QAAQ,EAAE,CAAA;IACjB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,eAAe,EAAE,MAAM,CAAA;IACvB,MAAM,EAAE,OAAO,CAAA;IACf,uEAAuE;IACvE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,sBAAsB,GAAG,IAAI,CAAA;IAC/E,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9C,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IAClD,aAAa,IAAI,IAAI,CAAA;IACrB,KAAK,IAAI,IAAI,CAAA;IACb,UAAU,IAAI,IAAI,CAAA;IAClB,WAAW,IAAI,IAAI,CAAA;IACnB;;;0EAGsE;IACtE,SAAS,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACxE;AAED,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;EAoFxB,CAAA;AAOD,eAAO,MAAM,WAAW,GAAI,GAAG,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,KAAG,MACb,CAAA;AAE7C,eAAO,MAAM,cAAc,GAAI,GAAG,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,KAAG,MACc,CAAA;AAE3E,eAAO,MAAM,mBAAmB,GAAI,GAAG,IAAI,CAAC,SAAS,EAAE,OAAO,GAAG,iBAAiB,CAAC,KAAG,MAC3B,CAAA;AAE3D;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,cAAc,GACzB,GAAG,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,EAC3B,KAAK,wBAAwB,KAC5B,MASF,CAAA;AAED,eAAO,MAAM,WAAW,GACtB,GAAG,IAAI,CAAC,SAAS,EAAE,OAAO,GAAG,iBAAiB,CAAC,EAC/C,KAAK,wBAAwB,KAC5B,MAC8E,CAAA"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type PostalAddress } from '@fayz-ai/core';
|
|
2
|
+
import type { ShippingQuoteOption } from '@fayz-ai/shop/types';
|
|
3
|
+
/**
|
|
4
|
+
* Where the shopper wants it delivered, decided before the cart exists.
|
|
5
|
+
*
|
|
6
|
+
* Two different things live here and they are persisted differently on purpose:
|
|
7
|
+
*
|
|
8
|
+
* the ADDRESS is durable — a CEP maps to the same street for years, and
|
|
9
|
+
* carrying it to checkout is the whole point (the buyer types the number and
|
|
10
|
+
* nothing else);
|
|
11
|
+
*
|
|
12
|
+
* the QUOTE is not persisted at all. It is a price, and a price restored from
|
|
13
|
+
* localStorage is a price the shopper can edit with devtools. It is re-asked
|
|
14
|
+
* on demand, and resolved for the current cart through resolveOptionRate —
|
|
15
|
+
* the same rule shop_shipping_for applies — so it can never go stale. The
|
|
16
|
+
* order is charged from shop_place_order, which recomputes from the same
|
|
17
|
+
* zones and ignores anything the client sends.
|
|
18
|
+
*/
|
|
19
|
+
export type DeliveryStatus = 'idle' | 'loading' | 'served' | 'unserved' | 'error';
|
|
20
|
+
export interface DeliveryState {
|
|
21
|
+
postalCode: string;
|
|
22
|
+
address: PostalAddress | null;
|
|
23
|
+
options: ShippingQuoteOption[];
|
|
24
|
+
selectedZoneId: string | null;
|
|
25
|
+
status: DeliveryStatus;
|
|
26
|
+
error: string | null;
|
|
27
|
+
/** Look the CEP up and quote it. `subtotal` decides free-above thresholds. */
|
|
28
|
+
resolve: (postalCode: string, subtotal: number) => Promise<void>;
|
|
29
|
+
selectZone: (zoneId: string) => void;
|
|
30
|
+
clear: () => void;
|
|
31
|
+
}
|
|
32
|
+
export declare const useDeliveryStore: import("zustand").UseBoundStore<Omit<import("zustand").StoreApi<DeliveryState>, "persist"> & {
|
|
33
|
+
persist: {
|
|
34
|
+
setOptions: (options: Partial<import("zustand/middleware").PersistOptions<DeliveryState, {
|
|
35
|
+
postalCode: string;
|
|
36
|
+
address: PostalAddress | null;
|
|
37
|
+
}>>) => void;
|
|
38
|
+
clearStorage: () => void;
|
|
39
|
+
rehydrate: () => Promise<void> | void;
|
|
40
|
+
hasHydrated: () => boolean;
|
|
41
|
+
onHydrate: (fn: (state: DeliveryState) => void) => () => void;
|
|
42
|
+
onFinishHydration: (fn: (state: DeliveryState) => void) => () => void;
|
|
43
|
+
getOptions: () => Partial<import("zustand/middleware").PersistOptions<DeliveryState, {
|
|
44
|
+
postalCode: string;
|
|
45
|
+
address: PostalAddress | null;
|
|
46
|
+
}>>;
|
|
47
|
+
};
|
|
48
|
+
}>;
|
|
49
|
+
/**
|
|
50
|
+
* The freight for the chosen zone at THIS subtotal, or null when there is no
|
|
51
|
+
* zone in play (no CEP yet, or a store that configured none) and the caller
|
|
52
|
+
* should use the store-wide rate. Null never means free.
|
|
53
|
+
*
|
|
54
|
+
* The free-above rule is applied here rather than trusting the `rate` the
|
|
55
|
+
* server resolved, because that one was resolved for whatever the cart held at
|
|
56
|
+
* quote time. Re-deriving from `baseRate`/`freeAbove` — the same CASE
|
|
57
|
+
* shop_shipping_for runs — means adding an item can never leave a stale price
|
|
58
|
+
* on screen. An earlier version fell back to the config flat rate when the
|
|
59
|
+
* quote went stale, which showed R$ 8 (the flat rate) for an address in an
|
|
60
|
+
* R$ 18 zone: shown one number, charged another.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveOptionRate(option: ShippingQuoteOption, subtotal: number): number;
|
|
63
|
+
export declare const selectQuotedShipping: (state: Pick<DeliveryState, "options" | "selectedZoneId" | "status">, subtotal: number) => number | null;
|
|
64
|
+
//# sourceMappingURL=delivery.store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"delivery.store.d.ts","sourceRoot":"","sources":["../../src/stores/delivery.store.ts"],"names":[],"mappings":"AAEA,OAAO,EAAyC,KAAK,aAAa,EAAE,MAAM,eAAe,CAAA;AAEzF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAE9D;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,UAAU,GAAG,OAAO,CAAA;AAEjF,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,aAAa,GAAG,IAAI,CAAA;IAC7B,OAAO,EAAE,mBAAmB,EAAE,CAAA;IAC9B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,MAAM,EAAE,cAAc,CAAA;IACtB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IAEpB,8EAA8E;IAC9E,OAAO,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAChE,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAWD,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;EA4E5B,CAAA;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGvF;AAED,eAAO,MAAM,oBAAoB,GAC/B,OAAO,IAAI,CAAC,aAAa,EAAE,SAAS,GAAG,gBAAgB,GAAG,QAAQ,CAAC,EACnE,UAAU,MAAM,KACf,MAAM,GAAG,IAIX,CAAA"}
|
package/dist/testids.d.ts
CHANGED
|
@@ -52,6 +52,9 @@ export declare const TID: {
|
|
|
52
52
|
readonly pdpAddToCart: "pdp-add-to-cart";
|
|
53
53
|
readonly pdpRoot: "pdp-root";
|
|
54
54
|
readonly pdpGallery: "pdp-gallery";
|
|
55
|
+
readonly pdpGalleryImage: "pdp-gallery-image";
|
|
56
|
+
readonly pdpGalleryThumbs: "pdp-gallery-thumbs";
|
|
57
|
+
readonly pdpGalleryThumb: "pdp-gallery-thumb";
|
|
55
58
|
readonly pdpActions: "pdp-actions";
|
|
56
59
|
readonly pdpEnquiryButton: "pdp-enquiry-button";
|
|
57
60
|
readonly enquiryForm: "enquiry-form";
|
|
@@ -79,6 +82,14 @@ export declare const TID: {
|
|
|
79
82
|
readonly cartTotal: "cart-total";
|
|
80
83
|
readonly goCheckout: "go-checkout";
|
|
81
84
|
readonly cartEmpty: "cart-empty";
|
|
85
|
+
readonly deliveryEstimator: "delivery-estimator";
|
|
86
|
+
readonly deliveryCepInput: "delivery-cep-input";
|
|
87
|
+
readonly deliveryCepSubmit: "delivery-cep-submit";
|
|
88
|
+
readonly deliveryOption: "delivery-option";
|
|
89
|
+
readonly deliveryUnserved: "delivery-unserved";
|
|
90
|
+
readonly deliveryError: "delivery-error";
|
|
91
|
+
readonly deliveryAddress: "delivery-address";
|
|
92
|
+
readonly deliveryClear: "delivery-clear";
|
|
82
93
|
readonly checkoutEmail: "checkout-email";
|
|
83
94
|
readonly checkoutName: "checkout-name";
|
|
84
95
|
readonly checkoutStreet: "checkout-street";
|
package/dist/testids.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testids.d.ts","sourceRoot":"","sources":["../src/testids.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,GAAG;;;;;;;;8BASG,MAAM;iCACH,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;oCAgCH,MAAM;;;;;;;;;;sCAWJ,MAAM;sCACN,MAAM,SAAS,MAAM
|
|
1
|
+
{"version":3,"file":"testids.d.ts","sourceRoot":"","sources":["../src/testids.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,GAAG;;;;;;;;8BASG,MAAM;iCACH,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;oCAgCH,MAAM;;;;;;;;;;sCAWJ,MAAM;sCACN,MAAM,SAAS,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uCA6DpB,MAAM;;;;;;;;;;;;;;;;;CAkBxB,CAAA"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Order } from '@fayz-ai/shop/types';
|
|
1
|
+
import type { Order, ShippingAddressInput, PaymentMethodKind } from '@fayz-ai/shop/types';
|
|
2
2
|
import type { ResolvedStorefrontConfig } from '../config';
|
|
3
3
|
import type { CartState } from '../stores/cart.store';
|
|
4
4
|
import type { SessionState } from '../stores/session.store';
|
|
@@ -18,6 +18,9 @@ export interface PlaceStorefrontOrderInput {
|
|
|
18
18
|
session?: Pick<SessionState, 'customerId' | 'email'>;
|
|
19
19
|
customer: StorefrontCheckoutCustomer;
|
|
20
20
|
address?: StorefrontCheckoutAddress;
|
|
21
|
+
/** Structured address; without it the order keeps only the free-text note. */
|
|
22
|
+
shippingAddress?: ShippingAddressInput;
|
|
23
|
+
paymentMethod?: PaymentMethodKind;
|
|
21
24
|
markPaid?: boolean;
|
|
22
25
|
}
|
|
23
26
|
export interface PlaceStorefrontOrderResult {
|
|
@@ -29,5 +32,5 @@ export interface PlaceStorefrontOrderResult {
|
|
|
29
32
|
* It owns the order payload math and customer-session handshake so app-owned
|
|
30
33
|
* routes can customize UX without copying provider/business logic.
|
|
31
34
|
*/
|
|
32
|
-
export declare function placeStorefrontOrder({ config, cart, session, customer, address, markPaid, }: PlaceStorefrontOrderInput): Promise<PlaceStorefrontOrderResult>;
|
|
35
|
+
export declare function placeStorefrontOrder({ config, cart, session, customer, address, shippingAddress, paymentMethod, markPaid, }: PlaceStorefrontOrderInput): Promise<PlaceStorefrontOrderResult>;
|
|
33
36
|
//# sourceMappingURL=checkout.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"checkout.d.ts","sourceRoot":"","sources":["../../src/workflows/checkout.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAA;
|
|
1
|
+
{"version":3,"file":"checkout.d.ts","sourceRoot":"","sources":["../../src/workflows/checkout.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAEzF,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAA;AACzD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAA;AAErD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AAE3D,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,yBAAyB;IACxC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,yBAAyB;IACxC,MAAM,EAAE,wBAAwB,CAAA;IAChC,IAAI,EAAE,IAAI,CAAC,SAAS,EAAE,OAAO,GAAG,cAAc,GAAG,iBAAiB,CAAC,CAAA;IACnE,OAAO,CAAC,EAAE,IAAI,CAAC,YAAY,EAAE,YAAY,GAAG,OAAO,CAAC,CAAA;IACpD,QAAQ,EAAE,0BAA0B,CAAA;IACpC,OAAO,CAAC,EAAE,yBAAyB,CAAA;IACnC,8EAA8E;IAC9E,eAAe,CAAC,EAAE,oBAAoB,CAAA;IACtC,aAAa,CAAC,EAAE,iBAAiB,CAAA;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,KAAK,CAAA;IACZ,UAAU,EAAE,MAAM,CAAA;CACnB;AA4BD;;;;GAIG;AACH,wBAAsB,oBAAoB,CAAC,EACzC,MAAM,EACN,IAAI,EACJ,OAAO,EACP,QAAQ,EACR,OAAO,EACP,eAAe,EACf,aAAa,EACb,QAAgB,GACjB,EAAE,yBAAyB,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAyCjE"}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"fayz": {
|
|
4
4
|
"status": "beta"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.8.
|
|
6
|
+
"version": "0.8.3",
|
|
7
7
|
"description": "Fayz SDK storefront kit — Shopify-style customer-facing ecommerce template components",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"main": "./dist/index.cjs",
|
|
@@ -43,12 +43,12 @@
|
|
|
43
43
|
"@supabase/supabase-js": "^2.45.0",
|
|
44
44
|
"lucide-react": "^0.400.0",
|
|
45
45
|
"zustand": "^4.5.0",
|
|
46
|
-
"@fayz-ai/auth": "^0.8.
|
|
47
|
-
"@fayz-ai/core": "^0.8.
|
|
46
|
+
"@fayz-ai/auth": "^0.8.3",
|
|
47
|
+
"@fayz-ai/core": "^0.8.3",
|
|
48
48
|
"@fayz-ai/plugin-auth": "^0.8.0",
|
|
49
|
-
"@fayz-ai/
|
|
50
|
-
"@fayz-ai/
|
|
51
|
-
"@fayz-ai/
|
|
49
|
+
"@fayz-ai/sdk": "^0.8.3",
|
|
50
|
+
"@fayz-ai/shop": "^0.8.3",
|
|
51
|
+
"@fayz-ai/ui": "^0.8.3"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/auth.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type AuthPluginOptions,
|
|
6
6
|
} from '@fayz-ai/plugin-auth'
|
|
7
7
|
import { getShopProvider } from '@fayz-ai/shop/runtime'
|
|
8
|
+
import { setShopAccessTokenResolver } from '@fayz-ai/sdk/shop'
|
|
8
9
|
import { useSessionStore } from './stores/session.store'
|
|
9
10
|
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
@@ -64,13 +65,41 @@ export function resolveAuthAdapter(
|
|
|
64
65
|
}).adapter
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
// The shopper's current access token, handed to the shop REST client so its
|
|
69
|
+
// requests reach PostgREST as the signed-in user instead of as `anon`. Held
|
|
70
|
+
// here rather than in the session store because it is a credential: it must not
|
|
71
|
+
// be persisted to localStorage alongside the customer id. The Supabase client
|
|
72
|
+
// owns its own refresh; this is only a mirror of the live value.
|
|
73
|
+
let _accessToken: string | null = null
|
|
74
|
+
|
|
75
|
+
/** Whether an authenticated identity was ever seen this session. Distinguishes
|
|
76
|
+
* "signed out" from "was never signed in" — see the onAuthStateChange guard. */
|
|
77
|
+
let _hadAuthUser = false
|
|
78
|
+
|
|
79
|
+
/** Refresh the mirrored token from the adapter. Safe to call on any event. */
|
|
80
|
+
async function syncAccessToken(adapter: AuthAdapter): Promise<void> {
|
|
81
|
+
try {
|
|
82
|
+
const existing = await adapter.getSession()
|
|
83
|
+
_accessToken = existing?.session.accessToken ?? null
|
|
84
|
+
} catch {
|
|
85
|
+
_accessToken = null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
/** Called once by createStorefrontApp. Restores any persisted session. */
|
|
68
90
|
export function initCustomerAuth(adapter: AuthAdapter): void {
|
|
69
91
|
_adapter = adapter
|
|
92
|
+
|
|
93
|
+
// Registered before the first await so no request can slip out anonymously
|
|
94
|
+
// while the session is still being restored.
|
|
95
|
+
setShopAccessTokenResolver(() => _accessToken)
|
|
96
|
+
|
|
70
97
|
adapter
|
|
71
98
|
.getSession()
|
|
72
99
|
.then((existing) => {
|
|
100
|
+
_accessToken = existing?.session.accessToken ?? null
|
|
73
101
|
if (!existing) return
|
|
102
|
+
_hadAuthUser = true
|
|
74
103
|
const store = useSessionStore.getState()
|
|
75
104
|
// Re-link only when the persisted shop session doesn't match the auth user
|
|
76
105
|
if (store.email !== existing.user.email) {
|
|
@@ -78,8 +107,26 @@ export function initCustomerAuth(adapter: AuthAdapter): void {
|
|
|
78
107
|
}
|
|
79
108
|
})
|
|
80
109
|
.catch(() => {})
|
|
110
|
+
|
|
81
111
|
adapter.onAuthStateChange((user) => {
|
|
82
|
-
if (!user)
|
|
112
|
+
if (!user) {
|
|
113
|
+
_accessToken = null
|
|
114
|
+
// Only tear down the shop session if there WAS an authenticated identity
|
|
115
|
+
// to lose. The Supabase adapter fires this once on boot with a null user
|
|
116
|
+
// (INITIAL_SESSION), and clearing unconditionally wiped the guest's local
|
|
117
|
+
// session on every page load — a guest who checked out could no longer
|
|
118
|
+
// find their order after a reload. The mock adapter never surfaced this
|
|
119
|
+
// because it always restored a session.
|
|
120
|
+
if (_hadAuthUser) {
|
|
121
|
+
_hadAuthUser = false
|
|
122
|
+
useSessionStore.getState().signOut()
|
|
123
|
+
}
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
_hadAuthUser = true
|
|
127
|
+
// Covers token refresh too, not just sign-in: the callback carries the user
|
|
128
|
+
// but not the token, and a stale token would 401 every read an hour in.
|
|
129
|
+
void syncAccessToken(adapter)
|
|
83
130
|
})
|
|
84
131
|
}
|
|
85
132
|
|
|
@@ -137,6 +184,11 @@ export async function establishCustomerSession(
|
|
|
137
184
|
if (opts?.password) {
|
|
138
185
|
const adapter = getCustomerAuthAdapter()
|
|
139
186
|
const session = await adapter.signIn(normalized, opts.password)
|
|
187
|
+
// Before linkCustomer, not after: linkCustomer calls shop_resolve_customer,
|
|
188
|
+
// which reads auth.uid() to fill auth_user_id. Sent with the old token that
|
|
189
|
+
// link silently no-ops and the account stays unlinked forever.
|
|
190
|
+
_accessToken = session.accessToken ?? null
|
|
191
|
+
_hadAuthUser = true
|
|
140
192
|
return linkCustomer(session.user.email ? session.user : { ...session.user, email: normalized }, opts.name)
|
|
141
193
|
}
|
|
142
194
|
// Guest / passwordless checkout: do NOT create an auth account (that made
|
|
@@ -159,6 +211,8 @@ export async function signUpCustomer(
|
|
|
159
211
|
const adapter = getCustomerAuthAdapter()
|
|
160
212
|
const normalized = email.trim().toLowerCase()
|
|
161
213
|
const session = await adapter.signUp(normalized, password, name)
|
|
214
|
+
_accessToken = session.accessToken ?? null
|
|
215
|
+
_hadAuthUser = true
|
|
162
216
|
return linkCustomer(session.user.email ? session.user : { ...session.user, email: normalized }, name)
|
|
163
217
|
}
|
|
164
218
|
|
|
@@ -166,6 +220,8 @@ export async function signOutCustomer(): Promise<void> {
|
|
|
166
220
|
try {
|
|
167
221
|
await getCustomerAuthAdapter().signOut()
|
|
168
222
|
} finally {
|
|
223
|
+
_accessToken = null
|
|
224
|
+
_hadAuthUser = false
|
|
169
225
|
useSessionStore.getState().signOut()
|
|
170
226
|
}
|
|
171
227
|
}
|
|
@@ -13,6 +13,9 @@ import { useStorefrontConfig } from '../config'
|
|
|
13
13
|
import { navigateTo } from '../router'
|
|
14
14
|
import { formatMoney } from '../format'
|
|
15
15
|
import { QuantityInput } from './QuantityInput'
|
|
16
|
+
import { formatPostalCode } from '@fayz-ai/core'
|
|
17
|
+
import { useDeliveryStore } from '../stores/delivery.store'
|
|
18
|
+
import { DeliveryEstimator } from './DeliveryEstimator'
|
|
16
19
|
import { TID } from '../testids'
|
|
17
20
|
import { SmoothImage } from './SmoothImage'
|
|
18
21
|
|
|
@@ -34,9 +37,15 @@ export function CartDrawer() {
|
|
|
34
37
|
return () => document.removeEventListener('keydown', onKey)
|
|
35
38
|
}, [cart.isOpen, cart.closeDrawer])
|
|
36
39
|
|
|
40
|
+
// Subscribed, not just read: selectShipping consults this store through
|
|
41
|
+
// getState(), which does not subscribe, so without this hook a fresh quote
|
|
42
|
+
// would change the price and leave the drawer showing the old one.
|
|
43
|
+
const delivery = useDeliveryStore()
|
|
44
|
+
|
|
37
45
|
const subtotal = selectSubtotal(cart)
|
|
38
46
|
const discountTotal = selectDiscountTotal(cart)
|
|
39
47
|
const shipping = selectShipping(cart, config)
|
|
48
|
+
|
|
40
49
|
const total = selectTotal(cart, config)
|
|
41
50
|
const money = (v: number) => formatMoney(v, config.currency, config.locale)
|
|
42
51
|
|
|
@@ -198,6 +207,12 @@ export function CartDrawer() {
|
|
|
198
207
|
</div>
|
|
199
208
|
)}
|
|
200
209
|
|
|
210
|
+
{/* Second chance to give a CEP for anyone who added to cart from
|
|
211
|
+
the catalogue and never opened a product page. */}
|
|
212
|
+
<div className="mb-3">
|
|
213
|
+
<DeliveryEstimator compact />
|
|
214
|
+
</div>
|
|
215
|
+
|
|
201
216
|
<dl className="space-y-1.5 text-sm">
|
|
202
217
|
<div className="flex justify-between">
|
|
203
218
|
<dt className="text-muted-foreground">Subtotal</dt>
|
|
@@ -219,7 +234,14 @@ export function CartDrawer() {
|
|
|
219
234
|
</div>
|
|
220
235
|
)}
|
|
221
236
|
<div className="flex justify-between">
|
|
222
|
-
<dt className="text-muted-foreground">
|
|
237
|
+
<dt className="text-muted-foreground">
|
|
238
|
+
Frete
|
|
239
|
+
{delivery.status === 'served' && delivery.postalCode && (
|
|
240
|
+
<span className="ml-1 text-xs">
|
|
241
|
+
· {formatPostalCode(delivery.postalCode)}
|
|
242
|
+
</span>
|
|
243
|
+
)}
|
|
244
|
+
</dt>
|
|
223
245
|
<dd data-testid={TID.cartShipping} data-price={shipping.toFixed(2)}>
|
|
224
246
|
{shipping === 0 ? 'Grátis' : money(shipping)}
|
|
225
247
|
</dd>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { MapPin, Truck } from 'lucide-react'
|
|
3
|
+
import { formatPostalCode } from '@fayz-ai/core'
|
|
4
|
+
import { useDeliveryStore, resolveOptionRate } from '../stores/delivery.store'
|
|
5
|
+
import { useCartStore, selectSubtotal } from '../stores/cart.store'
|
|
6
|
+
import { useStorefrontConfig } from '../config'
|
|
7
|
+
import { formatMoney } from '../format'
|
|
8
|
+
import { TID } from '../testids'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* "Do you deliver to me, for how much, and when" — asked on the product page,
|
|
12
|
+
* before the shopper has invested anything in a cart.
|
|
13
|
+
*
|
|
14
|
+
* The CEP is kept in the delivery store, so the answer follows them to the cart
|
|
15
|
+
* and lands at checkout with street/district/city/UF already filled. That is
|
|
16
|
+
* the actual payoff: the buyer types a house number instead of an address.
|
|
17
|
+
*
|
|
18
|
+
* `subtotal` is the PRE-discount cart subtotal, the same number shop_place_order
|
|
19
|
+
* uses to resolve free-above thresholds. Quoting against anything else is how
|
|
20
|
+
* the cart and the order end up disagreeing (see migration 0017).
|
|
21
|
+
*/
|
|
22
|
+
export function DeliveryEstimator({ compact = false }: { compact?: boolean }) {
|
|
23
|
+
const config = useStorefrontConfig()
|
|
24
|
+
const cart = useCartStore()
|
|
25
|
+
const delivery = useDeliveryStore()
|
|
26
|
+
const [draft, setDraft] = useState(() => formatPostalCode(delivery.postalCode))
|
|
27
|
+
|
|
28
|
+
const subtotal = selectSubtotal(cart)
|
|
29
|
+
const money = (value: number) => formatMoney(value, config.currency, config.locale)
|
|
30
|
+
const loading = delivery.status === 'loading'
|
|
31
|
+
|
|
32
|
+
async function submit(event: React.FormEvent) {
|
|
33
|
+
event.preventDefault()
|
|
34
|
+
await delivery.resolve(draft, subtotal)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function eta(option: { etaMinDays?: number; etaMaxDays?: number }): string | null {
|
|
38
|
+
const { etaMinDays: min, etaMaxDays: max } = option
|
|
39
|
+
if (min == null && max == null) return null
|
|
40
|
+
if (min === 0 && (max === 0 || max === 1)) return 'hoje ou amanhã'
|
|
41
|
+
if (min != null && max != null && min !== max) return `${min} a ${max} dias úteis`
|
|
42
|
+
return `${max ?? min} dia(s) útil(eis)`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<section
|
|
47
|
+
data-testid={TID.deliveryEstimator}
|
|
48
|
+
className={compact ? 'rounded-lg border p-3' : 'mt-6 rounded-lg border p-4'}
|
|
49
|
+
>
|
|
50
|
+
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
51
|
+
<Truck className="h-4 w-4 text-primary" />
|
|
52
|
+
Calcular entrega
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<form onSubmit={submit} className="mt-3 flex gap-2">
|
|
56
|
+
<input
|
|
57
|
+
data-testid={TID.deliveryCepInput}
|
|
58
|
+
aria-label="CEP"
|
|
59
|
+
inputMode="numeric"
|
|
60
|
+
autoComplete="postal-code"
|
|
61
|
+
placeholder="00000-000"
|
|
62
|
+
value={draft}
|
|
63
|
+
onChange={(event) => setDraft(formatPostalCode(event.target.value))}
|
|
64
|
+
className="min-w-0 flex-1 rounded-lg border bg-background px-3 py-2 text-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
|
65
|
+
/>
|
|
66
|
+
<button
|
|
67
|
+
type="submit"
|
|
68
|
+
data-testid={TID.deliveryCepSubmit}
|
|
69
|
+
disabled={loading}
|
|
70
|
+
className="shrink-0 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition hover:opacity-90 disabled:opacity-50"
|
|
71
|
+
>
|
|
72
|
+
{loading ? '...' : 'Calcular'}
|
|
73
|
+
</button>
|
|
74
|
+
</form>
|
|
75
|
+
|
|
76
|
+
{/* Shown whenever there is one, including alongside a price: the range
|
|
77
|
+
may be covered even though the CEP itself was mistyped. */}
|
|
78
|
+
{delivery.error && (
|
|
79
|
+
<p data-testid={TID.deliveryError} className="mt-3 text-sm text-destructive">
|
|
80
|
+
{delivery.error}
|
|
81
|
+
</p>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{delivery.address && (
|
|
85
|
+
<p data-testid={TID.deliveryAddress} className="mt-3 flex items-start gap-1.5 text-xs text-muted-foreground">
|
|
86
|
+
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
87
|
+
<span>
|
|
88
|
+
{[delivery.address.street, delivery.address.district].filter(Boolean).join(', ')}
|
|
89
|
+
{delivery.address.street || delivery.address.district ? ' — ' : ''}
|
|
90
|
+
{delivery.address.city}/{delivery.address.state}
|
|
91
|
+
</span>
|
|
92
|
+
</p>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{delivery.status === 'served' && (
|
|
96
|
+
<ul className="mt-3 space-y-2">
|
|
97
|
+
{delivery.options.map((option) => {
|
|
98
|
+
const selected = delivery.selectedZoneId === option.zoneId
|
|
99
|
+
const label = eta(option)
|
|
100
|
+
// Resolved for the cart as it stands right now, not for whatever it
|
|
101
|
+
// held when the CEP was typed — the same rule the server applies.
|
|
102
|
+
const rate = resolveOptionRate(option, subtotal)
|
|
103
|
+
return (
|
|
104
|
+
<li key={option.zoneId}>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
data-testid={TID.deliveryOption}
|
|
108
|
+
data-zone={option.zoneId}
|
|
109
|
+
data-rate={rate.toFixed(2)}
|
|
110
|
+
aria-pressed={selected}
|
|
111
|
+
onClick={() => delivery.selectZone(option.zoneId)}
|
|
112
|
+
className={`flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left text-sm transition ${
|
|
113
|
+
selected ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/40'
|
|
114
|
+
}`}
|
|
115
|
+
>
|
|
116
|
+
<span className="min-w-0">
|
|
117
|
+
<span className="block truncate font-medium">
|
|
118
|
+
{option.carrier ? `${option.carrier} · ${option.name}` : option.name}
|
|
119
|
+
</span>
|
|
120
|
+
{label && <span className="block text-xs text-muted-foreground">{label}</span>}
|
|
121
|
+
</span>
|
|
122
|
+
<span className="shrink-0 font-semibold">
|
|
123
|
+
{rate === 0 ? 'Grátis' : money(rate)}
|
|
124
|
+
</span>
|
|
125
|
+
</button>
|
|
126
|
+
</li>
|
|
127
|
+
)
|
|
128
|
+
})}
|
|
129
|
+
</ul>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{/*
|
|
133
|
+
No options for a valid address. For a store that configured zones this
|
|
134
|
+
means "outside our delivery area" and checkout will refuse the order, so
|
|
135
|
+
it is said plainly here rather than at the end of the funnel. A store
|
|
136
|
+
with no zones at all never reaches this state — it quotes nothing and
|
|
137
|
+
keeps its flat rate.
|
|
138
|
+
*/}
|
|
139
|
+
{delivery.status === 'unserved' && (
|
|
140
|
+
<p data-testid={TID.deliveryUnserved} className="mt-3 text-sm text-destructive">
|
|
141
|
+
Ainda não entregamos nesse CEP.
|
|
142
|
+
</p>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{delivery.postalCode && (
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
data-testid={TID.deliveryClear}
|
|
149
|
+
onClick={() => { delivery.clear(); setDraft('') }}
|
|
150
|
+
className="mt-3 text-xs text-muted-foreground underline-offset-2 hover:underline"
|
|
151
|
+
>
|
|
152
|
+
Trocar CEP
|
|
153
|
+
</button>
|
|
154
|
+
)}
|
|
155
|
+
</section>
|
|
156
|
+
)
|
|
157
|
+
}
|