@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,77 @@
|
|
|
1
|
+
import type { ImageVariants } from '../types/media';
|
|
2
|
+
import type { Product } from '../types/product';
|
|
3
|
+
|
|
4
|
+
export interface MediaFrame {
|
|
5
|
+
id: string;
|
|
6
|
+
url: string;
|
|
7
|
+
alt: string;
|
|
8
|
+
type: 'image' | 'video';
|
|
9
|
+
/** Tier map for this frame, when the image is Media-backed. Consumed by
|
|
10
|
+
* ProductMediaFrame → <Image variants>. Absent for video frames and for
|
|
11
|
+
* non-Media-backed images. */
|
|
12
|
+
variants?: ImageVariants | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Picks the largest tier a PDP hero should ever request. `view` is capped at
|
|
17
|
+
* 1600px (queek_backend config/media.php) which covers every theme's hero box
|
|
18
|
+
* at DPR2 — the raw `original` is uncapped and unoptimized.
|
|
19
|
+
*/
|
|
20
|
+
function heroUrl(variants: ImageVariants | null | undefined): string | null {
|
|
21
|
+
return variants?.view?.url ?? variants?.card2x?.url ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Builds the ordered list of PDP gallery frames for a product: main image,
|
|
26
|
+
* then gallery images, then the product video (if any) appended last — was
|
|
27
|
+
* duplicated verbatim as `buildFrames`/`buildGalleryFrames` in every theme's
|
|
28
|
+
* pages/product.tsx; centralized here per the project's reuse-over-duplication
|
|
29
|
+
* rule. Pair with `ProductMediaFrame` to render whatever comes back.
|
|
30
|
+
*/
|
|
31
|
+
export function buildMediaFrames(product: Product): MediaFrame[] {
|
|
32
|
+
const frames: MediaFrame[] = [];
|
|
33
|
+
const mainVariants = product.media.image_variants ?? null;
|
|
34
|
+
|
|
35
|
+
if (product.media.image) {
|
|
36
|
+
frames.push({
|
|
37
|
+
id: 'main',
|
|
38
|
+
// WAS: `product.media.original ?? product.media.image` — which shipped the
|
|
39
|
+
// full raw original into every theme's PDP hero, unconditionally. Now the
|
|
40
|
+
// capped `view` tier (<=1600px webp) wins whenever the backend has one,
|
|
41
|
+
// and `original ?? image` survives untouched as the fallback for
|
|
42
|
+
// non-Media-backed images (external url, SVG, legacy upload) where
|
|
43
|
+
// image_variants is null. Themes that read `frame.url` directly get the
|
|
44
|
+
// downgrade for free; those that render through ProductMediaFrame also
|
|
45
|
+
// get a real srcset off `variants`.
|
|
46
|
+
url: heroUrl(mainVariants) ?? product.media.original ?? product.media.image,
|
|
47
|
+
alt: product.title,
|
|
48
|
+
type: 'image',
|
|
49
|
+
variants: mainVariants,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (product.media.gallery) {
|
|
54
|
+
for (const item of product.media.gallery) {
|
|
55
|
+
const itemVariants = item.variants ?? null;
|
|
56
|
+
frames.push({
|
|
57
|
+
id: String(item.id),
|
|
58
|
+
// item.url is already the CARD url server-side (480px) — too small for a
|
|
59
|
+
// main hero frame, which is why the `view` tier is preferred here too.
|
|
60
|
+
url: heroUrl(itemVariants) ?? item.url,
|
|
61
|
+
alt: item.alt ?? product.title,
|
|
62
|
+
type: 'image',
|
|
63
|
+
variants: itemVariants,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (frames.length === 0 && product.media.thumbnail) {
|
|
69
|
+
frames.push({ id: 'thumb', url: product.media.thumbnail, alt: product.title, type: 'image' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (product.media.video_url) {
|
|
73
|
+
frames.push({ id: 'video', url: product.media.video_url, alt: product.title, type: 'video' });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return frames;
|
|
77
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LegacyProductLike,
|
|
3
|
+
Product,
|
|
4
|
+
ProductCategory,
|
|
5
|
+
ProductOption,
|
|
6
|
+
ProductPricing,
|
|
7
|
+
ProductVariantOptionValue,
|
|
8
|
+
} from '../types/product';
|
|
9
|
+
|
|
10
|
+
const KIND_SET = new Set(['physical', 'service', 'digital']);
|
|
11
|
+
const STATUS_SET = new Set(['active', 'inactive']);
|
|
12
|
+
|
|
13
|
+
function toNumber(value: unknown, fallback = 0): number {
|
|
14
|
+
const next = typeof value === 'number' ? value : Number(value);
|
|
15
|
+
|
|
16
|
+
return Number.isFinite(next) ? next : fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toBool(value: unknown, fallback = false): boolean {
|
|
20
|
+
if (typeof value === 'boolean') {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (value === 1 || value === '1' || value === 'true') {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (value === 0 || value === '0' || value === 'false') {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Same base/sale/compare-at shape as normalizePricing(), but a variant's own price lives on
|
|
37
|
+
* FLAT fields (price/discount_price/compare_at_price/discount_amount — see
|
|
38
|
+
* ProductResource::resolveShopVariants on the backend), never nested under a `pricing` key.
|
|
39
|
+
* Reading `variant.pricing?.sale_amount` (as this used to) is always undefined, so every
|
|
40
|
+
* variant silently fell back to the base product's price — picking Denim vs Taupe never
|
|
41
|
+
* changed what was shown, even though the backend had already computed the right number.
|
|
42
|
+
*/
|
|
43
|
+
type RawVariant = NonNullable<LegacyProductLike['variants']>[number];
|
|
44
|
+
|
|
45
|
+
function normalizeVariantPricing(variant: RawVariant, raw: LegacyProductLike): ProductPricing {
|
|
46
|
+
const hasFlatBase = typeof variant.price === 'number' || typeof variant.price === 'string';
|
|
47
|
+
const productPricing = normalizePricing(raw);
|
|
48
|
+
|
|
49
|
+
if (!hasFlatBase && !variant.pricing) {
|
|
50
|
+
return productPricing;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const baseAmount = toNumber(variant.pricing?.base_amount, hasFlatBase ? toNumber(variant.price) : productPricing.base_amount);
|
|
54
|
+
const saleAmount = toNumber(variant.pricing?.sale_amount, toNumber(variant.discount_price, baseAmount));
|
|
55
|
+
const compareAtAmount = toNumber(variant.pricing?.compare_at_amount, toNumber(variant.compare_at_price, baseAmount));
|
|
56
|
+
const discountAmount = toNumber(
|
|
57
|
+
variant.pricing?.discount_amount,
|
|
58
|
+
toNumber(variant.discount_amount, Math.max(compareAtAmount - saleAmount, 0)),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
base_amount: baseAmount,
|
|
63
|
+
sale_amount: saleAmount,
|
|
64
|
+
compare_at_amount: compareAtAmount,
|
|
65
|
+
discount_amount: discountAmount,
|
|
66
|
+
discount_percent: toNumber(
|
|
67
|
+
variant.pricing?.discount_percent,
|
|
68
|
+
compareAtAmount > 0 ? Math.round((discountAmount / compareAtAmount) * 100) : 0,
|
|
69
|
+
),
|
|
70
|
+
is_price_from: false,
|
|
71
|
+
tax_inclusive: productPricing.tax_inclusive,
|
|
72
|
+
price_range: {
|
|
73
|
+
min_amount: saleAmount,
|
|
74
|
+
max_amount: saleAmount,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizePricing(raw: LegacyProductLike): ProductPricing {
|
|
80
|
+
const legacyBase = toNumber(raw.price, 0);
|
|
81
|
+
const legacySale = toNumber(raw.discount_price, legacyBase);
|
|
82
|
+
const legacyCompareAt = toNumber(raw.compare_at_price, legacyBase);
|
|
83
|
+
const source = raw.pricing ?? {};
|
|
84
|
+
const baseAmount = toNumber(source.base_amount, legacyBase);
|
|
85
|
+
const saleAmount = toNumber(source.sale_amount, legacySale);
|
|
86
|
+
const compareAtAmount = toNumber(source.compare_at_amount, legacyCompareAt || baseAmount);
|
|
87
|
+
const discountAmount = toNumber(source.discount_amount, Math.max(compareAtAmount - saleAmount, 0));
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
base_amount: baseAmount,
|
|
91
|
+
sale_amount: saleAmount,
|
|
92
|
+
compare_at_amount: compareAtAmount,
|
|
93
|
+
discount_amount: discountAmount,
|
|
94
|
+
discount_percent: toNumber(
|
|
95
|
+
source.discount_percent,
|
|
96
|
+
compareAtAmount > 0 ? Math.round((discountAmount / compareAtAmount) * 100) : 0,
|
|
97
|
+
),
|
|
98
|
+
is_price_from: toBool(source.is_price_from, toBool(raw.is_price_from, false)),
|
|
99
|
+
tax_inclusive: toBool(source.tax_inclusive, false),
|
|
100
|
+
price_range: {
|
|
101
|
+
min_amount: toNumber(source.price_range?.min_amount, saleAmount),
|
|
102
|
+
max_amount: toNumber(source.price_range?.max_amount, saleAmount),
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeVariantOptionValues(
|
|
108
|
+
value: ProductVariantOptionValue[] | Record<string, string> | undefined,
|
|
109
|
+
): ProductVariantOptionValue[] {
|
|
110
|
+
if (!value) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return Object.entries(value).map(([optionName, optionValue]) => ({
|
|
119
|
+
option_name: optionName,
|
|
120
|
+
value: optionValue,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeOptions(raw: LegacyProductLike): ProductOption[] {
|
|
125
|
+
if (!Array.isArray(raw.options)) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return raw.options.reduce<ProductOption[]>((rows, option) => {
|
|
130
|
+
const name = typeof option.name === 'string' ? option.name.trim() : '';
|
|
131
|
+
if (name === '') {
|
|
132
|
+
return rows;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// values_detailed (objects carrying color_code/image) is the richer, authoritative source —
|
|
136
|
+
// values (plain strings) is a fallback for a shape that never sends values_detailed. These
|
|
137
|
+
// describe the SAME set of values, never both at once — concatenating them (as this used to)
|
|
138
|
+
// double-counted every option value.
|
|
139
|
+
const source = Array.isArray(option.values_detailed) && option.values_detailed.length > 0
|
|
140
|
+
? option.values_detailed
|
|
141
|
+
: (Array.isArray(option.values) ? option.values : []);
|
|
142
|
+
|
|
143
|
+
const values = source
|
|
144
|
+
.map((entry) => {
|
|
145
|
+
if (typeof entry === 'string') {
|
|
146
|
+
return {
|
|
147
|
+
value: entry,
|
|
148
|
+
label: entry,
|
|
149
|
+
color_code: null,
|
|
150
|
+
image: null,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const value = typeof entry.value === 'string' ? entry.value : '';
|
|
155
|
+
if (value === '') {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
value,
|
|
161
|
+
label: typeof entry.label === 'string' && entry.label !== '' ? entry.label : value,
|
|
162
|
+
color_code: typeof entry.color_code === 'string' ? entry.color_code : null,
|
|
163
|
+
image: typeof entry.image === 'string' ? entry.image : null,
|
|
164
|
+
};
|
|
165
|
+
})
|
|
166
|
+
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
|
|
167
|
+
|
|
168
|
+
rows.push({
|
|
169
|
+
name,
|
|
170
|
+
type: option.type ?? null,
|
|
171
|
+
values,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return rows;
|
|
175
|
+
}, []);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function normalizeCategories(raw: LegacyProductLike): ProductCategory[] {
|
|
179
|
+
if (!Array.isArray(raw.categories)) {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return raw.categories.reduce<ProductCategory[]>((rows, category) => {
|
|
184
|
+
const name = typeof category.name === 'string' ? category.name.trim() : '';
|
|
185
|
+
if (name === '') {
|
|
186
|
+
return rows;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
rows.push({
|
|
190
|
+
id: String(category.id ?? ''),
|
|
191
|
+
name,
|
|
192
|
+
slug: String(category.slug ?? ''),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return rows;
|
|
196
|
+
}, []);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function normalizeProduct(raw: LegacyProductLike): Product {
|
|
200
|
+
const shopId = String(raw.shop_id ?? raw.vendor_id ?? raw.shop?.id ?? raw.vendor?.id ?? '');
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
shop_id: shopId,
|
|
204
|
+
id: String(raw.id ?? ''),
|
|
205
|
+
p_id: toNumber(raw.p_id, 0),
|
|
206
|
+
title: String(raw.title ?? ''),
|
|
207
|
+
slug: String(raw.slug ?? ''),
|
|
208
|
+
excerpt: raw.excerpt ?? null,
|
|
209
|
+
description: raw.description ?? null,
|
|
210
|
+
kind:
|
|
211
|
+
typeof raw.kind === 'string' && KIND_SET.has(raw.kind)
|
|
212
|
+
? (raw.kind as Product['kind'])
|
|
213
|
+
: 'physical',
|
|
214
|
+
status:
|
|
215
|
+
typeof raw.status === 'string' && STATUS_SET.has(raw.status)
|
|
216
|
+
? (raw.status as Product['status'])
|
|
217
|
+
: 'active',
|
|
218
|
+
barcode: typeof raw.barcode === 'string' && raw.barcode !== '' ? raw.barcode : null,
|
|
219
|
+
currency: String(raw.currency ?? 'NGN'),
|
|
220
|
+
pricing: normalizePricing(raw),
|
|
221
|
+
shop: {
|
|
222
|
+
id: String(raw.shop?.id ?? raw.vendor?.id ?? shopId),
|
|
223
|
+
name: raw.shop?.name ?? raw.vendor?.name ?? null,
|
|
224
|
+
slug: raw.shop?.slug ?? raw.vendor?.slug ?? null,
|
|
225
|
+
logo: raw.shop?.logo ?? raw.vendor?.logo ?? null,
|
|
226
|
+
rating: toNumber(raw.shop?.rating ?? raw.vendor?.rating, 0),
|
|
227
|
+
rating_count: toNumber(raw.shop?.rating_count ?? raw.vendor?.rating_count, 0),
|
|
228
|
+
},
|
|
229
|
+
media: {
|
|
230
|
+
thumbnail: raw.media?.thumbnail ?? raw.thumbnail ?? null,
|
|
231
|
+
image: raw.media?.image ?? raw.image ?? null,
|
|
232
|
+
original: raw.media?.original ?? null,
|
|
233
|
+
primary_variant_image: raw.media?.primary_variant_image ?? null,
|
|
234
|
+
// Srcset tier maps — MUST be carried through: this whitelist is the only
|
|
235
|
+
// path products take into the app (use-products, use-product-sections,
|
|
236
|
+
// use-related-products, use-shop, api/products, api/collections, qr/*),
|
|
237
|
+
// so dropping them here makes <Image variants> dead at every call site.
|
|
238
|
+
// `?? null` (not `?? undefined`) keeps "no map" and "backend sent null"
|
|
239
|
+
// indistinguishable, which is exactly how consumers treat them.
|
|
240
|
+
image_variants: raw.media?.image_variants ?? null,
|
|
241
|
+
primary_variant_image_variants: raw.media?.primary_variant_image_variants ?? null,
|
|
242
|
+
// Top-level fields on the real payload, not nested under media (see
|
|
243
|
+
// LegacyProductLike's own note) — media?.video_url/video_poster_url
|
|
244
|
+
// covers a preview/demo payload that happens to nest them there too.
|
|
245
|
+
video_url: raw.media?.video_url ?? raw.video_url ?? null,
|
|
246
|
+
video_poster_url: raw.media?.video_poster_url ?? raw.video_poster_url ?? null,
|
|
247
|
+
// Pass-through: gallery items carry their own `variants` (ProductResource::
|
|
248
|
+
// buildGalleryPayload) and are not field-whitelisted here.
|
|
249
|
+
gallery: raw.media?.gallery ?? [],
|
|
250
|
+
},
|
|
251
|
+
inventory: {
|
|
252
|
+
in_stock: toBool(raw.inventory?.in_stock, toBool(raw.in_stock, toNumber(raw.stock, 0) > 0)),
|
|
253
|
+
tracking: toBool(raw.inventory?.tracking, toBool(raw.inventory_tracking, false)),
|
|
254
|
+
quantity: toNumber(raw.inventory?.quantity, toNumber(raw.stock, 0)),
|
|
255
|
+
},
|
|
256
|
+
variants_count: Array.isArray(raw.variants) ? raw.variants.length : 0,
|
|
257
|
+
review_summary: {
|
|
258
|
+
rating: toNumber(raw.review_summary?.rating ?? raw.rating, 0),
|
|
259
|
+
review_count: toNumber(raw.review_summary?.review_count ?? raw.review_count, 0),
|
|
260
|
+
},
|
|
261
|
+
flags: {
|
|
262
|
+
featured: toBool(raw.flags?.featured, false),
|
|
263
|
+
is_marketplace: toBool(raw.flags?.is_marketplace, true),
|
|
264
|
+
is_wholesale: toBool(raw.flags?.is_wholesale, false),
|
|
265
|
+
is_new: toBool(raw.flags?.is_new, false),
|
|
266
|
+
},
|
|
267
|
+
categories: normalizeCategories(raw),
|
|
268
|
+
options: normalizeOptions(raw),
|
|
269
|
+
variants: Array.isArray(raw.variants)
|
|
270
|
+
? raw.variants.map((variant, index) => ({
|
|
271
|
+
id: String(variant.id ?? `variant-${index}`),
|
|
272
|
+
sku: variant.sku ?? null,
|
|
273
|
+
barcode: typeof variant.barcode === 'string' && variant.barcode !== '' ? variant.barcode : null,
|
|
274
|
+
title: variant.title ?? null,
|
|
275
|
+
option_values: normalizeVariantOptionValues(variant.option_values),
|
|
276
|
+
pricing: normalizeVariantPricing(variant, raw),
|
|
277
|
+
inventory: {
|
|
278
|
+
// Same flat-vs-nested mismatch as pricing: the backend sends in_stock/stock/
|
|
279
|
+
// track_inventory directly on the variant, never nested under `inventory`.
|
|
280
|
+
in_stock: toBool(variant.inventory?.in_stock, toBool(variant.in_stock, true)),
|
|
281
|
+
tracking: toBool(variant.inventory?.tracking, toBool(variant.track_inventory, false)),
|
|
282
|
+
quantity: toNumber(variant.inventory?.quantity, toNumber(variant.stock, 0)),
|
|
283
|
+
},
|
|
284
|
+
media: variant.media ?? {
|
|
285
|
+
thumbnail: raw.media?.thumbnail ?? null,
|
|
286
|
+
image: raw.media?.image ?? null,
|
|
287
|
+
},
|
|
288
|
+
weight: variant.weight ?? null,
|
|
289
|
+
position: variant.position ?? index,
|
|
290
|
+
is_active: toBool(variant.is_active, true),
|
|
291
|
+
}))
|
|
292
|
+
: [],
|
|
293
|
+
addons: raw.addons ?? [],
|
|
294
|
+
reviews: raw.reviews ?? [],
|
|
295
|
+
created_at: raw.created_at ?? null,
|
|
296
|
+
updated_at: raw.updated_at ?? null,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function extractProducts(value: unknown): LegacyProductLike[] {
|
|
301
|
+
if (!Array.isArray(value)) {
|
|
302
|
+
if (value && typeof value === 'object') {
|
|
303
|
+
const payload = value as Record<string, unknown>;
|
|
304
|
+
if (Array.isArray(payload.data)) {
|
|
305
|
+
return extractProducts(payload.data);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (payload.data && typeof payload.data === 'object') {
|
|
309
|
+
return [payload.data as LegacyProductLike];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Check if this is a grouped response: [{title, data: [products]}, ...]
|
|
317
|
+
const first = value[0] as Record<string, unknown> | undefined;
|
|
318
|
+
if (first && Array.isArray(first.data) && typeof first.title === 'string') {
|
|
319
|
+
return (value as Array<{ data: LegacyProductLike[] }>).flatMap((group) => group.data ?? []);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return value as LegacyProductLike[];
|
|
323
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor resolution from a hostname: which store is this request for?
|
|
3
|
+
*
|
|
4
|
+
* Lived at `app/middleware.ts` until core needed it — `lib/core/api/products.ts`
|
|
5
|
+
* and `lib/core/sdk/server-store-client.ts` both import from here, which meant
|
|
6
|
+
* core depended on the app. Nothing here is app-specific: it reads env and
|
|
7
|
+
* resolves a slug. (The actual Next middleware is `proxy.ts`; the old filename
|
|
8
|
+
* suggested otherwise and was never that.)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Mirrors backend config/storefront.php `reserved_slugs`. Keep in sync.
|
|
12
|
+
// These are subdomains the storefront proxy must NOT treat as vendor slugs.
|
|
13
|
+
export const RESERVED_VENDOR_SLUGS = new Set([
|
|
14
|
+
// Live platform subdomains
|
|
15
|
+
'dokploy', 'dashboard', 'pos', 'qr', 'themes', 'preview', 'edge', 'api',
|
|
16
|
+
// Standard reserved
|
|
17
|
+
'www', 'app', 'admin', 'root', 'shop', 'store',
|
|
18
|
+
// Common technical / infra
|
|
19
|
+
'cdn', 'assets', 'media', 'static', 'health', 'status',
|
|
20
|
+
// Common platform-service subdomains
|
|
21
|
+
'mail', 'smtp', 'webmail', 'email', 'blog', 'help', 'docs', 'support',
|
|
22
|
+
'billing', 'accounts', 'auth', 'login', 'signup', 'register', 'logout',
|
|
23
|
+
// Internal / brand
|
|
24
|
+
'staff', 'team', 'queek', 'merchant', 'vendor', 'rider', 'customer',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// Every sluggable backend model (Vendor, Product, Category, Region, Service —
|
|
28
|
+
// app/Models/*.php `getSlugOptions()`) is Spatie Sluggable output: lowercase
|
|
29
|
+
// alphanumeric, optionally hyphen-separated, never a dot/underscore/uppercase.
|
|
30
|
+
// Anything else cannot be a real slug of any kind, so reject it before any
|
|
31
|
+
// backend round-trip instead of burning calls that all 404. This is what stops
|
|
32
|
+
// scanners probing `.env`, `.ssh`, `docker-compose.yaml`, `*.php` etc —
|
|
33
|
+
// RESERVED_VENDOR_SLUGS alone only covers known words, not the infinite space
|
|
34
|
+
// of file-extension guesses.
|
|
35
|
+
const SLUG_FORMAT = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
36
|
+
|
|
37
|
+
export function isValidSlugFormat(slug: string): boolean {
|
|
38
|
+
return SLUG_FORMAT.test(slug);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const REMOTE_RESOLVE_REVALIDATE_SECONDS = 300;
|
|
42
|
+
const REMOTE_RESOLVE_TIMEOUT_MS = 3000;
|
|
43
|
+
|
|
44
|
+
let lastRemoteFailureLoggedAt = 0;
|
|
45
|
+
const REMOTE_FAILURE_LOG_WINDOW_MS = 60_000;
|
|
46
|
+
|
|
47
|
+
export function shouldBypassVendorProxy(pathname: string): boolean {
|
|
48
|
+
return pathname.startsWith('/_next') || pathname.startsWith('/api') || pathname.includes('.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveSubdomainVendorSlug(hostname: string): string | null {
|
|
52
|
+
const storefrontDomain = process.env.STOREFRONT_DOMAIN ?? 'queek.com.ng';
|
|
53
|
+
const domains = [`.${storefrontDomain}`, '.localhost'];
|
|
54
|
+
|
|
55
|
+
const matchedDomain = domains.find((d) => hostname.endsWith(d));
|
|
56
|
+
if (!matchedDomain) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const slug = hostname.slice(0, -matchedDomain.length);
|
|
61
|
+
|
|
62
|
+
if (!slug || slug.includes('.') || RESERVED_VENDOR_SLUGS.has(slug)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return slug;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resolveCustomDomainVendorSlug(hostname: string): string | null {
|
|
70
|
+
const raw = process.env.CUSTOM_DOMAIN_VENDOR_MAP;
|
|
71
|
+
if (!raw) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const map = JSON.parse(raw) as Record<string, string>;
|
|
77
|
+
const slug = map[hostname];
|
|
78
|
+
|
|
79
|
+
return typeof slug === 'string' && slug !== '' ? slug : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function resolveCustomDomainVendorSlugRemote(hostname: string): Promise<string | null> {
|
|
86
|
+
if (!hostname) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Middleware runs server-side — prefer the server-only absolute URL. The
|
|
91
|
+
// public var can be a same-origin relative path (checkout-proxy rollout),
|
|
92
|
+
// which `fetch()` can't resolve without a document origin here.
|
|
93
|
+
const baseUrl = process.env.QUEEK_API_URL ?? process.env.NEXT_PUBLIC_QUEEK_API_URL;
|
|
94
|
+
if (!baseUrl) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/client/resolve-by-host?host=${encodeURIComponent(hostname)}`;
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
const timeoutId = setTimeout(() => controller.abort(), REMOTE_RESOLVE_TIMEOUT_MS);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const response = await fetch(url, {
|
|
104
|
+
method: 'GET',
|
|
105
|
+
headers: { Accept: 'application/json' },
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
next: {
|
|
108
|
+
revalidate: REMOTE_RESOLVE_REVALIDATE_SECONDS,
|
|
109
|
+
tags: [`host:${hostname}`],
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
logRemoteFailure(`status ${response.status}`);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const payload = (await response.json()) as { data?: { slug?: unknown } };
|
|
119
|
+
const slug = payload?.data?.slug;
|
|
120
|
+
|
|
121
|
+
return typeof slug === 'string' && slug !== '' ? slug : null;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
logRemoteFailure(error instanceof Error ? error.message : 'unknown');
|
|
124
|
+
return null;
|
|
125
|
+
} finally {
|
|
126
|
+
clearTimeout(timeoutId);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function logRemoteFailure(reason: string): void {
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
if (now - lastRemoteFailureLoggedAt < REMOTE_FAILURE_LOG_WINDOW_MS) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
lastRemoteFailureLoggedAt = now;
|
|
136
|
+
console.warn(`[middleware] resolve-by-host failed: ${reason}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isPreviewSubdomain(hostname: string): boolean {
|
|
140
|
+
const storefrontDomain = process.env.STOREFRONT_DOMAIN ?? 'queek.com.ng';
|
|
141
|
+
const domains = [`.${storefrontDomain}`, '.localhost'];
|
|
142
|
+
|
|
143
|
+
return domains.some((d) => {
|
|
144
|
+
if (!hostname.endsWith(d)) return false;
|
|
145
|
+
return hostname.slice(0, -d.length) === 'preview';
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function resolveVendorSlug(options: {
|
|
150
|
+
host: string | null;
|
|
151
|
+
searchVendor: string | null;
|
|
152
|
+
}): Promise<string | null> {
|
|
153
|
+
const hostname = options.host?.split(':')[0] ?? '';
|
|
154
|
+
const subdomainSlug = resolveSubdomainVendorSlug(hostname);
|
|
155
|
+
|
|
156
|
+
if (subdomainSlug) {
|
|
157
|
+
return subdomainSlug;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const staticCustomDomainSlug = resolveCustomDomainVendorSlug(hostname);
|
|
161
|
+
if (staticCustomDomainSlug) {
|
|
162
|
+
return staticCustomDomainSlug;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (hostname) {
|
|
166
|
+
const remoteCustomDomainSlug = await resolveCustomDomainVendorSlugRemote(hostname);
|
|
167
|
+
if (remoteCustomDomainSlug) {
|
|
168
|
+
return remoteCustomDomainSlug;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return options.searchVendor;
|
|
173
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a YouTube/Vimeo URL to its embeddable iframe src. Returns null for
|
|
3
|
+
* a direct video file URL (caller falls back to an HTML5 `<video>` tag) or
|
|
4
|
+
* an unrecognized host.
|
|
5
|
+
*/
|
|
6
|
+
export function resolveVideoEmbedUrl(url: string): string | null {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = new URL(url);
|
|
9
|
+
const host = parsed.hostname.replace(/^www\./, '');
|
|
10
|
+
|
|
11
|
+
// YouTube
|
|
12
|
+
if (host === 'youtube.com' || host === 'm.youtube.com') {
|
|
13
|
+
const id = parsed.searchParams.get('v');
|
|
14
|
+
if (id) return `https://www.youtube.com/embed/${id}`;
|
|
15
|
+
}
|
|
16
|
+
if (host === 'youtu.be') {
|
|
17
|
+
const id = parsed.pathname.replace(/^\//, '');
|
|
18
|
+
if (id) return `https://www.youtube.com/embed/${id}`;
|
|
19
|
+
}
|
|
20
|
+
if (host === 'youtube.com' && parsed.pathname.startsWith('/embed/')) {
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Vimeo
|
|
25
|
+
if (host === 'vimeo.com') {
|
|
26
|
+
const id = parsed.pathname.replace(/^\//, '').split('/')[0];
|
|
27
|
+
if (id && /^\d+$/.test(id)) {
|
|
28
|
+
return `https://player.vimeo.com/video/${id}`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (host === 'player.vimeo.com') {
|
|
32
|
+
return url;
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|