diff --git a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php index 61d80839..b55ad8c7 100644 --- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php +++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Domain\Order; use Exception; +use HiEvents\DomainObjects\CapacityAssignmentDomainObject; use HiEvents\DomainObjects\Enums\TicketType; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; @@ -13,6 +14,7 @@ use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Repository\Interfaces\TicketRepositoryInterface; use HiEvents\Services\Domain\Ticket\AvailableTicketQuantitiesFetchService; +use HiEvents\Services\Domain\Ticket\DTO\AvailableTicketQuantitiesDTO; use HiEvents\Services\Domain\Ticket\DTO\AvailableTicketQuantitiesResponseDTO; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; @@ -186,7 +188,23 @@ private function validateTicketQuantity(int $ticketIndex, array $ticketAndQuanti { $totalQuantity = collect($ticketAndQuantities['quantities'])->sum('quantity'); $maxPerOrder = (int)$ticket->getMaxPerOrder() ?: 100; - $minPerOrder = (int)$ticket->getMinPerOrder() ?: 1; + + $capacityMaximum = $this->availableTicketQuantities + ->ticketQuantities + ->where('ticket_id', $ticket->getId()) + ->map(fn(AvailableTicketQuantitiesDTO $price) => $price->capacities) + ->flatten() + ->min(fn(CapacityAssignmentDomainObject $capacity) => $capacity->getCapacity()); + + $ticketAvailableQuantity = $this->availableTicketQuantities + ->ticketQuantities + ->first(fn(AvailableTicketQuantitiesDTO $price) => $price->ticket_id === $ticket->getId()) + ->quantity_available; + + # if there are fewer tickets available than the configured minimum, we allow less than the minimum to be purchased + $minPerOrder = min((int)$ticket->getMinPerOrder() ?: 1, + $capacityMaximum ?: $maxPerOrder, + $ticketAvailableQuantity ?: $maxPerOrder); $this->validateTicketPricesQuantity( quantities: $ticketAndQuantities['quantities'], diff --git a/frontend/src/components/common/NumberSelector/index.tsx b/frontend/src/components/common/NumberSelector/index.tsx index 02890e26..4c795dbb 100644 --- a/frontend/src/components/common/NumberSelector/index.tsx +++ b/frontend/src/components/common/NumberSelector/index.tsx @@ -11,9 +11,10 @@ interface NumberSelectorProps extends TextInputProps { fieldName: string, min?: number; max?: number; + sharedValues?: SharedValues; } -export const NumberSelector = ({formInstance, fieldName, min, max}: NumberSelectorProps) => { +export const NumberSelector = ({formInstance, fieldName, min, max, sharedValues}: NumberSelectorProps) => { const handlers = useRef(null); // Start with 0, ensuring it's treated as number for consistency const [value, setValue] = useState(0); @@ -21,6 +22,8 @@ export const NumberSelector = ({formInstance, fieldName, min, max}: NumberSelect const minValue = min || 0; const maxValue = max || 100; + const [sharedVals] = useState(sharedValues ?? new SharedValues(maxValue)); + useEffect(() => { formInstance.setFieldValue(fieldName, value); }, [value]); @@ -35,22 +38,37 @@ export const NumberSelector = ({formInstance, fieldName, min, max}: NumberSelect const increment = () => { // Adjust from 0 to minValue on the first increment, if minValue is greater than 0 - if (value === 0 && minValue > 0) { - setValue(minValue); + if (value === 0 && minValue > 1) { + // If incrementing from 0, we have a few scenarios: + // 1. If there is sufficient quantity, increment to the minValue + // 2. If there is insufficient quantity to reach minValue, increment to the remaining quantity + // 3. If another NumberSelector is sharing this NumberSelector's SharedValues, and the amount + // selected on that NumberSelector is less than minValue, increment to an amount where the + // combined count across the NumberSelectors is minValue (or at least 1) + let adjustedMinimum = Math.max(1, minValue - sharedVals.currentValue) + setValue(sharedVals.changeValue(Math.min(adjustedMinimum, maxValue, sharedVals.quantityRemaining))) + } else if (sharedVals.currentValue < minValue) { + setValue(prevValue => prevValue + (sharedVals.changeValue(minValue - sharedVals.currentValue))) } else if (value < maxValue) { - setValue(prevValue => Math.min(maxValue, prevValue + 1)); + setValue(prevValue => prevValue + sharedVals.changeValue(1)); } }; const decrement = () => { - // Ensure decrement does not go below minValue - if (value > minValue) { - setValue(prevValue => Math.max(minValue, prevValue - 1)); + // Ensure decrement does not bring the current shared value between 0 and minValue + if (sharedVals.currentValue > minValue) { + setValue(prevValue => prevValue + sharedVals.changeValue(-1)); } else { + sharedVals.changeValue(-value) setValue(0); } }; + const changeValue = (newValue: number) => { + let adjustedDifference = sharedVals.changeValue(newValue - value); + setValue(value + adjustedDifference); + }; + return (
setValue(value as number)} + onChange={changeValue} classNames={{input: classes.input}} /> = maxValue} + disabled={value >= maxValue || sharedVals.quantityRemaining == 0} onMouseDown={(event) => event.preventDefault()} className={classes.control} > @@ -127,3 +145,26 @@ export const NumberSelectorSelect = ({formInstance, fieldName, min, max, classNa ); } +// Used to aggregate related NumberSelectors together, to allow them to share a common maximum +// and know about the collective values of all the selectors +export class SharedValues { + sharedMax: number; + currentValue: number; + + constructor(sharedMax: number) { + this.sharedMax = sharedMax; + this.currentValue = 0; + } + + get quantityRemaining() { + return this.sharedMax - this.currentValue; + } + + changeValue(difference: number) { + let adjustedDifference = Math.min(difference, this.sharedMax - this.currentValue); + this.currentValue += adjustedDifference; + + return adjustedDifference; + } +} + diff --git a/frontend/src/components/routes/ticket-widget/SelectTickets/Prices/Tiered/index.tsx b/frontend/src/components/routes/ticket-widget/SelectTickets/Prices/Tiered/index.tsx index fbb56b2f..3bb3c390 100644 --- a/frontend/src/components/routes/ticket-widget/SelectTickets/Prices/Tiered/index.tsx +++ b/frontend/src/components/routes/ticket-widget/SelectTickets/Prices/Tiered/index.tsx @@ -1,7 +1,7 @@ import {Currency, TicketPriceDisplay} from "../../../../../common/Currency"; import {Event, Ticket, TicketType} from "../../../../../../types.ts"; import {Group, TextInput} from "@mantine/core"; -import {NumberSelector} from "../../../../../common/NumberSelector"; +import {NumberSelector, SharedValues} from "../../../../../common/NumberSelector"; import {UseFormReturnType} from "@mantine/form"; import {t} from "@lingui/macro"; import {TicketPriceAvailability} from "../../../../../common/TicketPriceAvailability"; @@ -14,6 +14,8 @@ interface TieredPricingProps { } export const TieredPricing = ({ticket, event, form, ticketIndex}: TieredPricingProps) => { + + const sharedValues = new SharedValues(Math.min(ticket.max_per_order ?? 100, ticket.quantity_available ?? 10000)); return ( <> {ticket?.prices?.map((price, index) => { @@ -63,6 +65,7 @@ export const TieredPricing = ({ticket, event, form, ticketIndex}: TieredPricingP max={(Math.min(price.quantity_remaining ?? 50, ticket.max_per_order ?? 50))} fieldName={`tickets.${ticketIndex}.quantities.${index}.quantity`} formInstance={form} + sharedValues={sharedValues} /> {form.errors[`tickets.${ticketIndex}.quantities.${index}.quantity`] && (