@reblu/site-next 1.12.0 → 1.14.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 (45) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +80 -0
  3. package/dist/analytics.cjs.map +1 -1
  4. package/dist/analytics.js.map +1 -1
  5. package/dist/cart.cjs +137 -0
  6. package/dist/cart.cjs.map +1 -0
  7. package/dist/cart.d.cts +92 -0
  8. package/dist/cart.d.ts +92 -0
  9. package/dist/cart.js +108 -0
  10. package/dist/cart.js.map +1 -0
  11. package/dist/collection-BE5dhf-r.d.cts +106 -0
  12. package/dist/collection-BE5dhf-r.d.ts +106 -0
  13. package/dist/consent.cjs.map +1 -1
  14. package/dist/consent.js.map +1 -1
  15. package/dist/json-ld.cjs +28 -2
  16. package/dist/json-ld.cjs.map +1 -1
  17. package/dist/json-ld.d.cts +44 -35
  18. package/dist/json-ld.d.ts +44 -35
  19. package/dist/json-ld.js +26 -1
  20. package/dist/json-ld.js.map +1 -1
  21. package/dist/opening-hours.cjs +127 -0
  22. package/dist/opening-hours.cjs.map +1 -0
  23. package/dist/opening-hours.d.cts +53 -0
  24. package/dist/opening-hours.d.ts +53 -0
  25. package/dist/opening-hours.js +102 -0
  26. package/dist/opening-hours.js.map +1 -0
  27. package/dist/shop-route-handlers.cjs +140 -0
  28. package/dist/shop-route-handlers.cjs.map +1 -0
  29. package/dist/shop-route-handlers.d.cts +62 -0
  30. package/dist/shop-route-handlers.d.ts +62 -0
  31. package/dist/shop-route-handlers.js +109 -0
  32. package/dist/shop-route-handlers.js.map +1 -0
  33. package/dist/shop-server.cjs +22 -4
  34. package/dist/shop-server.cjs.map +1 -1
  35. package/dist/shop-server.d.cts +17 -101
  36. package/dist/shop-server.d.ts +17 -101
  37. package/dist/shop-server.js +21 -4
  38. package/dist/shop-server.js.map +1 -1
  39. package/dist/shop.cjs +5 -4
  40. package/dist/shop.cjs.map +1 -1
  41. package/dist/shop.d.cts +2 -2
  42. package/dist/shop.d.ts +2 -2
  43. package/dist/shop.js +5 -4
  44. package/dist/shop.js.map +1 -1
  45. package/package.json +34 -3
package/dist/cart.d.ts ADDED
@@ -0,0 +1,92 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as zustand_middleware from 'zustand/middleware';
3
+ import * as zustand from 'zustand';
4
+
5
+ /**
6
+ * `CartProvider` — mount this once near the root layout (inside a Client
7
+ * Component boundary). It owns the ONE explicit-hydration call
8
+ * `useCartStore.persist.rehydrate()` requires (`store.ts`'s `skipHydration`
9
+ * doc) — a SITE that forgets to mount it gets an always-empty cart that never
10
+ * reads back whatever a previous visit (or page reload) persisted.
11
+ */
12
+ declare function CartProvider({ children }: {
13
+ children: React.ReactNode;
14
+ }): react_jsx_runtime.JSX.Element;
15
+
16
+ /**
17
+ * `useCartStore` — the SDK's cart state (REB-829). Ported verbatim (same
18
+ * shape, same quantity-clamping rules) from demo-site's local
19
+ * `lib/store/cart-store.ts`, which every SITE was otherwise expected to
20
+ * hand-copy. `CART_STORAGE_KEY` stays exactly `'shop-cart'` on purpose: a
21
+ * SITE migrating from the local copy keeps its visitors' existing carts.
22
+ *
23
+ * `skipHydration: true` (zustand persist) is load-bearing for SSR: without
24
+ * it, zustand's persist middleware reads `localStorage` synchronously during
25
+ * module evaluation, which throws (or silently desyncs) on the server. The
26
+ * `CartProvider` (`./provider`) calls `useCartStore.persist.rehydrate()`
27
+ * inside a `useEffect`, so hydration only ever happens client-side, after
28
+ * mount — the same explicit-hydration contract the SITE README documents.
29
+ */
30
+ declare const CART_STORAGE_KEY = "shop-cart";
31
+ interface CartItem {
32
+ productId: string;
33
+ variantId?: string | null;
34
+ name: string;
35
+ variantName?: string;
36
+ /** Integer cents — every Reblu shop price travels as cents, never a float. */
37
+ unitPrice: number;
38
+ quantity: number;
39
+ imageUrl?: string;
40
+ /** `null` means untracked/unlimited stock — never clamp against it. */
41
+ maxStock: number | null;
42
+ /** Basis points, e.g. `2100` = 21%. `null`/`undefined` = unknown or exempt. */
43
+ taxRateBps?: number | null;
44
+ taxRateName?: string | null;
45
+ }
46
+ interface CartStore {
47
+ items: CartItem[];
48
+ addItem: (item: CartItem) => void;
49
+ removeItem: (productId: string, variantId?: string | null) => void;
50
+ updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
51
+ clearCart: () => void;
52
+ getTotal: () => number;
53
+ getItemCount: () => number;
54
+ }
55
+ declare const useCartStore: zustand.UseBoundStore<Omit<zustand.StoreApi<CartStore>, "setState" | "persist"> & {
56
+ setState(partial: CartStore | Partial<CartStore> | ((state: CartStore) => CartStore | Partial<CartStore>), replace?: false | undefined): unknown;
57
+ setState(state: CartStore | ((state: CartStore) => CartStore), replace: true): unknown;
58
+ persist: {
59
+ setOptions: (options: Partial<zustand_middleware.PersistOptions<CartStore, CartStore, unknown>>) => void;
60
+ clearStorage: () => void;
61
+ rehydrate: () => Promise<void> | void;
62
+ hasHydrated: () => boolean;
63
+ onHydrate: (fn: (state: CartStore) => void) => () => void;
64
+ onFinishHydration: (fn: (state: CartStore) => void) => () => void;
65
+ getOptions: () => Partial<zustand_middleware.PersistOptions<CartStore, CartStore, unknown>>;
66
+ };
67
+ }>;
68
+
69
+ interface UseCartResult {
70
+ items: CartItem[];
71
+ /** Sum of every line's `unitPrice * quantity`, in integer cents. */
72
+ total: number;
73
+ /** Sum of every line's `quantity`. */
74
+ itemCount: number;
75
+ addItem: (item: CartItem) => void;
76
+ removeItem: (productId: string, variantId?: string | null) => void;
77
+ updateQuantity: (productId: string, variantId: string | null | undefined, quantity: number) => void;
78
+ clearCart: () => void;
79
+ }
80
+ /**
81
+ * `useCart` — the ergonomic entry point for a SITE's cart UI: a single
82
+ * selector call that re-renders only on `items` changes, with `total`/
83
+ * `itemCount` derived inline (cheap — a plain `reduce` over a typically-small
84
+ * array) rather than via `useCartStore`'s own `getTotal`/`getItemCount`
85
+ * selectors, which are stable function references and would NOT trigger a
86
+ * re-render on their own. A SITE that wants raw store access (existing
87
+ * per-field selectors, e.g. `useCartStore((s) => s.addItem)`) can still
88
+ * import `useCartStore` directly — this hook doesn't replace it.
89
+ */
90
+ declare function useCart(): UseCartResult;
91
+
92
+ export { CART_STORAGE_KEY, type CartItem, CartProvider, type CartStore, type UseCartResult, useCart, useCartStore };
package/dist/cart.js ADDED
@@ -0,0 +1,108 @@
1
+ "use client";
2
+
3
+ // src/cart/provider.tsx
4
+ import { useEffect } from "react";
5
+
6
+ // src/cart/store.ts
7
+ import { create } from "zustand";
8
+ import { persist } from "zustand/middleware";
9
+ var CART_STORAGE_KEY = "shop-cart";
10
+ function isSameItem(a, productId, variantId) {
11
+ return a.productId === productId && (a.variantId ?? null) === (variantId ?? null);
12
+ }
13
+ function normalizeQuantity(quantity, maxStock) {
14
+ if (!Number.isFinite(quantity)) return 0;
15
+ const truncated = Math.trunc(quantity);
16
+ if (truncated < 1) return 0;
17
+ if (maxStock === null) return truncated;
18
+ const maxTruncated = Math.trunc(maxStock);
19
+ if (maxTruncated < 1) return 0;
20
+ return Math.min(truncated, maxTruncated);
21
+ }
22
+ var useCartStore = create()(
23
+ persist(
24
+ (set, get) => ({
25
+ items: [],
26
+ addItem: (item) => {
27
+ set((state) => {
28
+ const existing = state.items.find((i) => isSameItem(i, item.productId, item.variantId));
29
+ if (existing) {
30
+ if (item.maxStock !== null && Math.trunc(item.maxStock) < 1) {
31
+ return {
32
+ items: state.items.filter((i) => !isSameItem(i, item.productId, item.variantId))
33
+ };
34
+ }
35
+ const newQty = normalizeQuantity(existing.quantity + item.quantity, item.maxStock);
36
+ if (newQty < 1) {
37
+ return {};
38
+ }
39
+ return {
40
+ items: state.items.map(
41
+ (i) => isSameItem(i, item.productId, item.variantId) ? { ...i, quantity: newQty, maxStock: item.maxStock } : i
42
+ )
43
+ };
44
+ }
45
+ const qty = normalizeQuantity(item.quantity, item.maxStock);
46
+ if (qty < 1) {
47
+ return {};
48
+ }
49
+ return { items: [...state.items, { ...item, quantity: qty }] };
50
+ });
51
+ },
52
+ removeItem: (productId, variantId) => {
53
+ set((state) => ({
54
+ items: state.items.filter((i) => !isSameItem(i, productId, variantId))
55
+ }));
56
+ },
57
+ updateQuantity: (productId, variantId, quantity) => {
58
+ const existing = get().items.find((i) => isSameItem(i, productId, variantId));
59
+ if (!existing) return;
60
+ const newQty = normalizeQuantity(quantity, existing.maxStock);
61
+ if (newQty < 1) {
62
+ get().removeItem(productId, variantId);
63
+ return;
64
+ }
65
+ set((state) => ({
66
+ items: state.items.map(
67
+ (i) => isSameItem(i, productId, variantId) ? { ...i, quantity: newQty } : i
68
+ )
69
+ }));
70
+ },
71
+ clearCart: () => set({ items: [] }),
72
+ getTotal: () => get().items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0),
73
+ getItemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0)
74
+ }),
75
+ {
76
+ name: CART_STORAGE_KEY,
77
+ skipHydration: true
78
+ }
79
+ )
80
+ );
81
+
82
+ // src/cart/provider.tsx
83
+ import { Fragment, jsx } from "react/jsx-runtime";
84
+ function CartProvider({ children }) {
85
+ useEffect(() => {
86
+ useCartStore.persist.rehydrate();
87
+ }, []);
88
+ return /* @__PURE__ */ jsx(Fragment, { children });
89
+ }
90
+
91
+ // src/cart/use-cart.ts
92
+ function useCart() {
93
+ const items = useCartStore((s) => s.items);
94
+ const addItem = useCartStore((s) => s.addItem);
95
+ const removeItem = useCartStore((s) => s.removeItem);
96
+ const updateQuantity = useCartStore((s) => s.updateQuantity);
97
+ const clearCart = useCartStore((s) => s.clearCart);
98
+ const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
99
+ const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
100
+ return { items, total, itemCount, addItem, removeItem, updateQuantity, clearCart };
101
+ }
102
+ export {
103
+ CART_STORAGE_KEY,
104
+ CartProvider,
105
+ useCart,
106
+ useCartStore
107
+ };
108
+ //# sourceMappingURL=cart.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cart/provider.tsx","../src/cart/store.ts","../src/cart/use-cart.ts"],"sourcesContent":["'use client'\n\nimport { useEffect } from 'react'\nimport { useCartStore } from './store'\n\n/**\n * `CartProvider` — mount this once near the root layout (inside a Client\n * Component boundary). It owns the ONE explicit-hydration call\n * `useCartStore.persist.rehydrate()` requires (`store.ts`'s `skipHydration`\n * doc) — a SITE that forgets to mount it gets an always-empty cart that never\n * reads back whatever a previous visit (or page reload) persisted.\n */\nexport function CartProvider({ children }: { children: React.ReactNode }) {\n useEffect(() => {\n useCartStore.persist.rehydrate()\n }, [])\n\n return <>{children}</>\n}\n","import { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\n\n/**\n * `useCartStore` — the SDK's cart state (REB-829). Ported verbatim (same\n * shape, same quantity-clamping rules) from demo-site's local\n * `lib/store/cart-store.ts`, which every SITE was otherwise expected to\n * hand-copy. `CART_STORAGE_KEY` stays exactly `'shop-cart'` on purpose: a\n * SITE migrating from the local copy keeps its visitors' existing carts.\n *\n * `skipHydration: true` (zustand persist) is load-bearing for SSR: without\n * it, zustand's persist middleware reads `localStorage` synchronously during\n * module evaluation, which throws (or silently desyncs) on the server. The\n * `CartProvider` (`./provider`) calls `useCartStore.persist.rehydrate()`\n * inside a `useEffect`, so hydration only ever happens client-side, after\n * mount — the same explicit-hydration contract the SITE README documents.\n */\nexport const CART_STORAGE_KEY = 'shop-cart'\n\nexport interface CartItem {\n productId: string\n variantId?: string | null\n name: string\n variantName?: string\n /** Integer cents — every Reblu shop price travels as cents, never a float. */\n unitPrice: number\n quantity: number\n imageUrl?: string\n /** `null` means untracked/unlimited stock — never clamp against it. */\n maxStock: number | null\n /** Basis points, e.g. `2100` = 21%. `null`/`undefined` = unknown or exempt. */\n taxRateBps?: number | null\n taxRateName?: string | null\n}\n\nexport interface CartStore {\n items: CartItem[]\n addItem: (item: CartItem) => void\n removeItem: (productId: string, variantId?: string | null) => void\n updateQuantity: (\n productId: string,\n variantId: string | null | undefined,\n quantity: number,\n ) => void\n clearCart: () => void\n getTotal: () => number\n getItemCount: () => number\n}\n\nfunction isSameItem(a: CartItem, productId: string, variantId?: string | null): boolean {\n return a.productId === productId && (a.variantId ?? null) === (variantId ?? null)\n}\n\n/**\n * REB-829 review finding: neither `addItem` nor `updateQuantity` validated\n * `quantity` — `0`, a negative number, `NaN` (e.g. a stray `parseInt('')`\n * upstream), a non-integer, or `maxStock` itself being `0` (out of stock)\n * all used to leave an invalid line sitting in the cart, silently reaching\n * checkout.\n *\n * Normalizes to a safe POSITIVE INTEGER clamped to `maxStock`. Returns `0`\n * to signal \"not a valid positive quantity\" — callers decide what that\n * means for them: `addItem` ignores the call (never creates/corrupts a\n * line), `updateQuantity` removes the line (same as its pre-existing\n * `quantity <= 0` → remove contract, just now reached by more inputs).\n *\n * `Math.trunc` rounds toward zero (not `Math.floor`, which would round a\n * negative input further negative before the `< 1` check below ever saw it\n * — not a behavioural difference here since any negative result is\n * rejected either way, but `trunc` is the operation that actually means\n * \"drop the fractional part\").\n */\nfunction normalizeQuantity(quantity: number, maxStock: number | null): number {\n if (!Number.isFinite(quantity)) return 0\n const truncated = Math.trunc(quantity)\n if (truncated < 1) return 0\n if (maxStock === null) return truncated\n const maxTruncated = Math.trunc(maxStock)\n if (maxTruncated < 1) return 0\n return Math.min(truncated, maxTruncated)\n}\n\nexport const useCartStore = create<CartStore>()(\n persist(\n (set, get) => ({\n items: [],\n\n addItem: (item) => {\n set((state) => {\n const existing = state.items.find((i) => isSameItem(i, item.productId, item.variantId))\n\n if (existing) {\n // REB-829 review follow-up (a): incoming maxStock is the\n // FRESHEST stock signal available — if it says 0, the product is\n // confirmed out of stock right now, so the existing line is\n // removed outright (same contract as updateQuantity's own\n // \"quantity resolves to <1 → remove\"), not merely left alone.\n if (item.maxStock !== null && Math.trunc(item.maxStock) < 1) {\n return {\n items: state.items.filter((i) => !isSameItem(i, item.productId, item.variantId)),\n }\n }\n\n const newQty = normalizeQuantity(existing.quantity + item.quantity, item.maxStock)\n if (newQty < 1) {\n // Invalid delta (NaN/non-positive quantity) — leave the\n // existing, previously-valid line untouched rather than\n // destroying good data because of one bad call.\n return {}\n }\n\n // Refresh maxStock with the incoming value on every successful\n // merge — the stale-maxStock finding: a stored line used to keep\n // its ORIGINAL maxStock forever, so a later updateQuantity call\n // clamped against stock data that could be long out of date.\n return {\n items: state.items.map((i) =>\n isSameItem(i, item.productId, item.variantId)\n ? { ...i, quantity: newQty, maxStock: item.maxStock }\n : i,\n ),\n }\n }\n\n const qty = normalizeQuantity(item.quantity, item.maxStock)\n if (qty < 1) {\n // Invalid quantity, or maxStock 0 (out of stock) — never create\n // a line nobody could actually buy.\n return {}\n }\n\n return { items: [...state.items, { ...item, quantity: qty }] }\n })\n },\n\n removeItem: (productId, variantId) => {\n set((state) => ({\n items: state.items.filter((i) => !isSameItem(i, productId, variantId)),\n }))\n },\n\n updateQuantity: (productId, variantId, quantity) => {\n const existing = get().items.find((i) => isSameItem(i, productId, variantId))\n if (!existing) return\n\n const newQty = normalizeQuantity(quantity, existing.maxStock)\n if (newQty < 1) {\n get().removeItem(productId, variantId)\n return\n }\n\n set((state) => ({\n items: state.items.map((i) =>\n isSameItem(i, productId, variantId) ? { ...i, quantity: newQty } : i,\n ),\n }))\n },\n\n clearCart: () => set({ items: [] }),\n\n getTotal: () => get().items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0),\n\n getItemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0),\n }),\n {\n name: CART_STORAGE_KEY,\n skipHydration: true,\n },\n ),\n)\n","'use client'\n\nimport { useCartStore, type CartItem } from './store'\n\nexport interface UseCartResult {\n items: CartItem[]\n /** Sum of every line's `unitPrice * quantity`, in integer cents. */\n total: number\n /** Sum of every line's `quantity`. */\n itemCount: number\n addItem: (item: CartItem) => void\n removeItem: (productId: string, variantId?: string | null) => void\n updateQuantity: (\n productId: string,\n variantId: string | null | undefined,\n quantity: number,\n ) => void\n clearCart: () => void\n}\n\n/**\n * `useCart` — the ergonomic entry point for a SITE's cart UI: a single\n * selector call that re-renders only on `items` changes, with `total`/\n * `itemCount` derived inline (cheap — a plain `reduce` over a typically-small\n * array) rather than via `useCartStore`'s own `getTotal`/`getItemCount`\n * selectors, which are stable function references and would NOT trigger a\n * re-render on their own. A SITE that wants raw store access (existing\n * per-field selectors, e.g. `useCartStore((s) => s.addItem)`) can still\n * import `useCartStore` directly — this hook doesn't replace it.\n */\nexport function useCart(): UseCartResult {\n const items = useCartStore((s) => s.items)\n const addItem = useCartStore((s) => s.addItem)\n const removeItem = useCartStore((s) => s.removeItem)\n const updateQuantity = useCartStore((s) => s.updateQuantity)\n const clearCart = useCartStore((s) => s.clearCart)\n\n const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0)\n const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)\n\n return { items, total, itemCount, addItem, removeItem, updateQuantity, clearCart }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,106 @@
1
+ import { ShopProductsParams, ListResult, RebluClient } from '@reblu/site-client';
2
+ import { ShopProductListItem, ShopFacets } from '@reblu/site-contracts';
3
+
4
+ /**
5
+ * Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`
6
+ * from `next/navigation`, or a plain `new URLSearchParams(record)`) into
7
+ * `ShopProductsParams`. Hostile/unknown params are dropped: only the known
8
+ * scalar keys and the two bracket groups are ever read.
9
+ */
10
+ declare function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams;
11
+ /**
12
+ * Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of
13
+ * {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so
14
+ * `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`
15
+ * for any already-canonical (non-empty-string, in-range) input — that is the
16
+ * property the round-trip tests pin.
17
+ */
18
+ declare function serializeShopParams(params: ShopProductsParams): URLSearchParams;
19
+ /**
20
+ * Convert Next's `searchParams` route prop —
21
+ * `Record<string, string | string[] | undefined>`, already URL-decoded, with
22
+ * a repeated query key grouped into an array — into a `URLSearchParams` for
23
+ * {@link parseShopSearchParams}.
24
+ *
25
+ * NOT the same as `new URLSearchParams(searchParams)`: the native
26
+ * `URLSearchParams` constructor only accepts `Record<string, string>` —
27
+ * handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls
28
+ * `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two
29
+ * repeated `attr[color]=red` / `attr[color]=blue` entries. That silently
30
+ * breaks `parseShopSearchParams`'s repeat-count-based array/scalar
31
+ * distinction (see its own module doc) for every multi-value `attr[<key>]`
32
+ * filter — this helper is what actually preserves the repeat.
33
+ */
34
+ declare function toURLSearchParams(searchParams: Record<string, string | string[] | undefined>): URLSearchParams;
35
+ /**
36
+ * The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept
37
+ * structurally identical to its `ShopFiltersHrefs` type WITHOUT a package
38
+ * dependency on `@reblu/site-blocks` (this package has none today, and this
39
+ * one shape does not warrant adding one). Any object matching this shape is
40
+ * assignable to `ShopFiltersHrefs` by structural typing.
41
+ */
42
+ interface ShopFilterHrefs {
43
+ taxonomy: (axisKey: string, value: string | undefined) => string;
44
+ attr: (fieldKey: string, value: string) => string;
45
+ price: (min: number | undefined, max: number | undefined) => string;
46
+ onSale: (nextActive: boolean) => string;
47
+ clearAll: () => string;
48
+ }
49
+ /**
50
+ * Build `ShopFilters`' href set from the CURRENT params and pathname. Every
51
+ * href preserves every OTHER current param (category, q, sort, every other
52
+ * taxonomy/attr filter) and resets pagination (`page`) — a filter change is
53
+ * never silently stuck on page 4 of a now-smaller result set.
54
+ *
55
+ * Pure and framework-agnostic (no `next/navigation`): a Server Component calls
56
+ * it directly with its own `searchParams` prop and route pathname — no
57
+ * `'use client'` boundary required. See `./use-shop-filter-links` for the
58
+ * client-side hook that wraps this with `usePathname`/`useSearchParams`.
59
+ */
60
+ declare function buildShopFilterHrefs(current: ShopProductsParams, pathname: string): ShopFilterHrefs;
61
+
62
+ /**
63
+ * `getShopCollection` (REB-809) — fetches the product list AND its facets with
64
+ * the EXACT SAME params in one call. This is the seam that keeps a shop
65
+ * collection page correct: facet counts must reflect the same filter context
66
+ * the product list is showing, and a hand-rolled page that calls
67
+ * `client.shop.products(params)` and `client.shop.facets(someOtherParams)`
68
+ * separately is exactly how they drift apart.
69
+ *
70
+ * Server-safe (no `'use client'`, no React) — call it directly from an async
71
+ * Server Component (route `page.tsx`) with the params parsed via
72
+ * {@link parseShopSearchParams} from that page's `searchParams` prop.
73
+ */
74
+ interface ShopCollection {
75
+ products: ListResult<ShopProductListItem>;
76
+ facets: ShopFacets;
77
+ /**
78
+ * R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;
79
+ * `'unavailable'` when it failed and `facets` above is the degraded
80
+ * {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an
81
+ * empty `facets` as "this tenant configured no filterable axes" when it may
82
+ * really mean "the facets endpoint is down right now". A logged
83
+ * `console.warn` (see below) covers the case nobody checks this field.
84
+ */
85
+ facetsStatus: 'ok' | 'unavailable';
86
+ }
87
+ /**
88
+ * R4-facets-failure-takes-down-listing: `products` and `facets` are NOT
89
+ * equally critical. A product listing with no products is not a listing —
90
+ * that failure must propagate so the caller's own error handling (or a
91
+ * thrown-away `try/catch` degrading the whole page) sees it. A product
92
+ * listing with no FACETS is still a perfectly good listing, just without
93
+ * filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead
94
+ * of letting one upstream endpoint's outage take the other down too, is what
95
+ * `Promise.allSettled` (rather than `Promise.all`) buys here.
96
+ *
97
+ * R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged
98
+ * `[getShopCollection]` — the same convention `@reblu/site-next` already uses
99
+ * elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,
100
+ * `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the
101
+ * returned `facetsStatus: 'unavailable'` lets a caller that wants to act on
102
+ * it (retry, surface a banner, alert) do so without parsing a log line.
103
+ */
104
+ declare function getShopCollection(client: RebluClient, params?: ShopProductsParams): Promise<ShopCollection>;
105
+
106
+ export { type ShopFilterHrefs as S, type ShopCollection as a, buildShopFilterHrefs as b, getShopCollection as g, parseShopSearchParams as p, serializeShopParams as s, toURLSearchParams as t };
@@ -0,0 +1,106 @@
1
+ import { ShopProductsParams, ListResult, RebluClient } from '@reblu/site-client';
2
+ import { ShopProductListItem, ShopFacets } from '@reblu/site-contracts';
3
+
4
+ /**
5
+ * Parse a `URLSearchParams` (or anything with that shape — `ReadonlyURLSearchParams`
6
+ * from `next/navigation`, or a plain `new URLSearchParams(record)`) into
7
+ * `ShopProductsParams`. Hostile/unknown params are dropped: only the known
8
+ * scalar keys and the two bracket groups are ever read.
9
+ */
10
+ declare function parseShopSearchParams(searchParams: URLSearchParams): ShopProductsParams;
11
+ /**
12
+ * Serialize `ShopProductsParams` into a `URLSearchParams` — the inverse of
13
+ * {@link parseShopSearchParams}. Falsy/empty/undefined fields are omitted, so
14
+ * `parseShopSearchParams(serializeShopParams(params))` round-trips to `params`
15
+ * for any already-canonical (non-empty-string, in-range) input — that is the
16
+ * property the round-trip tests pin.
17
+ */
18
+ declare function serializeShopParams(params: ShopProductsParams): URLSearchParams;
19
+ /**
20
+ * Convert Next's `searchParams` route prop —
21
+ * `Record<string, string | string[] | undefined>`, already URL-decoded, with
22
+ * a repeated query key grouped into an array — into a `URLSearchParams` for
23
+ * {@link parseShopSearchParams}.
24
+ *
25
+ * NOT the same as `new URLSearchParams(searchParams)`: the native
26
+ * `URLSearchParams` constructor only accepts `Record<string, string>` —
27
+ * handed an array value (`{ 'attr[color]': ['red', 'blue'] }`), it calls
28
+ * `String()` on it, producing ONE entry `attr[color]=red,blue` instead of two
29
+ * repeated `attr[color]=red` / `attr[color]=blue` entries. That silently
30
+ * breaks `parseShopSearchParams`'s repeat-count-based array/scalar
31
+ * distinction (see its own module doc) for every multi-value `attr[<key>]`
32
+ * filter — this helper is what actually preserves the repeat.
33
+ */
34
+ declare function toURLSearchParams(searchParams: Record<string, string | string[] | undefined>): URLSearchParams;
35
+ /**
36
+ * The navigation seam `@reblu/site-blocks`'s `ShopFilters` calls `hrefs` — kept
37
+ * structurally identical to its `ShopFiltersHrefs` type WITHOUT a package
38
+ * dependency on `@reblu/site-blocks` (this package has none today, and this
39
+ * one shape does not warrant adding one). Any object matching this shape is
40
+ * assignable to `ShopFiltersHrefs` by structural typing.
41
+ */
42
+ interface ShopFilterHrefs {
43
+ taxonomy: (axisKey: string, value: string | undefined) => string;
44
+ attr: (fieldKey: string, value: string) => string;
45
+ price: (min: number | undefined, max: number | undefined) => string;
46
+ onSale: (nextActive: boolean) => string;
47
+ clearAll: () => string;
48
+ }
49
+ /**
50
+ * Build `ShopFilters`' href set from the CURRENT params and pathname. Every
51
+ * href preserves every OTHER current param (category, q, sort, every other
52
+ * taxonomy/attr filter) and resets pagination (`page`) — a filter change is
53
+ * never silently stuck on page 4 of a now-smaller result set.
54
+ *
55
+ * Pure and framework-agnostic (no `next/navigation`): a Server Component calls
56
+ * it directly with its own `searchParams` prop and route pathname — no
57
+ * `'use client'` boundary required. See `./use-shop-filter-links` for the
58
+ * client-side hook that wraps this with `usePathname`/`useSearchParams`.
59
+ */
60
+ declare function buildShopFilterHrefs(current: ShopProductsParams, pathname: string): ShopFilterHrefs;
61
+
62
+ /**
63
+ * `getShopCollection` (REB-809) — fetches the product list AND its facets with
64
+ * the EXACT SAME params in one call. This is the seam that keeps a shop
65
+ * collection page correct: facet counts must reflect the same filter context
66
+ * the product list is showing, and a hand-rolled page that calls
67
+ * `client.shop.products(params)` and `client.shop.facets(someOtherParams)`
68
+ * separately is exactly how they drift apart.
69
+ *
70
+ * Server-safe (no `'use client'`, no React) — call it directly from an async
71
+ * Server Component (route `page.tsx`) with the params parsed via
72
+ * {@link parseShopSearchParams} from that page's `searchParams` prop.
73
+ */
74
+ interface ShopCollection {
75
+ products: ListResult<ShopProductListItem>;
76
+ facets: ShopFacets;
77
+ /**
78
+ * R3-002: `'ok'` when `client.shop.facets(...)` actually succeeded;
79
+ * `'unavailable'` when it failed and `facets` above is the degraded
80
+ * {@link EMPTY_FACETS} fallback. NOT silent — check this before treating an
81
+ * empty `facets` as "this tenant configured no filterable axes" when it may
82
+ * really mean "the facets endpoint is down right now". A logged
83
+ * `console.warn` (see below) covers the case nobody checks this field.
84
+ */
85
+ facetsStatus: 'ok' | 'unavailable';
86
+ }
87
+ /**
88
+ * R4-facets-failure-takes-down-listing: `products` and `facets` are NOT
89
+ * equally critical. A product listing with no products is not a listing —
90
+ * that failure must propagate so the caller's own error handling (or a
91
+ * thrown-away `try/catch` degrading the whole page) sees it. A product
92
+ * listing with no FACETS is still a perfectly good listing, just without
93
+ * filter UI — degrading `facets` to {@link EMPTY_FACETS} on failure, instead
94
+ * of letting one upstream endpoint's outage take the other down too, is what
95
+ * `Promise.allSettled` (rather than `Promise.all`) buys here.
96
+ *
97
+ * R3-002: that degradation must not be SILENT. `console.warn`, bracket-tagged
98
+ * `[getShopCollection]` — the same convention `@reblu/site-next` already uses
99
+ * elsewhere (`route-handlers.ts`'s `[createProxyRoute]`,
100
+ * `set-password.tsx`'s `[SetPassword]`) — fires once per failed call, and the
101
+ * returned `facetsStatus: 'unavailable'` lets a caller that wants to act on
102
+ * it (retry, surface a banner, alert) do so without parsing a log line.
103
+ */
104
+ declare function getShopCollection(client: RebluClient, params?: ShopProductsParams): Promise<ShopCollection>;
105
+
106
+ export { type ShopFilterHrefs as S, type ShopCollection as a, buildShopFilterHrefs as b, getShopCollection as g, parseShopSearchParams as p, serializeShopParams as s, toURLSearchParams as t };