@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.
Files changed (120) hide show
  1. package/README.md +37 -0
  2. package/api/collections.ts +93 -0
  3. package/api/orders.ts +23 -0
  4. package/apps/analytics-events.ts +32 -0
  5. package/apps/apps.css +613 -0
  6. package/apps/follow-card.tsx +192 -0
  7. package/apps/gtag.ts +19 -0
  8. package/apps/subscribe-form.tsx +124 -0
  9. package/client.ts +52 -0
  10. package/components/auth/auth-email-login-step.tsx +46 -0
  11. package/components/auth/auth-email-otp-step.tsx +54 -0
  12. package/components/auth/auth-email-otp-verify-step.tsx +82 -0
  13. package/components/auth/auth-email-register-step.tsx +59 -0
  14. package/components/auth/auth-flow.tsx +26 -0
  15. package/components/auth/auth-signup-step.tsx +53 -0
  16. package/components/blog/category-filter.tsx +92 -0
  17. package/components/blog/index.ts +12 -0
  18. package/components/blog/pagination.tsx +73 -0
  19. package/components/blog/post-categories.tsx +42 -0
  20. package/components/blog/post-meta.tsx +49 -0
  21. package/components/blog/related-posts.tsx +54 -0
  22. package/components/blog/share-buttons.tsx +89 -0
  23. package/components/cart/cart-panel-controller.tsx +67 -0
  24. package/components/cart/default-cart-shell.tsx +207 -0
  25. package/components/checkout/checkout-contact.tsx +84 -0
  26. package/components/checkout/default-checkout-shell.tsx +177 -0
  27. package/components/checkout/policy-links.tsx +98 -0
  28. package/components/image.tsx +149 -0
  29. package/components/markdown.tsx +54 -0
  30. package/components/product-media-frame.tsx +56 -0
  31. package/components/variant-picker.css +84 -0
  32. package/components/variant-picker.tsx +107 -0
  33. package/design-token-preview.tsx +81 -0
  34. package/edit-mode-active.tsx +324 -0
  35. package/edit-mode.tsx +80 -0
  36. package/hooks/use-address-autocomplete.ts +62 -0
  37. package/hooks/use-auth-flow.tsx +346 -0
  38. package/hooks/use-auth.ts +17 -0
  39. package/hooks/use-cart.ts +14 -0
  40. package/hooks/use-categories.ts +112 -0
  41. package/hooks/use-collection-params.ts +67 -0
  42. package/hooks/use-href.ts +15 -0
  43. package/hooks/use-posts.ts +75 -0
  44. package/hooks/use-product-detail.ts +69 -0
  45. package/hooks/use-product-questions.ts +77 -0
  46. package/hooks/use-product-sections.ts +165 -0
  47. package/hooks/use-products.ts +216 -0
  48. package/hooks/use-related-products.ts +56 -0
  49. package/hooks/use-resolve-coordinates.ts +48 -0
  50. package/hooks/use-reviews.ts +167 -0
  51. package/hooks/use-search.ts +55 -0
  52. package/hooks/use-shop-cart.ts +31 -0
  53. package/hooks/use-shop-params.ts +68 -0
  54. package/hooks/use-shop.ts +186 -0
  55. package/hooks/use-variant-selection.ts +98 -0
  56. package/index.ts +49 -0
  57. package/package.json +41 -0
  58. package/page-renderer.tsx +365 -0
  59. package/provider.tsx +91 -0
  60. package/sdk/client.ts +221 -0
  61. package/sdk/error-handler.ts +37 -0
  62. package/sdk/platform.ts +78 -0
  63. package/sdk/queek-client.ts +104 -0
  64. package/sdk/reauth-guard.ts +27 -0
  65. package/sdk/server-store-client.ts +165 -0
  66. package/shared-blocks/button.tsx +41 -0
  67. package/shared-blocks/callout.tsx +35 -0
  68. package/shared-blocks/content-default.tsx +23 -0
  69. package/shared-blocks/core-blocks.css +1792 -0
  70. package/shared-blocks/divider.tsx +17 -0
  71. package/shared-blocks/embed.tsx +40 -0
  72. package/shared-blocks/faq.tsx +44 -0
  73. package/shared-blocks/image.tsx +29 -0
  74. package/shared-blocks/index.ts +24 -0
  75. package/shared-blocks/manifest.ts +76 -0
  76. package/shared-blocks/product-qa.tsx +203 -0
  77. package/shared-blocks/quote.tsx +20 -0
  78. package/shared-blocks/reviews.tsx +436 -0
  79. package/shared-blocks/table.tsx +45 -0
  80. package/shared-blocks/video.tsx +60 -0
  81. package/stores/address-modal-store.ts +15 -0
  82. package/stores/auth-modal-store.ts +17 -0
  83. package/stores/cart-panel-store.ts +17 -0
  84. package/stores/cart-store.ts +172 -0
  85. package/stores/checkout-store.ts +167 -0
  86. package/stores/edit-preview-store.ts +61 -0
  87. package/stores/location-store.ts +41 -0
  88. package/stores/orders-sheet-store.ts +21 -0
  89. package/stores/policy-modal-store.ts +15 -0
  90. package/stores/product-modal-store.ts +18 -0
  91. package/stores/search-modal-store.ts +17 -0
  92. package/stores/toast-store.ts +46 -0
  93. package/stores/user-store.ts +88 -0
  94. package/theme-context.tsx +28 -0
  95. package/theme-variant-resolver.ts +44 -0
  96. package/types/block.ts +292 -0
  97. package/types/cart.ts +21 -0
  98. package/types/media.ts +55 -0
  99. package/types/menu.ts +27 -0
  100. package/types/order.ts +119 -0
  101. package/types/page.ts +86 -0
  102. package/types/product.ts +279 -0
  103. package/types/theme.ts +380 -0
  104. package/types/vendor.ts +243 -0
  105. package/utils/addon-selection.ts +31 -0
  106. package/utils/benefit-icon.tsx +145 -0
  107. package/utils/brand-palette.ts +64 -0
  108. package/utils/brand.ts +518 -0
  109. package/utils/contact-location.ts +44 -0
  110. package/utils/format.ts +64 -0
  111. package/utils/hero-content.ts +35 -0
  112. package/utils/image-srcset.ts +157 -0
  113. package/utils/markdown.tsx +237 -0
  114. package/utils/match-variant.ts +31 -0
  115. package/utils/menu-link.ts +41 -0
  116. package/utils/placeholder-resolver.ts +184 -0
  117. package/utils/product-media.ts +77 -0
  118. package/utils/product-normalizer.ts +323 -0
  119. package/utils/vendor-host.ts +173 -0
  120. 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
+ }