diff --git a/backend/app/DomainObjects/Generated/AccountDeletionRequestDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountDeletionRequestDomainObjectAbstract.php index 0d2a4f9af1..3474d3d9fd 100644 --- a/backend/app/DomainObjects/Generated/AccountDeletionRequestDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/AccountDeletionRequestDomainObjectAbstract.php @@ -13,7 +13,6 @@ abstract class AccountDeletionRequestDomainObjectAbstract extends \HiEvents\Doma final public const ID = 'id'; final public const ACCOUNT_ID = 'account_id'; final public const REQUESTED_BY_USER_ID = 'requested_by_user_id'; - final public const CANCELLED_BY_USER_ID = 'cancelled_by_user_id'; final public const INITIATED_BY = 'initiated_by'; final public const REASON = 'reason'; final public const STATUS = 'status'; @@ -22,6 +21,7 @@ abstract class AccountDeletionRequestDomainObjectAbstract extends \HiEvents\Doma final public const SCHEDULED_DELETION_AT = 'scheduled_deletion_at'; final public const REMINDER_SENT_AT = 'reminder_sent_at'; final public const CANCELLED_AT = 'cancelled_at'; + final public const CANCELLED_BY_USER_ID = 'cancelled_by_user_id'; final public const COMPLETED_AT = 'completed_at'; final public const DELETION_MANIFEST = 'deletion_manifest'; final public const CREATED_AT = 'created_at'; @@ -30,7 +30,6 @@ abstract class AccountDeletionRequestDomainObjectAbstract extends \HiEvents\Doma protected int $id; protected int $account_id; protected int $requested_by_user_id; - protected ?int $cancelled_by_user_id = null; protected string $initiated_by; protected ?string $reason = null; protected string $status = 'REQUESTED'; @@ -39,6 +38,7 @@ abstract class AccountDeletionRequestDomainObjectAbstract extends \HiEvents\Doma protected string $scheduled_deletion_at; protected ?string $reminder_sent_at = null; protected ?string $cancelled_at = null; + protected ?int $cancelled_by_user_id = null; protected ?string $completed_at = null; protected array|string|null $deletion_manifest = null; protected ?string $created_at = null; @@ -50,7 +50,6 @@ public function toArray(): array 'id' => $this->id ?? null, 'account_id' => $this->account_id ?? null, 'requested_by_user_id' => $this->requested_by_user_id ?? null, - 'cancelled_by_user_id' => $this->cancelled_by_user_id ?? null, 'initiated_by' => $this->initiated_by ?? null, 'reason' => $this->reason ?? null, 'status' => $this->status ?? null, @@ -59,6 +58,7 @@ public function toArray(): array 'scheduled_deletion_at' => $this->scheduled_deletion_at ?? null, 'reminder_sent_at' => $this->reminder_sent_at ?? null, 'cancelled_at' => $this->cancelled_at ?? null, + 'cancelled_by_user_id' => $this->cancelled_by_user_id ?? null, 'completed_at' => $this->completed_at ?? null, 'deletion_manifest' => $this->deletion_manifest ?? null, 'created_at' => $this->created_at ?? null, @@ -99,17 +99,6 @@ public function getRequestedByUserId(): int return $this->requested_by_user_id; } - public function setCancelledByUserId(?int $cancelled_by_user_id): self - { - $this->cancelled_by_user_id = $cancelled_by_user_id; - return $this; - } - - public function getCancelledByUserId(): ?int - { - return $this->cancelled_by_user_id; - } - public function setInitiatedBy(string $initiated_by): self { $this->initiated_by = $initiated_by; @@ -198,6 +187,17 @@ public function getCancelledAt(): ?string return $this->cancelled_at; } + public function setCancelledByUserId(?int $cancelled_by_user_id): self + { + $this->cancelled_by_user_id = $cancelled_by_user_id; + return $this; + } + + public function getCancelledByUserId(): ?int + { + return $this->cancelled_by_user_id; + } + public function setCompletedAt(?string $completed_at): self { $this->completed_at = $completed_at; diff --git a/backend/app/DomainObjects/Generated/ProductAddonDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductAddonDomainObjectAbstract.php new file mode 100644 index 0000000000..33d2a194ce --- /dev/null +++ b/backend/app/DomainObjects/Generated/ProductAddonDomainObjectAbstract.php @@ -0,0 +1,104 @@ + $this->id ?? null, + 'product_id' => $this->product_id ?? null, + 'addon_product_id' => $this->addon_product_id ?? null, + 'order' => $this->order ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setProductId(int $product_id): self + { + $this->product_id = $product_id; + return $this; + } + + public function getProductId(): int + { + return $this->product_id; + } + + public function setAddonProductId(int $addon_product_id): self + { + $this->addon_product_id = $addon_product_id; + return $this; + } + + public function getAddonProductId(): int + { + return $this->addon_product_id; + } + + public function setOrder(int $order): self + { + $this->order = $order; + return $this; + } + + public function getOrder(): int + { + return $this->order; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php index 482362a6f6..9788aa66d0 100644 --- a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php @@ -37,6 +37,7 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const IS_HIGHLIGHTED = 'is_highlighted'; final public const HIGHLIGHT_MESSAGE = 'highlight_message'; final public const WAITLIST_ENABLED = 'waitlist_enabled'; + final public const IS_ADDON_ONLY = 'is_addon_only'; protected int $id; protected int $event_id; @@ -65,6 +66,7 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected bool $is_highlighted = false; protected ?string $highlight_message = null; protected ?bool $waitlist_enabled = null; + protected bool $is_addon_only = false; public function toArray(): array { @@ -96,6 +98,7 @@ public function toArray(): array 'is_highlighted' => $this->is_highlighted ?? null, 'highlight_message' => $this->highlight_message ?? null, 'waitlist_enabled' => $this->waitlist_enabled ?? null, + 'is_addon_only' => $this->is_addon_only ?? null, ]; } @@ -395,4 +398,15 @@ public function getWaitlistEnabled(): ?bool { return $this->waitlist_enabled; } + + public function setIsAddonOnly(bool $is_addon_only): self + { + $this->is_addon_only = $is_addon_only; + return $this; + } + + public function getIsAddonOnly(): bool + { + return $this->is_addon_only; + } } diff --git a/backend/app/DomainObjects/ProductAddonDomainObject.php b/backend/app/DomainObjects/ProductAddonDomainObject.php new file mode 100644 index 0000000000..43dd5c9ed0 --- /dev/null +++ b/backend/app/DomainObjects/ProductAddonDomainObject.php @@ -0,0 +1,7 @@ +getTaxAndFees()?->filter(fn (TaxAndFeesDomainObject $taxAndFee) => $taxAndFee->isFee()); } + public function setAddons(Collection $addons): ProductDomainObject + { + $this->addons = $addons; + + return $this; + } + + public function getAddons(): ?Collection + { + return $this->addons; + } + + public function getAddonProductIds(): ?array + { + return $this->addons?->map(fn (ProductDomainObject $addon) => $addon->getId())->all(); + } + public function isSoldOut(): bool { if (! $this->getProductPrices() || $this->getProductPrices()->isEmpty()) { diff --git a/backend/app/Http/Actions/Products/CreateProductAction.php b/backend/app/Http/Actions/Products/CreateProductAction.php index 905474b9d0..7e584205cf 100644 --- a/backend/app/Http/Actions/Products/CreateProductAction.php +++ b/backend/app/Http/Actions/Products/CreateProductAction.php @@ -12,6 +12,8 @@ use HiEvents\Resources\Product\ProductResource; use HiEvents\Services\Application\Handlers\Product\CreateProductHandler; use HiEvents\Services\Application\Handlers\Product\DTO\UpsertProductDTO; +use HiEvents\Services\Domain\Product\Exception\InvalidAddonProductException; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Throwable; @@ -43,6 +45,10 @@ public function __invoke(int $eventId, UpsertProductRequest $request): JsonRespo throw ValidationException::withMessages([ 'tax_and_fee_ids' => $e->getMessage(), ]); + } catch (InvalidAddonProductException|UnrecognizedProductIdException $e) { + throw ValidationException::withMessages([ + 'addon_product_ids' => $e->getMessage(), + ]); } return $this->resourceResponse( diff --git a/backend/app/Http/Actions/Products/EditProductAction.php b/backend/app/Http/Actions/Products/EditProductAction.php index b200bc2425..44cf39e416 100644 --- a/backend/app/Http/Actions/Products/EditProductAction.php +++ b/backend/app/Http/Actions/Products/EditProductAction.php @@ -12,6 +12,8 @@ use HiEvents\Resources\Product\ProductResource; use HiEvents\Services\Application\Handlers\Product\DTO\UpsertProductDTO; use HiEvents\Services\Application\Handlers\Product\EditProductHandler; +use HiEvents\Services\Domain\Product\Exception\InvalidAddonProductException; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Http\JsonResponse; use Illuminate\Validation\ValidationException; use Throwable; @@ -46,6 +48,10 @@ public function __invoke(UpsertProductRequest $request, int $eventId, int $produ throw ValidationException::withMessages([ 'type' => $e->getMessage(), ]); + } catch (InvalidAddonProductException|UnrecognizedProductIdException $e) { + throw ValidationException::withMessages([ + 'addon_product_ids' => $e->getMessage(), + ]); } return $this->resourceResponse(ProductResource::class, $product); diff --git a/backend/app/Http/Actions/Products/GetProductAction.php b/backend/app/Http/Actions/Products/GetProductAction.php index e7066d6541..d58bc0a312 100644 --- a/backend/app/Http/Actions/Products/GetProductAction.php +++ b/backend/app/Http/Actions/Products/GetProductAction.php @@ -6,8 +6,10 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\TaxAndFeesDomainObject; +use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Resources\Product\ProductResource; @@ -30,6 +32,7 @@ public function __invoke(int $eventId, int $productId): JsonResponse|Response $product = $this->productRepository ->loadRelation(TaxAndFeesDomainObject::class) ->loadRelation(ProductPriceDomainObject::class) + ->loadRelation(new Relationship(domainObject: ProductDomainObject::class, name: 'addons')) ->findFirstWhere([ ProductDomainObjectAbstract::EVENT_ID => $eventId, ProductDomainObjectAbstract::ID => $productId, diff --git a/backend/app/Http/Request/Product/UpsertProductRequest.php b/backend/app/Http/Request/Product/UpsertProductRequest.php index 1543a726db..e9b18e7d89 100644 --- a/backend/app/Http/Request/Product/UpsertProductRequest.php +++ b/backend/app/Http/Request/Product/UpsertProductRequest.php @@ -40,6 +40,9 @@ public function rules(): array 'type' => ['required', Rule::in(ProductPriceType::valuesArray())], 'product_type' => ['required', Rule::in(ProductType::valuesArray())], 'tax_and_fee_ids' => 'array', + 'addon_product_ids' => 'array', + 'addon_product_ids.*' => 'integer', + 'is_addon_only' => 'boolean', 'product_category_id' => ['required', 'integer'], 'is_highlighted' => 'boolean', 'highlight_message' => 'string|nullable|max:255', diff --git a/backend/app/Models/Product.php b/backend/app/Models/Product.php index e1645fd47f..d157b89ca9 100644 --- a/backend/app/Models/Product.php +++ b/backend/app/Models/Product.php @@ -37,6 +37,13 @@ public function tax_and_fees(): BelongsToMany return $this->belongsToMany(TaxAndFee::class, 'product_taxes_and_fees'); } + public function addons(): BelongsToMany + { + return $this->belongsToMany(self::class, 'product_addons', 'product_id', 'addon_product_id') + ->withPivot('order') + ->orderByPivot('order'); + } + public function capacity_assignments(): BelongsToMany { return $this->belongsToMany(CapacityAssignment::class, 'product_capacity_assignments'); diff --git a/backend/app/Repository/Eloquent/ProductRepository.php b/backend/app/Repository/Eloquent/ProductRepository.php index 53a2726249..3439ee5a9c 100644 --- a/backend/app/Repository/Eloquent/ProductRepository.php +++ b/backend/app/Repository/Eloquent/ProductRepository.php @@ -140,6 +140,38 @@ public function addTaxesAndFeesToProduct(int $productId, array $taxIds): void Product::findOrFail($productId)?->tax_and_fees()->sync($taxIds); } + public function syncAddons(int $productId, array $addonProductIds): void + { + $syncData = []; + foreach (array_values($addonProductIds) as $position => $addonProductId) { + $syncData[$addonProductId] = ['order' => $position]; + } + + Product::findOrFail($productId)->addons()->sync($syncData); + } + + public function detachAddonAssociations(int $productId): void + { + $this->runQuery( + fn () => $this->db->table('product_addons') + ->where('product_id', $productId) + ->orWhere('addon_product_id', $productId) + ->delete() + ); + } + + public function findParentProductIds(array $addonProductIds): Collection + { + return $this->runQuery( + fn () => collect( + $this->db->table('product_addons') + ->whereIn('addon_product_id', $addonProductIds) + ->get(['product_id', 'addon_product_id']) + )->groupBy('addon_product_id') + ->map(fn ($rows) => $rows->pluck('product_id')->all()) + ); + } + public function addCapacityAssignmentToProducts(int $capacityAssignmentId, array $productIds): void { $productIds = array_unique($productIds); diff --git a/backend/app/Repository/Interfaces/ProductRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductRepositoryInterface.php index 97bc1be000..1b008ba433 100644 --- a/backend/app/Repository/Interfaces/ProductRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/ProductRepositoryInterface.php @@ -26,6 +26,15 @@ public function getCapacityAssignmentsByProductId(int $productId): Collection; public function addTaxesAndFeesToProduct(int $productId, array $taxIds): void; + public function syncAddons(int $productId, array $addonProductIds): void; + + public function detachAddonAssociations(int $productId): void; + + /** + * @return Collection> map of addon_product_id => parent product ids + */ + public function findParentProductIds(array $addonProductIds): Collection; + public function addCapacityAssignmentToProducts(int $capacityAssignmentId, array $productIds): void; public function addCheckInListToProducts(int $checkInListId, array $productIds): void; diff --git a/backend/app/Resources/Product/ProductResource.php b/backend/app/Resources/Product/ProductResource.php index bc265f3ba6..88b944fe33 100644 --- a/backend/app/Resources/Product/ProductResource.php +++ b/backend/app/Resources/Product/ProductResource.php @@ -64,6 +64,18 @@ public function toArray(Request $request): array 'is_highlighted' => $this->getIsHighlighted(), 'highlight_message' => $this->getHighlightMessage(), 'waitlist_enabled' => $this->getWaitlistEnabled(), + 'is_addon_only' => $this->getIsAddonOnly(), + 'addon_product_ids' => $this->when( + $this->getAddons() !== null, + fn () => $this->getAddonProductIds(), + ), + 'addons' => $this->when( + $this->getAddons() !== null, + fn () => $this->getAddons()->map(fn (ProductDomainObject $addon) => [ + 'id' => $addon->getId(), + 'title' => $addon->getTitle(), + ]), + ), ]; } } diff --git a/backend/app/Resources/Product/ProductResourcePublic.php b/backend/app/Resources/Product/ProductResourcePublic.php index 8ad91123a3..00790447ce 100644 --- a/backend/app/Resources/Product/ProductResourcePublic.php +++ b/backend/app/Resources/Product/ProductResourcePublic.php @@ -54,6 +54,11 @@ public function toArray(Request $request): array 'is_highlighted' => $this->getIsHighlighted(), 'highlight_message' => $this->getHighlightMessage(), 'waitlist_enabled' => $this->getWaitlistEnabled(), + 'is_addon_only' => $this->getIsAddonOnly(), + 'addon_product_ids' => $this->when( + $this->getAddons() !== null, + fn () => $this->getAddonProductIds(), + ), ]; } } diff --git a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php index ed121a4af0..e7bd029543 100644 --- a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php @@ -52,6 +52,7 @@ public function handle(GetPublicEventDTO $data): EventDomainObject nested: [ new Relationship(ProductPriceDomainObject::class), new Relationship(TaxAndFeesDomainObject::class), + new Relationship(domainObject: ProductDomainObject::class, name: 'addons'), ], orderAndDirections: [ new OrderAndDirection('order', 'asc'), diff --git a/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php b/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php index 1c5fb7b895..f854610a2a 100644 --- a/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php +++ b/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php @@ -60,12 +60,14 @@ public function handle(UpsertProductDTO $productsData): ProductDomainObject ->setIsHighlighted($productsData->is_highlighted ?? false) ->setHighlightMessage($productsData->highlight_message) ->setWaitlistEnabled($productsData->waitlist_enabled) + ->setIsAddonOnly($productsData->is_addon_only ?? false) ->setProductPrices($productPrices) ->setEventId($productsData->event_id) ->setProductType($productsData->product_type->name) ->setProductCategoryId($category->getId()), accountId: $productsData->account_id, taxAndFeeIds: $productsData->tax_and_fee_ids, + addonProductIds: $productsData->is_addon_only ? [] : $productsData->addon_product_ids, ); } } diff --git a/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php b/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php index 17600dc30d..bfbf71ccb5 100644 --- a/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php +++ b/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php @@ -37,6 +37,8 @@ public function __construct( public readonly ?bool $show_quantity_remaining = false, public readonly ?bool $is_hidden_without_promo_code = false, public readonly ?array $tax_and_fee_ids = [], + public readonly ?array $addon_product_ids = [], + public readonly ?bool $is_addon_only = false, public readonly ?int $product_id = null, public readonly ?bool $is_highlighted = false, public readonly ?string $highlight_message = null, diff --git a/backend/app/Services/Application/Handlers/Product/EditProductHandler.php b/backend/app/Services/Application/Handlers/Product/EditProductHandler.php index ba20fbf0ae..da94b77806 100644 --- a/backend/app/Services/Application/Handlers/Product/EditProductHandler.php +++ b/backend/app/Services/Application/Handlers/Product/EditProductHandler.php @@ -12,9 +12,11 @@ use HiEvents\Events\CapacityChangedEvent; use HiEvents\Exceptions\CannotChangeProductTypeException; use HiEvents\Helper\DateHelper; +use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Services\Application\Handlers\Product\DTO\UpsertProductDTO; +use HiEvents\Services\Domain\Product\ProductAddonAssociationService; use HiEvents\Services\Domain\Product\ProductPriceUpdateService; use HiEvents\Services\Domain\ProductCategory\GetProductCategoryService; use HiEvents\Services\Domain\Tax\DTO\TaxAndProductAssociateParams; @@ -37,6 +39,7 @@ public function __construct( private readonly TaxAndProductAssociationService $taxAndProductAssociationService, private readonly DatabaseManager $databaseManager, private readonly ProductPriceUpdateService $priceUpdateService, + private readonly ProductAddonAssociationService $productAddonAssociationService, private readonly HtmlPurifierService $purifier, private readonly EventRepositoryInterface $eventRepository, private readonly GetProductCategoryService $getProductCategoryService, @@ -60,6 +63,12 @@ public function handle(UpsertProductDTO $productsData): DomainObjectInterface $this->addTaxes($product, $productsData); + $this->productAddonAssociationService->associateAddons( + productId: $product->getId(), + eventId: $productsData->event_id, + addonProductIds: $productsData->is_addon_only ? [] : ($productsData->addon_product_ids ?? []), + ); + $this->priceUpdateService->updatePrices( $product, $productsData, @@ -81,6 +90,7 @@ public function handle(UpsertProductDTO $productsData): DomainObjectInterface return $this->productRepository ->loadRelation(ProductPriceDomainObject::class) + ->loadRelation(new Relationship(domainObject: ProductDomainObject::class, name: 'addons')) ->findById($product->getId()); }); } @@ -124,6 +134,7 @@ private function updateProduct(UpsertProductDTO $productsData, array $where): Pr 'is_highlighted' => $productsData->is_highlighted ?? false, 'highlight_message' => $productsData->highlight_message, 'waitlist_enabled' => $productsData->waitlist_enabled, + 'is_addon_only' => $productsData->is_addon_only ?? false, ], where: $where ); diff --git a/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php b/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php index 5d54441540..003bf0a711 100644 --- a/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php +++ b/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php @@ -2,8 +2,10 @@ namespace HiEvents\Services\Application\Handlers\Product; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\TaxAndFeesDomainObject; +use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Services\Domain\Product\ProductFilterService; @@ -21,6 +23,7 @@ public function handle(int $eventId, QueryParamsDTO $queryParamsDTO): LengthAwar $productPaginator = $this->productRepository ->loadRelation(ProductPriceDomainObject::class) ->loadRelation(TaxAndFeesDomainObject::class) + ->loadRelation(new Relationship(domainObject: ProductDomainObject::class, name: 'addons')) ->findByEventId($eventId, $queryParamsDTO); $filteredProducts = $this->productFilterService->filterProducts( diff --git a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php index 8044dcbcf4..eaf9e1984f 100644 --- a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php +++ b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php @@ -28,6 +28,7 @@ public function handle(int $eventId): Collection nested: [ new Relationship(ProductPriceDomainObject::class), new Relationship(TaxAndFeesDomainObject::class), + new Relationship(domainObject: ProductDomainObject::class, name: 'addons'), ], orderAndDirections: [ new OrderAndDirection( diff --git a/backend/app/Services/Domain/Event/DuplicateEventService.php b/backend/app/Services/Domain/Event/DuplicateEventService.php index c37ccf3118..d845cd7d81 100644 --- a/backend/app/Services/Domain/Event/DuplicateEventService.php +++ b/backend/app/Services/Domain/Event/DuplicateEventService.php @@ -34,6 +34,7 @@ use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\ImageRepositoryInterface; use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceOccurrenceOverrideRepositoryInterface; use HiEvents\Services\Domain\CapacityAssignment\CreateCapacityAssignmentService; use HiEvents\Services\Domain\CheckInList\CreateCheckInListService; @@ -66,6 +67,7 @@ public function __construct( private readonly ProductPriceOccurrenceOverrideRepositoryInterface $priceOverrideRepository, private readonly ProductOccurrenceVisibilityRepositoryInterface $visibilityRepository, private readonly EventLocationRepositoryInterface $eventLocationRepository, + private readonly ProductRepositoryInterface $productRepository, ) {} /** @@ -277,6 +279,8 @@ private function cloneExistingProducts( } }); + $this->cloneProductAddons($event, $oldProductToNewProductMap); + if ($duplicateQuestions) { $this->clonePerProductQuestions($event, $newEventId, $oldProductToNewProductMap); } @@ -296,6 +300,29 @@ private function cloneExistingProducts( return [$oldProductToNewProductMap, $oldPriceToNewPriceMap]; } + private function cloneProductAddons(EventDomainObject $event, array $oldProductToNewProductMap): void + { + $event->getProductCategories()?->each(function (ProductCategoryDomainObject $productCategory) use ($oldProductToNewProductMap) { + /** @var ProductDomainObject $product */ + foreach ($productCategory->getProducts() as $product) { + $newProductId = $oldProductToNewProductMap[$product->getId()] ?? null; + if ($newProductId === null) { + continue; + } + + $newAddonIds = collect($product->getAddons() ?? []) + ->map(fn (ProductDomainObject $addon) => $oldProductToNewProductMap[$addon->getId()] ?? null) + ->filter() + ->values() + ->all(); + + if ($newAddonIds !== []) { + $this->productRepository->syncAddons($newProductId, $newAddonIds); + } + } + }); + } + /** * @throws Throwable */ @@ -466,6 +493,7 @@ private function getEventWithRelations(string $eventId, string $accountId): Even new Relationship(ProductDomainObject::class, [ new Relationship(ProductPriceDomainObject::class), new Relationship(TaxAndFeesDomainObject::class), + new Relationship(domainObject: ProductDomainObject::class, name: 'addons'), ]), ]) ) diff --git a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php index 98936ee67c..9aae2937fc 100644 --- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php +++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php @@ -55,6 +55,7 @@ public function validateRequestData(int $eventId, array $data = []): array $this->validateTypes($data); $promoCode = $this->validatePromoCode($eventId, $data); $this->validateProductSelection($data); + $this->validateAddonProducts($data); $this->validateOccurrence($eventId, $data); $this->availableProductQuantities = $this->fetchAvailableProductQuantitiesService @@ -262,6 +263,46 @@ private function validateProductSelection(array $data): void } } + /** + * @throws ValidationException + */ + private function validateAddonProducts(array $data): void + { + $productLines = collect($data['products']); + + $requestedQuantities = $productLines + ->groupBy(fn ($line) => (int) $line['product_id']) + ->map(fn ($lines) => $lines->sum(fn ($line) => collect($line['quantities'])->sum('quantity'))); + + $selectedProductIds = $requestedQuantities->filter(fn ($quantity) => $quantity > 0)->keys(); + + $selectedAddonOnlyProducts = $this->getProducts($data) + ->filter(fn (ProductDomainObject $product) => $product->getIsAddonOnly() + && $selectedProductIds->contains($product->getId())); + + if ($selectedAddonOnlyProducts->isEmpty()) { + return; + } + + $parentIdsByAddon = $this->productRepository->findParentProductIds( + $selectedAddonOnlyProducts->map(fn (ProductDomainObject $product) => $product->getId())->values()->all(), + ); + + foreach ($selectedAddonOnlyProducts as $addon) { + $hasSelectedParent = collect($parentIdsByAddon->get($addon->getId(), [])) + ->contains(fn ($parentId) => $selectedProductIds->contains($parentId)); + + if (! $hasSelectedParent) { + $productIndex = $productLines->search(fn ($line) => (int) $line['product_id'] === $addon->getId()); + throw ValidationException::withMessages([ + 'products.'.(is_int($productIndex) ? $productIndex : 0) => __(':product is an add-on and can only be purchased with the product it belongs to', [ + 'product' => $addon->getTitle(), + ]), + ]); + } + } + } + /** * @throws ValidationException */ diff --git a/backend/app/Services/Domain/Product/CreateProductService.php b/backend/app/Services/Domain/Product/CreateProductService.php index 0d91eeada9..3a949c406a 100644 --- a/backend/app/Services/Domain/Product/CreateProductService.php +++ b/backend/app/Services/Domain/Product/CreateProductService.php @@ -23,6 +23,7 @@ public function __construct( private readonly ProductRepositoryInterface $productRepository, private readonly DatabaseManager $databaseManager, private readonly TaxAndProductAssociationService $taxAndProductAssociationService, + private readonly ProductAddonAssociationService $productAddonAssociationService, private readonly ProductPriceCreateService $priceCreateService, private readonly HtmlPurifierService $purifier, private readonly EventRepositoryInterface $eventRepository, @@ -37,14 +38,23 @@ public function createProduct( ProductDomainObject $product, int $accountId, ?array $taxAndFeeIds = null, + ?array $addonProductIds = null, ): ProductDomainObject { - return $this->databaseManager->transaction(function () use ($accountId, $taxAndFeeIds, $product) { + return $this->databaseManager->transaction(function () use ($accountId, $taxAndFeeIds, $addonProductIds, $product) { $persistedProduct = $this->persistProduct($product); if ($taxAndFeeIds) { $this->associateTaxesAndFees($persistedProduct, $taxAndFeeIds, $accountId); } + if ($addonProductIds !== null) { + $this->productAddonAssociationService->associateAddons( + productId: $persistedProduct->getId(), + eventId: $persistedProduct->getEventId(), + addonProductIds: $addonProductIds, + ); + } + $product = $this->createProductPrices($persistedProduct, $product); $this->domainEventDispatcherService->dispatch( @@ -91,6 +101,7 @@ private function persistProduct(ProductDomainObject $productsData): ProductDomai 'is_highlighted' => $productsData->getIsHighlighted(), 'highlight_message' => $productsData->getHighlightMessage(), 'waitlist_enabled' => $productsData->getWaitlistEnabled(), + 'is_addon_only' => $productsData->getIsAddonOnly(), ]); } diff --git a/backend/app/Services/Domain/Product/DeleteProductService.php b/backend/app/Services/Domain/Product/DeleteProductService.php index b06c1f27bc..ee3f48fc7f 100644 --- a/backend/app/Services/Domain/Product/DeleteProductService.php +++ b/backend/app/Services/Domain/Product/DeleteProductService.php @@ -49,6 +49,8 @@ public function deleteProduct(int $productId, int $eventId): void ProductPriceDomainObjectAbstract::PRODUCT_ID => $productId, ] ); + + $this->productRepository->detachAddonAssociations($productId); }); $this->domainEventDispatcherService->dispatch( diff --git a/backend/app/Services/Domain/Product/Exception/InvalidAddonProductException.php b/backend/app/Services/Domain/Product/Exception/InvalidAddonProductException.php new file mode 100644 index 0000000000..44a1da9507 --- /dev/null +++ b/backend/app/Services/Domain/Product/Exception/InvalidAddonProductException.php @@ -0,0 +1,7 @@ +eventProductValidationService->validateProductIds($addonProductIds, $eventId); + } + + $this->productRepository->syncAddons($productId, $addonProductIds); + } +} diff --git a/backend/database/migrations/2026_07_30_000000_create_product_addons_table.php b/backend/database/migrations/2026_07_30_000000_create_product_addons_table.php new file mode 100644 index 0000000000..60a6e3c98b --- /dev/null +++ b/backend/database/migrations/2026_07_30_000000_create_product_addons_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->foreignId('addon_product_id')->constrained('products')->cascadeOnDelete(); + $table->integer('order')->default(0); + $table->timestamps(); + + $table->unique(['product_id', 'addon_product_id']); + $table->index('addon_product_id'); + }); + + DB::statement('ALTER TABLE product_addons ADD CONSTRAINT product_addons_no_self_reference CHECK (product_id <> addon_product_id)'); + + Schema::table('products', function (Blueprint $table) { + $table->boolean('is_addon_only')->default(false); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('is_addon_only'); + }); + + Schema::dropIfExists('product_addons'); + } +}; diff --git a/backend/tests/Unit/Services/Application/Handlers/Product/GetProductsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Product/GetProductsHandlerTest.php index d709fc479c..48974ca512 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Product/GetProductsHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Product/GetProductsHandlerTest.php @@ -40,7 +40,7 @@ public function test_handle_filters_the_paginated_products_as_a_flat_collection( $paginator = new LengthAwarePaginator(collect([$product]), 1, 25); $queryParams = new QueryParamsDTO; - $this->productRepository->shouldReceive('loadRelation')->twice()->andReturnSelf(); + $this->productRepository->shouldReceive('loadRelation')->times(3)->andReturnSelf(); $this->productRepository->shouldReceive('findByEventId') ->once() ->with(10, $queryParams) diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php index 4d1de2c276..874151e17d 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php @@ -749,11 +749,106 @@ public function test_max_per_order_applies_per_occurrence_for_recurring_events() $this->assertTrue(true); } + public function test_rejects_addon_only_product_without_parent_in_order(): void + { + $this->setupEventLookup(1); + + $addon = $this->createFullProductMock(productId: 10, priceId: 100, isAddonOnly: true, productType: 'GENERAL'); + + $this->productRepository + ->shouldReceive('findWhereIn') + ->andReturn(collect([$addon])); + + $this->productRepository + ->shouldReceive('findParentProductIds') + ->with([10]) + ->andReturn(collect([10 => [99]])); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('add-on'); + + $this->service->validateRequestData(1, $this->createRequestData(10)); + } + + public function test_allows_addon_only_product_with_parent_in_order(): void + { + $occurrence = $this->createOccurrence(); + $this->setupOccurrenceLookup(1, 10, $occurrence); + $this->setupEventLookup(1); + $this->setupAvailabilityFor([[9, 90], [10, 100]]); + + $parent = $this->createFullProductMock(productId: 9, priceId: 90); + $addon = $this->createFullProductMock(productId: 10, priceId: 100, isAddonOnly: true, productType: 'GENERAL'); + + $this->productRepository + ->shouldReceive('findWhereIn') + ->andReturn(collect([$parent, $addon])); + + $this->productRepository + ->shouldReceive('findParentProductIds') + ->with([10]) + ->andReturn(collect([10 => [9]])); + + $data = [ + 'products' => [ + [ + 'product_id' => 9, + 'event_occurrence_id' => 10, + 'quantities' => [['price_id' => 90, 'quantity' => 1]], + ], + [ + 'product_id' => 10, + 'event_occurrence_id' => 10, + 'quantities' => [['price_id' => 100, 'quantity' => 2]], + ], + ], + ]; + + $this->service->validateRequestData(1, $data); + $this->assertTrue(true); + } + + public function test_ignores_addon_only_product_with_zero_quantity(): void + { + $occurrence = $this->createOccurrence(); + $this->setupOccurrenceLookup(1, 10, $occurrence); + $this->setupEventLookup(1); + $this->setupAvailabilityFor([[9, 90], [10, 100]]); + + $parent = $this->createFullProductMock(productId: 9, priceId: 90); + $addon = $this->createFullProductMock(productId: 10, priceId: 100, isAddonOnly: true, productType: 'GENERAL'); + + $this->productRepository + ->shouldReceive('findWhereIn') + ->andReturn(collect([$parent, $addon])); + + $this->productRepository->shouldNotReceive('findParentProductIds'); + + $data = [ + 'products' => [ + [ + 'product_id' => 9, + 'event_occurrence_id' => 10, + 'quantities' => [['price_id' => 90, 'quantity' => 1]], + ], + [ + 'product_id' => 10, + 'event_occurrence_id' => 10, + 'quantities' => [['price_id' => 100, 'quantity' => 0]], + ], + ], + ]; + + $this->service->validateRequestData(1, $data); + $this->assertTrue(true); + } + private function createTicketProductStub(int $id): ProductDomainObject|MockInterface { $product = Mockery::mock(ProductDomainObject::class); $product->shouldReceive('getId')->andReturn($id); $product->shouldReceive('getProductType')->andReturn('TICKET'); + $product->shouldReceive('getIsAddonOnly')->andReturn(false); return $product; } @@ -838,6 +933,7 @@ private function setupProducts(int $eventId, int $productId, int $priceId, strin $product->shouldReceive('getProductType')->andReturn($productType); $product->shouldReceive('getIsHidden')->andReturn(false); $product->shouldReceive('getIsHiddenWithoutPromoCode')->andReturn(false); + $product->shouldReceive('getIsAddonOnly')->andReturn(false); $this->productRepository ->shouldReceive('loadRelation')->andReturnSelf(); @@ -847,6 +943,56 @@ private function setupProducts(int $eventId, int $productId, int $priceId, strin ->andReturn(collect([$product])); } + private function createFullProductMock( + int $productId, + int $priceId, + bool $isAddonOnly = false, + string $productType = 'TICKET', + ): ProductDomainObject|MockInterface { + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getId')->andReturn($priceId); + $price->shouldReceive('getIsHidden')->andReturn(false); + $price->shouldReceive('getLabel')->andReturn(null); + + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getId')->andReturn($productId); + $product->shouldReceive('getEventId')->andReturn(1); + $product->shouldReceive('getTitle')->andReturn('Product '.$productId); + $product->shouldReceive('getMaxPerOrder')->andReturn(10); + $product->shouldReceive('getMinPerOrder')->andReturn(1); + $product->shouldReceive('getType')->andReturn('PAID'); + $product->shouldReceive('isSoldOut')->andReturn(false); + $product->shouldReceive('getProductPrices')->andReturn(collect([$price])); + $product->shouldReceive('getProductType')->andReturn($productType); + $product->shouldReceive('getIsHidden')->andReturn(false); + $product->shouldReceive('getIsHiddenWithoutPromoCode')->andReturn(false); + $product->shouldReceive('getIsAddonOnly')->andReturn($isAddonOnly); + + return $product; + } + + private function setupAvailabilityFor(array $productPricePairs): void + { + $this->availabilityService + ->shouldReceive('getAvailableProductQuantities') + ->andReturn(new AvailableProductQuantitiesResponseDTO( + productQuantities: collect($productPricePairs)->map( + fn (array $pair) => AvailableProductQuantitiesDTO::fromArray([ + 'product_id' => $pair[0], + 'price_id' => $pair[1], + 'product_title' => 'Product '.$pair[0], + 'product_type' => 'TICKET', + 'price_label' => null, + 'quantity_available' => 100, + 'quantity_reserved' => 0, + 'initial_quantity_available' => 100, + 'capacities' => new Collection, + ]) + ), + capacities: collect(), + )); + } + private function createRequestData(int $occurrenceId, int $productId = 10, int $priceId = 100, int $quantity = 1, ?float $price = null): array { $quantityData = [ diff --git a/backend/tests/Unit/Services/Domain/Product/ProductAddonAssociationServiceTest.php b/backend/tests/Unit/Services/Domain/Product/ProductAddonAssociationServiceTest.php new file mode 100644 index 0000000000..26bcd22b91 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Product/ProductAddonAssociationServiceTest.php @@ -0,0 +1,89 @@ +productRepository = Mockery::mock(ProductRepositoryInterface::class); + $this->eventProductValidationService = Mockery::mock(EventProductValidationService::class); + + $this->service = new ProductAddonAssociationService( + $this->productRepository, + $this->eventProductValidationService, + ); + } + + public function test_rejects_self_reference(): void + { + $this->expectException(InvalidAddonProductException::class); + + $this->service->associateAddons(productId: 1, eventId: 10, addonProductIds: [2, 1]); + } + + public function test_dedupes_and_syncs_valid_addons(): void + { + $this->eventProductValidationService + ->shouldReceive('validateProductIds') + ->once() + ->with([2, 3], 10); + + $this->productRepository + ->shouldReceive('syncAddons') + ->once() + ->with(1, [2, 3]); + + $this->service->associateAddons(productId: 1, eventId: 10, addonProductIds: [2, 3, 2, '3']); + $this->assertTrue(true); + } + + public function test_empty_list_syncs_without_validating(): void + { + $this->eventProductValidationService->shouldNotReceive('validateProductIds'); + + $this->productRepository + ->shouldReceive('syncAddons') + ->once() + ->with(1, []); + + $this->service->associateAddons(productId: 1, eventId: 10, addonProductIds: []); + $this->assertTrue(true); + } + + public function test_propagates_cross_event_validation_failure(): void + { + $this->eventProductValidationService + ->shouldReceive('validateProductIds') + ->andThrow(new UnrecognizedProductIdException('Invalid product ids: 5')); + + $this->productRepository->shouldNotReceive('syncAddons'); + + $this->expectException(UnrecognizedProductIdException::class); + + $this->service->associateAddons(productId: 1, eventId: 10, addonProductIds: [5]); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceTest.php b/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceTest.php index cd9a3557b6..c59b03df63 100644 --- a/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceTest.php @@ -111,6 +111,39 @@ public function test_filter_attaches_filtered_products_to_visible_categories(): $this->assertSame([$product], $result->first()->getProducts()->values()->all()); } + public function test_filter_products_keeps_addon_only_products(): void + { + $addonOnly = $this->createFreeProduct(id: 1, priceId: 100)->setIsAddonOnly(true); + + $this->expectAccountConfigurationLoad(); + $this->expectQuantities([ + $this->createQuantityDto(productId: 1, priceId: 100, quantityAvailable: 5), + ]); + + $result = $this->service->filterProducts( + products: collect([$addonOnly]), + hideSoldOutProducts: false, + ); + + $this->assertSame([$addonOnly], $result->all()); + } + + public function test_filter_products_rejects_hidden_addon_only_products(): void + { + $hiddenAddon = $this->createFreeProduct(id: 1, priceId: 100) + ->setIsAddonOnly(true) + ->setIsHidden(true); + + $this->expectAccountConfigurationLoad(); + $this->expectQuantities([ + $this->createQuantityDto(productId: 1, priceId: 100, quantityAvailable: 5), + ]); + + $result = $this->service->filterProducts(collect([$hiddenAddon])); + + $this->assertTrue($result->isEmpty()); + } + private function createFreeProduct(int $id, int $priceId, int $productCategoryId = 5): ProductDomainObject { return (new ProductDomainObject) diff --git a/docker/development/.gitignore b/docker/development/.gitignore new file mode 100644 index 0000000000..51511d1f8f --- /dev/null +++ b/docker/development/.gitignore @@ -0,0 +1 @@ +test-results/ diff --git a/docker/e2e/docker-compose.e2e.yml b/docker/e2e/docker-compose.e2e.yml index 084b559cbe..95fa167c97 100644 --- a/docker/e2e/docker-compose.e2e.yml +++ b/docker/e2e/docker-compose.e2e.yml @@ -35,6 +35,7 @@ services: dockerfile: Dockerfile.ssr environment: NODE_ENV: production + WIDGET_TEST_PAGE_ENABLED: 'true' VITE_API_URL_CLIENT: 'http://localhost:8123/api' VITE_API_URL_SERVER: 'http://backend:8080' VITE_FRONTEND_URL: 'http://localhost:8123' diff --git a/e2e/api/types.ts b/e2e/api/types.ts index ecaf22178d..1cf60f9fd2 100644 --- a/e2e/api/types.ts +++ b/e2e/api/types.ts @@ -83,11 +83,14 @@ export interface CreateProductPricePayload { export interface CreateProductPayload { title: string; + description?: string; product_type: ProductKind; type: ProductPriceType; product_category_id: number; prices: CreateProductPricePayload[]; tax_and_fee_ids?: number[]; + addon_product_ids?: number[]; + is_addon_only?: boolean; max_per_order?: number; min_per_order?: number; is_hidden?: boolean; diff --git a/e2e/pages/checkout.page.ts b/e2e/pages/checkout.page.ts index 162e8a87c7..4989534387 100644 --- a/e2e/pages/checkout.page.ts +++ b/e2e/pages/checkout.page.ts @@ -1,4 +1,6 @@ -import { type Page } from '@playwright/test'; +import { type Frame, type FrameLocator, type Locator, type Page } from '@playwright/test'; + +export type CheckoutSurface = Page | Frame; export interface BuyerDetails { firstName: string; @@ -6,8 +8,36 @@ export interface BuyerDetails { email: string; } +export async function setWidgetQuantity(scope: Locator | FrameLocator | CheckoutSurface, quantity: number): Promise { + const selector = scope.locator('.hi-product-quantity-selector').first(); + const input = selector.locator('input'); + if (!(await input.isVisible())) { + if (quantity === 0) { + return; + } + await selector.getByRole('button', { name: 'Increase quantity' }).click(); + } + await input.fill(String(quantity)); + if (quantity !== 0) { + await input.blur(); + } +} + export class CheckoutPage { - constructor(private readonly page: Page) {} + private readonly surface: CheckoutSurface; + + constructor(private readonly page: Page, surface?: CheckoutSurface) { + this.surface = surface ?? page; + } + + private async reloadSurface(): Promise { + if (this.surface === this.page) { + await this.page.reload(); + } else { + await (this.surface as Frame).goto(this.surface.url()); + } + await this.surface.waitForLoadState('networkidle'); + } async gotoPublicEvent(eventId: number, slug: string): Promise { await this.page.goto(`/event/${eventId}/${slug}`); @@ -15,50 +45,53 @@ export class CheckoutPage { } async setFirstProductQuantity(quantity: number): Promise { - const input = this.page.locator('.hi-product-quantity-selector input').first(); - await input.fill(String(quantity)); + await setWidgetQuantity(this.surface, quantity); } async setQuantityForProduct(productTitle: string, quantity: number): Promise { - const row = this.page.locator('.hi-product-row').filter({ hasText: productTitle }); - await row.locator('.hi-product-quantity-selector input').fill(String(quantity)); + const row = this.surface.locator('.hi-product-row').filter({ hasText: productTitle }); + await setWidgetQuantity(row, quantity); + } + + async setAddonQuantity(addonTitle: string, quantity: number): Promise { + const addonRow = this.surface.locator('.hi-product-addon').filter({ hasText: addonTitle }); + await setWidgetQuantity(addonRow, quantity); } async applyPromoCode(code: string): Promise { - await this.page.getByText('Have a promo code?').click(); - await this.page.locator('.hi-promo-code-input').fill(code); - await this.page.getByTestId('promo-code-apply-button').click(); + await this.surface.getByText('Have a promo code?').click(); + await this.surface.locator('.hi-promo-code-input').fill(code); + await this.surface.getByTestId('promo-code-apply-button').click(); } async answerTextQuestion(title: string, value: string): Promise { - await this.page.getByLabel(new RegExp(`^${title}`)).fill(value); + await this.surface.getByLabel(new RegExp(`^${title}`)).fill(value); } async chooseRadioOption(option: string): Promise { - await this.page.getByRole('radio', { name: option }).check(); + await this.surface.getByRole('radio', { name: option }).check(); } async chooseOfflinePayment(): Promise { - const offlineTab = this.page.getByRole('button', { name: 'Offline' }); + const offlineTab = this.surface.getByRole('button', { name: 'Offline' }); if (await offlineTab.isVisible()) { await offlineTab.click(); } - await this.page.getByTestId('offline-payment-button').click(); - await this.page.waitForURL(/\/checkout\/\d+\/[^/]+\/summary/); - await this.page.reload(); - await this.page.waitForLoadState('networkidle'); + await this.surface.getByTestId('offline-payment-button').click(); + await this.surface.waitForURL(/\/checkout\/\d+\/[^/]+\/summary/); + await this.reloadSurface(); } async continueToCheckout(): Promise { - await this.page.getByTestId('checkout-continue-button').click(); - await this.page.waitForURL(/\/checkout\/\d+\/[^/]+\/details/); + await this.surface.getByTestId('checkout-continue-button').click(); + await this.surface.waitForURL(/\/checkout\/\d+\/[^/]+\/details/); } private async fillContact(index: number, details: BuyerDetails): Promise { - await this.page.getByLabel(/^First Name/).nth(index).fill(details.firstName); - await this.page.getByLabel(/^Last Name/).nth(index).fill(details.lastName); - await this.page.getByLabel(/^Email Address/).nth(index).fill(details.email); - await this.page.getByLabel(/^Confirm Email Address/).nth(index).fill(details.email); + await this.surface.getByLabel(/^First Name/).nth(index).fill(details.firstName); + await this.surface.getByLabel(/^Last Name/).nth(index).fill(details.lastName); + await this.surface.getByLabel(/^Email Address/).nth(index).fill(details.email); + await this.surface.getByLabel(/^Confirm Email Address/).nth(index).fill(details.email); } async fillOrderDetails(details: BuyerDetails): Promise { @@ -70,15 +103,14 @@ export class CheckoutPage { } async completeFreeOrder(): Promise { - await this.page.getByRole('button', { name: 'Complete Order' }).click(); - await this.page.waitForURL(/\/checkout\/\d+\/[^/]+\/summary/); - await this.page.reload(); - await this.page.waitForLoadState('networkidle'); + await this.surface.getByRole('button', { name: 'Complete Order' }).click(); + await this.surface.waitForURL(/\/checkout\/\d+\/[^/]+\/summary/); + await this.reloadSurface(); } async continueToPayment(): Promise { - await this.page.getByRole('button', { name: 'Continue to Payment' }).click(); - await this.page.waitForURL(/\/checkout\/\d+\/[^/]+\/payment/); + await this.surface.getByRole('button', { name: 'Continue to Payment' }).click(); + await this.surface.waitForURL(/\/checkout\/\d+\/[^/]+\/payment/); } async fillStripeCard(card = '4242424242424242'): Promise { diff --git a/e2e/pages/embedded-widget.ts b/e2e/pages/embedded-widget.ts new file mode 100644 index 0000000000..d5a3240baa --- /dev/null +++ b/e2e/pages/embedded-widget.ts @@ -0,0 +1,78 @@ +import { type Frame, type Locator, type Page } from '@playwright/test'; + +export const EMBED_HOST_URL = 'http://localhost:59173/'; + +const WIDGET_IFRAME_SELECTOR = 'iframe[title="Hi.Events Widget"]'; +const CHECKOUT_IFRAME_SELECTOR = 'iframe[title="Hi.Events Checkout"]'; + +async function resolveFrame(page: Page, selector: string): Promise { + const iframe = page.locator(selector); + await iframe.waitFor({ state: 'attached', timeout: 20_000 }); + const handle = await iframe.elementHandle(); + const frame = handle ? await handle.contentFrame() : null; + if (!frame) { + throw new Error(`No content frame found for ${selector}`); + } + return frame; +} + +export async function openEmbeddedWidget( + page: Page, + baseURL: string, + eventId: number, + attributes: Record = {}, +): Promise { + const extraAttributes = Object.entries(attributes) + .map(([name, value]) => `data-hievents-${name}="${value}"`) + .join(' '); + + const html = ` + + + + Embed Host + + + + +

Third-party host page

+
+

Host page footer content

+ +`; + + await page.context().grantPermissions(['local-network-access'], { origin: new URL(EMBED_HOST_URL).origin }); + await page.route(`${EMBED_HOST_URL}**`, (route) => { + if (route.request().url() === EMBED_HOST_URL) { + return route.fulfill({ contentType: 'text/html', body: html }); + } + return route.fulfill({ status: 404, body: '' }); + }); + + await page.goto(EMBED_HOST_URL); + + return resolveFrame(page, WIDGET_IFRAME_SELECTOR); +} + +export function widgetIframe(page: Page): Locator { + return page.locator(WIDGET_IFRAME_SELECTOR); +} + +export function checkoutModal(page: Page): Locator { + return page.locator('#hievents-checkout-modal'); +} + +export function checkoutModalDialog(page: Page): Locator { + return page.getByRole('dialog', { name: 'Checkout' }); +} + +export function resolveCheckoutFrame(page: Page): Promise { + return resolveFrame(page, CHECKOUT_IFRAME_SELECTOR); +} + +export async function openModalCheckout(page: Page, widgetFrame: Frame): Promise { + await widgetFrame.getByTestId('checkout-continue-button').click(); + const checkoutFrame = await resolveCheckoutFrame(page); + await checkoutFrame.waitForURL(/\/checkout\/\d+\/[^/]+\/details/); + return checkoutFrame; +} diff --git a/e2e/pages/product-create.page.ts b/e2e/pages/product-create.page.ts index 765ab562a6..0dc4e4afb7 100644 --- a/e2e/pages/product-create.page.ts +++ b/e2e/pages/product-create.page.ts @@ -1,5 +1,15 @@ import type { Locator, Page } from '@playwright/test'; +export type ProductLedgerRow = + | 'description' + | 'sale-window' + | 'event-page' + | 'taxes' + | 'order-limits' + | 'addons' + | 'highlight' + | 'access'; + export class ProductCreatePage { constructor(private readonly page: Page) {} @@ -14,12 +24,11 @@ export class ProductCreatePage { await this.page.getByRole('heading', { name: 'Create Ticket or Product' }).waitFor(); } - async selectPriceType(optionName: RegExp): Promise { + async selectPriceType(segmentLabel: string): Promise { await this.page - .getByLabel('Create Ticket or Product') - .getByText('Paid Product', { exact: true }) + .getByTestId('product-price-type') + .getByText(segmentLabel, { exact: true }) .click(); - await this.page.getByRole('option', { name: optionName }).click(); } async fillTier(index: number, price: string, label: string): Promise { @@ -31,8 +40,8 @@ export class ProductCreatePage { await this.page.getByTestId('product-add-tier-button').click(); } - async openAdvancedOptions(): Promise { - await this.page.getByRole('button', { name: /Taxes, Fees, Visibility/ }).click(); + async openLedgerRow(row: ProductLedgerRow): Promise { + await this.page.getByTestId(`product-ledger-${row}`).click(); } hiddenSwitch(): Locator { @@ -40,12 +49,22 @@ export class ProductCreatePage { } async submitCreate(): Promise { - await this.page.getByRole('button', { name: 'Create Product' }).click(); + await this.page.getByTestId('product-create-submit-button').click(); } - async openEditModal(): Promise { - await this.page.getByTestId('product-manage-button').click(); + async openEditModal(index = 0): Promise { + await this.page.getByTestId('product-manage-button').nth(index).click(); await this.page.getByTestId('product-edit-menu-item').click(); await this.page.getByRole('heading', { name: 'Edit Product' }).waitFor(); } + + addonOnlySwitch(): Locator { + return this.page.getByLabel('Only available as an add-on'); + } + + async selectAddonProduct(productTitle: string): Promise { + await this.page.getByRole('combobox', { name: 'Add-on products' }).click(); + await this.page.getByRole('option', { name: productTitle }).click(); + await this.page.keyboard.press('Escape'); + } } diff --git a/e2e/tests/account/taxes-fees.spec.ts b/e2e/tests/account/taxes-fees.spec.ts index e310dc6c0c..86f5ec33be 100644 --- a/e2e/tests/account/taxes-fees.spec.ts +++ b/e2e/tests/account/taxes-fees.spec.ts @@ -117,7 +117,7 @@ test.describe('taxes and fees', () => { await products.openCreateModal(); await page.getByLabel(/^Name/).fill(title); await page.getByLabel(/^Price/).fill('30'); - await products.openAdvancedOptions(); + await products.openLedgerRow('taxes'); await page.getByRole('combobox', { name: 'Taxes and Fees' }).click(); await page.getByRole('option', { name: new RegExp(`^${taxName}`) }).click(); @@ -127,7 +127,7 @@ test.describe('taxes and fees', () => { await expect(page.getByRole('heading', { name: title })).toBeVisible(); await products.openEditModal(); - await products.openAdvancedOptions(); + await products.openLedgerRow('taxes'); await expect(page.getByRole('dialog').getByText(new RegExp(`^${taxName}`))).toBeVisible(); }); }); diff --git a/e2e/tests/checkout/addon-checkout.spec.ts b/e2e/tests/checkout/addon-checkout.spec.ts new file mode 100644 index 0000000000..daf7e89c0c --- /dev/null +++ b/e2e/tests/checkout/addon-checkout.spec.ts @@ -0,0 +1,190 @@ +import { test, expect } from '../../fixtures'; +import { CheckoutPage, setWidgetQuantity } from '../../pages/checkout.page'; +import { createDraftEvent, enableOfflinePayments } from '../../api/factory'; +import { uniqueEmail } from '../../utils/unique'; + +test.describe('add-on checkout', () => { + test('a buyer sees add-ons under the parent ticket and orders both', async ({ page, api, account }) => { + const event = await createDraftEvent(api, account.organizerId); + const categories = await api.listProductCategories(event.eventId); + const categoryId = categories[0].id; + + const addon = await api.createProduct(event.eventId, { + title: 'Parking Pass', + product_type: 'GENERAL', + type: 'FREE', + product_category_id: categoryId, + is_addon_only: true, + prices: [{ price: 0 }], + }); + + await api.createProduct(event.eventId, { + title: 'Festival Ticket', + product_type: 'TICKET', + type: 'FREE', + product_category_id: categoryId, + addon_product_ids: [addon.id], + prices: [{ price: 0 }], + }); + + await api.publishEvent(event.eventId); + + const checkout = new CheckoutPage(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + + await expect(page.locator('.hi-product-row h3').filter({ hasText: 'Festival Ticket' })).toBeVisible(); + await expect(page.locator('.hi-product-row h3').filter({ hasText: 'Parking Pass' })).toHaveCount(0); + await expect(page.locator('.hi-product-addon').filter({ hasText: 'Parking Pass' })).toBeVisible(); + await expect(page.getByText('Add Festival Ticket first')).toBeVisible(); + + await checkout.setQuantityForProduct('Festival Ticket', 1); + await expect(page.getByText('Add Festival Ticket first')).toHaveCount(0); + + await checkout.setAddonQuantity('Parking Pass', 1); + + await checkout.setQuantityForProduct('Festival Ticket', 0); + await expect(page.getByText('Add Festival Ticket first')).toBeVisible(); + await expect(page.locator('.hi-product-addon').filter({ hasText: 'Parking Pass' }).locator('input')).toBeHidden(); + + await checkout.setQuantityForProduct('Festival Ticket', 1); + await checkout.setAddonQuantity('Parking Pass', 1); + await checkout.continueToCheckout(); + + await expect(page.getByText('Festival Ticket').first()).toBeVisible(); + await expect(page.getByText('Parking Pass').first()).toBeVisible(); + }); + + test('a paid add-on is charged as part of the order total', async ({ page, api, account }) => { + const event = await createDraftEvent(api, account.organizerId); + const categories = await api.listProductCategories(event.eventId); + const categoryId = categories[0].id; + + const addon = await api.createProduct(event.eventId, { + title: 'Camping Pitch', + product_type: 'GENERAL', + type: 'PAID', + product_category_id: categoryId, + is_addon_only: true, + prices: [{ price: 5 }], + }); + + await api.createProduct(event.eventId, { + title: 'Weekend Ticket', + product_type: 'TICKET', + type: 'PAID', + product_category_id: categoryId, + addon_product_ids: [addon.id], + prices: [{ price: 10 }], + }); + + await enableOfflinePayments(api, event.eventId); + await api.publishEvent(event.eventId); + + const buyer = { firstName: 'Addon', lastName: 'Payer', email: uniqueEmail('addon-payer') }; + + const checkout = new CheckoutPage(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + await checkout.setQuantityForProduct('Weekend Ticket', 1); + await checkout.setAddonQuantity('Camping Pitch', 2); + await checkout.continueToCheckout(); + + await expect(page.getByText('Weekend Ticket').first()).toBeVisible(); + await expect(page.getByText('Camping Pitch').first()).toBeVisible(); + await expect(page.getByText('× 2', { exact: true })).toBeVisible(); + + await checkout.fillOrderDetails(buyer); + await checkout.fillFirstAttendee(buyer); + await checkout.continueToPayment(); + await checkout.chooseOfflinePayment(); + + await expect(page.getByText('Your order is awaiting payment')).toBeVisible(); + await expect(page.getByText('$20.00').first()).toBeVisible(); + }); + + test('a product sold on its own can also be an add-on, with both steppers kept in sync', async ({ page, api, account }) => { + const event = await createDraftEvent(api, account.organizerId); + const categories = await api.listProductCategories(event.eventId); + const categoryId = categories[0].id; + + const shirt = await api.createProduct(event.eventId, { + title: 'Tour Shirt', + product_type: 'GENERAL', + type: 'FREE', + product_category_id: categoryId, + prices: [{ price: 0 }], + }); + + await api.createProduct(event.eventId, { + title: 'Gig Ticket', + product_type: 'TICKET', + type: 'FREE', + product_category_id: categoryId, + addon_product_ids: [shirt.id], + prices: [{ price: 0 }], + }); + + await api.publishEvent(event.eventId); + + const checkout = new CheckoutPage(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + + const shirtRow = page.locator('.hi-product-row').filter({ has: page.locator('h3', { hasText: 'Tour Shirt' }) }); + const shirtAddon = page.locator('.hi-product-addon').filter({ hasText: 'Tour Shirt' }); + await expect(shirtRow).toBeVisible(); + await expect(shirtAddon).toBeVisible(); + + await checkout.setQuantityForProduct('Gig Ticket', 1); + await checkout.setAddonQuantity('Tour Shirt', 2); + await expect(shirtRow.locator('.hi-product-quantity-selector input').first()).toHaveValue('2'); + + await setWidgetQuantity(shirtRow, 1); + await expect(shirtAddon.locator('input')).toHaveValue('1'); + + await checkout.continueToCheckout(); + + await expect(page.getByText('Gig Ticket').first()).toBeVisible(); + await expect(page.getByText('Tour Shirt').first()).toBeVisible(); + await expect(page.getByText('× 1', { exact: true })).toHaveCount(2); + }); + + test('a buyer completes an order that includes an add-on', async ({ page, api, account, mailpit }) => { + const event = await createDraftEvent(api, account.organizerId); + const categories = await api.listProductCategories(event.eventId); + const categoryId = categories[0].id; + + const addon = await api.createProduct(event.eventId, { + title: 'Drink Token', + product_type: 'GENERAL', + type: 'FREE', + product_category_id: categoryId, + is_addon_only: true, + prices: [{ price: 0 }], + }); + + await api.createProduct(event.eventId, { + title: 'Entry Ticket', + product_type: 'TICKET', + type: 'FREE', + product_category_id: categoryId, + addon_product_ids: [addon.id], + prices: [{ price: 0 }], + }); + + await api.publishEvent(event.eventId); + + const buyerEmail = uniqueEmail('addon-buyer'); + const buyer = { firstName: 'Addon', lastName: 'Buyer', email: buyerEmail }; + + const checkout = new CheckoutPage(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + await checkout.setQuantityForProduct('Entry Ticket', 1); + await checkout.setAddonQuantity('Drink Token', 1); + await checkout.continueToCheckout(); + await checkout.fillOrderDetails(buyer); + await checkout.fillFirstAttendee(buyer); + await checkout.completeFreeOrder(); + + await expect(page.getByText(`You're going to ${event.title}`)).toBeVisible(); + await mailpit.waitForMessage(buyerEmail); + }); +}); diff --git a/e2e/tests/checkout/donation-tiered-checkout.spec.ts b/e2e/tests/checkout/donation-tiered-checkout.spec.ts index cc1a1eb202..cada38e36d 100644 --- a/e2e/tests/checkout/donation-tiered-checkout.spec.ts +++ b/e2e/tests/checkout/donation-tiered-checkout.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '../../fixtures'; -import { CheckoutPage } from '../../pages/checkout.page'; +import { CheckoutPage, setWidgetQuantity } from '../../pages/checkout.page'; import { createLiveEventWithProduct, enableOfflinePayments } from '../../api/factory'; import { uniqueEmail } from '../../utils/unique'; @@ -26,7 +26,7 @@ test.describe('donation and tiered checkout', () => { await expect(vipRow.locator('.hi-price-tier-label')).toHaveText('VIP'); await expect(vipRow.getByText('$20.00')).toBeVisible(); - await standardRow.locator('.hi-product-quantity-selector input').fill('1'); + await setWidgetQuantity(standardRow, 1); await checkout.continueToCheckout(); await checkout.fillOrderDetails(buyer); await checkout.fillFirstAttendee(buyer); diff --git a/e2e/tests/checkout/kitchen-sink-recurring.spec.ts b/e2e/tests/checkout/kitchen-sink-recurring.spec.ts index 438d1165a4..13220cdfa5 100644 --- a/e2e/tests/checkout/kitchen-sink-recurring.spec.ts +++ b/e2e/tests/checkout/kitchen-sink-recurring.spec.ts @@ -103,7 +103,7 @@ function buildCheckoutOptions( await expect(paneTime).toContainText(/7:00\s?PM/i); await expect(paneLocation).toHaveCount(0); await expect(selector.productsLoadingOverlay()).toHaveCount(0); - await expect(standardRow.getByText(BASE_STANDARD_INCLUSIVE)).toBeVisible(); + await expect(standardRow.getByText(BASE_STANDARD_INCLUSIVE).first()).toBeVisible(); const secondLabel = dayButtonLabel(second.start_date); await selector.navigateToMonthOf(second.start_date); @@ -113,7 +113,7 @@ function buildCheckoutOptions( await expect(pane.getByText(OCCURRENCE_LABEL)).toBeVisible(); await expect(paneLocation).toContainText('Warehouse 9, Brooklyn'); await expect(selector.productsLoadingOverlay()).toHaveCount(0); - await expect(standardRow.getByText(TOTALS.standardInclusive)).toBeVisible(); + await expect(standardRow.getByText(TOTALS.standardInclusive).first()).toBeVisible(); }; const expectSummaryDetails = async (page: Page) => { diff --git a/e2e/tests/checkout/kitchen-sink.shared.ts b/e2e/tests/checkout/kitchen-sink.shared.ts index c5f56e7ab1..b03bed7f3c 100644 --- a/e2e/tests/checkout/kitchen-sink.shared.ts +++ b/e2e/tests/checkout/kitchen-sink.shared.ts @@ -1,5 +1,5 @@ -import { expect, type APIRequestContext, type Page } from '@playwright/test'; -import { CheckoutPage, type BuyerDetails } from '../../pages/checkout.page'; +import { expect, type APIRequestContext, type Frame, type Page } from '@playwright/test'; +import { CheckoutPage, setWidgetQuantity, type BuyerDetails, type CheckoutSurface } from '../../pages/checkout.page'; import type { ApiClient } from '../../api/api-client'; import type { Occurrence } from '../../api/types'; import { createDraftEvent, OFFLINE_PAYMENT_INSTRUCTIONS } from '../../api/factory'; @@ -236,6 +236,10 @@ export interface KitchenSinkCheckoutOptions { paymentMode: 'offline' | 'stripe'; attendeeCollection: 'PER_ATTENDEE' | 'PER_ORDER'; totals: KitchenSinkTotals; + surfaces?: { + select: CheckoutSurface; + resolveCheckout: () => Promise; + }; occurrence?: { select: (page: Page) => Promise; expectSummaryDetails: (page: Page) => Promise; @@ -243,11 +247,11 @@ export interface KitchenSinkCheckoutOptions { emailBodyContains?: string[]; } -async function fillContactBlock(page: Page, index: number, details: BuyerDetails): Promise { - await page.getByLabel(/^First Name/).nth(index).fill(details.firstName); - await page.getByLabel(/^Last Name/).nth(index).fill(details.lastName); - await page.getByLabel(/^Email Address/).nth(index).fill(details.email); - await page.getByLabel(/^Confirm Email Address/).nth(index).fill(details.email); +async function fillContactBlock(root: CheckoutSurface, index: number, details: BuyerDetails): Promise { + await root.getByLabel(/^First Name/).nth(index).fill(details.firstName); + await root.getByLabel(/^Last Name/).nth(index).fill(details.lastName); + await root.getByLabel(/^Email Address/).nth(index).fill(details.email); + await root.getByLabel(/^Confirm Email Address/).nth(index).fill(details.email); } export async function runKitchenSinkCheckout( @@ -257,6 +261,9 @@ export async function runKitchenSinkCheckout( opts: KitchenSinkCheckoutOptions, ): Promise { const { paymentMode, totals } = opts; + if (opts.surfaces && paymentMode === 'stripe') { + throw new Error('Stripe payment is not supported when running on embedded surfaces'); + } const buyer: BuyerDetails = { firstName: 'Kitchen', lastName: 'Sink', email: uniqueEmail('kitchensink') }; const guests: BuyerDetails[] = opts.attendeeCollection === 'PER_ATTENDEE' ? [ @@ -267,45 +274,25 @@ export async function runKitchenSinkCheckout( ] : []; - const checkout = new CheckoutPage(page); - const productRow = (title: string) => page.locator('.hi-product-row').filter({ hasText: title }); - const summaryLineItem = (name: string) => page.getByTitle(name).locator('../..'); - const summaryRow = (label: string) => - page.locator('[class*="totalsRow"]').filter({ has: page.getByText(label, { exact: true }) }); + const selectRoot: CheckoutSurface = opts.surfaces?.select ?? page; + const selection = new CheckoutPage(page, selectRoot); + const productRow = (title: string) => selectRoot.locator('.hi-product-row').filter({ hasText: title }); - const expectSummaryLineItems = async () => { - await expect(summaryLineItem('Standard Ticket').getByText(totals.standardBase)).toBeVisible(); - await expect(summaryLineItem('Seated Ticket - Front Row').getByText('$40.00')).toBeVisible(); - await expect(summaryLineItem('Supporter Donation').getByText('$12.50')).toBeVisible(); - await expect(summaryLineItem('Event T-Shirt').getByText('$10.00')).toBeVisible(); - await expect(summaryLineItem('Secret VIP').getByText('$40.00')).toBeVisible(); - await expect(summaryLineItem('Secret VIP').getByText('$50.00')).toBeVisible(); - }; - const expectSummaryTotals = async () => { - await expect(summaryRow('Subtotal')).toContainText(totals.subtotal); - await expect(summaryRow('Fees')).toContainText(totals.fees); - await expect(summaryRow('Taxes')).toContainText(totals.taxes); - await expect(summaryRow('Total')).toContainText(totals.total); - }; - const expectCompletedSummary = async () => { - await expect(page.getByText(`You're going to ${scenario.title}`)).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Additional Information' })).toBeVisible(); - await expect(page.getByText(POST_CHECKOUT_MESSAGE)).toBeVisible(); - }; - - await checkout.gotoPublicEvent(scenario.eventId, scenario.slug); + if (!opts.surfaces) { + await selection.gotoPublicEvent(scenario.eventId, scenario.slug); + } if (opts.occurrence) { await opts.occurrence.select(page); } - await expect(page.getByRole('heading', { name: scenario.gaCategoryName })).toBeVisible(); - await expect(page.getByText(GA_DESCRIPTION)).toBeVisible(); - await expect(page.getByRole('heading', { name: scenario.extrasCategoryName })).toBeVisible(); - await expect(page.getByText(EXTRAS_DESCRIPTION)).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Tickets', exact: true })).toBeVisible(); - await expect(page.getByText('There are no tickets available for this event')).toBeVisible(); + await expect(selectRoot.getByRole('heading', { name: scenario.gaCategoryName })).toBeVisible(); + await expect(selectRoot.getByText(GA_DESCRIPTION)).toBeVisible(); + await expect(selectRoot.getByRole('heading', { name: scenario.extrasCategoryName })).toBeVisible(); + await expect(selectRoot.getByText(EXTRAS_DESCRIPTION)).toBeVisible(); + await expect(selectRoot.getByRole('heading', { name: 'Tickets', exact: true })).toBeVisible(); + await expect(selectRoot.getByText('There are no tickets available for this event')).toBeVisible(); - await expect(productRow('Standard Ticket').getByText(totals.standardInclusive)).toBeVisible(); + await expect(productRow('Standard Ticket').getByText(totals.standardInclusive).first()).toBeVisible(); const seatedRow = productRow('Seated Ticket'); const balconyTier = seatedRow.locator('.hi-price-tier-row').filter({ hasText: 'Balcony' }); const frontRowTier = seatedRow.locator('.hi-price-tier-row').filter({ hasText: 'Front Row' }); @@ -313,66 +300,102 @@ export async function runKitchenSinkCheckout( await expect(frontRowTier.getByText('$40.00')).toBeVisible(); const donationRow = productRow('Supporter Donation'); await expect(donationRow.getByLabel(/^Amount/)).toBeVisible(); - await expect(productRow('Event T-Shirt').getByText('$13.75')).toBeVisible(); - await expect(page.getByText('Secret VIP')).toHaveCount(0); - await expect(page.getByText('Staff Comp')).toHaveCount(0); + await expect(productRow('Event T-Shirt').getByText('$13.75').first()).toBeVisible(); + await expect(selectRoot.getByText('Secret VIP')).toHaveCount(0); + await expect(selectRoot.getByText('Staff Comp')).toHaveCount(0); - await checkout.applyPromoCode(scenario.promoCode); + await selection.applyPromoCode(scenario.promoCode); const vipRow = productRow('Secret VIP'); await expect(vipRow.getByText('$40.00')).toBeVisible(); await expect(vipRow.getByText('$50.00')).toBeVisible(); - await expect(productRow('Standard Ticket').getByText(totals.standardInclusive)).toBeVisible(); + await expect(productRow('Standard Ticket').getByText(totals.standardInclusive).first()).toBeVisible(); await expect(balconyTier.getByText('$15.00')).toBeVisible(); await expect(frontRowTier.getByText('$40.00')).toBeVisible(); - await expect(productRow('Event T-Shirt').getByText('$13.75')).toBeVisible(); - await expect(page.getByText('Staff Comp')).toHaveCount(0); + await expect(productRow('Event T-Shirt').getByText('$13.75').first()).toBeVisible(); + await expect(selectRoot.getByText('Staff Comp')).toHaveCount(0); - await checkout.setQuantityForProduct('Standard Ticket', 1); - await frontRowTier.locator('.hi-product-quantity-selector input').fill('1'); + await selection.setQuantityForProduct('Standard Ticket', 1); + await setWidgetQuantity(frontRowTier, 1); await donationRow.getByLabel(/^Amount/).fill('12.50'); - await donationRow.locator('.hi-product-quantity-selector input').fill('1'); - await checkout.setQuantityForProduct('Event T-Shirt', 1); - await checkout.setQuantityForProduct('Secret VIP', 1); - await checkout.continueToCheckout(); + await setWidgetQuantity(donationRow, 1); + await selection.setQuantityForProduct('Event T-Shirt', 1); + await selection.setQuantityForProduct('Secret VIP', 1); - await expect(page.getByLabel(/^First Name/)).toHaveCount(1 + guests.length); + await selectRoot.getByTestId('checkout-continue-button').click(); + const checkoutRoot: CheckoutSurface = opts.surfaces ? await opts.surfaces.resolveCheckout() : page; + await checkoutRoot.waitForURL(/\/checkout\/\d+\/[^/]+\/details/); + const checkout = new CheckoutPage(page, checkoutRoot); + + const summaryLineItem = (name: string) => checkoutRoot.getByTitle(name).locator('../..'); + const summaryRow = (label: string) => + checkoutRoot.locator('[class*="totalsRow"]').filter({ has: checkoutRoot.getByText(label, { exact: true }) }); + const reloadCheckout = async () => { + if (checkoutRoot === page) { + await page.reload(); + } else { + await (checkoutRoot as Frame).goto(checkoutRoot.url()); + } + await checkoutRoot.waitForLoadState('networkidle'); + }; + + const expectSummaryLineItems = async () => { + await expect(summaryLineItem('Standard Ticket').getByText(totals.standardBase)).toBeVisible(); + await expect(summaryLineItem('Seated Ticket - Front Row').getByText('$40.00')).toBeVisible(); + await expect(summaryLineItem('Supporter Donation').getByText('$12.50')).toBeVisible(); + await expect(summaryLineItem('Event T-Shirt').getByText('$10.00')).toBeVisible(); + await expect(summaryLineItem('Secret VIP').getByText('$40.00')).toBeVisible(); + await expect(summaryLineItem('Secret VIP').getByText('$50.00')).toBeVisible(); + }; + const expectSummaryTotals = async () => { + await expect(summaryRow('Subtotal')).toContainText(totals.subtotal); + await expect(summaryRow('Fees')).toContainText(totals.fees); + await expect(summaryRow('Taxes')).toContainText(totals.taxes); + await expect(summaryRow('Total')).toContainText(totals.total); + }; + const expectCompletedSummary = async () => { + await expect(checkoutRoot.getByText(`You're going to ${scenario.title}`)).toBeVisible(); + await expect(checkoutRoot.getByRole('heading', { name: 'Additional Information' })).toBeVisible(); + await expect(checkoutRoot.getByText(POST_CHECKOUT_MESSAGE)).toBeVisible(); + }; + + await expect(checkoutRoot.getByLabel(/^First Name/)).toHaveCount(1 + guests.length); const attendeeHeadingCount = opts.attendeeCollection === 'PER_ATTENDEE' ? 4 : 1; - await expect(page.getByRole('heading', { name: 'Attendee 1' })).toHaveCount(attendeeHeadingCount); - await expect(page.getByRole('heading', { name: 'Event T-Shirt' })).toHaveCount(0); - await expect(page.getByText(PRE_CHECKOUT_MESSAGE)).toBeVisible(); - - await expect(page.getByLabel(/^How did you hear about us/)).toBeVisible(); - await expect(page.getByLabel(/^Anything else we should know/)).toBeVisible(); - await expect(page.getByRole('radio', { name: 'Medium' })).toHaveCount(1); - const standardSection = page.locator('[class*="ticketSection"]').filter({ hasText: 'Standard Ticket' }); + await expect(checkoutRoot.getByRole('heading', { name: 'Attendee 1' })).toHaveCount(attendeeHeadingCount); + await expect(checkoutRoot.getByRole('heading', { name: 'Event T-Shirt' })).toHaveCount(0); + await expect(checkoutRoot.getByText(PRE_CHECKOUT_MESSAGE)).toBeVisible(); + + await expect(checkoutRoot.getByLabel(/^How did you hear about us/)).toBeVisible(); + await expect(checkoutRoot.getByLabel(/^Anything else we should know/)).toBeVisible(); + await expect(checkoutRoot.getByRole('radio', { name: 'Medium' })).toHaveCount(1); + const standardSection = checkoutRoot.locator('[class*="ticketSection"]').filter({ hasText: 'Standard Ticket' }); await expect(standardSection.getByRole('radio', { name: 'Medium' })).toBeVisible(); await expectSummaryLineItems(); await expectSummaryTotals(); - await expect(page.getByText('-$10.00')).toBeVisible(); + await expect(checkoutRoot.getByText('-$10.00')).toBeVisible(); await checkout.fillOrderDetails(buyer); for (const [index, guest] of guests.entries()) { - await fillContactBlock(page, index + 1, guest); + await fillContactBlock(checkoutRoot, index + 1, guest); } - await page.getByRole('button', { name: 'Continue to Payment' }).click(); - await expect(page.getByText('This field is required.')).toHaveCount(2); + await checkoutRoot.getByRole('button', { name: 'Continue to Payment' }).click(); + await expect(checkoutRoot.getByText('This field is required.')).toHaveCount(2); await checkout.answerTextQuestion('How did you hear about us?', 'Kitchen sink e2e'); await checkout.chooseRadioOption('Medium'); await expectSummaryTotals(); await checkout.continueToPayment(); - await expect(page.getByRole('button', { name: 'Online' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Offline' })).toBeVisible(); - await expect(page.getByRole('button', { name: `Pay ${totals.total}` })).toBeVisible(); + await expect(checkoutRoot.getByRole('button', { name: 'Online' })).toBeVisible(); + await expect(checkoutRoot.getByRole('button', { name: 'Offline' })).toBeVisible(); + await expect(checkoutRoot.getByRole('button', { name: `Pay ${totals.total}` })).toBeVisible(); if (paymentMode === 'offline') { await checkout.chooseOfflinePayment(); - await expect(page.getByText('Your order is awaiting payment')).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Payment Instructions' })).toBeVisible(); - await expect(page.getByText(OFFLINE_PAYMENT_INSTRUCTIONS)).toBeVisible(); + await expect(checkoutRoot.getByText('Your order is awaiting payment')).toBeVisible(); + await expect(checkoutRoot.getByRole('heading', { name: 'Payment Instructions' })).toBeVisible(); + await expect(checkoutRoot.getByText(OFFLINE_PAYMENT_INSTRUCTIONS)).toBeVisible(); } else { await checkout.payWithStripeTestCard(); const { orderShortId, sessionId } = parsePaymentReturnUrl(page.url()); @@ -382,20 +405,19 @@ export async function runKitchenSinkCheckout( await page.waitForLoadState('networkidle'); } - await page.getByRole('button', { name: /Order Summary/ }).click(); + await checkoutRoot.getByRole('button', { name: /Order Summary/ }).click(); await expectSummaryLineItems(); await expectSummaryTotals(); for (const guest of guests) { - await expect(page.getByText(`${guest.firstName} ${guest.lastName}`)).toBeVisible(); + await expect(checkoutRoot.getByText(`${guest.firstName} ${guest.lastName}`)).toBeVisible(); } if (paymentMode === 'offline') { - await expect(page.getByRole('heading', { name: 'Additional Information' })).toHaveCount(0); - const orderShortId = page.url().match(/\/checkout\/\d+\/([^/?]+)\/summary/)![1]; + await expect(checkoutRoot.getByRole('heading', { name: 'Additional Information' })).toHaveCount(0); + const orderShortId = checkoutRoot.url().match(/\/checkout\/\d+\/([^/?]+)\/summary/)![1]; const orderId = await deps.api.findOrderIdByShortId(scenario.eventId, orderShortId); await deps.api.markOrderAsPaid(scenario.eventId, orderId); - await page.reload(); - await page.waitForLoadState('networkidle'); + await reloadCheckout(); } await expectCompletedSummary(); if (opts.occurrence) { diff --git a/e2e/tests/management/product-create.spec.ts b/e2e/tests/management/product-create.spec.ts index 67683fceee..2bc58e4828 100644 --- a/e2e/tests/management/product-create.spec.ts +++ b/e2e/tests/management/product-create.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '../../fixtures'; import { ProductCreatePage } from '../../pages/product-create.page'; -import { createDraftEvent } from '../../api/factory'; +import { createDraftEvent, createDraftEventWithTicket } from '../../api/factory'; import { uniqueName } from '../../utils/unique'; test.describe('product creation', () => { @@ -26,7 +26,7 @@ test.describe('product creation', () => { const products = new ProductCreatePage(authedPage); await products.goto(event.eventId); await products.openCreateModal(); - await products.selectPriceType(/Donation/); + await products.selectPriceType('Donation'); await authedPage.getByLabel(/^Name/).fill(title); await authedPage.getByLabel(/^Minimum Price/).fill('5'); await products.submitCreate(); @@ -42,7 +42,7 @@ test.describe('product creation', () => { const products = new ProductCreatePage(authedPage); await products.goto(event.eventId); await products.openCreateModal(); - await products.selectPriceType(/Tiered Product/); + await products.selectPriceType('Tiers'); await authedPage.getByLabel(/^Name/).fill(title); await products.fillTier(0, '10', 'Early Bird'); await products.addTier(); @@ -53,7 +53,38 @@ test.describe('product creation', () => { await expect(authedPage.getByText('$10.00 – $20.00', { exact: true })).toBeVisible(); }); - test('advanced options persist and are shown when reopening the edit modal', async ({ authedPage, api, account }) => { + test('an organizer creates an add-on product and the settings persist', async ({ authedPage, api, account }) => { + const event = await createDraftEventWithTicket(api, account.organizerId, { productTitle: 'Main Ticket' }); + const title = uniqueName('Parking Pass'); + + const products = new ProductCreatePage(authedPage); + await products.goto(event.eventId); + await products.openCreateModal(); + await authedPage.getByLabel(/^Name/).fill(title); + await authedPage.getByLabel(/^Price/).fill('10'); + await products.openLedgerRow('addons'); + await products.selectAddonProduct('Main Ticket'); + await products.openLedgerRow('addons'); + await expect(authedPage.getByText('1 add-on', { exact: true })).toBeVisible(); + + await products.openLedgerRow('addons'); + await products.addonOnlySwitch().check(); + await expect(authedPage.getByRole('combobox', { name: 'Add-on products' })).toHaveCount(0); + await products.openLedgerRow('addons'); + await expect(authedPage.getByText('Add-on only', { exact: true })).toBeVisible(); + await products.submitCreate(); + + await expect(authedPage.getByRole('heading', { name: title })).toBeVisible(); + await expect(authedPage.getByText('Add-on only', { exact: true })).toBeVisible(); + + await products.openEditModal(1); + await expect(authedPage.getByLabel(/^Name/)).toHaveValue(title); + await products.openLedgerRow('addons'); + await expect(products.addonOnlySwitch()).toBeChecked(); + await expect(authedPage.getByRole('combobox', { name: 'Add-on products' })).toHaveCount(0); + }); + + test('ledger settings persist and are shown when reopening the edit modal', async ({ authedPage, api, account }) => { const event = await createDraftEvent(api, account.organizerId); const title = uniqueName('Hidden Ticket'); const saleStart = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 16); @@ -63,10 +94,12 @@ test.describe('product creation', () => { await products.openCreateModal(); await authedPage.getByLabel(/^Name/).fill(title); await authedPage.getByLabel(/^Price/).fill('15'); - await products.openAdvancedOptions(); + await products.openLedgerRow('order-limits'); await authedPage.getByLabel('Minimum Per Order').fill('1'); await authedPage.getByLabel('Maximum Per Order').fill('2'); + await products.openLedgerRow('sale-window'); await authedPage.getByRole('textbox', { name: 'Sale Start Date' }).fill(saleStart); + await products.openLedgerRow('access'); await products.hiddenSwitch().check(); await products.submitCreate(); @@ -75,10 +108,12 @@ test.describe('product creation', () => { await products.openEditModal(); await expect(authedPage.getByLabel(/^Name/)).toHaveValue(title); - await products.openAdvancedOptions(); + await products.openLedgerRow('order-limits'); await expect(authedPage.getByLabel('Minimum Per Order')).toHaveValue('1'); await expect(authedPage.getByLabel('Maximum Per Order')).toHaveValue('2'); + await products.openLedgerRow('sale-window'); await expect(authedPage.getByRole('textbox', { name: 'Sale Start Date' })).toHaveValue(saleStart); + await products.openLedgerRow('access'); await expect(products.hiddenSwitch()).toBeChecked(); }); }); diff --git a/e2e/tests/widget/embedded-widget.spec.ts b/e2e/tests/widget/embedded-widget.spec.ts new file mode 100644 index 0000000000..ef914ea890 --- /dev/null +++ b/e2e/tests/widget/embedded-widget.spec.ts @@ -0,0 +1,123 @@ +import { test, expect } from '../../fixtures'; +import { CheckoutPage, setWidgetQuantity } from '../../pages/checkout.page'; +import { + EMBED_HOST_URL, + checkoutModal, + checkoutModalDialog, + openEmbeddedWidget, + openModalCheckout, + resolveCheckoutFrame, + widgetIframe, +} from '../../pages/embedded-widget'; +import { createDraftEvent, createLiveEventWithFreeTicket } from '../../api/factory'; +import { + arrangeKitchenSinkEvent, + runKitchenSinkCheckout, + type KitchenSinkTotals, +} from '../checkout/kitchen-sink.shared'; +import { uniqueEmail } from '../../utils/unique'; + +const KITCHEN_SINK_TOTALS: KitchenSinkTotals = { + standardBase: '$25.00', + standardInclusive: '$30.25', + subtotal: '$127.50', + fees: '$5.00', + taxes: '$4.00', + total: '$136.50', +}; + +test.describe('embedded widget', () => { + test('widget.js boots the widget in an iframe, applies embed attributes, and auto-resizes', async ({ page, api, account, baseURL }) => { + const event = await createDraftEvent(api, account.organizerId); + const categories = await api.listProductCategories(event.eventId); + await api.createProduct(event.eventId, { + title: 'Embedded Ticket', + product_type: 'TICKET', + type: 'FREE', + product_category_id: categories[0].id, + prices: [{ price: 0 }], + description: '

General admission for the embedded event.

Doors open one hour before the show starts.

All sales are final and tickets are non-transferable.

', + }); + await api.publishEvent(event.eventId); + + const widget = await openEmbeddedWidget(page, baseURL!, event.eventId, { + 'continue-button-text': 'Grab tickets', + }); + + await expect(widget.getByRole('heading', { name: 'Embedded Ticket' })).toBeVisible(); + await expect(widget.getByTestId('checkout-continue-button')).toContainText('Grab tickets'); + + const iframe = widgetIframe(page); + await expect.poll(async () => (await iframe.boundingBox())?.height ?? 0).toBeGreaterThan(100); + const collapsedHeight = (await iframe.boundingBox())!.height; + + await widget.getByRole('button', { name: 'Details' }).click(); + await expect(widget.getByText('Doors open one hour before the show starts.')).toBeVisible(); + await expect.poll(async () => (await iframe.boundingBox())!.height).toBeGreaterThan(collapsedHeight); + }); + + test('a buyer completes checkout in the popup modal without leaving the host page', async ({ page, api, account, baseURL }) => { + const event = await createLiveEventWithFreeTicket(api, account.organizerId); + const buyer = { firstName: 'Embed', lastName: 'Buyer', email: uniqueEmail('embed-buyer') }; + + const widget = await openEmbeddedWidget(page, baseURL!, event.eventId); + await expect(widget.getByRole('heading', { name: event.productTitle })).toBeVisible(); + await setWidgetQuantity(widget, 1); + + const checkoutFrame = await openModalCheckout(page, widget); + const dialogBox = (await checkoutModalDialog(page).boundingBox())!; + expect(dialogBox.width).toBeGreaterThan(700); + expect(dialogBox.width).toBeLessThan(800); + + const checkout = new CheckoutPage(page, checkoutFrame); + await expect(checkoutFrame.getByRole('heading', { name: 'Your Details' })).toBeVisible(); + await checkout.fillOrderDetails(buyer); + await checkout.fillFirstAttendee(buyer); + await checkout.completeFreeOrder(); + + await expect(checkoutFrame.getByText(`You're going to ${event.title}`)).toBeVisible(); + expect(page.url()).toBe(EMBED_HOST_URL); + + await page.getByRole('button', { name: 'Close checkout' }).click(); + await expect(checkoutModal(page)).toHaveCount(0); + await expect(widget.getByRole('heading', { name: event.productTitle })).toBeVisible(); + }); + + test('the checkout modal is full-screen on mobile and closing it asks before abandoning the order', async ({ page, api, account, baseURL }) => { + await page.setViewportSize({ width: 390, height: 844 }); + + const event = await createLiveEventWithFreeTicket(api, account.organizerId); + const widget = await openEmbeddedWidget(page, baseURL!, event.eventId); + await setWidgetQuantity(widget, 1); + + const checkoutFrame = await openModalCheckout(page, widget); + await expect(checkoutFrame.getByRole('heading', { name: 'Your Details' })).toBeVisible(); + + const dialogBox = (await checkoutModalDialog(page).boundingBox())!; + expect(dialogBox.width).toBe(390); + expect(dialogBox.height).toBeGreaterThan(800); + + await page.getByRole('button', { name: 'Close checkout' }).click(); + await expect(checkoutFrame.getByText('Are you sure you want to leave?')).toBeVisible(); + await checkoutFrame.getByRole('button', { name: 'Yes, cancel my order' }).click(); + await expect(checkoutModal(page)).toHaveCount(0); + await expect(widget.getByRole('heading', { name: event.productTitle })).toBeVisible(); + }); + + test('a buyer completes the kitchen-sink checkout inside the embedded widget', async ({ page, api, account, publicApi, mailpit, baseURL }) => { + test.slow(); + + const scenario = await arrangeKitchenSinkEvent(api, account.organizerId); + const widget = await openEmbeddedWidget(page, baseURL!, scenario.eventId); + + await runKitchenSinkCheckout(page, scenario, { api, publicApi, mailpit }, { + paymentMode: 'offline', + attendeeCollection: 'PER_ATTENDEE', + totals: KITCHEN_SINK_TOTALS, + surfaces: { + select: widget, + resolveCheckout: () => resolveCheckoutFrame(page), + }, + }); + }); +}); diff --git a/e2e/tests/widget/widget-checkout.spec.ts b/e2e/tests/widget/widget-checkout.spec.ts index 06fc75368a..5b5d63c2ab 100644 --- a/e2e/tests/widget/widget-checkout.spec.ts +++ b/e2e/tests/widget/widget-checkout.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../../fixtures'; import { createLiveEventWithFreeTicket } from '../../api/factory'; +import { setWidgetQuantity } from '../../pages/checkout.page'; test.describe('widget checkout', () => { test('a buyer starts checkout from the standalone product widget', async ({ page, api, account }) => { @@ -9,7 +10,7 @@ test.describe('widget checkout', () => { await page.waitForLoadState('networkidle'); await expect(page.getByRole('heading', { name: event.productTitle })).toBeVisible(); - await page.locator('.hi-product-quantity-selector input').first().fill('1'); + await setWidgetQuantity(page, 1); const [checkoutPage] = await Promise.all([ page.context().waitForEvent('page'), diff --git a/e2e/tests/widget/widget-playground.spec.ts b/e2e/tests/widget/widget-playground.spec.ts new file mode 100644 index 0000000000..00bf40b1fd --- /dev/null +++ b/e2e/tests/widget/widget-playground.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '../../fixtures'; +import { setWidgetQuantity } from '../../pages/checkout.page'; +import { createLiveEventWithFreeTicket } from '../../api/factory'; + +test.describe('widget playground', () => { + test('the playground renders a widget with the configured settings', async ({ page, api, account }) => { + const event = await createLiveEventWithFreeTicket(api, account.organizerId); + + await page.goto(`/widget-test?id=${event.eventId}&continueButtonText=Playground+continue&hostBg=%2318181b`); + await page.waitForLoadState('networkidle'); + + const widget = page.frameLocator('iframe[title="Hi.Events Widget"]'); + await expect(widget.getByRole('heading', { name: event.productTitle })).toBeVisible(); + await expect(widget.getByTestId('checkout-continue-button')).toContainText('Playground continue'); + + await expect(page.locator('#embed-code')).toContainText(`data-hievents-id="${event.eventId}"`); + await expect(page.locator('#embed-code')).toContainText('data-hievents-continue-button-text="Playground continue"'); + + await setWidgetQuantity(widget, 1); + await widget.getByTestId('checkout-continue-button').click(); + await expect(page.getByRole('dialog', { name: 'Checkout' })).toBeVisible(); + const checkout = page.frameLocator('iframe[title="Hi.Events Checkout"]'); + await expect(checkout.getByRole('heading', { name: 'Your Details' })).toBeVisible(); + + await expect(page.locator('#message-log')).toContainText('resize'); + await expect(page.locator('#message-log')).toContainText('hievents:open-checkout'); + }); +}); diff --git a/frontend/public/widget-test.html b/frontend/public/widget-test.html deleted file mode 100644 index 427e081666..0000000000 --- a/frontend/public/widget-test.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - Title - - - - - - - -

- Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusantium, alias asperiores atque autem cumque -

- -

- Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusantium, alias asperiores atque autem cumque -

- -
-
- -
- -

- Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusantium, alias asperiores atque autem cumque -

-

- Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusantium, alias asperiores atque autem cumque -

- - - diff --git a/frontend/server.js b/frontend/server.js index e971f08583..244c7a644c 100644 --- a/frontend/server.js +++ b/frontend/server.js @@ -51,6 +51,22 @@ async function main() { } }); + const widgetTestPageEnabled = !isProduction || process.env.WIDGET_TEST_PAGE_ENABLED === 'true'; + + if (widgetTestPageEnabled) { + app.get('/widget-test', async (req, res) => { + try { + const widgetTestHtml = await fs.readFile(path.join(__dirname, './src/widget-test/index.html'), 'utf-8'); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('X-Robots-Tag', 'noindex'); + return res.status(200).send(widgetTestHtml); + } catch (error) { + return res.status(404).send(''); + } + }); + } + let vite; if (!isProduction) { diff --git a/frontend/src/components/common/Currency/index.tsx b/frontend/src/components/common/Currency/index.tsx index dbe77d56ca..ffce5a7b55 100644 --- a/frontend/src/components/common/Currency/index.tsx +++ b/frontend/src/components/common/Currency/index.tsx @@ -37,6 +37,28 @@ export const Currency: React.FC = ({ ); }; +export const getDisplayPrice = (price: ProductPrice, taxAndServiceFeeDisplayType?: string): number => { + const totalTaxAndFees = (price.tax_total || 0) + (price.fee_total || 0); + + return taxAndServiceFeeDisplayType === 'INCLUSIVE' + ? Number(price.price) + totalTaxAndFees + : Number(price.price); +}; + +export const getInclusiveFeeNote = (hasFees: boolean, hasTax: boolean): string => { + if (hasFees && hasTax) { + return t`incl. fees & tax`; + } + return hasFees ? t`incl. fees` : t`incl. tax`; +}; + +export const getExclusiveFeeNote = (formattedAmount: string, hasFees: boolean, hasTax: boolean): string => { + if (hasFees && hasTax) { + return t`+ ${formattedAmount} fees & tax`; + } + return hasFees ? t`+ ${formattedAmount} fees` : t`+ ${formattedAmount} tax`; +}; + interface ProductPriceProps { product: Product; price: ProductPrice; @@ -44,6 +66,7 @@ interface ProductPriceProps { className?: string; freeLabel?: string | null; taxAndServiceFeeDisplayType?: 'INCLUSIVE' | 'EXCLUSIVE'; + feeDisplay?: 'popover' | 'none'; } export const ProductPriceDisplay: React.FC = ({ @@ -53,10 +76,25 @@ export const ProductPriceDisplay: React.FC = ({ className, freeLabel, taxAndServiceFeeDisplayType = 'exclusive', + feeDisplay = 'popover', }) => { let displayPrice = price.price; const totalTaxAndFees = (price.tax_total || 0) + (price.fee_total || 0); + if (feeDisplay === 'none') { + const inclusiveAwarePrice = getDisplayPrice(price, taxAndServiceFeeDisplayType); + + if (inclusiveAwarePrice === 0 && totalTaxAndFees === 0) { + return {freeLabel || t`Free`}; + } + + return ( + + {formatCurrency(inclusiveAwarePrice, currency)} + + ); + } + // Order taxes and service fees for display const orderedFees = [...(product.taxes || [])].sort((a, b) => a.type.localeCompare(b.type)); const feeDescriptions = orderedFees.map(fee => fee.name).join(', '); diff --git a/frontend/src/components/common/JoinWaitlistButton/index.tsx b/frontend/src/components/common/JoinWaitlistButton/index.tsx index 2831fc0db4..2296c4ccce 100644 --- a/frontend/src/components/common/JoinWaitlistButton/index.tsx +++ b/frontend/src/components/common/JoinWaitlistButton/index.tsx @@ -2,6 +2,7 @@ import {Event, IdParam, Product} from "../../../types.ts"; import {useDisclosure} from "@mantine/hooks"; import {JoinWaitlistModal} from "../../modals/JoinWaitlistModal"; import {t} from "@lingui/macro"; +import {IconCheck} from "@tabler/icons-react"; import {useWaitlistJoined} from "../../../hooks/useWaitlistJoined.ts"; interface JoinWaitlistButtonProps { @@ -18,19 +19,25 @@ export const JoinWaitlistButton = ({product, event, productPriceId, priceLabel, return ( <> - + {hasJoined ? ( + + + {t`On the waitlist`} + + ) : ( + + )} {modalOpen && ( void; } -export const NumberSelector = ({formInstance, fieldName, min, max, sharedValues}: NumberSelectorProps) => { +export const NumberSelector = ({formInstance, fieldName, min, max, sharedValues, selectorSize = 'default', onLimitReached}: NumberSelectorProps) => { const handlers = useRef(null); - // Start with 0, ensuring it's treated as number for consistency - const [value, setValue] = useState(0); + const [value, setValue] = useState(() => Number(_.get(formInstance.values, fieldName) ?? 0)); const minValue = min || 0; const maxValue = max || 100; - const [sharedVals] = useState(sharedValues ?? new SharedValues(maxValue)); + const [sharedVals] = useState(() => { + const shared = sharedValues ?? new SharedValues(maxValue); + shared.changeValue(Number(_.get(formInstance.values, fieldName) ?? 0)); + return shared; + }); useEffect(() => { formInstance.setFieldValue(fieldName, value); }, [value]); useEffect(() => { - // to handle application promo code after updating the quantity - const formValue = _.get(formInstance.values, fieldName) + const formValue = Number(_.get(formInstance.values, fieldName) ?? 0); if (formValue !== value) { - formInstance.setFieldValue(fieldName, value); + const adjustedDifference = sharedVals.changeValue(formValue - value); + setValue(value + adjustedDifference); } }, [formInstance.values]); @@ -48,16 +54,19 @@ export const NumberSelector = ({formInstance, fieldName, min, max, sharedValues} 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))) + const adjustedDifference = sharedVals.changeValue(minValue - sharedVals.currentValue); + setValue(prevValue => prevValue + adjustedDifference); } else if (value < maxValue) { - setValue(prevValue => prevValue + sharedVals.changeValue(1)); + const adjustedDifference = sharedVals.changeValue(1); + setValue(prevValue => prevValue + adjustedDifference); } }; const decrement = () => { // Ensure decrement does not bring the current shared value between 0 and minValue if (sharedVals.currentValue > minValue) { - setValue(prevValue => prevValue + sharedVals.changeValue(-1)); + const adjustedDifference = sharedVals.changeValue(-1); + setValue(prevValue => prevValue + adjustedDifference); } else { sharedVals.changeValue(-value) setValue(0); @@ -69,38 +78,68 @@ export const NumberSelector = ({formInstance, fieldName, min, max, sharedValues} setValue(value + adjustedDifference); }; - return ( -
- event.preventDefault()} - className={classes.control} - > - - + const atMax = value >= maxValue || sharedVals.quantityRemaining == 0; - + const handleIncrement = () => { + if (atMax) { + onLimitReached?.(); + return; + } + increment(); + }; + + const isEmpty = value === 0; + const buttonSize = selectorSize === 'compact' + ? (isEmpty ? 30 : 26) + : (isEmpty ? 38 : 30); + const iconSize = selectorSize === 'compact' + ? (isEmpty ? 14 : 13) + : (isEmpty ? 16 : 15); + + return ( +
+ {value > 0 && ( + <> + event.preventDefault()} + className={classNames(classes.control, classes.decrement)} + > + + + + changeValue(Number(newValue) || 0)} + aria-label={t`Quantity`} + classNames={{input: classes.input}} + /> + + )} = maxValue || sharedVals.quantityRemaining == 0} + size={buttonSize} + radius={999} + variant={'transparent'} + onClick={handleIncrement} + aria-label={t`Increase quantity`} + aria-disabled={atMax} + data-limit={atMax || undefined} onMouseDown={(event) => event.preventDefault()} - className={classes.control} + className={classNames(classes.control, classes.increment)} > - +
); diff --git a/frontend/src/components/common/ProductSelector/index.tsx b/frontend/src/components/common/ProductSelector/index.tsx index 5dfb667210..d70662e985 100644 --- a/frontend/src/components/common/ProductSelector/index.tsx +++ b/frontend/src/components/common/ProductSelector/index.tsx @@ -1,7 +1,7 @@ import {MultiSelect, Select} from "@mantine/core"; import {IconTicket} from "@tabler/icons-react"; import {UseFormReturnType} from "@mantine/form"; -import {ProductCategory, ProductType} from "../../../types.ts"; +import {IdParam, ProductCategory, ProductType} from "../../../types.ts"; import React from "react"; import {t} from "@lingui/macro"; @@ -14,6 +14,7 @@ interface ProductSelectorProps { productFieldName: string; tierFieldName?: string; includedProductTypes?: ProductType[]; + excludedProductIds?: IdParam[]; multiSelect?: boolean; showTierSelector?: boolean; noProductsMessage?: string; @@ -28,15 +29,18 @@ export const ProductSelector = ({ productFieldName, tierFieldName = 'product_price_id', includedProductTypes = [ProductType.Ticket, ProductType.General], + excludedProductIds = [], multiSelect = true, showTierSelector = false, noProductsMessage = t`No products available for selection`, }: ProductSelectorProps) => { + const excludedIds = excludedProductIds.map(String); const formattedData = productCategories?.map((category) => ({ group: category.name, items: category.products - ?.filter((product) => includedProductTypes.includes(product.product_type)) + ?.filter((product) => includedProductTypes.includes(product.product_type) + && !excludedIds.includes(String(product.id))) ?.map((product) => ({ value: String(product.id), label: product.title, diff --git a/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx b/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx index ffe13be01a..292fb6aa99 100644 --- a/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx +++ b/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx @@ -7,6 +7,7 @@ import { IconEyeOff, IconLock, IconPackage, + IconPuzzle, IconPencil, IconReceipt, IconSend, @@ -47,6 +48,8 @@ interface SortableProductProps { categories: ProductCategory[]; } +const addonBadgeLabel = (count: number): string => count === 1 ? t`1 add-on` : t`${count} add-ons`; + export const SortableProduct = ({product, currencyCode, category, categories}: SortableProductProps) => { const [isEditModalOpen, editModal] = useDisclosure(false); const [isDuplicateModalOpen, duplicateModal] = useDisclosure(false); @@ -327,6 +330,36 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S )} + {product.is_addon_only && ( + + } + > + {t`Add-on only`} + + + )} + {!!product.addons?.length && ( + addon.title).join(', ')} + withArrow + > + } + > + {addonBadgeLabel(product.addons.length)} + + + )} {product.is_highlighted && ( void; + children: ReactNode; +} + +export const LedgerRow = ({id, icon, label, summary, opened, onToggle, children}: LedgerRowProps) => { + const contentId = `product-ledger-content-${id}`; + + return ( +
+ + +
+ {children} +
+
+
+ ); +}; diff --git a/frontend/src/components/forms/ProductForm/ProductDrawer.module.scss b/frontend/src/components/forms/ProductForm/ProductDrawer.module.scss new file mode 100644 index 0000000000..a96d5522cd --- /dev/null +++ b/frontend/src/components/forms/ProductForm/ProductDrawer.module.scss @@ -0,0 +1,155 @@ +@use "../../../styles/mixins"; + +.content { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.body { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: 0; +} + +.header { + display: flex; + align-items: center; + gap: var(--mantine-spacing-md); + padding: 18px var(--mantine-spacing-lg); + border-bottom: 1px solid var(--hi-color-gray-2); + background-color: var(--mantine-color-body); + flex-shrink: 0; +} + +.headerText { + display: flex; + align-items: baseline; + gap: var(--mantine-spacing-sm); + min-width: 0; +} + +.headerTitle { + margin: 0; + font-size: 1.0625rem; + font-weight: 700; + letter-spacing: -0.01em; + white-space: nowrap; +} + +.headerSubtitle { + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-gray-dark); + @include mixins.ellipsis(); +} + +.headerActions { + display: flex; + align-items: center; + gap: var(--mantine-spacing-sm); + margin-inline-start: auto; + flex-shrink: 0; +} + +.escHint { + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-text-tertiary); + + @include mixins.respond-below(md) { + display: none; + } +} + +.main { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + flex: 1; + min-height: 0; +} + +.formColumn { + overflow-y: auto; + padding: var(--mantine-spacing-lg); + outline: none; +} + +.previewColumn { + overflow-y: auto; + padding: var(--mantine-spacing-lg); + background-color: var(--hi-color-gray); + border-left: 1px solid var(--hi-color-gray-2); +} + +.footer { + display: flex; + align-items: center; + gap: var(--mantine-spacing-md); + padding: var(--mantine-spacing-md) var(--mantine-spacing-lg); + border-top: 1px solid var(--hi-color-gray-2); + background-color: var(--mantine-color-body); + flex-shrink: 0; +} + +.footerHint { + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-gray-dark); + min-width: 0; +} + +.footerActions { + display: flex; + gap: var(--mantine-spacing-sm); + margin-inline-start: auto; + flex-shrink: 0; +} + +.placeholder { + display: flex; + flex-direction: column; + gap: var(--mantine-spacing-lg); +} + +.statusBelowForm { + display: none; +} + +@include mixins.respond-below(xl) { + .main { + grid-template-columns: minmax(0, 1fr); + } + + .previewColumn { + display: none; + } + + .statusBelowForm { + display: block; + margin-top: var(--mantine-spacing-md); + } +} + +@include mixins.respond-below(sm) { + .header, + .footer { + padding-left: var(--mantine-spacing-md); + padding-right: var(--mantine-spacing-md); + } + + .formColumn { + padding: var(--mantine-spacing-md); + } + + .footerHint { + display: none; + } + + .footerActions { + flex: 1; + + button { + flex: 1; + } + } +} diff --git a/frontend/src/components/forms/ProductForm/ProductDrawer.tsx b/frontend/src/components/forms/ProductForm/ProductDrawer.tsx new file mode 100644 index 0000000000..876d872914 --- /dev/null +++ b/frontend/src/components/forms/ProductForm/ProductDrawer.tsx @@ -0,0 +1,148 @@ +import React, {useId} from "react"; +import {t} from "@lingui/macro"; +import {Button, CloseButton, Drawer, Skeleton, Text} from "@mantine/core"; +import {UseFormReturnType} from "@mantine/form"; +import {modals} from "@mantine/modals"; +import {Event, Product} from "../../../types.ts"; +import {ProductPreview, ProductVisibilityStatusLine} from "./ProductPreview.tsx"; +import classes from "./ProductDrawer.module.scss"; + +interface ProductDrawerProps { + onClose: () => void; + title: string; + event?: Event; + form: UseFormReturnType; + loading?: boolean; + submitLabel: string; + submitLoading: boolean; + submitTestId?: string; + onSubmit: (values: Product) => void; + children: React.ReactNode; +} + +const FormPlaceholder = () => ( +
+ + {[45, 70, 55, 65].map((width, index) => ( + + ))} +
+); + +export const ProductDrawer = ({ + onClose, + title, + event, + form, + loading, + submitLabel, + submitLoading, + submitTestId, + onSubmit, + children, + }: ProductDrawerProps) => { + const headingId = useId(); + const formId = useId(); + + const handleClose = () => { + if (!form.isDirty()) { + onClose(); + return; + } + + modals.openConfirmModal({ + title: t`Discard changes?`, + children: ( + + {t`You have unsaved changes. Are you sure you want to discard them?`} + + ), + labels: {confirm: t`Discard`, cancel: t`Keep editing`}, + confirmProps: {color: 'red'}, + zIndex: 400, + onConfirm: onClose, + }); + }; + + const handleKeyDown = (keyboardEvent: React.KeyboardEvent) => { + if ((keyboardEvent.metaKey || keyboardEvent.ctrlKey) && keyboardEvent.key === 'Enter') { + keyboardEvent.preventDefault(); + (document.getElementById(formId) as HTMLFormElement | null)?.requestSubmit(); + } + }; + + return ( + +
+
+

{title}

+ {event?.title && {event.title}} +
+
+ {t`Esc to close`} + +
+
+ +
+
+ {loading + ? + : ( + <> +
+ {children} +
+
+ +
+ + )} +
+ +
+ +
+ + {t`Only a name is required — everything else has sensible defaults`} + +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/components/forms/ProductForm/ProductForm.module.scss b/frontend/src/components/forms/ProductForm/ProductForm.module.scss index 8d9ebd916c..c8f19ea07e 100644 --- a/frontend/src/components/forms/ProductForm/ProductForm.module.scss +++ b/frontend/src/components/forms/ProductForm/ProductForm.module.scss @@ -1,8 +1,10 @@ +@use "../../../styles/mixins"; + .priceTierCard { position: relative; padding: var(--hi-spacing-md) var(--hi-spacing-lg); margin-bottom: var(--hi-spacing-md); - border-left: 3px solid var(--mantine-color-blue-5); + border-left: 3px solid var(--mantine-color-primary-5); h3 { margin: 0 0 var(--hi-spacing-sm) 0; @@ -21,7 +23,7 @@ &.disabled { svg { - color: #b4b4b4; + color: var(--hi-color-text-tertiary); } } } @@ -32,110 +34,182 @@ } } -.visibilityOptions { - display: flex; - flex-direction: column; - gap: var(--hi-spacing-sm); +.typeCards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--hi-spacing-md); + margin-bottom: var(--hi-spacing-lg); } -.additionalToggle { +.typeCard { display: flex; - flex-direction: column; - gap: 4px; - padding: var(--hi-spacing-sm) var(--hi-spacing-md); - margin-top: var(--hi-spacing-sm); - margin-bottom: var(--hi-spacing-xl); + align-items: center; + gap: var(--hi-spacing-md); + padding: var(--hi-spacing-md) var(--hi-spacing-lg); + border: 1.5px solid var(--hi-color-gray-2); + border-radius: var(--hi-radius-lg); + background-color: var(--mantine-color-body); + font: inherit; + text-align: left; cursor: pointer; - background: var(--mantine-color-gray-0); - border: 1px solid var(--mantine-color-gray-2); - border-radius: var(--mantine-radius-md); - transition: all 0.15s ease; + transition: border-color 150ms cubic-bezier(0.4, 0, 0.2, 1), background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); - &:hover { - background: var(--mantine-color-gray-1); - border-color: var(--mantine-color-gray-3); + &:focus-visible { + outline: 2px solid var(--mantine-color-primary-4); + outline-offset: 2px; + } - .toggleLabel { - color: var(--mantine-color-gray-8); - } + &.selected { + border-color: var(--hi-primary); + background-color: var(--hi-accent-brand-soft); - svg { - color: var(--mantine-color-gray-6); + .typeCardIcon { + color: var(--hi-primary); } } - &:focus-visible { - outline: 2px solid var(--mantine-color-violet-4); - outline-offset: 2px; + &:disabled { + opacity: 0.5; + cursor: not-allowed; } +} + +.typeCardIcon { + display: inline-flex; + flex-shrink: 0; + color: var(--hi-color-gray-dark); +} - &.hasReminder { - border-color: var(--mantine-color-orange-3); - background: var(--mantine-color-orange-0); +.typeCardText { + display: flex; + flex-direction: column; + min-width: 0; +} - &:hover { - background: var(--mantine-color-orange-1); - border-color: var(--mantine-color-orange-4); - } - } +.typeCardLabel { + font-weight: 600; + font-size: var(--mantine-font-size-sm); +} - .toggleMain { - display: flex; - align-items: center; - gap: var(--hi-spacing-xs); +.typeCardDescription { + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-gray-dark); +} - svg { - flex-shrink: 0; - color: var(--mantine-color-gray-5); - transition: color 0.15s ease; - } +.priceBlock { + margin-bottom: var(--hi-spacing-sm); +} + +.priceBlockHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--hi-spacing-md); + margin-bottom: var(--hi-spacing-md); + flex-wrap: wrap; +} + +.priceBlockLabel { + font-weight: 600; + font-size: var(--mantine-font-size-sm); +} + +.ledger { + margin-bottom: var(--hi-spacing-lg); +} + +.ledgerHeading { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--hi-color-gray-dark); + padding-bottom: var(--hi-spacing-md); +} + +.ledgerRow { + border-top: 1px solid var(--hi-color-border); + + &:last-child { + border-bottom: 1px solid var(--hi-color-border); } +} - .toggleLabel { - font-size: 0.875rem; - font-weight: 500; - color: var(--mantine-color-gray-7); - transition: color 0.15s ease; +.ledgerRowButton { + display: flex; + align-items: center; + gap: var(--hi-spacing-md); + width: 100%; + min-height: 44px; + padding: var(--hi-spacing-md) var(--hi-spacing-sm); + border: none; + background: none; + font: inherit; + text-align: left; + cursor: pointer; + border-radius: var(--hi-radius-sm); + + &:hover { + background-color: var(--hi-color-gray); } - .toggleMeta { - margin-left: calc(16px + var(--hi-spacing-xs)); + &:focus-visible { + outline: 2px solid var(--mantine-color-primary-4); + outline-offset: -2px; } +} - .toggleDescription { - font-size: 0.8rem; - color: var(--mantine-color-gray-5); +.ledgerRowIcon { + display: inline-flex; + flex-shrink: 0; + color: var(--hi-primary); +} + +.ledgerRowLabel { + font-weight: 600; + font-size: var(--mantine-font-size-sm); + flex-shrink: 0; +} + +.ledgerRowSummary { + flex: 1; + min-width: 0; + text-align: right; + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-gray-dark); + @include mixins.ellipsis(); + + &.emphasized { + color: var(--hi-primary); + font-weight: 600; } +} - .taxReminder { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 0.8rem; - font-weight: 500; - color: var(--mantine-color-orange-7); +.ledgerRowChevron { + flex-shrink: 0; + margin-inline-start: auto; + color: var(--hi-color-text-tertiary); + transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1); - svg { - color: var(--mantine-color-orange-5); - } + &.open { + transform: rotate(180deg); } } -.additionalOptionsContent { +.ledgerRowContent { + padding: var(--hi-spacing-md) var(--hi-spacing-sm) var(--hi-spacing-lg); +} + +.switchStack { display: flex; flex-direction: column; - gap: var(--hi-spacing-lg); - margin-bottom: var(--hi-spacing-xl); + gap: var(--hi-spacing-sm); } -.fieldsetLegend { - display: inline-flex; - align-items: center; - gap: var(--hi-spacing-xs); - - svg { - flex-shrink: 0; - } +.fieldHint { + margin: var(--hi-spacing-xs) 0 0; + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-gray-dark); } .addTaxFeeButton { diff --git a/frontend/src/components/forms/ProductForm/ProductPreview.module.scss b/frontend/src/components/forms/ProductForm/ProductPreview.module.scss new file mode 100644 index 0000000000..6c02b5bd99 --- /dev/null +++ b/frontend/src/components/forms/ProductForm/ProductPreview.module.scss @@ -0,0 +1,83 @@ +.preview { + display: flex; + flex-direction: column; + gap: var(--hi-spacing-md); +} + +.eyebrow { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--hi-color-gray-dark); +} + +.widgetWrap { + pointer-events: none; + user-select: none; + border-radius: var(--hi-radius-lg); + overflow: hidden; + transition: opacity 250ms cubic-bezier(0.4, 0, 0.2, 1); + + &[data-hidden] { + opacity: 0.45; + } + + :global(.hi-promo-code-row) { + display: none; + } + + :global(.hi-donation-input-wrapper .mantine-TextInput-root) { + width: 110px !important; + } +} + +.statusLine { + display: flex; + align-items: flex-start; + gap: var(--hi-spacing-md); + padding: var(--hi-spacing-md); + border-radius: var(--hi-radius-md); + font-size: var(--mantine-font-size-sm); + line-height: 1.4; +} + +.statusDot { + flex-shrink: 0; + width: 8px; + height: 8px; + margin-top: 6px; + border-radius: 50%; +} + +.visible { + background-color: var(--hi-color-success-soft); + color: var(--hi-color-success); + + .statusDot { + background-color: var(--hi-color-success); + } +} + +.conditional { + background-color: color-mix(in srgb, var(--hi-color-warning) 12%, transparent); + color: var(--hi-color-warning); + + .statusDot { + background-color: var(--hi-color-warning); + } +} + +.hidden { + background-color: color-mix(in srgb, var(--hi-color-danger) 10%, transparent); + color: var(--hi-color-danger); + + .statusDot { + background-color: var(--hi-color-danger); + } +} + +.footnote { + font-size: 0.6875rem; + color: var(--hi-color-text-tertiary); +} diff --git a/frontend/src/components/forms/ProductForm/ProductPreview.tsx b/frontend/src/components/forms/ProductForm/ProductPreview.tsx new file mode 100644 index 0000000000..71c0ffef90 --- /dev/null +++ b/frontend/src/components/forms/ProductForm/ProductPreview.tsx @@ -0,0 +1,96 @@ +import {useMemo} from "react"; +import {t} from "@lingui/macro"; +import {UseFormReturnType} from "@mantine/form"; +import {useDebouncedValue} from "@mantine/hooks"; +import {useParams} from "react-router"; +import classNames from "classnames"; +import {Event, Product} from "../../../types.ts"; +import {useGetEventSettings} from "../../../queries/useGetEventSettings.ts"; +import {useGetTaxesAndFees} from "../../../queries/useGetTaxesAndFees.ts"; +import {nowInTimezone} from "../../../utilites/dates.ts"; +import {buildPreviewEvent} from "./buildPreviewEvent.ts"; +import {computeVisibilityStatus} from "./visibilityStatus.ts"; +import SelectProducts from "../../routes/product-widget/SelectProducts"; +import classes from "./ProductPreview.module.scss"; + +interface ProductPreviewProps { + form: UseFormReturnType; + event?: Event; +} + +interface ProductVisibilityStatusLineProps extends ProductPreviewProps { + dataTestId?: string; +} + +export const ProductVisibilityStatusLine = ({form, event, dataTestId}: ProductVisibilityStatusLineProps) => { + if (!event) { + return null; + } + + const status = computeVisibilityStatus(form.values, nowInTimezone(event.timezone)); + + return ( +
+ + {status.message} +
+ ); +}; + +export const ProductPreview = ({form, event}: ProductPreviewProps) => { + const {eventId} = useParams(); + const {data: eventSettings} = useGetEventSettings(eventId); + const {data: taxesAndFees} = useGetTaxesAndFees(); + const [debouncedValues] = useDebouncedValue(form.values, 200); + + const previewEvent = useMemo(() => { + if (!event) { + return undefined; + } + + return buildPreviewEvent( + event, + debouncedValues, + nowInTimezone(event.timezone), + taxesAndFees?.data, + eventSettings ?? event.settings, + ); + }, [event, debouncedValues, taxesAndFees, eventSettings]); + + if (!event || !previewEvent) { + return null; + } + + const isHiddenFromEveryone = computeVisibilityStatus(form.values, nowInTimezone(event.timezone)).level === 'hidden'; + + return ( +
+
{t`Live preview`}
+
+ +
+ +
{t`Updates as you type.`}
+
+ ); +}; diff --git a/frontend/src/components/forms/ProductForm/ProductPriceTierForm.tsx b/frontend/src/components/forms/ProductForm/ProductPriceTierForm.tsx new file mode 100644 index 0000000000..533c0f5cb8 --- /dev/null +++ b/frontend/src/components/forms/ProductForm/ProductPriceTierForm.tsx @@ -0,0 +1,114 @@ +import {t, Trans} from "@lingui/macro"; +import {UseFormReturnType} from "@mantine/form"; +import {Event, EventType, Product} from "../../../types.ts"; +import {ActionIcon, NumberInput, Switch, TextInput} from "@mantine/core"; +import {IconTrash, IconTrashOff} from "@tabler/icons-react"; +import {Callout} from "../../common/Callout"; +import {NavLink} from "react-router"; +import {getCurrencySymbol} from "../../../utilites/currency.ts"; +import {Card} from "../../common/Card"; +import classes from './ProductForm.module.scss'; +import {InputGroup} from "../../common/InputGroup"; +import {showError} from "../../../utilites/notifications.tsx"; +import classNames from "classnames"; + +interface ProductPriceTierFormProps { + form: UseFormReturnType, + product?: Product, + event?: Event, +} + +export const hasQuantityValue = (value: unknown): boolean => + value !== undefined && value !== null && value !== ''; + +export const SeriesQuantityWarning = ({eventId}: { eventId?: string | number }) => ( + + + This limits total sales across every date in your schedule combined — it is not a + per-date limit. To limit attendance for each date, set a capacity on the Occurrence Schedule page. + + +); + +export const ProductPriceTierForm = ({form, product, event}: ProductPriceTierFormProps) => { + const isRecurringTicket = event?.type === EventType.RECURRING && form.values.product_type === 'TICKET'; + + return form?.values?.prices?.map((price, index) => { + const existingPrice = product?.prices?.find((p) => Number(p.id) === Number(price.id)); + const deleteDisabled = form?.values?.prices?.length === 1 || (existingPrice && Number(existingPrice?.quantity_sold) > 0); + const cannotDeleteTitle = (() => { + if (existingPrice && Number(existingPrice?.quantity_sold) > 0) { + return t`You cannot delete this price tier because there are already products sold for this tier. You can hide it instead.` + } + if (form?.values?.prices?.length === 1) { + return t`You must have at least one price tier` + } + return ''; + })(); + + return ( + +

{price.label || Tier {index + 1}}

+ + + + + + {!product && isRecurringTicket && hasQuantityValue(price.initial_quantity_available) && ( + + )} + + + + + + + + { + if (deleteDisabled) { + showError(cannotDeleteTitle); + return; + } + form.removeListItem('prices', index) + }} + > + {!deleteDisabled && } + {deleteDisabled && } + +
+ ); + }) +} diff --git a/frontend/src/components/forms/ProductForm/buildPreviewEvent.ts b/frontend/src/components/forms/ProductForm/buildPreviewEvent.ts new file mode 100644 index 0000000000..332a9f6e8a --- /dev/null +++ b/frontend/src/components/forms/ProductForm/buildPreviewEvent.ts @@ -0,0 +1,135 @@ +import {t} from "@lingui/macro"; +import { + Event, + EventSettings, + EventType, + Product, + ProductPrice, + ProductPriceType, + ProductType, + TaxAndFee, + TaxAndFeeCalculationType, + TaxAndFeeType, +} from "../../../types.ts"; +import {Constants} from "../../../constants.ts"; + +const sumForType = (taxesAndFees: TaxAndFee[], type: TaxAndFeeType, basePrice: number): number => { + return taxesAndFees + .filter((item) => item.type === type) + .reduce((total, item) => { + if (item.calculation_type === TaxAndFeeCalculationType.Percentage) { + return total + (basePrice * Number(item.rate || 0)) / 100; + } + return total + Number(item.rate || 0); + }, 0); +}; + +const buildPreviewPrice = ( + id: number, + price: number, + selectedTaxesAndFees: TaxAndFee[], + overrides: Partial = {}, +): ProductPrice => ({ + id, + price, + is_available: true, + is_sold_out: false, + quantity_remaining: 100, + tax_total: Number(sumForType(selectedTaxesAndFees, TaxAndFeeType.Tax, price).toFixed(2)), + fee_total: Number(sumForType(selectedTaxesAndFees, TaxAndFeeType.Fee, price).toFixed(2)), + ...overrides, +}); + +const buildPreviewPrices = ( + values: Product, + selectedTaxesAndFees: TaxAndFee[], + nowInEventTz: string, +): ProductPrice[] => { + if (values.type === ProductPriceType.Tiered) { + const tiers = values.prices || []; + const visibleTiers = tiers.filter((tier) => !tier.is_hidden); + + return (visibleTiers.length > 0 ? visibleTiers : tiers).map((tier, index) => { + const isBeforeSaleStart = !!tier.sale_start_date && String(tier.sale_start_date) > nowInEventTz; + const isAfterSaleEnd = !!tier.sale_end_date && String(tier.sale_end_date) < nowInEventTz; + + return buildPreviewPrice(index + 1, Number(tier.price || 0), selectedTaxesAndFees, { + label: tier.label || t`Tier ${index + 1}`, + is_available: !isBeforeSaleStart && !isAfterSaleEnd, + is_before_sale_start_date: isBeforeSaleStart, + is_after_sale_end_date: isAfterSaleEnd, + }); + }); + } + + const basePrice = values.type === ProductPriceType.Free ? 0 : Number(values.prices?.[0]?.price || 0); + + return [buildPreviewPrice(1, basePrice, selectedTaxesAndFees)]; +}; + +const previewQuantityAvailable = (values: Product): number | undefined => { + if (!values.show_quantity_remaining) { + return undefined; + } + + const quantities = (values.type === ProductPriceType.Tiered ? values.prices || [] : [values.prices?.[0]]) + .map((price) => price?.initial_quantity_available) + .filter((quantity): quantity is number => quantity !== undefined && quantity !== null && String(quantity) !== ''); + + if (quantities.length === 0) { + return Constants.INFINITE_TICKETS; + } + + return quantities.reduce((total, quantity) => total + Number(quantity), 0); +}; + +export const buildPreviewEvent = ( + event: Event, + values: Product, + nowInEventTz: string, + taxesAndFees?: TaxAndFee[], + settings?: EventSettings, +): Event => { + const selectedTaxesAndFees = (taxesAndFees || []).filter( + (item) => (values.tax_and_fee_ids || []).map(String).includes(String(item.id)), + ); + + const prices = buildPreviewPrices(values, selectedTaxesAndFees, nowInEventTz); + + const previewProduct: Product = { + id: -1, + title: values.title?.trim() || (values.product_type === ProductType.General ? t`Untitled product` : t`Untitled ticket`), + description: values.description, + type: values.type, + product_type: values.product_type, + price: prices[0]?.price, + prices, + min_per_order: values.min_per_order ? Number(values.min_per_order) : undefined, + max_per_order: values.max_per_order ? Number(values.max_per_order) : undefined, + start_collapsed: values.start_collapsed, + show_quantity_remaining: values.show_quantity_remaining, + quantity_available: previewQuantityAvailable(values), + is_highlighted: values.is_highlighted, + highlight_message: values.highlight_message, + waitlist_enabled: false, + is_available: true, + is_sold_out: false, + taxes: selectedTaxesAndFees, + }; + + const categoryName = event.product_categories?.find( + (category) => String(category.id) === String(values.product_category_id), + )?.name; + + return { + ...event, + type: EventType.SINGLE, + occurrences: [], + settings: settings || event.settings, + product_categories: [{ + id: -1, + name: categoryName || t`Tickets`, + products: [previewProduct], + }], + }; +}; diff --git a/frontend/src/components/forms/ProductForm/index.tsx b/frontend/src/components/forms/ProductForm/index.tsx index cd0c74956a..895a88ec74 100644 --- a/frontend/src/components/forms/ProductForm/index.tsx +++ b/frontend/src/components/forms/ProductForm/index.tsx @@ -1,208 +1,100 @@ import {t, Trans} from "@lingui/macro"; import {UseFormReturnType} from "@mantine/form"; import { - Event, EventType, Product, ProductPriceType, + ProductType, TaxAndFee, - TaxAndFeeCalculationType, TaxAndFeeType } from "../../../types.ts"; import { - ActionIcon, Alert, Button, - Collapse, ComboboxItem, MultiSelect, NumberInput, + SegmentedControl, Select, Switch, TextInput } from "@mantine/core"; import { + IconAlignLeft, IconCalendar, - IconCash, - IconChevronDown, - IconChevronUp, - IconCoinOff, - IconCoins, IconEye, IconFlame, - IconHeartDollar, IconPlus, + IconPuzzle, IconReceipt, IconShirt, IconShoppingCart, IconTicket, - IconTrash, - IconTrashOff, + IconUsers, + IconWorld, } from "@tabler/icons-react"; import {Callout} from "../../common/Callout"; import {useDisclosure} from "@mantine/hooks"; import {NavLink, useParams} from "react-router"; -import {useEffect} from "react"; -import {CustomSelect, ItemProps} from "../../common/CustomSelect"; -import {formatCurrency, getCurrencySymbol} from "../../../utilites/currency.ts"; +import {useEffect, useState} from "react"; +import {getCurrencySymbol} from "../../../utilites/currency.ts"; import {useGetEvent} from "../../../queries/useGetEvent.ts"; import {useGetTaxesAndFees} from "../../../queries/useGetTaxesAndFees.ts"; -import {Card} from "../../common/Card"; import classes from './ProductForm.module.scss'; import {Fieldset} from "../../common/Fieldset"; import {Editor} from "../../common/Editor"; import {InputGroup} from "../../common/InputGroup"; -import {showError} from "../../../utilites/notifications.tsx"; import classNames from "classnames"; import {InputLabelWithHelp} from "../../common/InputLabelWithHelp"; import {CreateTaxOrFeeModal} from "../../modals/CreateTaxOrFeeModal"; +import {hasQuantityValue, ProductPriceTierForm, SeriesQuantityWarning} from "./ProductPriceTierForm.tsx"; +import {LedgerRow, LedgerRowId} from "./LedgerRow.tsx"; +import {ProductSelector} from "../../common/ProductSelector"; +import { + accessSummary, + addonsSummary, + descriptionSummary, + eventPageSummary, + highlightSummary, + orderLimitsSummary, + saleWindowSummary, + taxAndFeeLabel, + taxesSummary, + waitlistSummary, +} from "./ledgerSummaries.ts"; interface ProductFormProps { form: UseFormReturnType, product?: Product, - event?: Event, } -const hasQuantityValue = (value: unknown): boolean => - value !== undefined && value !== null && value !== ''; - -const SeriesQuantityWarning = ({eventId}: { eventId?: string | number }) => ( - - - This limits total sales across every date in your schedule combined — it is not a - per-date limit. To limit attendance for each date, set a capacity on the Occurrence Schedule page. - - -); - -const ProductPriceTierForm = ({form, product, event}: ProductFormProps) => { - const isRecurringTicket = event?.type === EventType.RECURRING && form.values.product_type === 'TICKET'; - - return form?.values?.prices?.map((price, index) => { - const existingPrice = product?.prices?.find((p) => Number(p.id) === Number(price.id)); - const deleteDisabled = form?.values?.prices?.length === 1 || (existingPrice && Number(existingPrice?.quantity_sold) > 0); - const cannotDeleteTitle = (() => { - if (existingPrice && Number(existingPrice?.quantity_sold) > 0) { - return t`You cannot delete this price tier because there are already products sold for this tier. You can hide it instead.` - } - if (form?.values?.prices?.length === 1) { - return t`You must have at least one price tier` - } - return ''; - })(); - - return ( - -

{price.label || Tier {index + 1}}

- - - - - - {!product && isRecurringTicket && hasQuantityValue(price.initial_quantity_available) && ( - - )} - - - - - - - - { - if (deleteDisabled) { - showError(cannotDeleteTitle); - return; - } - form.removeListItem('prices', index) - }} - > - {!deleteDisabled && } - {deleteDisabled && } - -
- ); - }) -} +const LEDGER_ROW_ORDER: LedgerRowId[] = [ + 'description', + 'sale-window', + 'event-page', + 'waitlist', + 'taxes', + 'order-limits', + 'addons', + 'highlight', + 'access', +]; + +const FIELD_TO_LEDGER_ROW: Array<[RegExp, LedgerRowId]> = [ + [/^description$/, 'description'], + [/^(sale_start_date|sale_end_date|hide_before_sale_start_date|hide_after_sale_end_date)$/, 'sale-window'], + [/^(show_quantity_remaining|hide_when_sold_out|start_collapsed)$/, 'event-page'], + [/^waitlist_enabled$/, 'waitlist'], + [/^tax_and_fee_ids/, 'taxes'], + [/^(min_per_order|max_per_order)$/, 'order-limits'], + [/^(addon_product_ids|is_addon_only)$/, 'addons'], + [/^(is_highlighted|highlight_message)$/, 'highlight'], + [/^(is_hidden|is_hidden_without_promo_code)$/, 'access'], +]; export const ProductForm = ({form, product}: ProductFormProps) => { - const productTypeOptions: ItemProps[] = [ - { - icon: , - label: t`Ticket`, - value: 'TICKET', - description: t`This product is a ticket. Buyers will be issued a ticket upon purchase`, - }, - { - icon: , - label: t`General`, - value: 'GENERAL', - description: t`This is a general product, like a t-shirt or a mug. No ticket will be issued`, - }, - ]; - - const productPriceOptions: ItemProps[] = [ - { - icon: , - label: t`Paid Product`, - value: 'PAID', - description: t`Standard product with a fixed price`, - }, - { - icon: , - label: t`Free Product`, - value: 'FREE', - description: t`Free product, no payment information required`, - }, - { - icon: , - label: t`Donation / Pay what you'd like product`, - value: 'DONATION', - description: t`Set a minimum price and let users pay more if they choose`, - }, - { - icon: , - label: t`Tiered Product`, - value: 'TIERED', - description: t`Multiple price options. Perfect for early bird products etc.`, - }, - ]; - const {eventId} = useParams(); - const [opened, {toggle}] = useDisclosure(false); + const [openRows, setOpenRows] = useState>(new Set()); const [taxFeeModalOpen, {open: openTaxFeeModal, close: closeTaxFeeModal}] = useDisclosure(false); const isFreeProduct = form.values.type === 'FREE'; const isDonationProduct = form.values.type === 'DONATION'; @@ -210,6 +102,7 @@ export const ProductForm = ({form, product}: ProductFormProps) => { const {data: taxesAndFees} = useGetTaxesAndFees(); const isRecurring = event?.type === EventType.RECURRING; const isRecurringTicket = isRecurring && form.values.product_type === 'TICKET'; + const typeLocked = Number(product?.quantity_sold) > 0; const handleTaxOrFeeCreated = (taxOrFee: TaxAndFee) => { const currentIds = form.values.tax_and_fee_ids || []; @@ -220,51 +113,129 @@ export const ProductForm = ({form, product}: ProductFormProps) => { return taxesAndFees?.data ?.filter((item) => item.type === type) .map((item: TaxAndFee) => ({ - label: item.name + ' - ' + (item.calculation_type === TaxAndFeeCalculationType.Percentage - ? item.rate + '%' - : formatCurrency(Number(item.rate), event?.currency || 'USD')), + label: taxAndFeeLabel(item, event?.currency), value: String(item.id), })) || []; } useEffect(() => { - if (form.values.type === ProductPriceType.Free) { + if (form.values.type === ProductPriceType.Free && form.values.price !== 0.00) { form.setFieldValue('price', 0.00); } - }, [form, form.values.type]); + }, [form.values.type, form.values.price]); useEffect(() => { if (event?.product_categories && event.product_categories.length === 1) { - form.setFieldValue('product_category_id', String(event.product_categories[0].id)); + const categoryId = String(event.product_categories[0].id); + if (form.values.product_category_id !== categoryId) { + form.setFieldValue('product_category_id', categoryId); + form.resetDirty(); + } } }, [event?.product_categories]); + useEffect(() => { + const errorRows = new Set(); + Object.keys(form.errors).forEach((field) => { + const match = FIELD_TO_LEDGER_ROW.find(([pattern]) => pattern.test(field)); + if (match) { + errorRows.add(match[1]); + } + }); + + if (errorRows.size === 0) { + return; + } + + setOpenRows((previous) => new Set([...previous, ...errorRows])); + + const firstErrorRow = LEDGER_ROW_ORDER.find((rowId) => errorRows.has(rowId)); + if (firstErrorRow) { + requestAnimationFrame(() => { + document.getElementById(`product-ledger-row-${firstErrorRow}`) + ?.scrollIntoView({block: 'nearest', behavior: 'smooth'}); + }); + } + }, [form.errors]); + + const toggleRow = (rowId: LedgerRowId) => { + setOpenRows((previous) => { + const next = new Set(previous); + if (next.has(rowId)) { + next.delete(rowId); + } else { + next.add(rowId); + } + return next; + }); + }; + const removeTaxesAndFees = () => { form.setFieldValue('tax_and_fee_ids', []); }; - // Context-aware helpers - const hasTaxes = form.values.tax_and_fee_ids && form.values.tax_and_fee_ids.length > 0; - const hasLimits = form.values.min_per_order || form.values.max_per_order; - const hasSalePeriod = form.values.sale_start_date || form.values.sale_end_date; - const hasHighlight = form.values.is_highlighted; + const showCategorySelect = (event?.product_categories?.length ?? 0) >= 2; + + const nameInput = ( + + ); return ( <> - {Number(product?.quantity_sold) > 0 && ( + {typeLocked && ( {t`You cannot change the product type as there are attendees associated with this product.`} )} - 0} - label={t`Product Type`} - required - form={form} - name={'product_type'} - optionList={productTypeOptions} - /> +
+ {[ + { + value: ProductType.Ticket, + icon: , + label: t`Ticket`, + description: t`Admits attendees to your event`, + testId: 'product-type-ticket', + }, + { + value: ProductType.General, + icon: , + label: t`Product`, + description: t`T-shirts, mugs and more`, + testId: 'product-type-general', + }, + ].map((option) => ( + + ))} +
{form.errors.product_type && ( @@ -272,319 +243,378 @@ export const ProductForm = ({form, product}: ProductFormProps) => { )} - 0} - label={t`Price Type`} - required - form={form} - name={'type'} - optionList={productPriceOptions} - /> - - {form.errors.type && ( - - {form.errors.type} - - )} - - {form.values.type === ProductPriceType.Tiered && ( - - - Tiered products allow you to offer multiple price options for the same product. - This is perfect for early bird products, or offering different price - options for different groups of people. - - - )} - - - - form.setFieldValue('description', value)} - /> - - } + placeholder={t`Select category...`} + data={event?.product_categories?.map((category) => ({ + value: String(category.id), + label: category.name, + }))} + /> + + ) : nameInput} + +
+
+ {t`Pricing`} + form.setFieldValue('type', value as ProductPriceType)} + disabled={typeLocked} + data-testid="product-price-type" + data={[ + {label: t`Paid`, value: ProductPriceType.Paid}, + {label: t`Free`, value: ProductPriceType.Free}, + {label: t`Donation`, value: ProductPriceType.Donation}, + {label: t`Tiers`, value: ProductPriceType.Tiered}, + ]} + />
- {!opened && ( -
- - {[ - hasTaxes && t`Taxes configured`, - hasLimits && t`Order limits set`, - hasSalePeriod && t`Sale period set`, - hasHighlight && t`Highlighted`, - ].filter(Boolean).join(' · ') || t`Sale period, order limits, visibility`} - -
- )} -
- -
-
- - {t`Taxes and Fees`} - - }> - - - - {(form.values.type === ProductPriceType.Free && !!form.values.tax_and_fee_ids?.length) && ( - -

- {t`You have taxes and fees added to a Free Product. Would you like to remove them?`} -

- -
- )} -
+ {form.errors.type && ( + + {form.errors.type} + + )} -
- - {t`Order Limits`} - - }> + {form.values.type !== ProductPriceType.Tiered && ( + <> - - + +

+ Please enter the price excluding taxes and fees. +

+

+ Taxes and fees can be added below. +

+ + )} + />} + placeholder="19.99"/> + +

+ This is the total quantity available across every date in your + schedule combined — not a per-date limit. To limit attendance for + each date, set a capacity on the Occurrence Schedule + page. +

+ + ) : ( + +

+ The number of products available for this product +

+

+ This value can be overridden if there are Capacity + Limits associated with this product. +

+
+ )} + />} + />
-
- -
- - {t`Sale Period`} - - }> - {isRecurring && ( - - Sale period dates apply across all dates in your schedule. To control pricing and - availability for individual dates, use the overrides on the Occurrence Schedule page. - + {!product && isRecurringTicket && hasQuantityValue(form.values.prices?.[0]?.initial_quantity_available) && ( + )} - - - - -
+ + )} -
- - {t`Visibility`} - - }> -
- - - - - - {t`You can create a promo code which targets this product on the`} - {t`Promo Code page`}} - {...form.getInputProps('is_hidden_without_promo_code', {type: 'checkbox'})} - label={t`Hide product unless user has applicable promo code`} - /> - + {form.values.type === ProductPriceType.Tiered && ( + <> + + + Tiered products allow you to offer multiple price options for the same product. + This is perfect for early bird products, or offering different price + options for different groups of people. + + +
+ {isRecurring && ( + + These prices apply across all dates in your schedule, and tier quantities limit + total sales across all dates combined. Sale dates on tiers apply globally. You can + override prices for individual dates on the Occurrence Schedule + page. + + )} +
+ + +
+
+ + )} +
- +
{t`Everything else — tap to edit`}
+ + } + label={t`Description`} + summary={descriptionSummary(form.values)} + opened={openRows.has('description')} + onToggle={toggleRow} + > + form.setFieldValue('description', value)} + error={form.errors.description as string} + /> + + + } + label={t`Sale window`} + summary={saleWindowSummary(form.values)} + opened={openRows.has('sale-window')} + onToggle={toggleRow} + > + {isRecurring && ( + + Sale period dates apply across all dates in your schedule. To control pricing and + availability for individual dates, use the overrides on the Occurrence Schedule page. + + )} + + + + + + + + + } + label={t`On the event page`} + summary={eventPageSummary(form.values)} + opened={openRows.has('event-page')} + onToggle={toggleRow} + > +
+ + + +
+
+ + } + label={t`Waitlist`} + summary={waitlistSummary(form.values)} + opened={openRows.has('waitlist')} + onToggle={toggleRow} + > + + + + } + label={t`Taxes & fees`} + summary={taxesSummary(form.values, taxesAndFees?.data, event?.currency)} + opened={openRows.has('taxes')} + onToggle={toggleRow} + > + + + + {(form.values.type === ProductPriceType.Free && !!form.values.tax_and_fee_ids?.length) && ( + +

+ {t`You have taxes and fees added to a Free Product. Would you like to remove them?`} +

+ +
+ )} +
+ + } + label={t`Order limits`} + summary={orderLimitsSummary(form.values)} + opened={openRows.has('order-limits')} + onToggle={toggleRow} + > + + + + + + + } + label={t`Add-ons`} + summary={addonsSummary(form.values)} + opened={openRows.has('addons')} + onToggle={toggleRow} + > + { + form.setFieldValue('is_addon_only', changeEvent.currentTarget.checked); + if (changeEvent.currentTarget.checked) { + form.setFieldValue('addon_product_ids', []); + } + }} + label={t`Only available as an add-on`} + description={t`This product won't appear on the event page on its own — buyers only see it as an add-on to the products it's attached to.`} + /> + {!form.values.is_addon_only && ( +
+ } + productCategories={event?.product_categories || []} + form={form} + productFieldName="addon_product_ids" + excludedProductIds={product?.id ? [product.id] : []} + noProductsMessage={t`Create more tickets or products to offer them as add-ons`} /> +

+ {t`Buyers can add these to their order when they select this product at checkout.`} +

-
- -
- - {t`Highlight`} - - }> + )} + + + } + label={t`Highlight`} + summary={highlightSummary(form.values)} + opened={openRows.has('highlight')} + onToggle={toggleRow} + > + + {form.values.is_highlighted && ( + + )} + + + } + label={t`Access`} + summary={accessSummary(form.values)} + opened={openRows.has('access')} + onToggle={toggleRow} + > +
{t`You can create a promo code which targets this product on the`} + {t`Promo Code page`}} + {...form.getInputProps('is_hidden_without_promo_code', {type: 'checkbox'})} + label={t`Hide product unless user has applicable promo code`} /> - {form.values.is_highlighted && ( - - )} -
-
-
+ +
+ +
{taxFeeModalOpen && ( value !== undefined && value !== null && value !== ''; + +export const formatLocalDateTime = (value: string | Date): string => { + const locale = getSafeLocale(getClientLocale()); + + return dayjs(value).locale(locale).format(localeFormats[locale].dayMonthTime); +}; + +export const descriptionSummary = (values: Product): RowSummary => { + const plainText = (values.description || '') + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + if (!plainText) { + return {text: t`None yet`, emphasized: false}; + } + + return { + text: plainText.length > 40 ? plainText.slice(0, 40) + '…' : plainText, + emphasized: true, + }; +}; + +export const saleWindowSummary = (values: Product): RowSummary => { + const start = hasValue(values.sale_start_date) ? formatLocalDateTime(values.sale_start_date as string) : undefined; + const end = hasValue(values.sale_end_date) ? formatLocalDateTime(values.sale_end_date as string) : undefined; + + if (start && end) { + return {text: `${start} → ${end}`, emphasized: true}; + } + if (start) { + return {text: t`From ${start}`, emphasized: true}; + } + if (end) { + return {text: t`Until ${end}`, emphasized: true}; + } + + return { + text: t`Always on sale`, + emphasized: !!values.hide_before_sale_start_date || !!values.hide_after_sale_end_date, + }; +}; + +export const eventPageSummary = (values: Product): RowSummary => { + const parts = [ + values.show_quantity_remaining && t`Shows remaining`, + values.hide_when_sold_out && t`Hides sold out`, + values.start_collapsed && t`Starts collapsed`, + ].filter(Boolean) as string[]; + + if (parts.length === 0) { + return {text: t`Defaults`, emphasized: false}; + } + + return {text: parts.join(' · '), emphasized: true}; +}; + +export const waitlistSummary = (values: Product): RowSummary => { + if (values.waitlist_enabled) { + return {text: t`On`, emphasized: true}; + } + + return {text: t`Off`, emphasized: false}; +}; + +export const taxAndFeeLabel = (item: TaxAndFee, currency?: string): string => { + return item.name + ' - ' + (item.calculation_type === TaxAndFeeCalculationType.Percentage + ? item.rate + '%' + : formatCurrency(Number(item.rate), currency || 'USD')); +}; + +export const taxesSummary = (values: Product, taxesAndFees?: TaxAndFee[], currency?: string): RowSummary => { + const selected = (taxesAndFees || []).filter( + (item) => (values.tax_and_fee_ids || []).map(String).includes(String(item.id)), + ); + + if (selected.length === 0) { + return {text: t`None`, emphasized: false}; + } + + const first = taxAndFeeLabel(selected[0], currency); + + if (selected.length === 1) { + return {text: first, emphasized: true}; + } + + const additionalCount = selected.length - 1; + + return {text: t`${first} + ${additionalCount} more`, emphasized: true}; +}; + +export const orderLimitsSummary = (values: Product): RowSummary => { + const min = hasValue(values.min_per_order) ? Number(values.min_per_order) : undefined; + const max = hasValue(values.max_per_order) ? Number(values.max_per_order) : undefined; + const emphasized = (min !== undefined && min !== 1) || (max !== undefined && max !== 100); + + if (min !== undefined && max !== undefined) { + return {text: t`${min}–${max} per order`, emphasized}; + } + if (min !== undefined) { + return {text: t`Min ${min} per order`, emphasized}; + } + if (max !== undefined) { + return {text: t`Up to ${max} per order`, emphasized}; + } + + return {text: t`No limits`, emphasized: false}; +}; + +export const addonsSummary = (values: Product): RowSummary => { + const count = values.addon_product_ids?.length || 0; + const parts = [ + count === 1 ? t`1 add-on` : count > 1 ? t`${count} add-ons` : undefined, + values.is_addon_only ? t`Add-on only` : undefined, + ].filter(Boolean) as string[]; + + if (parts.length === 0) { + return {text: t`None`, emphasized: false}; + } + + return {text: parts.join(' · '), emphasized: true}; +}; + +export const highlightSummary = (values: Product): RowSummary => { + if (!values.is_highlighted) { + return {text: t`Off`, emphasized: false}; + } + + return { + text: values.highlight_message ? `“${values.highlight_message}”` : t`Highlighted`, + emphasized: true, + }; +}; + +export const accessSummary = (values: Product): RowSummary => { + if (values.is_hidden) { + return {text: t`Hidden from everyone`, emphasized: true}; + } + if (values.is_hidden_without_promo_code) { + return {text: t`Promo code required`, emphasized: true}; + } + + return {text: t`Public`, emphasized: false}; +}; diff --git a/frontend/src/components/forms/ProductForm/visibilityStatus.ts b/frontend/src/components/forms/ProductForm/visibilityStatus.ts new file mode 100644 index 0000000000..218944d92c --- /dev/null +++ b/frontend/src/components/forms/ProductForm/visibilityStatus.ts @@ -0,0 +1,58 @@ +import {t} from "@lingui/macro"; +import {Product} from "../../../types.ts"; +import {formatLocalDateTime} from "./ledgerSummaries.ts"; + +export type VisibilityLevel = 'visible' | 'conditional' | 'hidden'; + +export interface VisibilityStatus { + level: VisibilityLevel; + message: string; +} + +const hasValue = (value: unknown): boolean => value !== undefined && value !== null && value !== ''; + +export const computeVisibilityStatus = (values: Product, nowInEventTz: string): VisibilityStatus => { + if (values.is_hidden) { + return { + level: 'hidden', + message: t`Hidden from everyone — the master hide switch is on.`, + }; + } + + if (values.is_addon_only) { + return { + level: 'conditional', + message: t`Only shown as an add-on to the products it's attached to.`, + }; + } + + if (values.is_hidden_without_promo_code) { + return { + level: 'conditional', + message: t`Only visible to buyers with an applicable promo code.`, + }; + } + + if (values.hide_before_sale_start_date && hasValue(values.sale_start_date) && String(values.sale_start_date) > nowInEventTz) { + const start = formatLocalDateTime(values.sale_start_date as string); + + return { + level: 'conditional', + message: t`Visible from ${start} — hidden until sales open.`, + }; + } + + if (values.hide_after_sale_end_date && hasValue(values.sale_end_date) && String(values.sale_end_date) < nowInEventTz) { + const end = formatLocalDateTime(values.sale_end_date as string); + + return { + level: 'conditional', + message: t`Hidden — sales ended ${end}.`, + }; + } + + return { + level: 'visible', + message: t`Visible to everyone on the event page.`, + }; +}; diff --git a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss index 6b7ddffe08..941b133de1 100644 --- a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss +++ b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss @@ -666,348 +666,23 @@ $transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); } } -// Tickets section - restyle .hi-product-widget-container +// Tickets section .ticketsSection { - // Override widget styles :global(.hi-product-widget-container) { + --widget-card-bg-color: var(--content-bg-color); + --widget-border-color: var(--border-color); background: transparent; padding: 0; font-family: $font-body; } - :global(.hi-product-category-rows) { - display: flex; - flex-direction: column; - gap: 12px; - } - - :global(.hi-product-category-row) { - margin-bottom: 0; - } - :global(.hi-product-category-title) { font-family: $font-display; font-size: 1.35rem; - font-weight: 700; - letter-spacing: -0.01em; - color: var(--primary-text-color); - margin: 0 0 18px; - } - - :global(.hi-product-category-description) { - color: var(--secondary-color); - font-size: 0.9rem; - line-height: 1.6; - margin-bottom: 16px; - } - - :global(.hi-product-rows) { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 0; - } - - :global(.hi-product-row) { - background: var(--content-bg-color); - border-radius: $radius-lg; - border: 2px solid var(--border-color); - padding: 0; - transition: all $transition-fast; - cursor: pointer; - position: relative; - overflow: hidden; - - &:hover { - border-color: color-mix(in srgb, var(--border-color) 150%, transparent); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - } - } - - :global(.hi-product-row.hi-product-highlighted) { - background: var(--accent-soft); - border-color: var(--primary-color); - - &:hover { - background: color-mix(in srgb, var(--accent-soft) 80%, var(--content-bg-color)); - border-color: var(--primary-color); - } - } - - :global(.hi-product-highlight-message) { - background: var(--primary-color); - color: var(--accent-contrast); - font-size: 0.75rem; - font-weight: 600; - padding: 6px 16px; - text-align: center; - letter-spacing: 0.02em; - } - - :global(.hi-product-row.selected) { - border-color: var(--primary-color); - background: var(--accent-soft); - } - - :global(.hi-title-row) { - padding: 0; - } - - :global(.hi-product-title) { - padding: 20px 24px; - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - - h3 { - font-family: $font-display; - font-weight: 700; - font-size: 1.1rem; - color: var(--primary-text-color); - margin: 0; - } } - :global(.hi-product-title-metadata) { - font-size: 0.8rem; - color: var(--secondary-text-color); - display: flex; - align-items: center; - gap: 8px; - } - - :global(.hi-product-collapse-arrow) { - color: var(--secondary-color); - - svg { - transition: transform $transition-fast; - } - } - - :global(.hi-product-content) { - padding: 0 24px 20px; - border-top: 1px solid var(--border-color); - margin-top: 0; - } - - :global(.hi-price-tiers-rows) { - padding-top: 16px; - } - - :global(.hi-price-tier-row) { - margin-bottom: 12px; - - &:last-child { - margin-bottom: 0; - } - } - - :global(.hi-price-tier) { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 12px; - } - - :global(.hi-price-tier-label) { - font-weight: 600; - color: var(--primary-text-color); - font-size: 0.95rem; - } - - :global(.hi-price-tier-price) { + :global(.hi-product-title h3) { font-family: $font-display; - font-weight: 700; - font-size: 1.1rem; - color: var(--primary-text-color); - } - - :global(.hi-product-quantity-selector) { - .button-input { - display: flex; - align-items: center; - gap: 4px; - background: var(--content-bg-color); - border-radius: $radius-sm; - padding: 4px; - border: 1px solid var(--border-color); - - button { - width: 36px; - height: 36px; - border-radius: $radius-xs; - border: none; - background: transparent; - color: var(--secondary-color); - display: flex; - align-items: center; - justify-content: center; - font-size: 0.9rem; - cursor: pointer; - transition: all $transition-fast; - - &:hover:not(:disabled) { - background: var(--accent-soft); - color: var(--primary-text-color); - } - - &:disabled { - opacity: 0.3; - cursor: not-allowed; - } - } - - input { - font-family: $font-display; - font-weight: 700; - min-width: 32px; - text-align: center; - font-size: 1rem; - color: var(--primary-text-color); - border: none; - background: transparent; - } - } - } - - :global(.hi-product-description-row) { - color: var(--secondary-color); - font-size: 0.875rem; - line-height: 1.6; - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--border-color); - } - - :global(.hi-product-quantity-error) { - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.3); - color: #dc2626; - border-radius: $radius-sm; - padding: 12px 16px; - font-size: 0.875rem; - margin: 12px 0; - } - - :global(.hi-no-products) { - text-align: center; - padding: 40px 20px; - color: var(--secondary-color); - } - - :global(.hi-no-products-message) { - font-size: 0.95rem; - margin: 0; - } - - :global(.hi-footer-row) { - margin-top: 24px; - padding-top: 24px; - border-top: 1px solid var(--border-color); - - .hi-product-page-message { - background: var(--accent-soft); - border-radius: $radius-sm; - padding: 12px 16px; - margin-bottom: 16px; - font-size: 0.9rem; - color: var(--primary-text-color); - } - } - - :global(.hi-continue-button) { - width: 100%; - padding: 18px 24px !important; - height: auto !important; - background: var(--primary-color) !important; - background-color: var(--primary-color) !important; - color: var(--accent-contrast) !important; - border: none !important; - border-radius: $radius-md !important; - font-family: $font-display; - font-size: 1rem !important; - font-weight: 700 !important; - text-transform: uppercase; - letter-spacing: 0.05em; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - transition: all $transition-fast; - - // Override Mantine button internals - :global(.mantine-Button-label) { - color: var(--accent-contrast) !important; - } - - &:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 8px 20px var(--accent-soft); - filter: brightness(1.1); - background: var(--primary-color) !important; - background-color: var(--primary-color) !important; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; - box-shadow: none; - } - } - - :global(.hi-promo-code-row) { - margin-top: 14px; - margin-bottom: 0; - } - - :global(.hi-have-a-promo-code-link) { - color: var(--secondary-color); - font-size: 0.9rem; - text-align: center; - display: block; - width: 100%; - - &:hover { - color: var(--primary-color); - } - } - - :global(.hi-promo-code-input-wrapper) { - gap: 12px; - } - - :global(.hi-promo-code-input) { - background: var(--content-bg-color); - border: 1px solid var(--border-color); - border-radius: $radius-sm; - color: var(--primary-text-color); - padding: 10px 14px; - } - - :global(.hi-apply-promo-code-button) { - background: var(--primary-color); - color: var(--accent-contrast); - border: none; - border-radius: $radius-sm; - font-weight: 600; - padding: 10px 20px; - - &:hover { - filter: brightness(1.1); - } - } - - :global(.hi-promo-code-applied) { - color: var(--primary-text-color); - font-size: 0.9rem; - - b { - color: var(--primary-color); - } } } diff --git a/frontend/src/components/modals/CreateProductModal/index.tsx b/frontend/src/components/modals/CreateProductModal/index.tsx index beee9d69fc..ece284d798 100644 --- a/frontend/src/components/modals/CreateProductModal/index.tsx +++ b/frontend/src/components/modals/CreateProductModal/index.tsx @@ -1,11 +1,11 @@ -import {Button} from "@mantine/core"; import {GenericModalProps, IdParam, Product, ProductPriceType, ProductType, TaxAndFee} from "../../../types.ts"; import {useForm} from "@mantine/form"; import {useParams} from "react-router"; -import {Modal} from "../../common/Modal"; import {ProductForm} from "../../forms/ProductForm"; +import {ProductDrawer} from "../../forms/ProductForm/ProductDrawer.tsx"; import {useEffect} from "react"; import {useGetTaxesAndFees} from "../../../queries/useGetTaxesAndFees.ts"; +import {useGetEvent} from "../../../queries/useGetEvent.ts"; import {t} from "@lingui/macro"; import {useCreateProduct} from "../../../mutations/useCreateProduct.ts"; import {showError, showSuccess} from "../../../utilites/notifications.tsx"; @@ -16,6 +16,7 @@ interface CreateProductModalProps extends GenericModalProps { export const CreateProductModal = ({onClose, selectedCategoryId = undefined}: CreateProductModalProps) => { const {eventId} = useParams(); + const {data: event} = useGetEvent(eventId); const {data: taxesAndFees, isFetched: taxesAndFeesLoaded} = useGetTaxesAndFees(); const createProductMutation = useCreateProduct(); const form = useForm({ @@ -38,6 +39,8 @@ export const CreateProductModal = ({onClose, selectedCategoryId = undefined}: Cr type: ProductPriceType.Paid, product_type: ProductType.Ticket, tax_and_fee_ids: undefined, + addon_product_ids: [], + is_addon_only: false, product_category_id: selectedCategoryId ? String(selectedCategoryId) : undefined, prices: [{ price: 0, @@ -73,22 +76,21 @@ export const CreateProductModal = ({onClose, selectedCategoryId = undefined}: Cr .map((item: TaxAndFee) => { return String(item.id); }) || []); + form.resetDirty(); }, [taxesAndFeesLoaded]); return ( - -
handleCreateProduct(values))}> - - - -
+ + ) }; diff --git a/frontend/src/components/modals/DuplicateProductModal/index.tsx b/frontend/src/components/modals/DuplicateProductModal/index.tsx index 1b2cb414fa..5b9ceb13f6 100644 --- a/frontend/src/components/modals/DuplicateProductModal/index.tsx +++ b/frontend/src/components/modals/DuplicateProductModal/index.tsx @@ -1,11 +1,11 @@ -import {Button} from "@mantine/core"; import {GenericModalProps, IdParam, Product, ProductPriceType, ProductType, TaxAndFee} from "../../../types.ts"; import {useForm} from "@mantine/form"; import {useParams} from "react-router"; -import {Modal} from "../../common/Modal"; import {ProductForm} from "../../forms/ProductForm"; +import {ProductDrawer} from "../../forms/ProductForm/ProductDrawer.tsx"; import {useEffect} from "react"; import {useGetTaxesAndFees} from "../../../queries/useGetTaxesAndFees.ts"; +import {useGetEvent} from "../../../queries/useGetEvent.ts"; import {t} from "@lingui/macro"; import {useCreateProduct} from "../../../mutations/useCreateProduct.ts"; import {useGetProduct} from "../../../queries/useGetProduct.ts"; @@ -17,6 +17,7 @@ interface DuplicateProductModalProps extends GenericModalProps { export const DuplicateProductModal = ({onClose, originalProductId}: DuplicateProductModalProps) => { const {eventId} = useParams(); + const {data: event} = useGetEvent(eventId); const {data: taxesAndFees, isFetched: taxesAndFeesLoaded} = useGetTaxesAndFees(); const {data: originalProduct} = useGetProduct(eventId, originalProductId); const createProductMutation = useCreateProduct(); @@ -40,6 +41,8 @@ export const DuplicateProductModal = ({onClose, originalProductId}: DuplicatePro type: ProductPriceType.Paid, product_type: ProductType.Ticket, tax_and_fee_ids: undefined, + addon_product_ids: [], + is_addon_only: false, product_category_id: undefined, prices: [{ price: 0, @@ -52,7 +55,7 @@ export const DuplicateProductModal = ({onClose, originalProductId}: DuplicatePro }); useEffect(() => { - if (!originalProduct || !taxesAndFeesLoaded) { + if (!originalProduct) { return; } @@ -74,8 +77,11 @@ export const DuplicateProductModal = ({onClose, originalProductId}: DuplicatePro highlight_message: originalProduct.highlight_message, type: originalProduct.type, tax_and_fee_ids: originalProduct.taxes_and_fees?.map(t => String(t.id)) ?? [], + addon_product_ids: originalProduct.addon_product_ids?.map(String) ?? [], + is_addon_only: originalProduct.is_addon_only ?? false, product_type: originalProduct.product_type, product_category_id: originalProduct.product_category_id, + price: originalProduct.type === ProductPriceType.Free ? 0.00 : undefined, prices: originalProduct.prices?.map(price => ({ price: price.price, label: price.label, @@ -85,11 +91,13 @@ export const DuplicateProductModal = ({onClose, originalProductId}: DuplicatePro is_hidden: price.is_hidden, })), }); + form.resetDirty(); }, [originalProduct]); useEffect(() => { if (taxesAndFeesLoaded) { form.setFieldValue("tax_and_fee_ids", taxesAndFees?.data?.filter(item => item.is_default).map((item: TaxAndFee) => String(item.id)) || []); + form.resetDirty(); } }, [taxesAndFeesLoaded]); @@ -110,13 +118,18 @@ export const DuplicateProductModal = ({onClose, originalProductId}: DuplicatePro }; return ( - -
- - - -
+ + + ); }; diff --git a/frontend/src/components/modals/EditProductModal/index.tsx b/frontend/src/components/modals/EditProductModal/index.tsx index 42b53980c5..17916dde08 100644 --- a/frontend/src/components/modals/EditProductModal/index.tsx +++ b/frontend/src/components/modals/EditProductModal/index.tsx @@ -1,16 +1,14 @@ -import {Button} from "@mantine/core"; import {GenericModalProps, IdParam, Product, ProductPriceType, ProductType} from "../../../types.ts"; import {useForm} from "@mantine/form"; import {useParams} from "react-router"; import {useEffect} from "react"; import {ProductForm} from "../../forms/ProductForm"; -import {Modal} from "../../common/Modal"; +import {ProductDrawer} from "../../forms/ProductForm/ProductDrawer.tsx"; import {useUpdateProduct} from "../../../mutations/useUpdateProduct.ts"; import {showSuccess} from "../../../utilites/notifications.tsx"; import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx"; import {t} from "@lingui/macro"; import {useGetProduct} from "../../../queries/useGetProduct.ts"; -import {LoadingMask} from "../../common/LoadingMask"; import {utcToTz} from "../../../utilites/dates.ts"; import {useGetEvent} from "../../../queries/useGetEvent.ts"; @@ -38,6 +36,8 @@ export const EditProductModal = ({onClose, productId}: GenericModalProps & { pro waitlist_enabled: null, type: ProductPriceType.Paid, tax_and_fee_ids: [], + addon_product_ids: [], + is_addon_only: false, prices: [], product_type: ProductType.Ticket, product_category_id: undefined, @@ -67,12 +67,15 @@ export const EditProductModal = ({onClose, productId}: GenericModalProps & { pro is_hidden_without_promo_code: product.is_hidden_without_promo_code, type: product.type, tax_and_fee_ids: product.taxes_and_fees?.map(t => String(t.id)) ?? [], + addon_product_ids: product.addon_product_ids?.map(String) ?? [], + is_addon_only: product.is_addon_only ?? false, is_hidden: product.is_hidden, is_highlighted: product.is_highlighted, highlight_message: product.highlight_message, waitlist_enabled: product.waitlist_enabled ?? null, product_type: product.product_type, product_category_id: String(product.product_category_id), + price: product.type === ProductPriceType.Free ? 0.00 : undefined, prices: product.prices?.map(p => ({ price: p.price ?? 0, label: p.label, @@ -83,6 +86,7 @@ export const EditProductModal = ({onClose, productId}: GenericModalProps & { pro is_hidden: p.is_hidden, })) ?? [], }); + form.resetDirty(); }, [product, event]); const handleEditProduct = (product: Product) => { @@ -101,19 +105,18 @@ export const EditProductModal = ({onClose, productId}: GenericModalProps & { pro } return ( - -
- - - - - -
+ + ) }; diff --git a/frontend/src/components/routes/product-widget/SelectProducts/Prices/FeeBreakdown/index.tsx b/frontend/src/components/routes/product-widget/SelectProducts/Prices/FeeBreakdown/index.tsx new file mode 100644 index 0000000000..095ed74371 --- /dev/null +++ b/frontend/src/components/routes/product-widget/SelectProducts/Prices/FeeBreakdown/index.tsx @@ -0,0 +1,48 @@ +import React, {useState} from "react"; +import {Collapse} from "@mantine/core"; +import {IconChevronDown} from "@tabler/icons-react"; +import classNames from "classnames"; +import {formatCurrency} from "../../../../../../utilites/currency.ts"; + +export interface FeeBreakdownRow { + label: React.ReactNode; + amount: number; + isTotal?: boolean; +} + +interface FeeBreakdownProps { + toggleLabel: React.ReactNode; + rows: FeeBreakdownRow[]; + currency?: string; + footnote?: React.ReactNode; +} + +export const FeeBreakdown = ({toggleLabel, rows, currency, footnote}: FeeBreakdownProps) => { + const [opened, setOpened] = useState(false); + + return ( + <> + + +
+ {rows.map((row, index) => ( +
+ {row.label} + {formatCurrency(row.amount, currency)} +
+ ))} + {footnote &&
{footnote}
} +
+
+ + ); +}; diff --git a/frontend/src/components/routes/product-widget/SelectProducts/Prices/Tiered/index.tsx b/frontend/src/components/routes/product-widget/SelectProducts/Prices/Tiered/index.tsx index 43408855c1..709cd663a6 100644 --- a/frontend/src/components/routes/product-widget/SelectProducts/Prices/Tiered/index.tsx +++ b/frontend/src/components/routes/product-widget/SelectProducts/Prices/Tiered/index.tsx @@ -1,11 +1,16 @@ -import {Currency, ProductPriceDisplay} from "../../../../../common/Currency"; -import {Event, IdParam, Product} from "../../../../../../types.ts"; -import {Group, TextInput} from "@mantine/core"; +import {Currency, getExclusiveFeeNote, getInclusiveFeeNote, ProductPriceDisplay} from "../../../../../common/Currency"; +import {Event, IdParam, Product, ProductPrice, TaxAndFeeType} from "../../../../../../types.ts"; +import {TextInput} from "@mantine/core"; import {NumberSelector} from "../../../../../common/NumberSelector"; import {UseFormReturnType} from "@mantine/form"; import {t} from "@lingui/macro"; +import {IconClock} from "@tabler/icons-react"; +import {useEffect, useRef, useState} from "react"; import {ProductPriceAvailability} from "../../../../../common/ProductPriceAvailability"; -import {getCurrencySymbol} from "../../../../../../utilites/currency.ts"; +import {formatCurrency, getCurrencySymbol} from "../../../../../../utilites/currency.ts"; +import {FeeBreakdown, FeeBreakdownRow} from "../FeeBreakdown"; + +const LIMIT_MESSAGE_TIMEOUT_MS = 4000; interface TieredPricingProps { event: Event; @@ -13,30 +18,196 @@ interface TieredPricingProps { form: UseFormReturnType; productIndex: number; eventOccurrenceId?: IdParam; + displayMode?: 'header' | 'list'; + showStepper?: boolean; } -export const TieredPricing = ({product, event, form, productIndex, eventOccurrenceId}: TieredPricingProps) => { +const getFeesAndTaxTotal = (price: ProductPrice): number => (price.tax_total || 0) + (price.fee_total || 0); + +const getAvailabilityReason = (price: ProductPrice): string => { + if (price.is_sold_out) { + return 'sold-out'; + } + if (price.is_after_sale_end_date) { + return 'ended'; + } + if (price.is_before_sale_start_date) { + return 'upcoming'; + } + return 'unavailable'; +}; + +export const TieredPricing = ({ + product, + event, + form, + productIndex, + eventOccurrenceId, + displayMode = 'list', + showStepper = true, + }: TieredPricingProps) => { + const [limitMessages, setLimitMessages] = useState<{ [priceIndex: number]: string }>({}); + const limitTimeoutsRef = useRef<{ [priceIndex: number]: ReturnType }>({}); + + useEffect(() => () => { + Object.values(limitTimeoutsRef.current).forEach(clearTimeout); + }, []); + + const priceDisplayMode = event?.settings?.price_display_mode; + const isInclusive = priceDisplayMode === 'INCLUSIVE'; + + const getQuantityCap = (price: ProductPrice): number => + Math.min(price.quantity_remaining ?? 50, product.max_per_order ?? 50); + + const flashLimitMessage = (price: ProductPrice, index: number) => { + const cap = getQuantityCap(price); + const limitedByStock = (price.quantity_remaining ?? Infinity) < (product.max_per_order ?? 50); + const message = limitedByStock ? t`Only ${cap} available` : t`Maximum ${cap} per order`; + + setLimitMessages(previous => ({...previous, [index]: message})); + clearTimeout(limitTimeoutsRef.current[index]); + limitTimeoutsRef.current[index] = setTimeout(() => { + setLimitMessages(previous => { + const next = {...previous}; + delete next[index]; + return next; + }); + }, LIMIT_MESSAGE_TIMEOUT_MS); + }; + + const exclusiveFootnote = isInclusive ? undefined : t`Added at checkout`; + + const buildSinglePriceRows = (price: ProductPrice): FeeBreakdownRow[] => { + const feeNames = (product.taxes || []).filter(item => item.type === TaxAndFeeType.Fee).map(item => item.name).join(', '); + const taxNames = (product.taxes || []).filter(item => item.type === TaxAndFeeType.Tax).map(item => item.name).join(', '); + + const rows: FeeBreakdownRow[] = [{label: t`Base price`, amount: Number(price.price)}]; + if ((price.fee_total || 0) > 0) { + rows.push({label: feeNames || t`Fees`, amount: price.fee_total || 0}); + } + if ((price.tax_total || 0) > 0) { + rows.push({label: taxNames || t`Tax`, amount: price.tax_total || 0}); + } + rows.push({label: t`Total`, amount: Number(price.price) + getFeesAndTaxTotal(price), isTotal: true}); + + return rows; + }; + + const renderQuantityControl = (price: ProductPrice, index: number) => ( +
+ {(product.is_available && price.is_available) && showStepper && ( + flashLimitMessage(price, index)} + /> + )} + {(!product.is_available || !price.is_available) && ( +
+ {(price.is_before_sale_start_date || price.is_after_sale_end_date) && !price.is_sold_out && ( + + )} + +
+ )} +
+ ); + + const renderRowMessages = (index: number) => ( + <> + {form.errors[`products.${productIndex}.quantities.${index}.quantity`] && ( +
+ {form.errors[`products.${productIndex}.quantities.${index}.quantity`]} +
+ )} + {limitMessages[index] && ( +
+ {limitMessages[index]} +
+ )} + + ); + + if (displayMode === 'header') { + const price = product.prices?.[0]; + if (!price) { + return null; + } + const feesAndTax = getFeesAndTaxTotal(price); + const isPriceAvailable = product.is_available && price.is_available; + + return ( +
+
+
+
+ + {price.is_discounted && ( +
+ +
+ )} +
+
+ {renderQuantityControl(price, 0)} +
+ {feesAndTax > 0 && isPriceAvailable && ( + 0, (price.tax_total || 0) > 0) + : getExclusiveFeeNote(formatCurrency(feesAndTax, event?.currency), (price.fee_total || 0) > 0, (price.tax_total || 0) > 0)} + rows={buildSinglePriceRows(price)} + currency={event?.currency} + footnote={exclusiveFootnote} + /> + )} + {renderRowMessages(0)} +
+ ); + } + return ( <> {product?.prices?.map((price, index) => { + const feesAndTax = getFeesAndTaxTotal(price); + const isPriceAvailable = product.is_available && price.is_available; + return ( -
- +
+
-
{price.label}
+ {price.label && ( +
{price.label}
+ )}
{product.type === 'DONATION' && ( -
+
)}
-
- {(product.is_available && price.is_available) && ( - <> - - {form.errors[`products.${productIndex}.quantities.${index}.quantity`] && ( -
- {form.errors[`products.${productIndex}.quantities.${index}.quantity`]} -
- )} - - )} - {(!product.is_available || !price.is_available) && ( - - )} -
- + {renderQuantityControl(price, index)} +
+ + {product.type !== 'DONATION' && feesAndTax > 0 && isPriceAvailable && ( + 0, (price.tax_total || 0) > 0) + : getExclusiveFeeNote(formatCurrency(feesAndTax, event?.currency), (price.fee_total || 0) > 0, (price.tax_total || 0) > 0)} + rows={buildSinglePriceRows(price)} + currency={event?.currency} + footnote={exclusiveFootnote} + /> + )} {price.is_discounted && ( -
+
)} + + {renderRowMessages(index)}
); })} diff --git a/frontend/src/components/routes/product-widget/SelectProducts/index.tsx b/frontend/src/components/routes/product-widget/SelectProducts/index.tsx index 5c1763f58a..d90f7753c0 100644 --- a/frontend/src/components/routes/product-widget/SelectProducts/index.tsx +++ b/frontend/src/components/routes/product-widget/SelectProducts/index.tsx @@ -20,7 +20,7 @@ import { ProductPriceQuantityFormValue } from "../../../../api/order.client.ts"; import {useForm} from "@mantine/form"; -import {range, useInputState, useResizeObserver} from "@mantine/hooks"; +import {useInputState, useResizeObserver} from "@mantine/hooks"; import React, {useEffect, useMemo, useRef, useState} from "react"; import {showError, showInfo, showSuccess} from "../../../../utilites/notifications.tsx"; import { @@ -48,9 +48,10 @@ import { PromoCodeValidationResponse } from "../../../../types.ts"; import {formatCurrency} from "../../../../utilites/currency.ts"; +import {getDisplayPrice} from "../../../common/Currency"; import {eventsClientPublic} from "../../../../api/event.client.ts"; import {promoCodeClientPublic} from "../../../../api/promo-code.client.ts"; -import {IconChevronRight, IconX} from "@tabler/icons-react" +import {IconCheck, IconChevronDown, IconX} from "@tabler/icons-react" import {getSessionIdentifier} from "../../../../utilites/sessionIdentifier.ts"; import {setCheckoutSessionIdentifier} from "../../../../utilites/checkoutSession.ts"; import {getEmbedParentUrl, getParentOrigin, sendHeightToParent} from "../../../../utilites/iframeResize.ts"; @@ -116,6 +117,7 @@ const SelectProducts = (props: SelectProductsProps) => { const [orderInProcessOverlayVisible, setOrderInProcessOverlayVisible] = useState(false); const [resizeRef, resizeObserverRect] = useResizeObserver(); const [collapsedProducts, setCollapsedProducts] = useState<{ [key: number]: boolean }>({}); + const [expandedDetails, setExpandedDetails] = useState<{ [key: number]: boolean }>({}); const [affiliateCode, setAffiliateCode] = useState(null); const [appliedPromoDetails, setAppliedPromoDetails] = useState<{ code: string; @@ -380,8 +382,74 @@ const SelectProducts = (props: SelectProductsProps) => { const productCategories = event?.product_categories || []; const productAreAvailable = productCategories && productCategories.some(category => !!category?.products?.length); const products: Product[] = productCategories.reduce((acc: Product[], category) => acc.concat(category.products ?? []), []); + const topLevelProducts = products.filter(product => !product.is_addon_only); const waitlistAvailable = products.some(product => product.waitlist_enabled); + const productsById = useMemo( + () => new Map(products.map(product => [Number(product.id), product])), + [productCategories], + ); + + const getProductFormIndex = (productId: number): number => + form.values.products?.findIndex(product => product.product_id === productId) ?? -1; + + const getProductQuantity = (productId: number): number => form.values.products + ?.find(product => product.product_id === productId) + ?.quantities?.reduce((acc, {quantity}) => acc + Number(quantity), 0) || 0; + + const getResolvableAddonIds = (product: Product): number[] => + (product.addon_product_ids || []) + .map(Number) + .filter(addonId => addonId !== Number(product.id) && productsById.has(addonId)); + + const renderProductDetails = (productId: number, description: string, className: string) => { + const isExpanded = expandedDetails[productId] ?? false; + + return ( +
+ + +
+ +
+ ); + }; + + useEffect(() => { + const formProducts = form.values.products; + if (!formProducts) { + return; + } + + products + .filter(product => product.is_addon_only) + .forEach(addon => { + const addonId = Number(addon.id); + const formIndex = formProducts.findIndex(formProduct => formProduct.product_id === addonId); + if (formIndex === -1 || getProductQuantity(addonId) === 0) { + return; + } + + const hasSelectedParent = topLevelProducts.some(parent => + getProductQuantity(Number(parent.id)) > 0 + && getResolvableAddonIds(parent).includes(addonId)); + + if (!hasSelectedParent) { + form.setFieldValue( + `products.${formIndex}.quantities`, + formProducts[formIndex].quantities.map(quantity => ({...quantity, quantity: 0})), + ); + } + }); + }, [form.values.products]); + const selectedProductQuantitySum = useMemo(() => { let total = 0; form.values.products?.forEach(({quantities}) => { @@ -522,7 +590,7 @@ const SelectProducts = (props: SelectProductsProps) => { || !productAreAvailable || selectedProductQuantitySum === 0 || props.widgetMode === 'preview' - || products?.every(product => product.is_sold_out) + || topLevelProducts.every(product => product.is_sold_out) || (needsOccurrenceSelection && !occurrenceSelected); const unavailableMessage = (() => { @@ -540,12 +608,16 @@ const SelectProducts = (props: SelectProductsProps) => { return null; })(); - let productIndex = 0; - const productFormSection = ( <>
{productCategories && productCategories.map((category) => { + const visibleProducts = (category.products || []).filter(product => !product.is_addon_only); + + if ((category.products?.length ?? 0) > 0 && visibleProducts.length === 0) { + return null; + } + return (

{

)} - {(category.products) && category.products.map((product) => { - const currentProductIndex = productIndex; - const quantityRange = range(product.min_per_order || 1, product.max_per_order || 25) - .map((n) => n.toString()); - quantityRange.unshift("0"); + {visibleProducts.map((product) => { + const currentProductIndex = getProductFormIndex(Number(product.id)); + const parentQuantity = getProductQuantity(Number(product.id)); + const addonIds = getResolvableAddonIds(product); const isProductCollapsed = collapsedProducts[Number(product.id)] ?? product.start_collapsed; const toggleCollapse = () => { @@ -583,16 +654,41 @@ const SelectProducts = (props: SelectProductsProps) => { })); }; + const isSimpleProduct = product.type !== 'TIERED' + && product.type !== 'DONATION' + && (product.prices?.length ?? 0) === 1; + + const availabilityState = product.is_sold_out + ? 'sold-out' + : product.is_before_sale_start_date + ? 'upcoming' + : product.is_after_sale_end_date + ? 'ended' + : undefined; + + const collapsedFromPrice = (() => { + if (!isProductCollapsed || product.type !== 'TIERED') { + return null; + } + const availablePrices = (product.prices || []).filter(price => price.is_available); + if (availablePrices.length === 0) { + return null; + } + return Math.min(...availablePrices.map(price => + getDisplayPrice(price, event?.settings?.price_display_mode))); + })(); + return ( -
+
{product.is_highlighted && product.highlight_message && (
{product.highlight_message}
)}
-

@@ -602,42 +698,69 @@ const SelectProducts = (props: SelectProductsProps) => { {(product.is_available && !!product.quantity_available && !(isRecurring && product.product_type === ProductType.Ticket)) && ( <> {product.quantity_available === Constants.INFINITE_TICKETS && ( - - Unlimited available - + + + Unlimited available + + )} {product.quantity_available !== Constants.INFINITE_TICKETS && ( - - {product.quantity_available} available - + + + {product.quantity_available} available + + )} )} {(!product.is_available && product.type === 'TIERED') && ( - + + + )} -

-