@wix/agent-skills 1.19.2 → 1.20.1
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/SKILL.md +200 -34
- package/skills/wix-headless-fast/entry/skill.md +10 -2
- package/skills/wix-headless-fast/install/attach.mjs +253 -0
- package/skills/wix-headless-fast/install/deploy.mjs +109 -9
- package/skills/wix-headless-fast/install/fast-path.mjs +28 -31
- package/skills/wix-headless-fast/references/blog/INSTRUCTIONS.md +224 -60
- package/skills/wix-headless-fast/references/blog/app/components/blog/BlogFeedView.tsx +2 -3
- package/skills/wix-headless-fast/references/blog/app/components/blog/PostView.tsx +2 -3
- package/skills/wix-headless-fast/references/blog/app/hooks/blog/useBlogFeed.ts +24 -116
- package/skills/wix-headless-fast/references/blog/app/hooks/blog/usePost.ts +16 -64
- package/skills/wix-headless-fast/references/blog/app/wix/blog/blog-feed-store.ts +137 -0
- package/skills/wix-headless-fast/references/blog/app/wix/blog/post-store.ts +78 -0
- package/skills/wix-headless-fast/references/blog/app/wix/blog/posts-core.ts +117 -0
- package/skills/wix-headless-fast/references/blog/app/wix/blog/posts.ts +13 -60
- package/skills/wix-headless-fast/references/blog/app/wix/blog/taxonomy-core.ts +47 -0
- package/skills/wix-headless-fast/references/blog/app/wix/blog/taxonomy.ts +10 -28
- package/skills/wix-headless-fast/references/blog/app-astro/pages/blog/[...slug].astro +2 -4
- package/skills/wix-headless-fast/references/blog/rest/posts.ts +42 -0
- package/skills/wix-headless-fast/references/blog/rest/taxonomy.ts +62 -0
- package/skills/wix-headless-fast/references/blog/seed/SEED.md +9 -0
- package/skills/wix-headless-fast/references/bookings/INSTRUCTIONS.md +179 -33
- package/skills/wix-headless-fast/references/bookings/app/components/bookings/ServiceBookingView.tsx +2 -1
- package/skills/wix-headless-fast/references/bookings/app/hooks/bookings/useBookingFlow.ts +26 -132
- package/skills/wix-headless-fast/references/bookings/app/hooks/bookings/useServices.ts +18 -49
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking-core.ts +218 -0
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking-flow-store.ts +169 -0
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/booking.ts +57 -142
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services-core.ts +92 -0
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services-store.ts +90 -0
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/services.ts +22 -61
- package/skills/wix-headless-fast/references/bookings/app/wix/bookings/types.ts +3 -1
- package/skills/wix-headless-fast/references/bookings/rest/booking.ts +113 -0
- package/skills/wix-headless-fast/references/bookings/rest/services.ts +55 -0
- package/skills/wix-headless-fast/references/bookings/seed/SEED.md +10 -0
- package/skills/wix-headless-fast/references/cms/INSTRUCTIONS.md +236 -62
- package/skills/wix-headless-fast/references/cms/app/hooks/cms/useCollection.ts +19 -52
- package/skills/wix-headless-fast/references/cms/app/hooks/cms/useItem.ts +16 -36
- package/skills/wix-headless-fast/references/cms/app/wix/cms/collection-store.ts +158 -0
- package/skills/wix-headless-fast/references/cms/app/wix/cms/item-store.ts +93 -0
- package/skills/wix-headless-fast/references/cms/app/wix/cms/items-core.ts +177 -0
- package/skills/wix-headless-fast/references/cms/app/wix/cms/items.ts +13 -41
- package/skills/wix-headless-fast/references/cms/rest/items.ts +125 -0
- package/skills/wix-headless-fast/references/cms/seed/SEED.md +9 -0
- package/skills/wix-headless-fast/references/events/INSTRUCTIONS.md +239 -83
- package/skills/wix-headless-fast/references/events/app/components/events/EventConfirmationView.tsx +1 -1
- package/skills/wix-headless-fast/references/events/app/components/events/EventRegistrationView.tsx +7 -5
- package/skills/wix-headless-fast/references/events/app/components/events/EventsView.tsx +2 -2
- package/skills/wix-headless-fast/references/events/app/hooks/events/useEventRegistration.ts +28 -110
- package/skills/wix-headless-fast/references/events/app/hooks/events/useEvents.ts +19 -54
- package/skills/wix-headless-fast/references/events/app/wix/events/events-core.ts +130 -0
- package/skills/wix-headless-fast/references/events/app/wix/events/events-store.ts +97 -0
- package/skills/wix-headless-fast/references/events/app/wix/events/events.ts +13 -94
- package/skills/wix-headless-fast/references/events/app/wix/events/registration-core.ts +113 -0
- package/skills/wix-headless-fast/references/events/app/wix/events/registration-store.ts +156 -0
- package/skills/wix-headless-fast/references/events/app/wix/events/registration.ts +38 -69
- package/skills/wix-headless-fast/references/events/rest/events.ts +57 -0
- package/skills/wix-headless-fast/references/events/rest/registration.ts +83 -0
- package/skills/wix-headless-fast/references/events/seed/SEED.md +9 -0
- package/skills/wix-headless-fast/references/forms/INSTRUCTIONS.md +214 -26
- package/skills/wix-headless-fast/references/forms/app/hooks/forms/useWixForm.ts +35 -267
- package/skills/wix-headless-fast/references/forms/app/wix/forms/form-store.ts +332 -0
- package/skills/wix-headless-fast/references/forms/app/wix/forms/forms-core.ts +238 -0
- package/skills/wix-headless-fast/references/forms/app/wix/forms/forms.ts +28 -243
- package/skills/wix-headless-fast/references/forms/app/wix/forms/submissions-core.ts +216 -0
- package/skills/wix-headless-fast/references/forms/app/wix/forms/submissions.ts +32 -204
- package/skills/wix-headless-fast/references/forms/rest/forms.ts +34 -0
- package/skills/wix-headless-fast/references/forms/rest/submissions.ts +56 -0
- package/skills/wix-headless-fast/references/forms/seed/SEED.md +8 -0
- package/skills/wix-headless-fast/references/forms/seed/seed-forms.mjs +15 -8
- package/skills/wix-headless-fast/references/members/INSTRUCTIONS.md +314 -63
- package/skills/wix-headless-fast/references/members/app/components/members/LoginForm.tsx +1 -1
- package/skills/wix-headless-fast/references/members/app/wix/members/auth-core.ts +105 -0
- package/skills/wix-headless-fast/references/members/app/wix/members/auth.ts +36 -65
- package/skills/wix-headless-fast/references/members/app/wix/members/client.ts +1 -1
- package/skills/wix-headless-fast/references/members/app/wix/members/members-core.ts +43 -0
- package/skills/wix-headless-fast/references/members/app/wix/members/members.ts +11 -32
- package/skills/wix-headless-fast/references/members/rest/auth.ts +215 -0
- package/skills/wix-headless-fast/references/members/rest/members.ts +26 -0
- package/skills/wix-headless-fast/references/portfolio/INSTRUCTIONS.md +238 -59
- package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/CollectionProjectsView.tsx +2 -2
- package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/CollectionsView.tsx +2 -2
- package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/GalleryMedia.tsx +35 -0
- package/skills/wix-headless-fast/references/portfolio/app/components/portfolio/ProjectDetailView.tsx +8 -38
- package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useCollectionProjects.ts +23 -50
- package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useCollections.ts +16 -34
- package/skills/wix-headless-fast/references/portfolio/app/hooks/portfolio/useProjectDetail.ts +22 -49
- package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/collection-projects-store.ts +82 -0
- package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/collections-store.ts +58 -0
- package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/portfolio-core.ts +177 -0
- package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/portfolio.ts +31 -118
- package/skills/wix-headless-fast/references/portfolio/app/wix/portfolio/project-detail-store.ts +81 -0
- package/skills/wix-headless-fast/references/portfolio/rest/portfolio.ts +75 -0
- package/skills/wix-headless-fast/references/portfolio/seed/SEED.md +8 -0
- package/skills/wix-headless-fast/references/pricing-plans/INSTRUCTIONS.md +212 -51
- package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/PlanDetailView.tsx +9 -20
- package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/PlansView.tsx +7 -32
- package/skills/wix-headless-fast/references/pricing-plans/app/components/pricing-plans/SubscribeButton.tsx +41 -0
- package/skills/wix-headless-fast/references/pricing-plans/app/hooks/pricing-plans/usePlanPurchase.ts +12 -33
- package/skills/wix-headless-fast/references/pricing-plans/app/hooks/pricing-plans/usePlans.ts +17 -34
- package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plan-purchase-store.ts +54 -0
- package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans-core.ts +106 -0
- package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans-store.ts +64 -0
- package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/plans.ts +7 -55
- package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/purchase-core.ts +43 -0
- package/skills/wix-headless-fast/references/pricing-plans/app/wix/pricing-plans/purchase.ts +11 -34
- package/skills/wix-headless-fast/references/pricing-plans/rest/plans.ts +31 -0
- package/skills/wix-headless-fast/references/pricing-plans/rest/purchase.ts +20 -0
- package/skills/wix-headless-fast/references/pricing-plans/seed/SEED.md +8 -0
- package/skills/wix-headless-fast/references/restaurants/INSTRUCTIONS.md +240 -47
- package/skills/wix-headless-fast/references/restaurants/app/components/restaurants/MenuView.tsx +2 -2
- package/skills/wix-headless-fast/references/restaurants/app/components/restaurants/OrderCartDrawer.tsx +2 -2
- package/skills/wix-headless-fast/references/restaurants/app/hooks/restaurants/useMenus.ts +14 -35
- package/skills/wix-headless-fast/references/restaurants/app/hooks/restaurants/useReservation.ts +28 -171
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu-core.ts +155 -0
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu-store.ts +89 -0
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/menu.ts +23 -117
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/ordering-core.ts +124 -0
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/ordering.ts +45 -114
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservation-store.ts +193 -0
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservations-core.ts +94 -0
- package/skills/wix-headless-fast/references/restaurants/app/wix/restaurants/reservations.ts +15 -87
- package/skills/wix-headless-fast/references/restaurants/rest/menu.ts +60 -0
- package/skills/wix-headless-fast/references/restaurants/rest/ordering.ts +169 -0
- package/skills/wix-headless-fast/references/restaurants/rest/reservations.ts +68 -0
- package/skills/wix-headless-fast/references/restaurants/seed/SEED.md +8 -0
- package/skills/wix-headless-fast/references/shared/SUPPLIED-CONTENT.md +23 -0
- package/skills/wix-headless-fast/references/shared/app/wix/media.ts +55 -1
- package/skills/wix-headless-fast/references/shared/app/wix/sdk.ts +13 -0
- package/skills/wix-headless-fast/references/shared/rest/client.ts +155 -0
- package/skills/wix-headless-fast/references/shared/rest/config.ts +3 -0
- package/skills/wix-headless-fast/references/shared/rest/media.ts +65 -0
- package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +371 -81
- 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/OptionPicker.tsx +95 -0
- package/skills/wix-headless-fast/references/storefront/app/components/storefront/QuickAdd.tsx +136 -0
- package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useProductDetail.ts +29 -130
- package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +26 -182
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart-core.ts +121 -0
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart.ts +53 -136
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog-core.ts +309 -0
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +73 -230
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/product-detail-store.ts +147 -0
- package/skills/wix-headless-fast/references/storefront/app/wix/storefront/shop-store.ts +289 -0
- 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/rest/cart.ts +132 -0
- package/skills/wix-headless-fast/references/storefront/rest/catalog.ts +123 -0
- package/skills/wix-headless-fast/references/storefront/seed/SEED.md +25 -5
- package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +24 -0
- package/skills/wix-manage/SKILL.md +4 -1
- package/skills/wix-manage/references/ecommerce/ecom-shipping.md +1 -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 +42 -41
- package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +7 -1
- package/skills/wix-vibe-headless/references/storefront/seed/SEED.md +3 -0
- package/skills/wix-vibe-headless/references/storefront/seed/seed-store.cjs +6 -0
- package/skills/wix-headless-fast/install/pin-agents-md.mjs +0 -57
package/skills/wix-headless-fast/references/storefront/app/components/storefront/CartDrawer.tsx
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
// Slide-over cart with quantity stepper, remove, live subtotal, and Wix-hosted checkout.
|
|
2
2
|
// Mount ONCE per page, as-is; opens via useCart().openCart() / CartButton. Styled from the
|
|
3
3
|
// @theme tokens.
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
4
5
|
import { useCart } from "../../hooks/storefront/useCart";
|
|
5
6
|
|
|
6
7
|
export default function CartDrawer() {
|
|
7
8
|
const { cart, open, closeCart, busy, error, updateQuantity, removeLine, checkout } = useCart();
|
|
9
|
+
|
|
10
|
+
// The overlay contract, done here rather than assumed from CSS: Escape closes (the backdrop
|
|
11
|
+
// click is pointer-only), the page behind stops scrolling, focus moves into the panel on open
|
|
12
|
+
// and returns to whatever opened it on close. Hooks run before the early return below.
|
|
13
|
+
const panelRef = useRef<HTMLElement>(null);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!open) return;
|
|
16
|
+
const opener = document.activeElement;
|
|
17
|
+
const previousOverflow = document.body.style.overflow;
|
|
18
|
+
document.body.style.overflow = "hidden";
|
|
19
|
+
panelRef.current?.focus();
|
|
20
|
+
const onKey = (e: KeyboardEvent) => e.key === "Escape" && closeCart();
|
|
21
|
+
document.addEventListener("keydown", onKey);
|
|
22
|
+
return () => {
|
|
23
|
+
document.removeEventListener("keydown", onKey);
|
|
24
|
+
document.body.style.overflow = previousOverflow;
|
|
25
|
+
if (opener instanceof HTMLElement) opener.focus();
|
|
26
|
+
};
|
|
27
|
+
}, [open, closeCart]);
|
|
28
|
+
|
|
8
29
|
if (!open) return null;
|
|
9
30
|
const lines = cart?.lines ?? [];
|
|
10
31
|
|
|
@@ -14,10 +35,13 @@ export default function CartDrawer() {
|
|
|
14
35
|
onClick={closeCart}
|
|
15
36
|
>
|
|
16
37
|
<aside
|
|
38
|
+
ref={panelRef}
|
|
39
|
+
tabIndex={-1}
|
|
17
40
|
role="dialog"
|
|
41
|
+
aria-modal="true"
|
|
18
42
|
aria-label="Cart"
|
|
19
43
|
onClick={(e) => e.stopPropagation()}
|
|
20
|
-
className="flex h-full w-full max-w-md flex-col bg-background text-foreground shadow-2xl"
|
|
44
|
+
className="flex h-full w-full max-w-md flex-col bg-background text-foreground shadow-2xl outline-none"
|
|
21
45
|
>
|
|
22
46
|
<div className="flex items-center justify-between border-b border-border px-6 py-5">
|
|
23
47
|
<span className="text-base font-semibold">
|
|
@@ -56,6 +80,9 @@ export default function CartDrawer() {
|
|
|
56
80
|
{d}
|
|
57
81
|
</p>
|
|
58
82
|
))}
|
|
83
|
+
{line.subscription && (
|
|
84
|
+
<p className="mt-0.5 text-xs text-muted-foreground">Subscription: {line.subscription}</p>
|
|
85
|
+
)}
|
|
59
86
|
{line.status !== "IN_STOCK" && (
|
|
60
87
|
<p className="mt-1 text-xs text-red-600">No longer available at this quantity</p>
|
|
61
88
|
)}
|
|
@@ -98,12 +125,19 @@ export default function CartDrawer() {
|
|
|
98
125
|
|
|
99
126
|
{lines.length > 0 && (
|
|
100
127
|
<div className="border-t border-border px-6 py-5">
|
|
128
|
+
{cart?.discount && (
|
|
129
|
+
<div className="mb-2 flex justify-between text-sm">
|
|
130
|
+
<span className="text-muted-foreground">Discount</span>
|
|
131
|
+
<span>−{cart.discount}</span>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
101
134
|
{cart?.subtotal && (
|
|
102
|
-
<div className="mb-
|
|
135
|
+
<div className="mb-1 flex justify-between text-sm">
|
|
103
136
|
<span className="text-muted-foreground">Subtotal</span>
|
|
104
137
|
<strong className="font-semibold">{cart.subtotal}</strong>
|
|
105
138
|
</div>
|
|
106
139
|
)}
|
|
140
|
+
<p className="mb-4 text-xs text-muted-foreground">Shipping and taxes are calculated at checkout.</p>
|
|
107
141
|
{error && <p className="mb-3 text-xs text-red-600">{error}</p>}
|
|
108
142
|
<button
|
|
109
143
|
type="button"
|
|
@@ -111,7 +145,7 @@ export default function CartDrawer() {
|
|
|
111
145
|
onClick={() => checkout().catch(() => {})}
|
|
112
146
|
className="w-full rounded-full bg-primary py-3.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
113
147
|
>
|
|
114
|
-
{busy ? "One moment…" : "
|
|
148
|
+
{busy ? "One moment…" : "Continue to secure checkout"}
|
|
115
149
|
</button>
|
|
116
150
|
</div>
|
|
117
151
|
)}
|
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
|
+
}
|
package/skills/wix-headless-fast/references/storefront/app/components/storefront/OptionPicker.tsx
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The purchase controls for one product — option groups (swatches for a color option, pills
|
|
2
|
+
// otherwise, out-of-stock choices disabled), choice modifiers, an optional quantity stepper, and
|
|
3
|
+
// the buy button gated by the hook: disabled with a plain reason ("Choose Size") until every
|
|
4
|
+
// choice is made, "Pre-order" when the resolved variant is pre-orderable, the hook's error inline.
|
|
5
|
+
// QuickAdd's picker and your product page mount the same component, so buying behaves the same
|
|
6
|
+
// in the gallery and on the PDP. Wire as-is; style via the tokens.
|
|
7
|
+
//
|
|
8
|
+
// const d = useProductDetail({ initial }); // the PDP
|
|
9
|
+
// <OptionPicker detail={d} showQuantity />
|
|
10
|
+
//
|
|
11
|
+
// Price, name, gallery, description, and layout stay yours — render d.price / d.compareAtPrice
|
|
12
|
+
// beside this (the range until every option is picked, then the variant's price).
|
|
13
|
+
import type { UseProductDetail } from "../../hooks/storefront/useProductDetail";
|
|
14
|
+
|
|
15
|
+
export default function OptionPicker({
|
|
16
|
+
detail: d,
|
|
17
|
+
showQuantity = false,
|
|
18
|
+
onAdded,
|
|
19
|
+
}: {
|
|
20
|
+
detail: UseProductDetail;
|
|
21
|
+
/** The PDP shows a quantity stepper; a tile picker adds one. */
|
|
22
|
+
showQuantity?: boolean;
|
|
23
|
+
/** Called after a successful add (a tile picker closes itself here). */
|
|
24
|
+
onAdded?: () => void;
|
|
25
|
+
}) {
|
|
26
|
+
if (!d.product) return null;
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex flex-col gap-3">
|
|
29
|
+
{d.optionGroups.map((g) => (
|
|
30
|
+
<fieldset key={g.id}>
|
|
31
|
+
<legend className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">{g.name}</legend>
|
|
32
|
+
<div className="flex flex-wrap gap-1.5">
|
|
33
|
+
{g.choices.map((c) =>
|
|
34
|
+
g.isColor && c.colorCode ? (
|
|
35
|
+
<button key={c.choiceId} type="button" aria-label={c.name} title={c.name} aria-pressed={c.selected}
|
|
36
|
+
disabled={!c.inStock} onClick={() => d.selectOption(g.name, c.name)}
|
|
37
|
+
className={`h-8 w-8 rounded-full border-2 disabled:opacity-30 ${c.selected ? "border-foreground" : "border-border"}`}
|
|
38
|
+
style={{ backgroundColor: c.colorCode }} />
|
|
39
|
+
) : (
|
|
40
|
+
<button key={c.choiceId} type="button" aria-pressed={c.selected} disabled={!c.inStock}
|
|
41
|
+
onClick={() => d.selectOption(g.name, c.name)}
|
|
42
|
+
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"}`}>
|
|
43
|
+
{c.name}
|
|
44
|
+
</button>
|
|
45
|
+
),
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
</fieldset>
|
|
49
|
+
))}
|
|
50
|
+
{d.product.modifiers.filter((m) => m.type === "choices").map((m) => (
|
|
51
|
+
<fieldset key={m.key}>
|
|
52
|
+
<legend className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
53
|
+
{m.name}{m.mandatory ? " *" : ""}
|
|
54
|
+
</legend>
|
|
55
|
+
<div className="flex flex-wrap gap-1.5">
|
|
56
|
+
{m.choices.map((c) => (
|
|
57
|
+
<button key={c.key} type="button" aria-pressed={d.modifierValues[m.key] === c.key}
|
|
58
|
+
onClick={() => d.setModifier(m.key, c.key)}
|
|
59
|
+
className={`rounded-full border px-3 py-1 text-sm ${d.modifierValues[m.key] === c.key ? "border-foreground bg-foreground text-background" : "border-border"}`}>
|
|
60
|
+
{c.name}
|
|
61
|
+
</button>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
</fieldset>
|
|
65
|
+
))}
|
|
66
|
+
{d.product.modifiers.filter((m) => m.type === "text").map((m) => (
|
|
67
|
+
<label key={m.key} className="flex flex-col gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
68
|
+
{m.name}{m.mandatory ? " *" : ""}
|
|
69
|
+
<input type="text" value={d.modifierValues[m.key] ?? ""} onChange={(e) => d.setModifier(m.key, e.target.value)}
|
|
70
|
+
className="rounded-md border border-border bg-background px-3 py-2 text-sm normal-case tracking-normal text-foreground" />
|
|
71
|
+
</label>
|
|
72
|
+
))}
|
|
73
|
+
<div className="flex items-stretch gap-2">
|
|
74
|
+
{showQuantity && (
|
|
75
|
+
<div className="flex items-center rounded-full border border-border">
|
|
76
|
+
<button type="button" aria-label="Decrease quantity" disabled={d.quantity <= 1} onClick={() => d.setQuantity(d.quantity - 1)}
|
|
77
|
+
className="px-3 py-2 text-sm disabled:opacity-40">−</button>
|
|
78
|
+
<span className="min-w-6 text-center text-sm tabular-nums">{d.quantity}</span>
|
|
79
|
+
<button type="button" aria-label="Increase quantity" onClick={() => d.setQuantity(d.quantity + 1)} className="px-3 py-2 text-sm">+</button>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
disabled={!d.canAdd || d.adding}
|
|
85
|
+
onClick={() => d.add().then(() => onAdded?.()).catch(() => {})}
|
|
86
|
+
className="flex-1 rounded-full bg-primary py-2.5 text-sm font-semibold text-primary-foreground disabled:opacity-50"
|
|
87
|
+
>
|
|
88
|
+
{d.adding ? "Adding…" : d.isPreorder ? "Pre-order" : "Add to cart"}
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
{d.blockedReason && !d.canAdd && <p className="text-xs text-muted-foreground">{d.blockedReason}</p>}
|
|
92
|
+
{d.error && <p className="text-xs text-red-600">{d.error}</p>}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
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 flex flex-col` — the picker anchors to that root and takes its
|
|
7
|
+
// width, and the control pins itself to the tile's bottom (mt-auto) so the action row lines up
|
|
8
|
+
// across a grid row whether or not a neighbour carries swatches or a struck price. Never overlay
|
|
9
|
+
// it on the image, never wrap it in a narrower positioned box.
|
|
10
|
+
// <QuickAdd product={p} /> Wire as-is; style via the tokens.
|
|
11
|
+
import { useEffect, useRef, useState } from "react";
|
|
12
|
+
import { useCart } from "../../hooks/storefront/useCart";
|
|
13
|
+
import { useProductDetail } from "../../hooks/storefront/useProductDetail";
|
|
14
|
+
import type { ProductSummary } from "../../wix/storefront/types";
|
|
15
|
+
import OptionPicker from "./OptionPicker";
|
|
16
|
+
|
|
17
|
+
export default function QuickAdd({ product }: { product: ProductSummary }) {
|
|
18
|
+
return (
|
|
19
|
+
// A tile is often one big <a>. A click inside the buy control must never become a navigation
|
|
20
|
+
// to the product page — this boundary cancels the link's default for everything below it
|
|
21
|
+
// (the button, the picker, the sheet). Still: keep the control OUTSIDE the tile's link.
|
|
22
|
+
<div
|
|
23
|
+
className="contents"
|
|
24
|
+
onClick={(e) => {
|
|
25
|
+
e.preventDefault();
|
|
26
|
+
e.stopPropagation();
|
|
27
|
+
}}
|
|
28
|
+
>
|
|
29
|
+
<QuickAddControl product={product} />
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function QuickAddControl({ product }: { product: ProductSummary }) {
|
|
35
|
+
const { addToCart, busy } = useCart();
|
|
36
|
+
const [open, setOpen] = useState(false);
|
|
37
|
+
const [error, setError] = useState<string | null>(null);
|
|
38
|
+
|
|
39
|
+
if (product.availability === "OUT_OF_STOCK" && !product.preorder) {
|
|
40
|
+
return <span className="mt-auto block pt-3 text-sm text-muted-foreground">Out of stock</span>;
|
|
41
|
+
}
|
|
42
|
+
if (product.quickAddable) {
|
|
43
|
+
return (
|
|
44
|
+
<div className="mt-auto pt-3">
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
disabled={busy}
|
|
48
|
+
onClick={() =>
|
|
49
|
+
addToCart(product.id, product.minPriceVariantId, 1).catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
|
50
|
+
}
|
|
51
|
+
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"
|
|
52
|
+
>
|
|
53
|
+
{product.preorder ? "Pre-order" : "Add to cart"}
|
|
54
|
+
</button>
|
|
55
|
+
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
// Options to pick, or a single variant that isn't plainly in stock (pre-order, partial stock):
|
|
60
|
+
// the picker resolves it through useProductDetail, exactly as the PDP would.
|
|
61
|
+
return (
|
|
62
|
+
<div className="mt-auto pt-3">
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={() => setOpen(true)}
|
|
66
|
+
className="w-full rounded-full border border-foreground py-2 text-sm font-medium transition-colors hover:bg-foreground hover:text-background"
|
|
67
|
+
>
|
|
68
|
+
{product.optionsSummary ? "Choose options" : product.preorder ? "Pre-order" : "Add to cart"}
|
|
69
|
+
</button>
|
|
70
|
+
{open && <QuickAddPicker product={product} onClose={() => setOpen(false)} />}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The picker fetches the full product only when it opens — cards never carry PDP data. Its controls
|
|
76
|
+
// are the shipped OptionPicker, the same component the PDP mounts.
|
|
77
|
+
function QuickAddPicker({ product, onClose }: { product: ProductSummary; onClose: () => void }) {
|
|
78
|
+
const d = useProductDetail({ slug: product.slug });
|
|
79
|
+
const panelRef = useRef<HTMLElement>(null);
|
|
80
|
+
|
|
81
|
+
// Overlay contract: Escape closes, scroll locks under md, focus moves in and back.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const opener = document.activeElement;
|
|
84
|
+
const isSmall = window.matchMedia("(max-width: 767px)").matches;
|
|
85
|
+
const previousOverflow = document.body.style.overflow;
|
|
86
|
+
if (isSmall) document.body.style.overflow = "hidden";
|
|
87
|
+
panelRef.current?.focus();
|
|
88
|
+
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
|
89
|
+
document.addEventListener("keydown", onKey);
|
|
90
|
+
return () => {
|
|
91
|
+
document.removeEventListener("keydown", onKey);
|
|
92
|
+
document.body.style.overflow = previousOverflow;
|
|
93
|
+
if (opener instanceof HTMLElement) opener.focus();
|
|
94
|
+
};
|
|
95
|
+
}, [onClose]);
|
|
96
|
+
|
|
97
|
+
const needsPdp = (d.product?.modifiers ?? []).some((m) => m.type === "text");
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
<div className="fixed inset-0 z-40 bg-foreground/40 md:hidden" onClick={onClose} />
|
|
102
|
+
<section
|
|
103
|
+
ref={panelRef}
|
|
104
|
+
tabIndex={-1}
|
|
105
|
+
role="dialog"
|
|
106
|
+
aria-modal="true"
|
|
107
|
+
aria-label={`Choose options for ${product.name}`}
|
|
108
|
+
// md+: anchored to the tile (its nearest `relative` ancestor), the tile's full width — and never
|
|
109
|
+
// narrower than 18rem even when mounted inside a small wrapper.
|
|
110
|
+
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"
|
|
111
|
+
>
|
|
112
|
+
<div className="mb-3 flex items-start justify-between gap-3">
|
|
113
|
+
<div className="min-w-0">
|
|
114
|
+
<p className="truncate text-sm font-semibold">{product.name}</p>
|
|
115
|
+
<p className="text-sm">
|
|
116
|
+
{d.price}
|
|
117
|
+
{d.compareAtPrice && <span className="ml-2 text-muted-foreground line-through">{d.compareAtPrice}</span>}
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
<button type="button" aria-label="Close" onClick={onClose} className="text-xl leading-none text-muted-foreground">×</button>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{!d.product && !d.notFound && <p className="text-sm text-muted-foreground">Loading options…</p>}
|
|
124
|
+
{d.notFound && <p className="text-sm text-muted-foreground">This product isn't available anymore.</p>}
|
|
125
|
+
|
|
126
|
+
{d.product && needsPdp && (
|
|
127
|
+
<a href={`/products/${product.slug}`} className="block rounded-full bg-primary py-2.5 text-center text-sm font-semibold text-primary-foreground">
|
|
128
|
+
Customize on the product page
|
|
129
|
+
</a>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{d.product && !needsPdp && <OptionPicker detail={d} onAdded={onClose} />}
|
|
133
|
+
</section>
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
}
|