/** * Effective quantity bounds used by the admin live preview. * * Mirrors the storefront rule: Step means "quantities must be a multiple of * Step", and the configured Minimum / Maximum are rounded inward onto that * multiple grid. Example: min 3, max 7, step 2 allows 4 and 6 only. * A Maximum of 0 means "no limit". */ export interface QuantityLimitSettings { quantity_custom_limits?: unknown; quantity_min?: unknown; quantity_max?: unknown; quantity_step?: unknown; } export interface EffectiveQuantityLimits { /** Whether custom limits are turned on (raw values are ignored otherwise). */ custom: boolean; /** Configured values after basic coercion (1 / 0 / 1 when disabled). */ configuredMin: number; configuredMax: number; step: number; /** Smallest permissible quantity, aligned to the step multiple. */ min: number; /** Largest permissible quantity, aligned to the step multiple. 0 = unbounded. */ max: number; /** False when no quantity satisfies min, max and step together. */ hasRange: boolean; } function toPositiveInt(value: unknown, fallback: number, floor: number) { const n = Math.trunc(Number(value)); if (!Number.isFinite(n) || n < floor) return fallback; return n; } export function getEffectiveQuantityLimits( settings: QuantityLimitSettings, ): EffectiveQuantityLimits { const custom = Boolean(settings.quantity_custom_limits); if (!custom) { return { custom: false, configuredMin: 1, configuredMax: 0, step: 1, min: 1, max: 0, hasRange: true, }; } const configuredMin = toPositiveInt(settings.quantity_min, 1, 1); const configuredMax = toPositiveInt(settings.quantity_max, 0, 0); const step = toPositiveInt(settings.quantity_step, 1, 1); const min = Math.ceil(configuredMin / step) * step; // Only a configured max of 0 means "no limit". A positive configured max // that rounds down to 0 (e.g. max 1 with step 2) is a real cap with no // permissible quantity, not an unbounded range. const hasUpperBound = configuredMax > 0; const max = hasUpperBound ? Math.floor(configuredMax / step) * step : 0; const hasRange = !hasUpperBound || max >= min; return { custom: true, configuredMin, configuredMax, step, min, max, hasRange, }; } /** * Snap an arbitrary quantity onto the permissible grid. Values are rounded to * the nearest multiple of step, then clamped into [min, max]. Returns `min` * when the range is empty so callers always get a deterministic number. */ export function alignPreviewQuantity( value: number, limits: EffectiveQuantityLimits, ): number { const { min, max, step } = limits; if (!limits.hasRange) return min; const numeric = Number.isFinite(value) ? value : min; let aligned = Math.round(numeric / step) * step; if (aligned < min) aligned = min; if (max > 0 && aligned > max) aligned = max; return aligned; } export function stepPreviewQuantity( value: number, limits: EffectiveQuantityLimits, direction: 1 | -1, ): number { if (!limits.hasRange) return value; const current = alignPreviewQuantity(value, limits); const next = current + direction * limits.step; if (next < limits.min) return current; if (limits.max > 0 && next > limits.max) return current; return next; } /** Human-readable summary for the settings UI. */ export function describeEffectiveQuantityLimits( limits: EffectiveQuantityLimits, ): string { if (!limits.custom) return ""; if (!limits.hasRange) { return `No quantity between ${limits.configuredMin} and ${limits.configuredMax} is a multiple of ${limits.step}. Shoppers will not be able to add this product to the cart until the limits are adjusted.`; } const upper = limits.max > 0 ? String(limits.max) : "no limit"; if (limits.step === 1) { return `Allowed quantities: ${limits.min} to ${upper}.`; } if (limits.max > 0 && limits.min === limits.max) { return `Allowed quantity: ${limits.min} only.`; } return `Allowed quantities: ${limits.min} to ${upper}, in multiples of ${limits.step}.`; }