nitro-v36-with-renderer-v36
/
src /
components /
catalog /
views /
page /
widgets /
CatalogSpinnerWidgetView.tsx
CatalogSpinnerWidgetView.tsx 9.4 Ko
Télécharger le fichier
import { FC, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { CatalogType, GetConfigurationValue, LocalizeText } from '../../../../../api';
import { getCatalogBundleDiscountFlatPriceSteps, getCatalogBundleDiscountItemCount } from '../../../../../api/catalog/CatalogBundleDiscount';
import { useCatalogBundleDiscountRuleset, useCatalogData, useCatalogUiState } from '../../../../../hooks';
import { BaseButton } from '../../../../../common/BaseButton';
import { NativeTextTree } from '../../../../../common/native-text/NativeTextTree';
const MIN_VALUE: number = 1;
const MAX_VALUE: number = 100;
const HOLD_STEP_DELAY_MS: number = 75;
const HOLD_ACCELERATION_DISTANCE: number = 35;
type QuantityStepDirection = -1 | 1;
export const clampCatalogPurchaseQuantity = (value: number, maxValue: number = MAX_VALUE): number => {
if (isNaN(value)) return MIN_VALUE;
return Math.min(Math.max(Math.trunc(value), MIN_VALUE), Math.max(MIN_VALUE, Math.trunc(maxValue)));
};
export const stepCatalogPurchaseQuantity = (
value: number,
direction: QuantityStepDirection,
maxValue: number = MAX_VALUE,
skippedQuantities: ReadonlySet<number> = new Set<number>()
): number => {
let nextValue = value + direction;
while (skippedQuantities.has(nextValue)) nextValue += direction;
return clampCatalogPurchaseQuantity(nextValue, maxValue);
};
export const CatalogSpinnerWidgetView: FC<{}> = () => {
const { currentOffer = null } = useCatalogData();
const { currentType = CatalogType.NORMAL, purchaseOptions = null, setPurchaseOptions = null } = useCatalogUiState();
const { data: bundleDiscountRuleset = null } = useCatalogBundleDiscountRuleset();
const quantityInputId = useId();
const quantity = purchaseOptions?.quantity ?? MIN_VALUE;
const maxPurchaseSize = Math.max(MIN_VALUE, Math.trunc(bundleDiscountRuleset?.maxPurchaseSize ?? MAX_VALUE));
const freeItemCount = currentOffer?.bundlePurchaseAllowed ? getCatalogBundleDiscountItemCount(quantity, bundleDiscountRuleset) : 0;
const skippedQuantities = useMemo(
() => new Set<number>(currentOffer?.bundlePurchaseAllowed ? getCatalogBundleDiscountFlatPriceSteps(bundleDiscountRuleset) : []),
[bundleDiscountRuleset, currentOffer?.bundlePurchaseAllowed]
);
const [quantityDraft, setQuantityDraft] = useState(() => quantity.toString());
const quantityRef = useRef(quantity);
const holdStartQuantityRef = useRef(quantity);
const holdDidRepeatRef = useRef(false);
const holdTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopStepping = useCallback(() => {
if (holdTimerRef.current === null) return;
clearInterval(holdTimerRef.current);
holdTimerRef.current = null;
}, []);
useEffect(() => {
stopStepping();
setQuantityDraft(MIN_VALUE.toString());
}, [currentOffer, stopStepping]);
useEffect(() => {
quantityRef.current = quantity;
}, [quantity]);
useEffect(() => () => stopStepping(), [stopStepping]);
useEffect(() => {
stopStepping();
}, [maxPurchaseSize, skippedQuantities, stopStepping]);
useEffect(() => {
if (quantity <= maxPurchaseSize) return;
quantityRef.current = maxPurchaseSize;
setQuantityDraft(maxPurchaseSize.toString());
setPurchaseOptions((prevValue) => ({ ...prevValue, quantity: maxPurchaseSize }));
}, [maxPurchaseSize, quantity, setPurchaseOptions]);
const updateQuantity = useCallback(
(value: number, syncDraft: boolean = true): boolean => {
value = clampCatalogPurchaseQuantity(value, maxPurchaseSize);
if (value === quantityRef.current) return false;
quantityRef.current = value;
if (syncDraft) setQuantityDraft(value.toString());
setPurchaseOptions((prevValue) => {
const newValue = { ...prevValue };
newValue.quantity = value;
return newValue;
});
return true;
},
[maxPurchaseSize, setPurchaseOptions]
);
const stepQuantity = useCallback(
(direction: QuantityStepDirection): boolean =>
updateQuantity(stepCatalogPurchaseQuantity(quantityRef.current, direction, maxPurchaseSize, skippedQuantities)),
[maxPurchaseSize, skippedQuantities, updateQuantity]
);
const startStepping = useCallback(
(direction: QuantityStepDirection) => {
stopStepping();
holdStartQuantityRef.current = quantityRef.current;
holdDidRepeatRef.current = false;
holdTimerRef.current = setInterval(() => {
holdDidRepeatRef.current = true;
if (!stepQuantity(direction)) {
stopStepping();
return;
}
if (Math.abs(quantityRef.current - holdStartQuantityRef.current) > HOLD_ACCELERATION_DISTANCE) {
stepQuantity(direction);
}
}, HOLD_STEP_DELAY_MS);
},
[stepQuantity, stopStepping]
);
const finishStepClick = (direction: QuantityStepDirection, mouseClickCount: number) => {
if (mouseClickCount === 0 || !holdDidRepeatRef.current) stepQuantity(direction);
holdDidRepeatRef.current = false;
};
const updateQuantityDraft = (value: string) => {
if (value.length && !/^\d+$/.test(value)) return;
if (!value.length) {
setQuantityDraft('');
updateQuantity(Number.NaN, false);
return;
}
const nextQuantity = clampCatalogPurchaseQuantity(Number(value), maxPurchaseSize);
setQuantityDraft(nextQuantity.toString());
updateQuantity(nextQuantity, false);
};
if (
!currentOffer?.bundlePurchaseAllowed ||
currentType === CatalogType.BUILDER ||
!GetConfigurationValue<boolean>('catalog.multiple.purchase.enabled', true)
)
return null;
return (
<NativeTextTree>
<div className="octane-catalog-standard-spinner">
<label className="octane-catalog-standard-spinner-label" htmlFor={quantityInputId}>
{LocalizeText('catalog.bundlewidget.quantity')}
</label>
{freeItemCount > 0 && (
<div className="octane-catalog-standard-spinner-discount">
<span className="octane-catalog-standard-spinner-discount-copy">
{LocalizeText('shop.bonus.items.count', ['amount'], [freeItemCount.toString()])}
</span>
<span aria-hidden="true" className="octane-catalog-standard-spinner-discount-star" />
</div>
)}
<BaseButton
type="button"
className="octane-catalog-standard-spinner-button octane-catalog-standard-spinner-button-less"
aria-controls={quantityInputId}
aria-label={`${LocalizeText('catalog.bundlewidget.quantity')} −`}
disabled={quantity <= MIN_VALUE}
onBlur={stopStepping}
onClick={(event) => finishStepClick(-1, event.detail)}
onLostPointerCapture={stopStepping}
onPointerCancel={stopStepping}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
startStepping(-1);
}}
onPointerUp={stopStepping}
/>
<div className="octane-catalog-standard-spinner-input-frame">
<input
id={quantityInputId}
className="octane-catalog-standard-spinner-value"
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={maxPurchaseSize.toString().length}
value={quantityDraft}
onChange={(event) => updateQuantityDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
stepQuantity(event.key === 'ArrowDown' ? -1 : 1);
}}
/>
</div>
<BaseButton
type="button"
className="octane-catalog-standard-spinner-button octane-catalog-standard-spinner-button-more"
aria-controls={quantityInputId}
aria-label={`${LocalizeText('catalog.bundlewidget.quantity')} +`}
disabled={quantity >= maxPurchaseSize}
onBlur={stopStepping}
onClick={(event) => finishStepClick(1, event.detail)}
onLostPointerCapture={stopStepping}
onPointerCancel={stopStepping}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
startStepping(1);
}}
onPointerUp={stopStepping}
/>
</div>
</NativeTextTree>
);
};
