import React, { useEffect, useMemo, useRef } from "react"; import { useQuantityButtonAnimation } from "../../components/useQuantityButtonAnimation"; import { Toggle } from "../../components/Toggle"; import { ColorPicker } from "../../components/ColorPicker"; import { CustomSelect } from "../../components/CustomSelect"; import { RangeSlider } from "../../components/RangeSlider"; import { HighlightableField } from "../../components/HighlightableField"; import { ChoiceGroup, ChoiceOption } from "../../components/ChoiceGroup"; import { PanelTabs } from "../../components/PanelTabs"; import { tabPanelProps } from "../../components/panelTabsUtils"; import type { Settings } from "../../types"; import type { BaseTabProps } from "../types"; import { alignPreviewQuantity, describeEffectiveQuantityLimits, getEffectiveQuantityLimits, stepPreviewQuantity, type EffectiveQuantityLimits, } from "./quantityPreviewLimits"; const QUANTITY_SUBTABS_ID = "b3-wvs-quantity-subtabs"; type QuantitySubTab = "quantity" | "stock"; const QUANTITY_STYLE_OPTIONS = [ { value: "default", label: "Default", desc: "Theme's native style", }, { value: "square", label: "Square", desc: "Square corners", }, { value: "rounded", label: "Rounded", desc: "Soft rounded corners", }, { value: "pill", label: "Pill", desc: "Fully rounded pill shape", }, ] as const; const QUANTITY_BUTTON_STYLE_OPTIONS = [ { value: "square", label: "Square", desc: "Square corners", }, { value: "rounded", label: "Rounded", desc: "Rounded corners", }, { value: "circle", label: "Circle", desc: "Circular buttons", }, ] as const; const QUANTITY_BUTTON_POSITION_OPTIONS = [ { value: "both-sides", label: "Both Sides", desc: "- left, + right", }, { value: "left", label: "Left Side", desc: "Both on left", }, { value: "right", label: "Right Side", desc: "Both on right", }, ] as const; export interface QuantityTabProps extends BaseTabProps { activeQuantitySubTab?: QuantitySubTab; setActiveQuantitySubTab?: React.Dispatch< React.SetStateAction >; quantityPreviewValue?: number; setQuantityPreviewValue?: React.Dispatch>; fontFamilyOptions?: Array<{ value: string; label: string }>; stockContent?: React.ReactNode; navigateToField?: (target: { id: string; tabId: string; subTab?: string; }) => void; } function QuantityStyleIcon({ style, }: { style: "default" | "square" | "rounded" | "pill"; }) { const radiusClass = style === "pill" ? "bwvs:rounded-full" : style === "rounded" ? "bwvs:rounded-md" : "bwvs:rounded-xs"; const borderClass = style === "default" ? "bwvs:border-dashed" : "bwvs:border-solid"; return (
); } function QuantityButtonStyleIcon({ style, }: { style: "square" | "rounded" | "circle"; }) { const radiusClass = style === "circle" ? "bwvs:rounded-full" : style === "rounded" ? "bwvs:rounded-md" : "bwvs:rounded-xs"; return (
); } function QuantityButtonPositionIcon({ position, }: { position: "both-sides" | "left" | "right"; }) { const buttonClass = "bwvs:w-3 bwvs:h-3 bwvs:bg-gray-300 bwvs:rounded-xs"; const inputClass = "bwvs:w-6 bwvs:h-3 bwvs:bg-gray-200 bwvs:legacy-border bwvs:border-gray-300 bwvs:rounded-xs"; return (
{position === "left" && ( <> )} {position === "right" && ( <> )} {position === "both-sides" && ( <> )}
); } export function QuantityTab({ settings, setSettings, activeQuantitySubTab: controlledActiveSubTab, setActiveQuantitySubTab: setControlledActiveSubTab, quantityPreviewValue: controlledPreviewValue, setQuantityPreviewValue: setControlledPreviewValue, fontFamilyOptions = [], stockContent, }: QuantityTabProps) { const [localActiveSubTab, setLocalActiveSubTab] = React.useState("quantity"); const [localPreviewValue, setLocalPreviewValue] = React.useState(1); const activeQuantitySubTab = controlledActiveSubTab ?? localActiveSubTab; const setActiveQuantitySubTab = setControlledActiveSubTab ?? setLocalActiveSubTab; const quantityPreviewValue = controlledPreviewValue ?? localPreviewValue; const setQuantityPreviewValue = setControlledPreviewValue ?? setLocalPreviewValue; useEffect(() => { if (!stockContent && activeQuantitySubTab === "stock") { setActiveQuantitySubTab("quantity"); } }, [activeQuantitySubTab, setActiveQuantitySubTab, stockContent]); const useCustomButtons = Boolean(settings.quantity_custom_buttons); const quantityStyle = settings.quantity_style ?? "default"; const showQuantityButtonLayoutControls = useCustomButtons && quantityStyle !== "default"; const normalizedBtnStyle = settings.quantity_button_style === "default" ? "square" : (settings.quantity_button_style ?? "square"); const positionRaw = settings.quantity_button_position ?? "both-sides"; const normalizedPosition: "both-sides" | "left" | "right" = [ "both-sides", "left", "right", ].includes(positionRaw) ? (positionRaw as "both-sides" | "left" | "right") : "both-sides"; const quantityLimits = useMemo( () => getEffectiveQuantityLimits({ quantity_custom_limits: settings.quantity_custom_limits, quantity_min: settings.quantity_min, quantity_max: settings.quantity_max, quantity_step: settings.quantity_step, }), [ settings.quantity_custom_limits, settings.quantity_min, settings.quantity_max, settings.quantity_step, ], ); // Keep the preview quantity inside the effective bounds whenever the limit // toggle or any of min/max/step changes. Saved settings are never touched. useEffect(() => { const aligned = alignPreviewQuantity(quantityPreviewValue, quantityLimits); if (aligned !== quantityPreviewValue) { setQuantityPreviewValue(aligned); } }, [quantityPreviewValue, setQuantityPreviewValue, quantityLimits]); return (

{stockContent ? "Quantity & Stock" : "Quantity"}

{stockContent ? "Manage quantity controls and how stock availability is communicated to shoppers." : "Manage quantity controls shown to shoppers."}

{/* Sub-tab Navigation */}
setActiveQuantitySubTab(id as QuantitySubTab)} tabs={[ { id: "quantity", label: "Quantity" }, ...(stockContent ? [{ id: "stock", label: "Stock" }] : []), ]} />
{activeQuantitySubTab === "quantity" && (

Start by enabling custom quantity buttons, then choose the style, position, sizing, and colors. Finish by setting optional limits for min/max/step.

setSettings({ ...settings, quantity_custom_buttons: checked }) } label="Custom Quantity Buttons" description="Enable B3 custom +/- buttons and styling for the quantity input." /> {!useCustomButtons && (

WooCommerce Defaults: Custom quantity buttons are disabled. Your theme's native WooCommerce quantity controls will be used.

)} {useCustomButtons && ( <> {/* Quantity Style */}

Quantity Style

Choose the overall appearance of the quantity input.

o.value)} onChange={(next) => setSettings({ ...settings, quantity_style: next as Settings["quantity_style"], }) } className="b3-wvs-choice-grid b3-wvs-choice-grid--compact" > {QUANTITY_STYLE_OPTIONS.map((style) => (
{style.label}
{style.desc}
))}
{/* Info message for default style */} {quantityStyle === "default" && (

Default Style: Uses your theme's native quantity input styling with stock WooCommerce quantity selector. Button style and position options are only available for Square, Rounded, and Pill styles, which add custom +/- buttons.

)} {showQuantityButtonLayoutControls ? ( <> {/* Button Style */}

Button Style

Style of the increment/decrement buttons.

o.value, )} onChange={(next) => setSettings({ ...settings, quantity_button_style: next as Settings["quantity_button_style"], }) } className="b3-wvs-choice-grid b3-wvs-choice-grid--medium" > {QUANTITY_BUTTON_STYLE_OPTIONS.map((style) => (
{style.label}
{style.desc && (
{style.desc}
)}
))}
{/* Button Position */}

Button Position

Where the +/- buttons appear relative to the input.

o.value, )} onChange={(next) => setSettings({ ...settings, quantity_button_position: next as Settings["quantity_button_position"], }) } className="b3-wvs-choice-grid b3-wvs-choice-grid--medium" > {QUANTITY_BUTTON_POSITION_OPTIONS.map((pos) => (
{pos.label}
{pos.desc}
))}
) : null} {/* Live Preview */} )} {/* Quantity Limits */}

Quantity Limits

Default Custom
{settings.quantity_custom_limits && ( )}
{useCustomButtons && ( <> {/* Quantity Sizing */}

Quantity Sizing

setSettings({ ...settings, quantity_width: val }) } /> setSettings({ ...settings, quantity_height: val }) } /> setSettings({ ...settings, quantity_button_gap: val }) } fullWidth />
{/* Border & Typography */}

Border & Typography

Default Custom
{settings.quantity_custom_sizing && ( )}
{/* Colors */}

Colors

setSettings({ ...settings, quantity_text_color: value }) } /> setSettings({ ...settings, quantity_bg_color: value }) } /> setSettings({ ...settings, quantity_border_color: value, }) } /> {/* Button colors - Only show for non-default styles */} {(settings.quantity_style ?? "default") !== "default" && ( <> setSettings({ ...settings, quantity_button_color: value, }) } /> setSettings({ ...settings, quantity_button_bg_color: value, }) } /> setSettings({ ...settings, quantity_button_hover_bg_color: value, }) } /> )}
)}
)} {activeQuantitySubTab === "stock" && stockContent}
); } // Sub-components to keep the main component cleaner function QuantityLivePreview({ settings, limits, quantityPreviewValue, setQuantityPreviewValue, }: { settings: Settings; limits: EffectiveQuantityLimits; quantityPreviewValue: number; setQuantityPreviewValue: React.Dispatch>; }) { const plusButtonRef = useRef(null); const minusButtonRef = useRef(null); // Same binder as the storefront runtime: the unsaved draft previews every // advertised mode with the real hover/click gating and class contract. const animType = settings.quantity_animation_type || "none"; const animateOnHover = settings.quantity_animate_on_hover !== false; const animateOnClick = Boolean(settings.quantity_animate_on_click); const playPlusAnimation = useQuantityButtonAnimation(plusButtonRef, { type: animType, direction: "plus", animateOnHover, animateOnClick, }); const playMinusAnimation = useQuantityButtonAnimation(minusButtonRef, { type: animType, direction: "minus", animateOnHover, animateOnClick, }); const btnPosRaw = settings.quantity_button_position ?? "both-sides"; const btnPos: "both-sides" | "left" | "right" = [ "both-sides", "left", "right", ].includes(btnPosRaw) ? (btnPosRaw as "both-sides" | "left" | "right") : "both-sides"; const btnStyleRaw = settings.quantity_button_style ?? "square"; const btnStyle = btnStyleRaw === "default" ? "square" : btnStyleRaw; const qtyStyle = settings.quantity_style ?? "default"; const width = settings.quantity_width ?? 80; const height = settings.quantity_height ?? 40; const buttonGap = settings.quantity_button_gap ?? 4; const useCustomSizing = settings.quantity_custom_sizing ?? false; const fontSize = useCustomSizing && settings.quantity_font_size !== null && settings.quantity_font_size !== undefined ? settings.quantity_font_size : 16; const borderWidth = useCustomSizing && settings.quantity_border_width !== null && settings.quantity_border_width !== undefined ? settings.quantity_border_width : 1; const borderRadius = useCustomSizing && settings.quantity_border_radius !== null && settings.quantity_border_radius !== undefined ? settings.quantity_border_radius : 4; const borderColor = settings.quantity_border_color ?? "#cccccc"; const btnColor = settings.quantity_button_color ?? "#374151"; const btnBg = settings.quantity_button_bg_color ?? "#f3f4f6"; const btnHoverBg = settings.quantity_button_hover_bg_color ?? "#e5e7eb"; const textColor = settings.quantity_text_color ?? "#000000"; const bgColor = settings.quantity_bg_color ?? "#ffffff"; const qtyMin = limits.min; const qtyMax = limits.max; const qtyStep = limits.step; const previewDisabled = !limits.hasRange; const emptyRangeWarning = previewDisabled ? (

{describeEffectiveQuantityLimits(limits)}

) : null; const getBtnRadius = () => { if (btnStyle === "square") return `${borderRadius}px`; if (btnStyle === "circle") return "50%"; if (btnStyle === "pill") return `${height / 2}px`; if (btnStyle === "rounded") return "8px"; return `${borderRadius}px`; }; const getInputRadius = () => { if (qtyStyle === "default") return `${borderRadius}px`; if (qtyStyle === "pill") return `${height / 2}px`; if (qtyStyle === "rounded") return "8px"; return `${borderRadius}px`; }; const handleIncrement = () => { if (previewDisabled) return; setQuantityPreviewValue((prev) => stepPreviewQuantity(prev, limits, 1)); }; const handleDecrement = () => { if (previewDisabled) return; setQuantityPreviewValue((prev) => stepPreviewQuantity(prev, limits, -1)); }; const handleInputChange = (e: React.ChangeEvent) => { if (previewDisabled) return; const val = parseInt(e.target.value, 10); if (!Number.isFinite(val)) return; setQuantityPreviewValue(alignPreviewQuantity(val, limits)); }; const buttonStyle: React.CSSProperties = { width: `${height}px`, height: `${height}px`, color: btnColor, border: `${borderWidth}px solid ${borderColor}`, borderRadius: getBtnRadius(), fontSize: `${fontSize}px`, fontWeight: 600, lineHeight: 1, padding: 0, boxSizing: "border-box", }; const buttonHoverVars = { ["--b3-wvs-qty-btn-bg" as string]: btnBg, ["--b3-wvs-qty-btn-bg-hover" as string]: btnHoverBg, } as React.CSSProperties; // No animation class is stamped statically: the binder adds/removes the // `b3-wvs-animate-quantity-*` classes on hover/click exactly like the // storefront, so a mode change or toggle flip is never silently ignored. const buttonClassBase = "b3-wvs-quantity-preview-button bwvs:flex bwvs:items-center bwvs:justify-center bwvs:leading-none bwvs:cursor-pointer bwvs:transition-colors bwvs:duration-200"; const inputStyle: React.CSSProperties = { width: `${width}px`, height: `${height}px`, backgroundColor: bgColor, color: textColor, border: `${borderWidth}px solid ${borderColor}`, borderRadius: getInputRadius(), fontSize: `${fontSize}px`, textAlign: "center", outline: "none", MozAppearance: "textfield", } as React.CSSProperties & { MozAppearance?: string }; const minusButton = ( ); const plusButton = ( ); const quantityInput = ( 0 ? qtyMax : undefined} step={qtyStep} disabled={previewDisabled} aria-label="Preview quantity" style={inputStyle} /> ); // For default style, show simple input only if (qtyStyle === "default") { const defaultInputStyle: React.CSSProperties = { width: `${width}px`, height: `${height}px`, backgroundColor: bgColor, color: textColor, border: `${borderWidth}px solid ${borderColor}`, borderRadius: `${borderRadius}px`, fontSize: `${fontSize}px`, textAlign: "center", outline: "none", MozAppearance: "textfield", } as React.CSSProperties & { MozAppearance?: string }; return (

Live Preview

0 ? qtyMax : undefined} step={qtyStep} disabled={previewDisabled} aria-label="Preview quantity" style={defaultInputStyle} /> Theme's native quantity input

Default style uses your theme's native quantity input styling.

{emptyRangeWarning}
); } const renderButtons = () => { const ordered = btnPos === "left" ? [minusButton, plusButton, quantityInput] : btnPos === "right" ? [quantityInput, minusButton, plusButton] : [minusButton, quantityInput, plusButton]; return (
{ordered.map((node, index) => ( {node} ))}
); }; return (

Live Preview

{renderButtons()}

This is a preview of your quantity controls. Actual appearance may vary based on your theme.

{emptyRangeWarning}
); } function QuantityLimitsInputs({ settings, setSettings, limits, }: { settings: Settings; setSettings: React.Dispatch>; limits: EffectiveQuantityLimits; }) { const summary = describeEffectiveQuantityLimits(limits); const showSummary = limits.hasRange && limits.step > 1; return (

Step means shoppers can only choose quantities that are multiples of that number. Minimum and Maximum are then narrowed to fit. For example, a minimum of 3, a maximum of 7 and a step of 2 allows 4 and 6 only. Products marked as sold individually always stay at 1.

{limits.hasRange ? ( showSummary ? (

{summary}

) : null ) : (

{summary}

)}
setSettings({ ...settings, quantity_min: parseInt(e.target.value) || 1, }) } className="bwvs:flex-1 bwvs:px-3 bwvs:py-2 bwvs:legacy-border bwvs:border-gray-300 bwvs:rounded-md bwvs:text-sm bwvs:text-center" />

Lowest quantity shoppers can choose

setSettings({ ...settings, quantity_max: parseInt(e.target.value) || 0, }) } className="bwvs:flex-1 bwvs:px-3 bwvs:py-2 bwvs:legacy-border bwvs:border-gray-300 bwvs:rounded-md bwvs:text-sm bwvs:text-center" />

Highest quantity shoppers can choose. 0 = no limit

setSettings({ ...settings, quantity_step: parseInt(e.target.value) || 1, }) } className="bwvs:flex-1 bwvs:px-3 bwvs:py-2 bwvs:legacy-border bwvs:border-gray-300 bwvs:rounded-md bwvs:text-sm bwvs:text-center" />

Quantities must be a multiple of this number

); } function BorderTypographyInputs({ settings, setSettings, fontFamilyOptions, }: { settings: Settings; setSettings: React.Dispatch>; fontFamilyOptions: Array<{ value: string; label: string }>; }) { return (
{ const val = e.target.value; if (val === "") { setSettings({ ...settings, quantity_border_width: null }); } else { const parsed = parseInt(val); if (!isNaN(parsed) && parsed >= 0 && parsed <= 10) { setSettings({ ...settings, quantity_border_width: parsed }); } } }} className={`bwvs:flex-1 bwvs:px-3 bwvs:py-2 bwvs:legacy-border bwvs:rounded-md bwvs:text-sm bwvs:text-center ${settings.quantity_border_width === null || settings.quantity_border_width === undefined ? "bwvs:border-red-500" : "bwvs:border-gray-300"}`} placeholder="1" />
{ const val = e.target.value; if (val === "") { setSettings({ ...settings, quantity_border_radius: null }); } else { const parsed = parseInt(val); if (!isNaN(parsed) && parsed >= 0 && parsed <= 20) { setSettings({ ...settings, quantity_border_radius: parsed }); } } }} className={`bwvs:flex-1 bwvs:px-3 bwvs:py-2 bwvs:legacy-border bwvs:rounded-md bwvs:text-sm bwvs:text-center ${settings.quantity_border_radius === null || settings.quantity_border_radius === undefined ? "bwvs:border-red-500" : "bwvs:border-gray-300"}`} placeholder="2" />
setSettings({ ...settings, quantity_font_size: val }) } /> {fontFamilyOptions.length > 0 ? (
setSettings({ ...settings, quantity_font_family: value }) } options={fontFamilyOptions} className="bwvs:mt-1" />
) : null}
); }