← Files WixARCHIVED FILE

skills/wix-headless-templates/storefront/project/src/components/storefront/OptionPicker.tsx

9.45 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// The purchase controls for one product — option groups (swatches for a color option, pills
// otherwise, choices unavailable WITH the current selection disabled), choice and free-text
// modifiers (with the merchant's character limit), the plan picker for a subscription product, an
// optional quantity stepper capped at the stock left, and the buy button gated by the hook:
// disabled with a plain reason ("Choose Size") until every choice is made, "Pre-order" (with the
// merchant's pre-order note) when the resolved variant is pre-orderable, "Only N left" when stock
// runs low, an email form to be notified when a sold-out item returns, the hook's error inline.
// QuickAdd's picker and your product page mount the same component, so buying behaves the same
// in the gallery and on the PDP. Wire as-is; style via the tokens.
//
//   const d = useProductDetail({ initial });            // the PDP
//   <OptionPicker detail={d} showQuantity />
//
// Price, name, gallery, description, and layout stay yours — render d.price / d.compareAtPrice /
// d.pricePerUnit beside this (the range until every option is picked, then the variant's price).
import { useState } from "react";
import { ONE_TIME_PLAN, type UseProductDetail } from "../../hooks/storefront/useProductDetail";

export default function OptionPicker({
  detail: d,
  showQuantity = false,
  onAdded,
}: {
  detail: UseProductDetail;
  /** The PDP shows a quantity stepper; a tile picker adds one. */
  showQuantity?: boolean;
  /** Called after a successful add (a tile picker closes itself here). */
  onAdded?: () => void;
}) {
  if (!d.product) return null;
  const legend = "mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground";
  return (
    <div className="flex flex-col gap-3">
      {d.optionGroups.map((g) => (
        <fieldset key={g.id}>
          <legend className={legend}>{g.name}</legend>
          <div className="flex flex-wrap gap-1.5">
            {g.choices.map((c) => {
              // unavailable = sold out with the other picks, or no variant has this combination
              const unavailable = !c.inStock || !c.exists;
              return g.isColor && c.colorCode ? (
                <button key={c.choiceId} type="button" aria-label={c.name} title={unavailable ? `${c.name} (unavailable)` : c.name} aria-pressed={c.selected}
                  disabled={unavailable} onClick={() => d.selectOption(g.id, c.choiceId)}
                  className={`h-8 w-8 rounded-full border-2 disabled:opacity-30 ${c.selected ? "border-foreground" : "border-border"}`}
                  style={{ backgroundColor: c.colorCode }} />
              ) : (
                <button key={c.choiceId} type="button" aria-pressed={c.selected} disabled={unavailable}
                  onClick={() => d.selectOption(g.id, c.choiceId)}
                  className={`rounded-control border px-3 py-1 text-sm disabled:line-through disabled:opacity-40 ${c.selected ? "border-foreground bg-foreground text-background" : "border-border"}`}>
                  {c.name}
                </button>
              );
            })}
          </div>
        </fieldset>
      ))}
      {d.product.modifiers.filter((m) => m.type === "choices").map((m) => (
        <fieldset key={m.key}>
          <legend className={legend}>
            {m.name}{m.mandatory ? " *" : ""}
          </legend>
          <div className="flex flex-wrap gap-1.5">
            {m.choices.map((c) => {
              const on = d.modifierValues[m.key] === c.key;
              return (
                <button key={c.key} type="button" aria-pressed={on}
                  // an optional modifier can be un-picked; a mandatory one only re-picked
                  onClick={() => d.setModifier(m.key, on && !m.mandatory ? "" : c.key)}
                  className={`rounded-control border px-3 py-1 text-sm ${on ? "border-foreground bg-foreground text-background" : "border-border"}`}>
                  {c.name}
                </button>
              );
            })}
          </div>
        </fieldset>
      ))}
      {d.product.modifiers.filter((m) => m.type === "text").map((m) => {
        const value = d.modifierValues[m.key] ?? "";
        return (
          <label key={m.key} className={`flex flex-col gap-1.5 ${legend} mb-0`}>
            <span className="flex justify-between">
              <span>{m.title}{m.mandatory ? " *" : ""}</span>
              {m.maxChars && <span className="tabular-nums normal-case tracking-normal">{value.length}/{m.maxChars}</span>}
            </span>
            <textarea rows={2} value={value} maxLength={m.maxChars ?? undefined} onChange={(e) => d.setModifier(m.key, e.target.value)}
              className="rounded-md border border-border bg-background px-3 py-2 text-sm normal-case tracking-normal text-foreground" />
          </label>
        );
      })}
      {d.plans.length > 0 && (
        <fieldset>
          <legend className={legend}>Purchase option</legend>
          <div className="flex flex-col gap-1.5">
            {d.plans.map((p) => (
              <label key={p.id} className={`flex cursor-pointer items-start justify-between gap-3 rounded-md border px-3 py-2 text-sm ${p.selected ? "border-foreground" : "border-border"}`}>
                <span className="flex items-start gap-2">
                  <input type="radio" name="purchase-option" className="mt-1" checked={p.selected} onChange={() => d.selectPlan(p.id)} />
                  <span>
                    <span className="font-medium">{p.name}</span>
                    <span className="block text-xs text-muted-foreground">{p.terms}{p.description ? ` · ${p.description}` : ""}</span>
                  </span>
                </span>
                {p.price && <span className="whitespace-nowrap tabular-nums">{p.price}</span>}
              </label>
            ))}
            {d.product.allowOneTimePurchase && (
              <label className={`flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm ${d.subscriptionPlanId === ONE_TIME_PLAN ? "border-foreground" : "border-border"}`}>
                <input type="radio" name="purchase-option" checked={d.subscriptionPlanId === ONE_TIME_PLAN} onChange={() => d.selectPlan(ONE_TIME_PLAN)} />
                <span className="font-medium">One-time purchase</span>
                {d.variant && <span className="ml-auto tabular-nums">{d.variant.price}</span>}
              </label>
            )}
          </div>
        </fieldset>
      )}
      <div className="flex items-stretch gap-2">
        {showQuantity && (
          <div className="flex items-center rounded-control border border-border">
            <button type="button" aria-label="Decrease quantity" disabled={d.quantity <= 1} onClick={() => d.setQuantity(d.quantity - 1)}
              className="px-3 py-2 text-sm disabled:opacity-40">−</button>
            <span className="min-w-6 text-center text-sm tabular-nums">{d.quantity}</span>
            <button type="button" aria-label="Increase quantity" disabled={d.quantity >= d.maxQuantity} onClick={() => d.setQuantity(d.quantity + 1)}
              className="px-3 py-2 text-sm disabled:opacity-40">+</button>
          </div>
        )}
        <button
          type="button"
          disabled={!d.canAdd || d.adding}
          onClick={() => d.add().then(() => onAdded?.()).catch(() => {})}
          className="flex-1 rounded-control bg-primary py-2.5 text-sm font-semibold text-primary-foreground disabled:opacity-50"
        >
          {d.adding ? "Adding…" : d.isPreorder ? "Pre-order" : "Add to cart"}
        </button>
      </div>
      {d.remaining !== null && d.remaining > 0 && d.remaining <= 10 && d.canAdd && !d.isPreorder && (
        <p className="text-xs text-muted-foreground">Only {d.remaining} left in stock</p>
      )}
      {d.isPreorder && d.preorderMessage && <p className="text-xs text-muted-foreground">{d.preorderMessage}</p>}
      {d.blockedReason && !d.canAdd && <p className="text-xs text-muted-foreground">{d.blockedReason}</p>}
      {d.canNotify && <NotifyMe detail={d} />}
      {d.error && <p className="text-xs text-destructive">{d.error}</p>}
    </div>
  );
}

// "Notify me when it's back": one email field; the store validates, calls Wix, and reports
// "created" / "already-subscribed".
function NotifyMe({ detail: d }: { detail: UseProductDetail }) {
  const [email, setEmail] = useState("");
  if (d.notifyResult === "created") return <p className="text-xs text-muted-foreground">We'll email you when it's back in stock.</p>;
  if (d.notifyResult === "already-subscribed") return <p className="text-xs text-muted-foreground">You're already on the list for this item.</p>;
  return (
    <form
      className="flex flex-col gap-1.5"
      onSubmit={(e) => {
        e.preventDefault();
        d.notify(email).catch(() => {});
      }}
    >
      <label className="text-xs font-medium uppercase tracking-wide text-muted-foreground" htmlFor="notify-email">Notify me when it's back</label>
      <div className="flex gap-2">
        <input id="notify-email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com"
          className="min-w-0 flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" />
        <button type="submit" disabled={d.notifying} className="rounded-control border border-foreground px-4 py-2 text-sm font-medium disabled:opacity-50">
          {d.notifying ? "Saving…" : "Notify me"}
        </button>
      </div>
      {d.notifyError && <p className="text-xs text-destructive">{d.notifyError}</p>}
    </form>
  );
}

SHA-256: 886945cb2ba907418ca1f3b0aa9fc033c40cd50e709d7a052e0f9eca11e001e2