@usequeek/theme-kit 0.1.0
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 +37 -0
- package/api/collections.ts +93 -0
- package/api/orders.ts +23 -0
- package/apps/analytics-events.ts +32 -0
- package/apps/apps.css +613 -0
- package/apps/follow-card.tsx +192 -0
- package/apps/gtag.ts +19 -0
- package/apps/subscribe-form.tsx +124 -0
- package/client.ts +52 -0
- package/components/auth/auth-email-login-step.tsx +46 -0
- package/components/auth/auth-email-otp-step.tsx +54 -0
- package/components/auth/auth-email-otp-verify-step.tsx +82 -0
- package/components/auth/auth-email-register-step.tsx +59 -0
- package/components/auth/auth-flow.tsx +26 -0
- package/components/auth/auth-signup-step.tsx +53 -0
- package/components/blog/category-filter.tsx +92 -0
- package/components/blog/index.ts +12 -0
- package/components/blog/pagination.tsx +73 -0
- package/components/blog/post-categories.tsx +42 -0
- package/components/blog/post-meta.tsx +49 -0
- package/components/blog/related-posts.tsx +54 -0
- package/components/blog/share-buttons.tsx +89 -0
- package/components/cart/cart-panel-controller.tsx +67 -0
- package/components/cart/default-cart-shell.tsx +207 -0
- package/components/checkout/checkout-contact.tsx +84 -0
- package/components/checkout/default-checkout-shell.tsx +177 -0
- package/components/checkout/policy-links.tsx +98 -0
- package/components/image.tsx +149 -0
- package/components/markdown.tsx +54 -0
- package/components/product-media-frame.tsx +56 -0
- package/components/variant-picker.css +84 -0
- package/components/variant-picker.tsx +107 -0
- package/design-token-preview.tsx +81 -0
- package/edit-mode-active.tsx +324 -0
- package/edit-mode.tsx +80 -0
- package/hooks/use-address-autocomplete.ts +62 -0
- package/hooks/use-auth-flow.tsx +346 -0
- package/hooks/use-auth.ts +17 -0
- package/hooks/use-cart.ts +14 -0
- package/hooks/use-categories.ts +112 -0
- package/hooks/use-collection-params.ts +67 -0
- package/hooks/use-href.ts +15 -0
- package/hooks/use-posts.ts +75 -0
- package/hooks/use-product-detail.ts +69 -0
- package/hooks/use-product-questions.ts +77 -0
- package/hooks/use-product-sections.ts +165 -0
- package/hooks/use-products.ts +216 -0
- package/hooks/use-related-products.ts +56 -0
- package/hooks/use-resolve-coordinates.ts +48 -0
- package/hooks/use-reviews.ts +167 -0
- package/hooks/use-search.ts +55 -0
- package/hooks/use-shop-cart.ts +31 -0
- package/hooks/use-shop-params.ts +68 -0
- package/hooks/use-shop.ts +186 -0
- package/hooks/use-variant-selection.ts +98 -0
- package/index.ts +49 -0
- package/package.json +41 -0
- package/page-renderer.tsx +365 -0
- package/provider.tsx +91 -0
- package/sdk/client.ts +221 -0
- package/sdk/error-handler.ts +37 -0
- package/sdk/platform.ts +78 -0
- package/sdk/queek-client.ts +104 -0
- package/sdk/reauth-guard.ts +27 -0
- package/sdk/server-store-client.ts +165 -0
- package/shared-blocks/button.tsx +41 -0
- package/shared-blocks/callout.tsx +35 -0
- package/shared-blocks/content-default.tsx +23 -0
- package/shared-blocks/core-blocks.css +1792 -0
- package/shared-blocks/divider.tsx +17 -0
- package/shared-blocks/embed.tsx +40 -0
- package/shared-blocks/faq.tsx +44 -0
- package/shared-blocks/image.tsx +29 -0
- package/shared-blocks/index.ts +24 -0
- package/shared-blocks/manifest.ts +76 -0
- package/shared-blocks/product-qa.tsx +203 -0
- package/shared-blocks/quote.tsx +20 -0
- package/shared-blocks/reviews.tsx +436 -0
- package/shared-blocks/table.tsx +45 -0
- package/shared-blocks/video.tsx +60 -0
- package/stores/address-modal-store.ts +15 -0
- package/stores/auth-modal-store.ts +17 -0
- package/stores/cart-panel-store.ts +17 -0
- package/stores/cart-store.ts +172 -0
- package/stores/checkout-store.ts +167 -0
- package/stores/edit-preview-store.ts +61 -0
- package/stores/location-store.ts +41 -0
- package/stores/orders-sheet-store.ts +21 -0
- package/stores/policy-modal-store.ts +15 -0
- package/stores/product-modal-store.ts +18 -0
- package/stores/search-modal-store.ts +17 -0
- package/stores/toast-store.ts +46 -0
- package/stores/user-store.ts +88 -0
- package/theme-context.tsx +28 -0
- package/theme-variant-resolver.ts +44 -0
- package/types/block.ts +292 -0
- package/types/cart.ts +21 -0
- package/types/media.ts +55 -0
- package/types/menu.ts +27 -0
- package/types/order.ts +119 -0
- package/types/page.ts +86 -0
- package/types/product.ts +279 -0
- package/types/theme.ts +380 -0
- package/types/vendor.ts +243 -0
- package/utils/addon-selection.ts +31 -0
- package/utils/benefit-icon.tsx +145 -0
- package/utils/brand-palette.ts +64 -0
- package/utils/brand.ts +518 -0
- package/utils/contact-location.ts +44 -0
- package/utils/format.ts +64 -0
- package/utils/hero-content.ts +35 -0
- package/utils/image-srcset.ts +157 -0
- package/utils/markdown.tsx +237 -0
- package/utils/match-variant.ts +31 -0
- package/utils/menu-link.ts +41 -0
- package/utils/placeholder-resolver.ts +184 -0
- package/utils/product-media.ts +77 -0
- package/utils/product-normalizer.ts +323 -0
- package/utils/vendor-host.ts +173 -0
- package/utils/video-embed.ts +38 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
import { createJSONStorage, persist } from 'zustand/middleware';
|
|
5
|
+
import type { CartAddon, CartItem } from '../types/cart';
|
|
6
|
+
import type { Product, ProductVariant } from '../types/product';
|
|
7
|
+
import { toast } from './toast-store';
|
|
8
|
+
import { pushAddToCart } from '../apps/analytics-events';
|
|
9
|
+
import { getActivePlatform, platformScopedStorage } from '../sdk/platform';
|
|
10
|
+
import { productRequiresVariant } from '../utils/match-variant';
|
|
11
|
+
|
|
12
|
+
interface CartState {
|
|
13
|
+
items: CartItem[];
|
|
14
|
+
/** Set once per vendor mount (see vendor-shell.tsx) — the store has no
|
|
15
|
+
* React context, so this is how it learns the active vendor's currency
|
|
16
|
+
* for GA4 event values. Defaults to the same 'NGN' fallback used
|
|
17
|
+
* everywhere else in the storefront (see checkout-controller.tsx). */
|
|
18
|
+
currency: string;
|
|
19
|
+
setCurrency: (currency: string) => void;
|
|
20
|
+
addProduct: (product: Product, quantity?: number, variant?: ProductVariant | null) => void;
|
|
21
|
+
addItemWithAddons: (product: Product, quantity: number, addons: CartAddon[], variant?: ProductVariant | null) => void;
|
|
22
|
+
removeItem: (id: string, shopId: string) => void;
|
|
23
|
+
setQuantity: (id: string, shopId: string, quantity: number) => void;
|
|
24
|
+
clearShopCart: (shopId: string) => void;
|
|
25
|
+
/** Replaces a shop's items with the server-persisted cart — only called when
|
|
26
|
+
* the local cart for that shop is empty (see use-cart-sync.ts), so this
|
|
27
|
+
* never clobbers what the customer is actively looking at. */
|
|
28
|
+
adoptServerItems: (shopId: string, items: CartItem[]) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `variantId` folds into the base id (not appended like addons) so a variant
|
|
33
|
+
* line and its product's plain (no-variant) line can never collide even if
|
|
34
|
+
* addons also match — two genuinely different purchasable things. Omitting it
|
|
35
|
+
* entirely for a no-variant product keeps every already-persisted cart's ids
|
|
36
|
+
* unchanged (existing localStorage carts, in-flight server carts) rather than
|
|
37
|
+
* reshaping ids for products that were never affected by this gap.
|
|
38
|
+
*/
|
|
39
|
+
export function makeItemId(productId: string, addons: CartAddon[], variantId?: string | null): string {
|
|
40
|
+
const base = variantId ? `${productId}::${variantId}` : productId;
|
|
41
|
+
|
|
42
|
+
if (addons.length === 0) {
|
|
43
|
+
return base;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return `${base}-${addons.map((addon) => addon.item_id).sort().join(',')}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const useCartStore = create<CartState>()(
|
|
50
|
+
persist(
|
|
51
|
+
(set, get) => ({
|
|
52
|
+
items: [],
|
|
53
|
+
currency: 'NGN',
|
|
54
|
+
setCurrency: (currency) => set({ currency }),
|
|
55
|
+
addProduct: (product, quantity = 1, variant = null) => {
|
|
56
|
+
get().addItemWithAddons(product, quantity, [], variant);
|
|
57
|
+
},
|
|
58
|
+
addItemWithAddons: (product, quantity = 1, addons = [], variant = null) => {
|
|
59
|
+
// A variant-enabled product has no valid "no selection" line: the cart
|
|
60
|
+
// save fails server-side (DiscountService's VariantSelectionException),
|
|
61
|
+
// so persisting one only moves the failure to checkout. Themes gate
|
|
62
|
+
// their add button too, but a theme is presentation — correctness
|
|
63
|
+
// cannot depend on all seven, or on the next one, getting it right.
|
|
64
|
+
if (!variant && productRequiresVariant(product)) {
|
|
65
|
+
toast({
|
|
66
|
+
title: 'Select options',
|
|
67
|
+
description: `Choose an option for ${product.title} before adding it to your cart.`,
|
|
68
|
+
});
|
|
69
|
+
console.error('[cart] refused a variant-enabled product added with no variant:', product.id);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const itemId = makeItemId(product.id, addons, variant?.id);
|
|
74
|
+
const addonTotal = addons.reduce((sum, addon) => sum + addon.price, 0);
|
|
75
|
+
// A variant's own price/stock override the base product's — the same
|
|
76
|
+
// precedence quick-add-page.tsx's matchedVariant already uses.
|
|
77
|
+
const unitPrice = (variant?.pricing.sale_amount ?? product.pricing.sale_amount) + addonTotal;
|
|
78
|
+
const itemImage = variant?.media.thumbnail ?? variant?.media.image ?? product.media.thumbnail ?? product.media.image ?? null;
|
|
79
|
+
|
|
80
|
+
// Confirm the add with a global toast (works across every theme) —
|
|
81
|
+
// except on QR, which has its own persistent cart bar (QrCartBar)
|
|
82
|
+
// always visible on screen, so a toast on top of it is redundant
|
|
83
|
+
// (and its "View cart" action has nothing QR-specific to open).
|
|
84
|
+
if (getActivePlatform() !== 'instore_qr') {
|
|
85
|
+
toast({
|
|
86
|
+
title: 'Added to cart',
|
|
87
|
+
description: variant?.title ? `${product.title} (${variant.title})` : product.title,
|
|
88
|
+
image: itemImage,
|
|
89
|
+
showCart: true,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
pushAddToCart(
|
|
93
|
+
{ item_id: product.id, item_name: product.title, price: unitPrice, quantity: Math.max(1, quantity) },
|
|
94
|
+
get().currency,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
set((state) => {
|
|
98
|
+
const existing = state.items.findIndex(
|
|
99
|
+
(entry) => entry.id === itemId && entry.shop_id === product.shop_id,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (existing >= 0) {
|
|
103
|
+
const items = [...state.items];
|
|
104
|
+
const current = items[existing];
|
|
105
|
+
items[existing] = {
|
|
106
|
+
...current,
|
|
107
|
+
quantity: current.quantity + Math.max(1, quantity),
|
|
108
|
+
unit_price: unitPrice,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return { items };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
items: [
|
|
116
|
+
...state.items,
|
|
117
|
+
{
|
|
118
|
+
id: itemId,
|
|
119
|
+
product_id: product.id,
|
|
120
|
+
shop_id: product.shop_id,
|
|
121
|
+
title: product.title,
|
|
122
|
+
slug: product.slug,
|
|
123
|
+
image: itemImage,
|
|
124
|
+
unit_price: unitPrice,
|
|
125
|
+
quantity: Math.max(1, quantity),
|
|
126
|
+
addons,
|
|
127
|
+
variant_id: variant?.id ?? null,
|
|
128
|
+
variant_title: variant?.title ?? null,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
removeItem: (id, shopId) => {
|
|
135
|
+
set((state) => ({
|
|
136
|
+
items: state.items.filter((entry) => !(entry.id === id && entry.shop_id === shopId)),
|
|
137
|
+
}));
|
|
138
|
+
},
|
|
139
|
+
setQuantity: (id, shopId, quantity) => {
|
|
140
|
+
if (quantity <= 0) {
|
|
141
|
+
get().removeItem(id, shopId);
|
|
142
|
+
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
set((state) => ({
|
|
147
|
+
items: state.items.map((entry) => {
|
|
148
|
+
if (entry.id === id && entry.shop_id === shopId) {
|
|
149
|
+
return { ...entry, quantity };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return entry;
|
|
153
|
+
}),
|
|
154
|
+
}));
|
|
155
|
+
},
|
|
156
|
+
clearShopCart: (shopId) => {
|
|
157
|
+
set((state) => ({
|
|
158
|
+
items: state.items.filter((entry) => entry.shop_id !== shopId),
|
|
159
|
+
}));
|
|
160
|
+
},
|
|
161
|
+
adoptServerItems: (shopId, items) => {
|
|
162
|
+
set((state) => ({
|
|
163
|
+
items: [...state.items.filter((entry) => entry.shop_id !== shopId), ...items],
|
|
164
|
+
}));
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
{
|
|
168
|
+
name: 'queek-storefront-cart',
|
|
169
|
+
storage: createJSONStorage(platformScopedStorage),
|
|
170
|
+
},
|
|
171
|
+
),
|
|
172
|
+
);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
import { createJSONStorage, persist } from 'zustand/middleware';
|
|
5
|
+
import { platformScopedStorage } from '../sdk/platform';
|
|
6
|
+
|
|
7
|
+
export interface ScheduleSlot {
|
|
8
|
+
id: string;
|
|
9
|
+
date: string;
|
|
10
|
+
min_time: string;
|
|
11
|
+
max_time: string;
|
|
12
|
+
time?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type CheckoutTab = 'summary' | 'checkout';
|
|
16
|
+
export type DeliveryType = 'instant' | 'schedule';
|
|
17
|
+
|
|
18
|
+
/** The Shipbubble courier the customer picked. Its price is re-derived from the
|
|
19
|
+
* server-cached quote (via request_token + service_code) at fee/order time —
|
|
20
|
+
* never trusted from the client — so display == charge. */
|
|
21
|
+
export interface ShipbubbleSelection {
|
|
22
|
+
request_token: string;
|
|
23
|
+
service_code: string;
|
|
24
|
+
courier_id: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Which of the vendor's (possibly several) active bank accounts the customer
|
|
28
|
+
* picked for a bank_transfer order — submitted with checkout and re-verified
|
|
29
|
+
* server-side against the vendor's real accounts before it's ever recorded. */
|
|
30
|
+
export interface SelectedBankAccount {
|
|
31
|
+
bank_name: string;
|
|
32
|
+
bank_code: string;
|
|
33
|
+
account_name: string;
|
|
34
|
+
account_number: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface CheckoutState {
|
|
38
|
+
deliveryMode: string | null;
|
|
39
|
+
deliveryType: DeliveryType;
|
|
40
|
+
selectedSlot: ScheduleSlot | null;
|
|
41
|
+
paymentMethodId: string | null;
|
|
42
|
+
paymentMethodName: string | null;
|
|
43
|
+
selectedBankAccount: SelectedBankAccount | null;
|
|
44
|
+
/** instore_qr dine-in only — which of the vendor's tables the customer is
|
|
45
|
+
* sitting at (App\Models\VendorTable id). Null for every other flow. */
|
|
46
|
+
selectedTableId: string | null;
|
|
47
|
+
promoCode: string;
|
|
48
|
+
vendorNote: string;
|
|
49
|
+
riderNote: string;
|
|
50
|
+
activeTab: CheckoutTab;
|
|
51
|
+
/** Selected delivery source when two options (queek/vendor) are available */
|
|
52
|
+
deliverySource: string | null;
|
|
53
|
+
/** Shipping zone UUID — required for a MANUAL shipping order */
|
|
54
|
+
shippingZoneId: string | null;
|
|
55
|
+
shippingZoneName: string | null;
|
|
56
|
+
/** The picked Shipbubble courier for an AUTOMATED shipping order (transient) */
|
|
57
|
+
shipbubbleSelection: ShipbubbleSelection | null;
|
|
58
|
+
/** Total order weight in kg — needed when selected zone has has_weight_pricing: true */
|
|
59
|
+
weightKg: number | null;
|
|
60
|
+
/** Transient (not persisted). When true, checkout will auto-resume
|
|
61
|
+
* to the checkout tab once the user authenticates. Set by the
|
|
62
|
+
* controller when a guest clicks "Proceed to checkout". */
|
|
63
|
+
pendingAuthResume: boolean;
|
|
64
|
+
/** Transient. True while the customer is searching/changing their delivery
|
|
65
|
+
* address — the resolved fee is stale then, so surfaces hide it. */
|
|
66
|
+
isEditingAddress: boolean;
|
|
67
|
+
/** The customer's saved address (CustomerAddress uuid) currently selected for
|
|
68
|
+
* delivery, when they picked one from their address book instead of
|
|
69
|
+
* searching a fresh address. Lets the backend reuse the cached courier
|
|
70
|
+
* geocode. Null whenever the address came from a fresh Google search. */
|
|
71
|
+
savedAddressId: string | null;
|
|
72
|
+
|
|
73
|
+
setDeliveryMode: (mode: string | null) => void;
|
|
74
|
+
setDeliveryType: (type: DeliveryType) => void;
|
|
75
|
+
setSelectedSlot: (slot: ScheduleSlot | null) => void;
|
|
76
|
+
setPaymentMethod: (id: string | null, name: string | null) => void;
|
|
77
|
+
setSelectedBankAccount: (account: SelectedBankAccount | null) => void;
|
|
78
|
+
setSelectedTableId: (tableId: string | null) => void;
|
|
79
|
+
setPromoCode: (code: string) => void;
|
|
80
|
+
setVendorNote: (note: string) => void;
|
|
81
|
+
setRiderNote: (note: string) => void;
|
|
82
|
+
setActiveTab: (tab: CheckoutTab) => void;
|
|
83
|
+
setDeliverySource: (source: string | null) => void;
|
|
84
|
+
setShippingZone: (id: string | null, name: string | null) => void;
|
|
85
|
+
setShipbubbleSelection: (selection: ShipbubbleSelection | null) => void;
|
|
86
|
+
setWeightKg: (weight: number | null) => void;
|
|
87
|
+
setPendingAuthResume: (value: boolean) => void;
|
|
88
|
+
setEditingAddress: (value: boolean) => void;
|
|
89
|
+
setSavedAddressId: (id: string | null) => void;
|
|
90
|
+
reset: () => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const useCheckoutStore = create<CheckoutState>()(
|
|
94
|
+
persist(
|
|
95
|
+
(set) => ({
|
|
96
|
+
deliveryMode: null,
|
|
97
|
+
deliveryType: 'instant',
|
|
98
|
+
selectedSlot: null,
|
|
99
|
+
paymentMethodId: null,
|
|
100
|
+
paymentMethodName: null,
|
|
101
|
+
selectedBankAccount: null,
|
|
102
|
+
selectedTableId: null,
|
|
103
|
+
promoCode: '',
|
|
104
|
+
vendorNote: '',
|
|
105
|
+
riderNote: '',
|
|
106
|
+
activeTab: 'summary',
|
|
107
|
+
deliverySource: null,
|
|
108
|
+
shippingZoneId: null,
|
|
109
|
+
shippingZoneName: null,
|
|
110
|
+
shipbubbleSelection: null,
|
|
111
|
+
weightKg: null,
|
|
112
|
+
pendingAuthResume: false,
|
|
113
|
+
isEditingAddress: false,
|
|
114
|
+
savedAddressId: null,
|
|
115
|
+
|
|
116
|
+
setDeliveryMode: (deliveryMode) => set({ deliveryMode, deliverySource: null, shippingZoneId: null, shippingZoneName: null, shipbubbleSelection: null }),
|
|
117
|
+
setDeliveryType: (deliveryType) => set({ deliveryType }),
|
|
118
|
+
setSelectedSlot: (selectedSlot) => set({ selectedSlot }),
|
|
119
|
+
// Changing the method invalidates a bank choice made for the previous one.
|
|
120
|
+
setPaymentMethod: (paymentMethodId, paymentMethodName) => set({ paymentMethodId, paymentMethodName, selectedBankAccount: null }),
|
|
121
|
+
setSelectedBankAccount: (selectedBankAccount) => set({ selectedBankAccount }),
|
|
122
|
+
setSelectedTableId: (selectedTableId) => set({ selectedTableId }),
|
|
123
|
+
setPromoCode: (promoCode) => set({ promoCode }),
|
|
124
|
+
setVendorNote: (vendorNote) => set({ vendorNote }),
|
|
125
|
+
setRiderNote: (riderNote) => set({ riderNote }),
|
|
126
|
+
setActiveTab: (activeTab) => set({ activeTab }),
|
|
127
|
+
setDeliverySource: (deliverySource) => set({ deliverySource }),
|
|
128
|
+
setShippingZone: (shippingZoneId, shippingZoneName) => set({ shippingZoneId, shippingZoneName }),
|
|
129
|
+
setShipbubbleSelection: (shipbubbleSelection) => set({ shipbubbleSelection }),
|
|
130
|
+
setWeightKg: (weightKg) => set({ weightKg }),
|
|
131
|
+
setPendingAuthResume: (pendingAuthResume) => set({ pendingAuthResume }),
|
|
132
|
+
setEditingAddress: (isEditingAddress) => set({ isEditingAddress }),
|
|
133
|
+
setSavedAddressId: (savedAddressId) => set({ savedAddressId }),
|
|
134
|
+
reset: () => set({
|
|
135
|
+
deliveryMode: null,
|
|
136
|
+
deliveryType: 'instant',
|
|
137
|
+
selectedSlot: null,
|
|
138
|
+
selectedBankAccount: null,
|
|
139
|
+
selectedTableId: null,
|
|
140
|
+
promoCode: '',
|
|
141
|
+
vendorNote: '',
|
|
142
|
+
riderNote: '',
|
|
143
|
+
activeTab: 'summary',
|
|
144
|
+
deliverySource: null,
|
|
145
|
+
shippingZoneId: null,
|
|
146
|
+
shipbubbleSelection: null,
|
|
147
|
+
shippingZoneName: null,
|
|
148
|
+
weightKg: null,
|
|
149
|
+
pendingAuthResume: false,
|
|
150
|
+
isEditingAddress: false,
|
|
151
|
+
savedAddressId: null,
|
|
152
|
+
}),
|
|
153
|
+
}),
|
|
154
|
+
{
|
|
155
|
+
name: 'queek-storefront-checkout',
|
|
156
|
+
storage: createJSONStorage(platformScopedStorage),
|
|
157
|
+
partialize: (state) => ({
|
|
158
|
+
deliveryMode: state.deliveryMode,
|
|
159
|
+
deliveryType: state.deliveryType,
|
|
160
|
+
selectedSlot: state.selectedSlot,
|
|
161
|
+
paymentMethodId: state.paymentMethodId,
|
|
162
|
+
paymentMethodName: state.paymentMethodName,
|
|
163
|
+
selectedTableId: state.selectedTableId,
|
|
164
|
+
}),
|
|
165
|
+
},
|
|
166
|
+
),
|
|
167
|
+
);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
import type { Block } from '../types/block';
|
|
5
|
+
|
|
6
|
+
interface EditPreviewState {
|
|
7
|
+
/** Draft sections streamed from the merchant editor. `null` = render what the server sent. */
|
|
8
|
+
blocks: Block[] | null;
|
|
9
|
+
/** Order of the newest draft. */
|
|
10
|
+
streamedSeq: number;
|
|
11
|
+
/** Order of the last refresh request — a payload can only carry what was
|
|
12
|
+
* already saved at that moment. */
|
|
13
|
+
refreshSeq: number;
|
|
14
|
+
setBlocks: (blocks: Block[]) => void;
|
|
15
|
+
markRefreshRequested: () => void;
|
|
16
|
+
/** Yield to the server, unless the draft is newer than the refresh that produced it. */
|
|
17
|
+
reconcile: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Draft page content streamed live from the merchant editor, so a section edit
|
|
22
|
+
* renders immediately instead of waiting for a network round trip.
|
|
23
|
+
*
|
|
24
|
+
* Before this, every field change took the long way: 900ms debounce → PUT the
|
|
25
|
+
* draft → postMessage 'refresh' → router.refresh() → RSC fetch → backend
|
|
26
|
+
* render, and the preview showed the OLD content the whole time. The merchant
|
|
27
|
+
* already holds the edited section in its own state; sending it costs nothing
|
|
28
|
+
* and removes both network hops from the interactive path.
|
|
29
|
+
*
|
|
30
|
+
* Design tokens have worked this way all along (design-token-preview.tsx) — this
|
|
31
|
+
* is the same idea applied to section content, which is the thing merchants
|
|
32
|
+
* actually spend their time editing.
|
|
33
|
+
*
|
|
34
|
+
* The server stays authoritative: PageRenderer drops this override the moment a
|
|
35
|
+
* fresh server payload arrives, so the debounced save still reconciles and a
|
|
36
|
+
* drifted preview cannot outlive one refresh.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* A monotonic counter, not a clock. Two events in the same millisecond are
|
|
40
|
+
* indistinguishable by Date.now(), and "typed at the same moment the refresh
|
|
41
|
+
* was requested" is precisely the case that has to resolve correctly.
|
|
42
|
+
*/
|
|
43
|
+
let sequence = 0;
|
|
44
|
+
const nextSeq = (): number => (sequence += 1);
|
|
45
|
+
|
|
46
|
+
export const useEditPreviewStore = create<EditPreviewState>()((set, get) => ({
|
|
47
|
+
blocks: null,
|
|
48
|
+
streamedSeq: 0,
|
|
49
|
+
refreshSeq: 0,
|
|
50
|
+
setBlocks: (blocks) => set({ blocks, streamedSeq: nextSeq() }),
|
|
51
|
+
markRefreshRequested: () => set({ refreshSeq: nextSeq() }),
|
|
52
|
+
reconcile: () => {
|
|
53
|
+
// Typing DURING the save round trip is the case this protects: the payload
|
|
54
|
+
// was rendered from what had been saved when the refresh was requested, so
|
|
55
|
+
// a draft streamed after that point is strictly newer. Clearing it there
|
|
56
|
+
// would flash the vendor's screen back to older content until their next
|
|
57
|
+
// keystroke re-streamed it.
|
|
58
|
+
if (get().streamedSeq > get().refreshSeq) return;
|
|
59
|
+
set({ blocks: null });
|
|
60
|
+
},
|
|
61
|
+
}));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
import { createJSONStorage, persist } from 'zustand/middleware';
|
|
5
|
+
import { platformScopedStorage } from '../sdk/platform';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LAT = 4.77149;
|
|
8
|
+
const DEFAULT_LNG = 7.01435;
|
|
9
|
+
|
|
10
|
+
interface LocationState {
|
|
11
|
+
latitude: number;
|
|
12
|
+
longitude: number;
|
|
13
|
+
address: string;
|
|
14
|
+
stateId: string | null;
|
|
15
|
+
setLocation: (lat: number, lng: number, address: string, stateId?: string | null) => void;
|
|
16
|
+
clearLocation: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const useLocationStore = create<LocationState>()(
|
|
20
|
+
persist(
|
|
21
|
+
(set) => ({
|
|
22
|
+
latitude: DEFAULT_LAT,
|
|
23
|
+
longitude: DEFAULT_LNG,
|
|
24
|
+
address: '',
|
|
25
|
+
stateId: null,
|
|
26
|
+
setLocation: (latitude, longitude, address, stateId = null) =>
|
|
27
|
+
set({ latitude, longitude, address, stateId }),
|
|
28
|
+
clearLocation: () =>
|
|
29
|
+
set({ latitude: DEFAULT_LAT, longitude: DEFAULT_LNG, address: '', stateId: null }),
|
|
30
|
+
}),
|
|
31
|
+
{
|
|
32
|
+
name: 'queek-storefront-location',
|
|
33
|
+
storage: createJSONStorage(platformScopedStorage),
|
|
34
|
+
merge: (persisted, current) => ({
|
|
35
|
+
...current,
|
|
36
|
+
...(persisted as Partial<LocationState>),
|
|
37
|
+
stateId: (persisted as Partial<LocationState>)?.stateId ?? null,
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
),
|
|
41
|
+
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
|
|
5
|
+
interface OrdersSheetState {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
activeOrderId: string | null;
|
|
8
|
+
open: () => void;
|
|
9
|
+
close: () => void;
|
|
10
|
+
openDetail: (id: string) => void;
|
|
11
|
+
closeDetail: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const useOrdersSheetStore = create<OrdersSheetState>()((set) => ({
|
|
15
|
+
isOpen: false,
|
|
16
|
+
activeOrderId: null,
|
|
17
|
+
open: () => set({ isOpen: true, activeOrderId: null }),
|
|
18
|
+
close: () => set({ isOpen: false, activeOrderId: null }),
|
|
19
|
+
openDetail: (id) => set({ activeOrderId: id }),
|
|
20
|
+
closeDetail: () => set({ activeOrderId: null }),
|
|
21
|
+
}));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
|
|
5
|
+
interface PolicyModalState {
|
|
6
|
+
activeSlug: string | null;
|
|
7
|
+
open: (slug: string) => void;
|
|
8
|
+
close: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const usePolicyModalStore = create<PolicyModalState>()((set) => ({
|
|
12
|
+
activeSlug: null,
|
|
13
|
+
open: (slug) => set({ activeSlug: slug }),
|
|
14
|
+
close: () => set({ activeSlug: null }),
|
|
15
|
+
}));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
import type { Product } from '../types/product';
|
|
5
|
+
|
|
6
|
+
interface ProductModalState {
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
product: Product | null;
|
|
9
|
+
open: (product: Product) => void;
|
|
10
|
+
close: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const useProductModalStore = create<ProductModalState>()((set) => ({
|
|
14
|
+
isOpen: false,
|
|
15
|
+
product: null,
|
|
16
|
+
open: (product) => set({ isOpen: true, product }),
|
|
17
|
+
close: () => set({ isOpen: false, product: null }),
|
|
18
|
+
}));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
|
|
5
|
+
interface SearchModalState {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
initialQuery: string;
|
|
8
|
+
open: (query?: string) => void;
|
|
9
|
+
close: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const useSearchModalStore = create<SearchModalState>()((set) => ({
|
|
13
|
+
isOpen: false,
|
|
14
|
+
initialQuery: '',
|
|
15
|
+
open: (query) => set({ isOpen: true, initialQuery: query ?? '' }),
|
|
16
|
+
close: () => set({ isOpen: false }),
|
|
17
|
+
}));
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
|
|
5
|
+
export interface Toast {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
/** Product thumbnail — shown instead of the generic checkmark icon when set. */
|
|
10
|
+
image?: string | null;
|
|
11
|
+
/** Show a "View cart" action that opens the cart panel. */
|
|
12
|
+
showCart?: boolean;
|
|
13
|
+
/** Auto-dismiss after this many ms (default 3200). */
|
|
14
|
+
duration?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ToastState {
|
|
18
|
+
toasts: Toast[];
|
|
19
|
+
show: (toast: Omit<Toast, 'id'>) => string;
|
|
20
|
+
dismiss: (id: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeId(): string {
|
|
24
|
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID();
|
|
25
|
+
return `t_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MAX_TOASTS = 3;
|
|
29
|
+
|
|
30
|
+
export const useToastStore = create<ToastState>((set) => ({
|
|
31
|
+
toasts: [],
|
|
32
|
+
show: (toast) => {
|
|
33
|
+
const id = makeId();
|
|
34
|
+
set((state) => ({ toasts: [...state.toasts, { ...toast, id }].slice(-MAX_TOASTS) }));
|
|
35
|
+
return id;
|
|
36
|
+
},
|
|
37
|
+
dismiss: (id) => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Headless helper so non-React code (the cart store) can raise a toast.
|
|
42
|
+
* Kept out of React render paths — call it from event handlers / store actions.
|
|
43
|
+
*/
|
|
44
|
+
export function toast(toast: Omit<Toast, 'id'>): string {
|
|
45
|
+
return useToastStore.getState().show(toast);
|
|
46
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
import { createJSONStorage, persist } from 'zustand/middleware';
|
|
5
|
+
|
|
6
|
+
export interface StorefrontUser {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
first_name: string;
|
|
10
|
+
last_name: string;
|
|
11
|
+
email: string | null;
|
|
12
|
+
phone: string;
|
|
13
|
+
avatar: string | null;
|
|
14
|
+
/** Backend flag: false for social/email signups that still lack a real phone. */
|
|
15
|
+
profile_complete?: boolean;
|
|
16
|
+
delivery_info?: {
|
|
17
|
+
map_lat: number;
|
|
18
|
+
map_lng: number;
|
|
19
|
+
address: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A signed-in customer whose account is missing the details we require to
|
|
25
|
+
* fulfil an order. The backend's `profile_complete` is itself derived purely
|
|
26
|
+
* from phone+name presence (`ClientAuthUserResource`: `phone !== null && name
|
|
27
|
+
* !== ''`), so both it and the raw `!user.phone` check are gated by
|
|
28
|
+
* `requiresPhone` together — there's no case where one applies without the
|
|
29
|
+
* other.
|
|
30
|
+
*
|
|
31
|
+
* `requiresPhone` — defaults true (delivery/pickup orders need a real number
|
|
32
|
+
* to reach the customer). Pass false for in-person channels like instore_qr:
|
|
33
|
+
* the vendor already has the customer physically present, so a phone is not
|
|
34
|
+
* needed to fulfil the order (matches POS, which never requires it either).
|
|
35
|
+
*
|
|
36
|
+
* `requiresEmail` — pass true for an automated-shipping vendor: Shipbubble's
|
|
37
|
+
* courier lookup needs a real email, which a phone-OTP-only signup never has.
|
|
38
|
+
* Defaults false everywhere else so this stays a no-op for every other caller.
|
|
39
|
+
*/
|
|
40
|
+
export function isProfileIncomplete(
|
|
41
|
+
user: StorefrontUser | null,
|
|
42
|
+
options: { requiresPhone?: boolean; requiresEmail?: boolean } = {},
|
|
43
|
+
): boolean {
|
|
44
|
+
if (!user) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const { requiresPhone = true, requiresEmail = false } = options;
|
|
48
|
+
return (requiresPhone && (user.profile_complete === false || !user.phone)) || (requiresEmail && !user.email);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface UserState {
|
|
52
|
+
user: StorefrontUser | null;
|
|
53
|
+
isAuthenticated: boolean;
|
|
54
|
+
setUser: (user: StorefrontUser) => void;
|
|
55
|
+
updateDeliveryInfo: (info: { map_lat: number; map_lng: number; address: string }) => void;
|
|
56
|
+
logout: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const STORAGE_KEY = 'queek-storefront-user';
|
|
60
|
+
|
|
61
|
+
export const useUserStore = create<UserState>()(
|
|
62
|
+
persist(
|
|
63
|
+
(set) => ({
|
|
64
|
+
user: null,
|
|
65
|
+
isAuthenticated: false,
|
|
66
|
+
setUser: (user) => set({ user, isAuthenticated: true }),
|
|
67
|
+
updateDeliveryInfo: (info) =>
|
|
68
|
+
set((state) => ({
|
|
69
|
+
user: state.user ? { ...state.user, delivery_info: info } : null,
|
|
70
|
+
})),
|
|
71
|
+
logout: () => set({ user: null, isAuthenticated: false }),
|
|
72
|
+
}),
|
|
73
|
+
{
|
|
74
|
+
name: STORAGE_KEY,
|
|
75
|
+
storage: createJSONStorage(() => localStorage),
|
|
76
|
+
},
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Cross-tab sync: when another tab logs in or out, rehydrate this tab
|
|
81
|
+
// so auth state stays consistent everywhere the SDK is used.
|
|
82
|
+
if (typeof window !== 'undefined') {
|
|
83
|
+
window.addEventListener('storage', (event) => {
|
|
84
|
+
if (event.key === STORAGE_KEY) {
|
|
85
|
+
void useUserStore.persist.rehydrate();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type ReactNode } from 'react';
|
|
4
|
+
import type { ThemeModule } from './types/theme';
|
|
5
|
+
|
|
6
|
+
const ThemeContext = createContext<ThemeModule | null>(null);
|
|
7
|
+
|
|
8
|
+
export function ThemeProvider({
|
|
9
|
+
theme,
|
|
10
|
+
children,
|
|
11
|
+
}: {
|
|
12
|
+
theme: ThemeModule;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}): JSX.Element {
|
|
15
|
+
return (
|
|
16
|
+
<ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useTheme(): ThemeModule {
|
|
21
|
+
const theme = useContext(ThemeContext);
|
|
22
|
+
|
|
23
|
+
if (!theme) {
|
|
24
|
+
throw new Error('useTheme must be used within ThemeProvider');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return theme;
|
|
28
|
+
}
|