@wix/agent-skills 1.19.2 → 1.20.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/package.json +1 -1
- package/skills/wix-app/references/SERVICE_PLUGIN.md +14 -1
- package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +7 -6
- package/skills/wix-app/references/service-plugin/ADDITIONAL-FEES.md +4 -0
- package/skills/wix-app/references/service-plugin/AVAILABILITY-TIME-SLOTS-CONFIGURATION.md +118 -0
- package/skills/wix-app/references/service-plugin/BOOKING-POLICY.md +65 -0
- package/skills/wix-app/references/service-plugin/BOOKINGS-PRICING.md +71 -0
- package/skills/wix-app/references/service-plugin/BOOKINGS-STAFF-SORTING.md +11 -3
- package/skills/wix-app/references/service-plugin/BOOKINGS-VALIDATION.md +137 -0
- package/skills/wix-app/references/service-plugin/DISCOUNT-TRIGGERS.md +23 -16
- package/skills/wix-app/references/service-plugin/GIFT-CARDS.md +4 -0
- package/skills/wix-app/references/service-plugin/PAYMENT-SETTINGS.md +4 -2
- package/skills/wix-app/references/service-plugin/REALTIME-PERMISSIONS.md +45 -0
- package/skills/wix-app/references/service-plugin/SHIPPING-RATES.md +15 -0
- package/skills/wix-app/references/service-plugin/TOOLS_PROVIDER.md +4 -0
- package/skills/wix-app/references/service-plugin/VALIDATIONS.md +4 -0
- package/skills/wix-headless/references/CAPABILITIES.md +2 -2
- package/skills/wix-headless/references/inline-recipes/experience-store.md +4 -3
- package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +98 -18
- package/skills/wix-headless/references/managed/DEPLOYMENT.md +8 -8
- package/skills/wix-headless-fast/install/fast-path.mjs +3 -1
- package/skills/wix-headless-fast/references/shared/app/wix/media.ts +13 -0
- package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +227 -56
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/CartDrawer.tsx +37 -3
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/FilterPanel.tsx +266 -0
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/QuickAdd.tsx +165 -0
- package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useProductDetail.ts +48 -15
- package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +129 -11
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart.ts +36 -6
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +190 -24
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/types.ts +62 -4
- package/skills/wix-headless-fast/references/storefront/app-astro/layouts/SiteLayout.astro +21 -2
- package/skills/wix-headless-fast/references/storefront/seed/SEED.md +5 -1
- package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +59 -6
- package/skills/wix-manage/SKILL.md +4 -1
- package/skills/wix-manage/references/events/create-wix-event.md +46 -0
- package/skills/wix-manage/references/events/manage-wix-events.md +51 -1
- package/skills/wix-manage/references/google-ads/manage-campaign-lifecycle.md +6 -6
- package/skills/wix-manage/references/seo/manage-seo-suggestions.md +351 -0
- package/skills/wix-vibe-headless/install/deploy.cjs +33 -2
- package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +1 -1
- package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +13 -4
- package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +1 -1
- package/skills/wix-vibe-headless/platforms/base44.md +19 -4
- package/skills/wix-vibe-headless/platforms/generic.md +6 -0
- package/skills/wix-vibe-headless/references/_shared/nav/nav.tanstack.js +30 -0
- package/skills/wix-vibe-headless/references/_shared/routing.md +86 -0
- package/skills/wix-vibe-headless/references/blog/INSTRUCTIONS.md +44 -0
- package/skills/wix-vibe-headless/references/blog/app/components/PostCard.jsx +1 -1
- package/skills/wix-vibe-headless/references/blog/app/components/PostChips.jsx +1 -1
- package/skills/wix-vibe-headless/references/blog/app/pages/CategoryPage.jsx +1 -1
- package/skills/wix-vibe-headless/references/blog/app/pages/PostDetail.jsx +1 -1
- package/skills/wix-vibe-headless/references/blog/app/pages/TagPage.jsx +1 -1
- package/skills/wix-vibe-headless/references/bookings/INSTRUCTIONS.md +42 -0
- package/skills/wix-vibe-headless/references/bookings/app/components/ServiceCard.jsx +1 -1
- package/skills/wix-vibe-headless/references/bookings/app/pages/ServiceDetail.jsx +1 -1
- package/skills/wix-vibe-headless/references/events/INSTRUCTIONS.md +42 -0
- package/skills/wix-vibe-headless/references/events/app/components/EventCard.jsx +1 -1
- package/skills/wix-vibe-headless/references/events/app/pages/EventDetail.jsx +1 -1
- package/skills/wix-vibe-headless/references/members/INSTRUCTIONS.md +42 -0
- package/skills/wix-vibe-headless/references/members/app/components/MemberMenu.jsx +1 -1
- package/skills/wix-vibe-headless/references/members/app/components/RequireAuth.jsx +1 -1
- package/skills/wix-vibe-headless/references/members/app/pages/Login.jsx +1 -1
- package/skills/wix-vibe-headless/references/portfolio/INSTRUCTIONS.md +43 -0
- package/skills/wix-vibe-headless/references/portfolio/app/components/CollectionCard.jsx +1 -1
- package/skills/wix-vibe-headless/references/portfolio/app/components/ProjectCard.jsx +1 -1
- package/skills/wix-vibe-headless/references/portfolio/app/pages/CollectionPage.jsx +1 -1
- package/skills/wix-vibe-headless/references/portfolio/app/pages/ProjectDetail.jsx +1 -1
- package/skills/wix-vibe-headless/references/pricing-plans/INSTRUCTIONS.md +43 -0
- package/skills/wix-vibe-headless/references/pricing-plans/app/components/PlanCard.jsx +1 -1
- package/skills/wix-vibe-headless/references/pricing-plans/app/pages/PlanDetail.jsx +1 -1
- package/skills/wix-vibe-headless/references/restaurants/INSTRUCTIONS.md +36 -0
- package/skills/wix-vibe-headless/references/shared/app/lib/nav.js +9 -0
- package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +43 -2
- package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +7 -1
package/skills/wix-headless-fast/references/storefront/app/components/storefront/FilterPanel.tsx
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// The gallery's filter layout — wire as-is, your grid goes inside:
|
|
2
|
+
//
|
|
3
|
+
// <FilterPanel shop={shop}>
|
|
4
|
+
// …your loading / empty / error states and your grid of tiles…
|
|
5
|
+
// </FilterPanel>
|
|
6
|
+
//
|
|
7
|
+
// Renders: a toolbar (result count, sort, and under `md` a Filters button); active-filter chips;
|
|
8
|
+
// then on `md`+ a two-column layout — a 16rem sidebar of collapsible groups (Price as a two-handle
|
|
9
|
+
// slider with numeric fields, Availability, one group per option facet with swatches or pills) beside
|
|
10
|
+
// your results, so expanded facets never push the products below the fold. Under `md` the same
|
|
11
|
+
// groups live in a bottom sheet: changes are staged and commit on Apply; dismissing keeps the
|
|
12
|
+
// current results. Sidebar changes commit immediately (a price pair on release/blur, valid only).
|
|
13
|
+
// Styled from the @theme tokens.
|
|
14
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
15
|
+
import { SORTS, type ShopFilters, type UseShop } from "../../hooks/storefront/useShop";
|
|
16
|
+
import type { CatalogSort } from "../../wix/storefront/catalog";
|
|
17
|
+
|
|
18
|
+
interface Draft {
|
|
19
|
+
min: string;
|
|
20
|
+
max: string;
|
|
21
|
+
inStockOnly: boolean;
|
|
22
|
+
choiceIds: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const draftFrom = (shop: UseShop): Draft => ({
|
|
26
|
+
min: String(shop.filters.minPrice ?? ""),
|
|
27
|
+
max: String(shop.filters.maxPrice ?? ""),
|
|
28
|
+
inStockOnly: !!shop.filters.inStockOnly,
|
|
29
|
+
choiceIds: shop.selectedChoiceIds,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function validPricePair(min: string, max: string): boolean {
|
|
33
|
+
if (min && !Number.isFinite(Number(min))) return false;
|
|
34
|
+
if (max && !Number.isFinite(Number(max))) return false;
|
|
35
|
+
if (min && max && Number(min) > Number(max)) return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function FilterPanel({ shop, children }: { shop: UseShop; children?: ReactNode }) {
|
|
40
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
41
|
+
const [staged, setStaged] = useState<Draft>(() => draftFrom(shop));
|
|
42
|
+
const panelRef = useRef<HTMLElement>(null);
|
|
43
|
+
|
|
44
|
+
// The sheet is an overlay: root-level, scrim, scroll lock, Escape, focus in and back.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!sheetOpen) return;
|
|
47
|
+
setStaged(draftFrom(shop));
|
|
48
|
+
const opener = document.activeElement;
|
|
49
|
+
const previousOverflow = document.body.style.overflow;
|
|
50
|
+
document.body.style.overflow = "hidden";
|
|
51
|
+
panelRef.current?.focus();
|
|
52
|
+
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setSheetOpen(false);
|
|
53
|
+
document.addEventListener("keydown", onKey);
|
|
54
|
+
return () => {
|
|
55
|
+
document.removeEventListener("keydown", onKey);
|
|
56
|
+
document.body.style.overflow = previousOverflow;
|
|
57
|
+
if (opener instanceof HTMLElement) opener.focus();
|
|
58
|
+
};
|
|
59
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
+
}, [sheetOpen]);
|
|
61
|
+
|
|
62
|
+
const commit = (d: Draft) => {
|
|
63
|
+
if (!validPricePair(d.min, d.max)) return;
|
|
64
|
+
const next: ShopFilters = {
|
|
65
|
+
...shop.filters,
|
|
66
|
+
minPrice: d.min || undefined,
|
|
67
|
+
maxPrice: d.max || undefined,
|
|
68
|
+
inStockOnly: d.inStockOnly || undefined,
|
|
69
|
+
};
|
|
70
|
+
const same =
|
|
71
|
+
next.minPrice === shop.filters.minPrice &&
|
|
72
|
+
next.maxPrice === shop.filters.maxPrice &&
|
|
73
|
+
!!next.inStockOnly === !!shop.filters.inStockOnly;
|
|
74
|
+
if (!same) shop.setFilters(next);
|
|
75
|
+
for (const id of d.choiceIds) if (!shop.selectedChoiceIds.includes(id)) shop.toggleChoice(id);
|
|
76
|
+
for (const id of shop.selectedChoiceIds) if (!d.choiceIds.includes(id)) shop.toggleChoice(id);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const activeCount =
|
|
80
|
+
shop.selectedChoiceIds.length + (shop.filters.inStockOnly ? 1 : 0) + (shop.filters.minPrice || shop.filters.maxPrice ? 1 : 0);
|
|
81
|
+
const choiceName = (id: string) => shop.facets.flatMap((f) => f.choices).find((c) => c.id === id)?.name ?? id;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="flex flex-col gap-4">
|
|
85
|
+
{/* Toolbar */}
|
|
86
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
87
|
+
<p className="text-sm text-muted-foreground" aria-live="polite">
|
|
88
|
+
{shop.total != null ? `${shop.total} ${shop.total === 1 ? "product" : "products"}` : ""}
|
|
89
|
+
</p>
|
|
90
|
+
<div className="flex items-center gap-2">
|
|
91
|
+
<button type="button" onClick={() => setSheetOpen(true)} className="rounded-md border border-border px-3 py-1.5 text-sm md:hidden">
|
|
92
|
+
Filters{activeCount ? ` (${activeCount})` : ""}
|
|
93
|
+
</button>
|
|
94
|
+
<label className="sr-only" htmlFor="shop-sort">Sort by</label>
|
|
95
|
+
<select id="shop-sort" value={shop.sort} onChange={(e) => shop.setSort(e.target.value as CatalogSort)}
|
|
96
|
+
className="rounded-md border border-border bg-background px-2 py-1.5 text-sm">
|
|
97
|
+
{Object.entries(SORTS).map(([key, { label }]) => <option key={key} value={key}>{label}</option>)}
|
|
98
|
+
</select>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Active chips */}
|
|
103
|
+
{shop.hasActiveFilters && (
|
|
104
|
+
<div className="flex flex-wrap items-center gap-1.5" aria-label="Active filters">
|
|
105
|
+
{shop.selectedChoiceIds.map((id) => (
|
|
106
|
+
<button key={id} type="button" onClick={() => shop.toggleChoice(id)} className="rounded-full bg-secondary px-2.5 py-1 text-xs text-foreground">
|
|
107
|
+
{choiceName(id)} ×
|
|
108
|
+
</button>
|
|
109
|
+
))}
|
|
110
|
+
{shop.filters.inStockOnly && (
|
|
111
|
+
<button type="button" onClick={() => shop.setFilters({ ...shop.filters, inStockOnly: undefined })} className="rounded-full bg-secondary px-2.5 py-1 text-xs">In stock ×</button>
|
|
112
|
+
)}
|
|
113
|
+
{(shop.filters.minPrice || shop.filters.maxPrice) && (
|
|
114
|
+
<button type="button" onClick={() => shop.setFilters({ ...shop.filters, minPrice: undefined, maxPrice: undefined })} className="rounded-full bg-secondary px-2.5 py-1 text-xs">
|
|
115
|
+
Price {shop.filters.minPrice ?? shop.priceRange?.min ?? "…"}–{shop.filters.maxPrice ?? shop.priceRange?.max ?? "…"} ×
|
|
116
|
+
</button>
|
|
117
|
+
)}
|
|
118
|
+
<button type="button" onClick={shop.clearFilters} className="text-xs text-muted-foreground underline">Clear all</button>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{/* Sidebar + results */}
|
|
123
|
+
<div className="md:grid md:grid-cols-[16rem_minmax(0,1fr)] md:gap-8">
|
|
124
|
+
<aside className="hidden md:block" aria-label="Filters">
|
|
125
|
+
<Groups shop={shop} draft={draftFrom(shop)} onChange={commit} immediate />
|
|
126
|
+
</aside>
|
|
127
|
+
<div className="min-w-0">{children}</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Sheet — small screens; staged until Apply */}
|
|
131
|
+
{sheetOpen && (
|
|
132
|
+
<div className="fixed inset-0 z-50 flex items-end bg-foreground/40 md:hidden" onClick={() => setSheetOpen(false)}>
|
|
133
|
+
<section ref={panelRef} tabIndex={-1} role="dialog" aria-modal="true" aria-label="Filters" onClick={(e) => e.stopPropagation()}
|
|
134
|
+
className="flex max-h-[85vh] w-full flex-col rounded-t-2xl bg-background text-foreground outline-none">
|
|
135
|
+
<div className="flex items-center justify-between px-5 pt-5">
|
|
136
|
+
<h2 className="text-base font-semibold">Filters</h2>
|
|
137
|
+
<button type="button" aria-label="Close filters" onClick={() => setSheetOpen(false)} className="text-xl leading-none">×</button>
|
|
138
|
+
</div>
|
|
139
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
140
|
+
<Groups shop={shop} draft={staged} onChange={setStaged} />
|
|
141
|
+
</div>
|
|
142
|
+
<div className="flex gap-2 border-t border-border bg-background px-5 py-3">
|
|
143
|
+
<button type="button" onClick={() => setStaged({ min: "", max: "", inStockOnly: false, choiceIds: [] })}
|
|
144
|
+
className="flex-1 rounded-full border border-border py-2.5 text-sm">Clear</button>
|
|
145
|
+
<button type="button" onClick={() => { commit(staged); setSheetOpen(false); }}
|
|
146
|
+
className="flex-1 rounded-full bg-primary py-2.5 text-sm font-semibold text-primary-foreground">Apply</button>
|
|
147
|
+
</div>
|
|
148
|
+
</section>
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The filter groups — one markup for the sidebar (immediate commit) and the sheet (staged).
|
|
156
|
+
function Groups({ shop, draft, onChange, immediate = false }: { shop: UseShop; draft: Draft; onChange: (d: Draft) => void; immediate?: boolean }) {
|
|
157
|
+
const toggle = (id: string) =>
|
|
158
|
+
onChange({ ...draft, choiceIds: draft.choiceIds.includes(id) ? draft.choiceIds.filter((x) => x !== id) : [...draft.choiceIds, id] });
|
|
159
|
+
return (
|
|
160
|
+
<div className="flex flex-col divide-y divide-border">
|
|
161
|
+
<Group title="Price">
|
|
162
|
+
<PriceControl
|
|
163
|
+
range={shop.priceRange}
|
|
164
|
+
min={draft.min}
|
|
165
|
+
max={draft.max}
|
|
166
|
+
onCommit={(min, max) => onChange({ ...draft, min, max })}
|
|
167
|
+
immediate={immediate}
|
|
168
|
+
/>
|
|
169
|
+
</Group>
|
|
170
|
+
<Group title="Availability">
|
|
171
|
+
<label className="flex items-center gap-2 text-sm">
|
|
172
|
+
<input type="checkbox" checked={draft.inStockOnly} onChange={(e) => onChange({ ...draft, inStockOnly: e.target.checked })} />
|
|
173
|
+
In stock only
|
|
174
|
+
</label>
|
|
175
|
+
</Group>
|
|
176
|
+
{shop.facets.map((facet) => (
|
|
177
|
+
<Group key={facet.name} title={facet.name} count={facet.choices.filter((c) => draft.choiceIds.includes(c.id)).length}>
|
|
178
|
+
<div className="flex flex-wrap gap-1.5">
|
|
179
|
+
{facet.choices.map((c) => {
|
|
180
|
+
const on = draft.choiceIds.includes(c.id);
|
|
181
|
+
return facet.isColor && c.colorCode ? (
|
|
182
|
+
<button key={c.id} type="button" aria-pressed={on} aria-label={c.name} title={c.name} onClick={() => toggle(c.id)}
|
|
183
|
+
className={`h-7 w-7 rounded-full border-2 ${on ? "border-foreground" : "border-border"}`} style={{ backgroundColor: c.colorCode }} />
|
|
184
|
+
) : (
|
|
185
|
+
<button key={c.id} type="button" aria-pressed={on} onClick={() => toggle(c.id)}
|
|
186
|
+
className={`rounded-full border px-3 py-1 text-sm ${on ? "border-foreground bg-foreground text-background" : "border-border bg-background text-foreground"}`}>
|
|
187
|
+
{c.name}
|
|
188
|
+
</button>
|
|
189
|
+
);
|
|
190
|
+
})}
|
|
191
|
+
</div>
|
|
192
|
+
</Group>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A collapsible group — native <details>, open by default, the selected count on the summary.
|
|
199
|
+
function Group({ title, count = 0, children }: { title: string; count?: number; children: ReactNode }) {
|
|
200
|
+
return (
|
|
201
|
+
<details open className="group py-3">
|
|
202
|
+
<summary className="flex cursor-pointer list-none items-center justify-between text-sm font-medium">
|
|
203
|
+
<span>{title}{count ? <span className="ml-1.5 text-xs text-muted-foreground">({count})</span> : null}</span>
|
|
204
|
+
<span aria-hidden="true" className="text-muted-foreground transition-transform group-open:rotate-180">⌄</span>
|
|
205
|
+
</summary>
|
|
206
|
+
<div className="pt-3">{children}</div>
|
|
207
|
+
</details>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Two-handle range slider bounded by the scope's real prices, with the numeric pair under it.
|
|
212
|
+
// Local drafts while dragging/typing; a VALID pair commits on release / blur / Enter (immediate mode)
|
|
213
|
+
// or on every change (staged mode, where Apply commits later).
|
|
214
|
+
function PriceControl({ range, min, max, onCommit, immediate }: {
|
|
215
|
+
range: UseShop["priceRange"]; min: string; max: string; onCommit: (min: string, max: string) => void; immediate: boolean;
|
|
216
|
+
}) {
|
|
217
|
+
const [lo, setLo] = useState(min);
|
|
218
|
+
const [hi, setHi] = useState(max);
|
|
219
|
+
useEffect(() => { setLo(min); setHi(max); }, [min, max]);
|
|
220
|
+
const bounds = range ?? null;
|
|
221
|
+
const loN = lo === "" ? (bounds?.min ?? 0) : Number(lo);
|
|
222
|
+
const hiN = hi === "" ? (bounds?.max ?? 0) : Number(hi);
|
|
223
|
+
const step = bounds ? Math.max(1, Math.round((bounds.max - bounds.min) / 100)) : 1;
|
|
224
|
+
const pct = (v: number) => (bounds ? ((v - bounds.min) / (bounds.max - bounds.min)) * 100 : 0);
|
|
225
|
+
const publish = (a: string, b: string) => {
|
|
226
|
+
// at the bounds the filter is "no bound" — omit it rather than sending the catalog's own extremes
|
|
227
|
+
const A = bounds && a !== "" && Number(a) <= bounds.min ? "" : a;
|
|
228
|
+
const B = bounds && b !== "" && Number(b) >= bounds.max ? "" : b;
|
|
229
|
+
if (validPricePair(A, B)) onCommit(A, B);
|
|
230
|
+
};
|
|
231
|
+
const setFromSlider = (which: "lo" | "hi", v: number) => {
|
|
232
|
+
const a = which === "lo" ? String(Math.min(v, hiN)) : lo;
|
|
233
|
+
const b = which === "hi" ? String(Math.max(v, loN)) : hi;
|
|
234
|
+
setLo(a); setHi(b);
|
|
235
|
+
if (!immediate) publish(a, b);
|
|
236
|
+
};
|
|
237
|
+
const release = () => immediate && publish(lo, hi);
|
|
238
|
+
const thumb = "pointer-events-none absolute inset-x-0 top-0 h-6 w-full appearance-none bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-foreground [&::-webkit-slider-thumb]:bg-background [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-foreground [&::-moz-range-thumb]:bg-background";
|
|
239
|
+
return (
|
|
240
|
+
<div className="flex flex-col gap-3">
|
|
241
|
+
{bounds && (
|
|
242
|
+
<div className="relative h-6" aria-hidden={false}>
|
|
243
|
+
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded bg-secondary" />
|
|
244
|
+
<div className="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-foreground" style={{ left: `${pct(loN)}%`, right: `${100 - pct(hiN)}%` }} />
|
|
245
|
+
<input type="range" aria-label="Minimum price" min={bounds.min} max={bounds.max} step={step} value={loN}
|
|
246
|
+
onChange={(e) => setFromSlider("lo", Number(e.target.value))} onPointerUp={release} onKeyUp={release} className={thumb} />
|
|
247
|
+
<input type="range" aria-label="Maximum price" min={bounds.min} max={bounds.max} step={step} value={hiN}
|
|
248
|
+
onChange={(e) => setFromSlider("hi", Number(e.target.value))} onPointerUp={release} onKeyUp={release} className={thumb} />
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
<div className="flex items-center gap-2">
|
|
252
|
+
<label className="sr-only" htmlFor="filter-min">Minimum price</label>
|
|
253
|
+
<input id="filter-min" inputMode="decimal" placeholder={bounds ? String(bounds.min) : "Min"} value={lo}
|
|
254
|
+
onChange={(e) => { setLo(e.target.value); if (!immediate) publish(e.target.value, hi); }}
|
|
255
|
+
onBlur={release} onKeyDown={(e) => e.key === "Enter" && release()}
|
|
256
|
+
className="w-full min-w-0 rounded-md border border-border bg-background px-2 py-1.5 text-sm" />
|
|
257
|
+
<span className="text-muted-foreground">–</span>
|
|
258
|
+
<label className="sr-only" htmlFor="filter-max">Maximum price</label>
|
|
259
|
+
<input id="filter-max" inputMode="decimal" placeholder={bounds ? String(bounds.max) : "Max"} value={hi}
|
|
260
|
+
onChange={(e) => { setHi(e.target.value); if (!immediate) publish(lo, e.target.value); }}
|
|
261
|
+
onBlur={release} onKeyDown={(e) => e.key === "Enter" && release()}
|
|
262
|
+
className="w-full min-w-0 rounded-md border border-border bg-background px-2 py-1.5 text-sm" />
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The card's purchase control — the three gallery purchase paths, decided from the product:
|
|
2
|
+
// • no options → Direct Add: one click, the cheapest variant, quantity 1
|
|
3
|
+
// • options / choice mods → Quick Add: a picker on the card (bottom sheet on small screens)
|
|
4
|
+
// • free-text modifier → the product page (the gallery can't collect the text)
|
|
5
|
+
// Mount as the LAST ROW of the tile's text block (under name and price), as a direct child of the
|
|
6
|
+
// tile root that carries `relative` — the picker anchors to that root and takes its width. Never
|
|
7
|
+
// overlay it on the image, never wrap it in a narrower positioned box.
|
|
8
|
+
// <QuickAdd product={p} /> Wire as-is; style via the tokens.
|
|
9
|
+
import { useEffect, useRef, useState } from "react";
|
|
10
|
+
import { useCart } from "../../hooks/storefront/useCart";
|
|
11
|
+
import { useProductDetail } from "../../hooks/storefront/useProductDetail";
|
|
12
|
+
import type { ProductSummary } from "../../wix/storefront/types";
|
|
13
|
+
|
|
14
|
+
export default function QuickAdd({ product }: { product: ProductSummary }) {
|
|
15
|
+
const { addToCart, busy } = useCart();
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
if (product.availability === "OUT_OF_STOCK" && !product.preorder) {
|
|
20
|
+
return <span className="text-sm text-muted-foreground">Out of stock</span>;
|
|
21
|
+
}
|
|
22
|
+
if (product.quickAddable) {
|
|
23
|
+
return (
|
|
24
|
+
<div>
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
disabled={busy}
|
|
28
|
+
onClick={() =>
|
|
29
|
+
addToCart(product.id, product.minPriceVariantId, 1).catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
|
30
|
+
}
|
|
31
|
+
className="w-full rounded-full border border-foreground py-2 text-sm font-medium transition-colors hover:bg-foreground hover:text-background disabled:opacity-50"
|
|
32
|
+
>
|
|
33
|
+
{product.preorder ? "Pre-order" : "Add to cart"}
|
|
34
|
+
</button>
|
|
35
|
+
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
// Options to pick, or a single variant that isn't plainly in stock (pre-order, partial stock):
|
|
40
|
+
// the picker resolves it through useProductDetail, exactly as the PDP would.
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={() => setOpen(true)}
|
|
46
|
+
className="w-full rounded-full border border-foreground py-2 text-sm font-medium transition-colors hover:bg-foreground hover:text-background"
|
|
47
|
+
>
|
|
48
|
+
{product.optionsSummary ? "Choose options" : product.preorder ? "Pre-order" : "Add to cart"}
|
|
49
|
+
</button>
|
|
50
|
+
{open && <QuickAddPicker product={product} onClose={() => setOpen(false)} />}
|
|
51
|
+
</>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The picker fetches the full product only when it opens — cards never carry PDP data.
|
|
56
|
+
function QuickAddPicker({ product, onClose }: { product: ProductSummary; onClose: () => void }) {
|
|
57
|
+
const d = useProductDetail({ slug: product.slug });
|
|
58
|
+
const panelRef = useRef<HTMLElement>(null);
|
|
59
|
+
|
|
60
|
+
// Overlay contract: Escape closes, scroll locks under md, focus moves in and back.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const opener = document.activeElement;
|
|
63
|
+
const isSmall = window.matchMedia("(max-width: 767px)").matches;
|
|
64
|
+
const previousOverflow = document.body.style.overflow;
|
|
65
|
+
if (isSmall) document.body.style.overflow = "hidden";
|
|
66
|
+
panelRef.current?.focus();
|
|
67
|
+
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
|
68
|
+
document.addEventListener("keydown", onKey);
|
|
69
|
+
return () => {
|
|
70
|
+
document.removeEventListener("keydown", onKey);
|
|
71
|
+
document.body.style.overflow = previousOverflow;
|
|
72
|
+
if (opener instanceof HTMLElement) opener.focus();
|
|
73
|
+
};
|
|
74
|
+
}, [onClose]);
|
|
75
|
+
|
|
76
|
+
const needsPdp = (d.product?.modifiers ?? []).some((m) => m.type === "text");
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<>
|
|
80
|
+
<div className="fixed inset-0 z-40 bg-foreground/40 md:hidden" onClick={onClose} />
|
|
81
|
+
<section
|
|
82
|
+
ref={panelRef}
|
|
83
|
+
tabIndex={-1}
|
|
84
|
+
role="dialog"
|
|
85
|
+
aria-modal="true"
|
|
86
|
+
aria-label={`Choose options for ${product.name}`}
|
|
87
|
+
// md+: anchored to the tile (its nearest `relative` ancestor), the tile's full width — and never
|
|
88
|
+
// narrower than 18rem even when mounted inside a small wrapper.
|
|
89
|
+
className="fixed inset-x-0 bottom-0 z-50 max-h-[85vh] overflow-y-auto rounded-t-2xl bg-background p-5 text-foreground shadow-2xl outline-none md:absolute md:inset-x-auto md:bottom-0 md:right-0 md:w-[max(100%,18rem)] md:max-h-none md:rounded-lg md:p-4"
|
|
90
|
+
>
|
|
91
|
+
<div className="mb-3 flex items-start justify-between gap-3">
|
|
92
|
+
<div className="min-w-0">
|
|
93
|
+
<p className="truncate text-sm font-semibold">{product.name}</p>
|
|
94
|
+
<p className="text-sm">
|
|
95
|
+
{d.price}
|
|
96
|
+
{d.compareAtPrice && <span className="ml-2 text-muted-foreground line-through">{d.compareAtPrice}</span>}
|
|
97
|
+
</p>
|
|
98
|
+
</div>
|
|
99
|
+
<button type="button" aria-label="Close" onClick={onClose} className="text-xl leading-none text-muted-foreground">×</button>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{!d.product && !d.notFound && <p className="text-sm text-muted-foreground">Loading options…</p>}
|
|
103
|
+
{d.notFound && <p className="text-sm text-muted-foreground">This product isn't available anymore.</p>}
|
|
104
|
+
|
|
105
|
+
{d.product && needsPdp && (
|
|
106
|
+
<a href={`/products/${product.slug}`} className="block rounded-full bg-primary py-2.5 text-center text-sm font-semibold text-primary-foreground">
|
|
107
|
+
Customize on the product page
|
|
108
|
+
</a>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{d.product && !needsPdp && (
|
|
112
|
+
<div className="flex flex-col gap-3">
|
|
113
|
+
{d.optionGroups.map((g) => (
|
|
114
|
+
<fieldset key={g.id}>
|
|
115
|
+
<legend className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">{g.name}</legend>
|
|
116
|
+
<div className="flex flex-wrap gap-1.5">
|
|
117
|
+
{g.choices.map((c) =>
|
|
118
|
+
g.isColor && c.colorCode ? (
|
|
119
|
+
<button key={c.choiceId} type="button" aria-label={c.name} title={c.name} aria-pressed={c.selected}
|
|
120
|
+
disabled={!c.inStock} onClick={() => d.selectOption(g.name, c.name)}
|
|
121
|
+
className={`h-8 w-8 rounded-full border-2 disabled:opacity-30 ${c.selected ? "border-foreground" : "border-border"}`}
|
|
122
|
+
style={{ backgroundColor: c.colorCode }} />
|
|
123
|
+
) : (
|
|
124
|
+
<button key={c.choiceId} type="button" aria-pressed={c.selected} disabled={!c.inStock}
|
|
125
|
+
onClick={() => d.selectOption(g.name, c.name)}
|
|
126
|
+
className={`rounded-full border px-3 py-1 text-sm disabled:line-through disabled:opacity-40 ${c.selected ? "border-foreground bg-foreground text-background" : "border-border"}`}>
|
|
127
|
+
{c.name}
|
|
128
|
+
</button>
|
|
129
|
+
),
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
</fieldset>
|
|
133
|
+
))}
|
|
134
|
+
{d.product.modifiers.filter((m) => m.type === "choices").map((m) => (
|
|
135
|
+
<fieldset key={m.key}>
|
|
136
|
+
<legend className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
137
|
+
{m.name}{m.mandatory ? " *" : ""}
|
|
138
|
+
</legend>
|
|
139
|
+
<div className="flex flex-wrap gap-1.5">
|
|
140
|
+
{m.choices.map((c) => (
|
|
141
|
+
<button key={c.key} type="button" aria-pressed={d.modifierValues[m.key] === c.key}
|
|
142
|
+
onClick={() => d.setModifier(m.key, c.key)}
|
|
143
|
+
className={`rounded-full border px-3 py-1 text-sm ${d.modifierValues[m.key] === c.key ? "border-foreground bg-foreground text-background" : "border-border"}`}>
|
|
144
|
+
{c.name}
|
|
145
|
+
</button>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
</fieldset>
|
|
149
|
+
))}
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
disabled={!d.canAdd || d.adding}
|
|
153
|
+
onClick={() => d.add().then(onClose).catch(() => {})}
|
|
154
|
+
className="rounded-full bg-primary py-2.5 text-sm font-semibold text-primary-foreground disabled:opacity-50"
|
|
155
|
+
>
|
|
156
|
+
{d.adding ? "Adding…" : d.isPreorder ? "Pre-order" : "Add to cart"}
|
|
157
|
+
</button>
|
|
158
|
+
{d.blockedReason && !d.canAdd && <p className="text-xs text-muted-foreground">{d.blockedReason}</p>}
|
|
159
|
+
{d.error && <p className="text-xs text-red-600">{d.error}</p>}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</section>
|
|
163
|
+
</>
|
|
164
|
+
);
|
|
165
|
+
}
|
package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useProductDetail.ts
CHANGED
|
@@ -30,11 +30,22 @@ export interface UseProductDetail {
|
|
|
30
30
|
setModifier: (key: string, value: string) => void;
|
|
31
31
|
/** The resolved variant; null while the selection is incomplete. */
|
|
32
32
|
variant: ProductVariant | null;
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Price to display right now: the product RANGE ("€24.99 – €34.99", one value when equal) until
|
|
35
|
+
* every option is picked, then the selected variant's price (an automatic discount applied).
|
|
36
|
+
*/
|
|
34
37
|
price: string;
|
|
38
|
+
/** Struck "was" price — only once a variant is resolved (or for a single-price product), never beside a range. */
|
|
35
39
|
compareAtPrice: string | null;
|
|
36
|
-
/**
|
|
40
|
+
/** The resolved variant is out of stock but pre-orderable — label the action "Pre-order". */
|
|
41
|
+
isPreorder: boolean;
|
|
42
|
+
/** False until every option is selected and the resolved variant is in stock or pre-orderable. */
|
|
37
43
|
canAdd: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Why the action is disabled — neutral guidance to render beside it ("Choose Size"), not an
|
|
46
|
+
* error; null when the buyer can add. Promote to error styling only after they try to buy.
|
|
47
|
+
*/
|
|
48
|
+
blockedReason: string | null;
|
|
38
49
|
quantity: number;
|
|
39
50
|
setQuantity: (n: number) => void;
|
|
40
51
|
/** Adds the resolved variant to the cart (opens the drawer). Throws on refusal. */
|
|
@@ -83,16 +94,33 @@ export function useProductDetail({ initial, slug }: UseProductDetailOptions): Us
|
|
|
83
94
|
[product, selections],
|
|
84
95
|
);
|
|
85
96
|
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
const missingModifier = (product?.modifiers ?? []).find(
|
|
98
|
+
(m) => m.mandatory && (modifierValues[m.key] ?? "").length === 0,
|
|
99
|
+
);
|
|
100
|
+
const missingOptions = (product?.options ?? []).filter((o) => !selections[o.name]).map((o) => o.name);
|
|
101
|
+
const selectionComplete = missingOptions.length === 0;
|
|
102
|
+
|
|
103
|
+
// In stock OR pre-orderable counts as buyable; a preorder add carries preOrderRequested.
|
|
104
|
+
const isPreorder = !!variant && !variant.inStock && variant.preorderEnabled;
|
|
105
|
+
const available = !!variant && (variant.inStock || variant.preorderEnabled);
|
|
106
|
+
|
|
107
|
+
const canAdd = !!product && available && !missingModifier;
|
|
108
|
+
|
|
109
|
+
const blockedReason: string | null = !product
|
|
110
|
+
? null
|
|
111
|
+
: !selectionComplete
|
|
112
|
+
? `Choose ${missingOptions.join(" and ")}`
|
|
113
|
+
: !variant
|
|
114
|
+
? "This combination isn't available"
|
|
115
|
+
: !available
|
|
116
|
+
? "Out of stock"
|
|
117
|
+
: missingModifier
|
|
118
|
+
? `Add ${missingModifier.name}`
|
|
119
|
+
: null;
|
|
89
120
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
variant !== null &&
|
|
94
|
-
variant.inStock &&
|
|
95
|
-
mandatoryModifiersFilled;
|
|
121
|
+
// Before every option is picked, the range — never an empty price, never a lone struck minimum.
|
|
122
|
+
const isRange = !!product && product.price !== product.maxPrice && !!product.maxPrice;
|
|
123
|
+
const rangeDisplay = product ? (isRange ? `${product.price} – ${product.maxPrice}` : product.price) : "";
|
|
96
124
|
|
|
97
125
|
async function add(): Promise<void> {
|
|
98
126
|
if (!product || !variant) return;
|
|
@@ -110,6 +138,7 @@ export function useProductDetail({ initial, slug }: UseProductDetailOptions): Us
|
|
|
110
138
|
await addToCart(product.id, variant.variantId, quantity, {
|
|
111
139
|
modifierChoices: choiceModifiers,
|
|
112
140
|
customTextFields: textModifiers,
|
|
141
|
+
preorder: isPreorder,
|
|
113
142
|
});
|
|
114
143
|
} catch (e) {
|
|
115
144
|
setError(e instanceof Error ? e.message : String(e));
|
|
@@ -123,14 +152,18 @@ export function useProductDetail({ initial, slug }: UseProductDetailOptions): Us
|
|
|
123
152
|
product,
|
|
124
153
|
notFound,
|
|
125
154
|
optionGroups,
|
|
126
|
-
selectOption: (optionName, choiceName) =>
|
|
127
|
-
setSelections((s) => ({ ...s, [optionName]: choiceName }))
|
|
155
|
+
selectOption: (optionName, choiceName) => {
|
|
156
|
+
setSelections((s) => ({ ...s, [optionName]: choiceName }));
|
|
157
|
+
setQuantity(1); // the ceiling belongs to the newly resolved variant
|
|
158
|
+
},
|
|
128
159
|
modifierValues,
|
|
129
160
|
setModifier: (key, value) => setModifierValues((v) => ({ ...v, [key]: value })),
|
|
130
161
|
variant,
|
|
131
|
-
price: variant?.price ||
|
|
132
|
-
compareAtPrice: variant ? variant.compareAtPrice : (product?.compareAtPrice ?? null),
|
|
162
|
+
price: variant?.price || rangeDisplay,
|
|
163
|
+
compareAtPrice: variant ? variant.compareAtPrice : isRange ? null : (product?.compareAtPrice ?? null),
|
|
164
|
+
isPreorder,
|
|
133
165
|
canAdd,
|
|
166
|
+
blockedReason,
|
|
134
167
|
quantity,
|
|
135
168
|
setQuantity,
|
|
136
169
|
add,
|