diff --git a/backend/app/DomainObjects/EventDomainObject.php b/backend/app/DomainObjects/EventDomainObject.php index eff9c28d..40a4852e 100644 --- a/backend/app/DomainObjects/EventDomainObject.php +++ b/backend/app/DomainObjects/EventDomainObject.php @@ -16,6 +16,8 @@ class EventDomainObject extends Generated\EventDomainObjectAbstract implements I { private ?Collection $products = null; + private ?Collection $productCategories = null; + private ?Collection $questions = null; private ?Collection $images = null; @@ -259,4 +261,15 @@ public function setEventStatistics(?EventStatisticDomainObject $eventStatistics) $this->eventStatistics = $eventStatistics; return $this; } + + public function setProductCategories(?Collection $productCategories): EventDomainObject + { + $this->productCategories = $productCategories; + return $this; + } + + public function getProductCategories(): ?Collection + { + return $this->productCategories; + } } diff --git a/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php index 667c860d..ac1749cb 100644 --- a/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php @@ -25,6 +25,7 @@ abstract class EventDailyStatisticDomainObjectAbstract extends \HiEvents\DomainO final public const VERSION = 'version'; final public const TOTAL_REFUNDED = 'total_refunded'; final public const TOTAL_VIEWS = 'total_views'; + final public const ATTENDEES_REGISTERED = 'attendees_registered'; protected int $id; protected int $event_id; @@ -41,6 +42,7 @@ abstract class EventDailyStatisticDomainObjectAbstract extends \HiEvents\DomainO protected int $version = 0; protected float $total_refunded = 0.0; protected int $total_views = 0; + protected int $attendees_registered = 0; public function toArray(): array { @@ -60,6 +62,7 @@ public function toArray(): array 'version' => $this->version ?? null, 'total_refunded' => $this->total_refunded ?? null, 'total_views' => $this->total_views ?? null, + 'attendees_registered' => $this->attendees_registered ?? null, ]; } @@ -227,4 +230,15 @@ public function getTotalViews(): int { return $this->total_views; } + + public function setAttendeesRegistered(int $attendees_registered): self + { + $this->attendees_registered = $attendees_registered; + return $this; + } + + public function getAttendeesRegistered(): int + { + return $this->attendees_registered; + } } diff --git a/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php index 787c721e..bd097faa 100644 --- a/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php @@ -28,7 +28,7 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const DELETED_AT = 'deleted_at'; final public const LOCATION = 'location'; final public const SHORT_ID = 'short_id'; - final public const PRODUCT_QUANTITY_AVAILABLE = 'ticket_quantity_available'; + final public const TICKET_QUANTITY_AVAILABLE = 'ticket_quantity_available'; protected int $id; protected int $account_id; diff --git a/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php index f3452205..e92c4640 100644 --- a/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php @@ -25,6 +25,7 @@ abstract class EventStatisticDomainObjectAbstract extends \HiEvents\DomainObject final public const VERSION = 'version'; final public const ORDERS_CREATED = 'orders_created'; final public const TOTAL_REFUNDED = 'total_refunded'; + final public const ATTENDEES_REGISTERED = 'attendees_registered'; protected int $id; protected int $event_id; @@ -41,6 +42,7 @@ abstract class EventStatisticDomainObjectAbstract extends \HiEvents\DomainObject protected int $version = 0; protected int $orders_created = 0; protected float $total_refunded = 0.0; + protected int $attendees_registered = 0; public function toArray(): array { @@ -60,6 +62,7 @@ public function toArray(): array 'version' => $this->version ?? null, 'orders_created' => $this->orders_created ?? null, 'total_refunded' => $this->total_refunded ?? null, + 'attendees_registered' => $this->attendees_registered ?? null, ]; } @@ -227,4 +230,15 @@ public function getTotalRefunded(): float { return $this->total_refunded; } + + public function setAttendeesRegistered(int $attendees_registered): self + { + $this->attendees_registered = $attendees_registered; + return $this; + } + + public function getAttendeesRegistered(): int + { + return $this->attendees_registered; + } } diff --git a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php index b47797b1..076da895 100644 --- a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php @@ -24,6 +24,7 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs final public const TOTAL_GROSS = 'total_gross'; final public const TOTAL_SERVICE_FEE = 'total_service_fee'; final public const TAXES_AND_FEES_ROLLUP = 'taxes_and_fees_rollup'; + final public const PRODUCT_TYPE = 'product_type'; protected int $id; protected int $order_id; @@ -39,6 +40,7 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs protected ?float $total_gross = null; protected ?float $total_service_fee = 0.0; protected array|string|null $taxes_and_fees_rollup = null; + protected string $product_type = 'TICKET'; public function toArray(): array { @@ -57,6 +59,7 @@ public function toArray(): array 'total_gross' => $this->total_gross ?? null, 'total_service_fee' => $this->total_service_fee ?? null, 'taxes_and_fees_rollup' => $this->taxes_and_fees_rollup ?? null, + 'product_type' => $this->product_type ?? null, ]; } @@ -213,4 +216,15 @@ public function getTaxesAndFeesRollup(): array|string|null { return $this->taxes_and_fees_rollup; } + + public function setProductType(string $product_type): self + { + $this->product_type = $product_type; + return $this; + } + + public function getProductType(): string + { + return $this->product_type; + } } diff --git a/backend/app/DomainObjects/Generated/ProductCategoryDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductCategoryDomainObjectAbstract.php new file mode 100644 index 00000000..19bc308d --- /dev/null +++ b/backend/app/DomainObjects/Generated/ProductCategoryDomainObjectAbstract.php @@ -0,0 +1,160 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'name' => $this->name ?? null, + 'no_products_message' => $this->no_products_message ?? null, + 'description' => $this->description ?? null, + 'is_hidden' => $this->is_hidden ?? null, + 'order' => $this->order ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setEventId(int $event_id): self + { + $this->event_id = $event_id; + return $this; + } + + public function getEventId(): int + { + return $this->event_id; + } + + public function setName(string $name): self + { + $this->name = $name; + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setNoProductsMessage(?string $no_products_message): self + { + $this->no_products_message = $no_products_message; + return $this; + } + + public function getNoProductsMessage(): ?string + { + return $this->no_products_message; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setIsHidden(bool $is_hidden): self + { + $this->is_hidden = $is_hidden; + return $this; + } + + public function getIsHidden(): bool + { + return $this->is_hidden; + } + + 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; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php index e1bda8ed..239642d4 100644 --- a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php @@ -12,6 +12,7 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const PLURAL_NAME = 'products'; final public const ID = 'id'; final public const EVENT_ID = 'event_id'; + final public const PRODUCT_CATEGORY_ID = 'product_category_id'; final public const TITLE = 'title'; final public const SALE_START_DATE = 'sale_start_date'; final public const SALE_END_DATE = 'sale_end_date'; @@ -35,6 +36,7 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected int $id; protected int $event_id; + protected ?int $product_category_id = null; protected string $title; protected ?string $sale_start_date = null; protected ?string $sale_end_date = null; @@ -54,13 +56,14 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected ?string $deleted_at = null; protected string $type = 'PAID'; protected ?bool $is_hidden = false; - protected string $product_type = 'PRODUCT'; + protected string $product_type = 'TICKET'; public function toArray(): array { return [ 'id' => $this->id ?? null, 'event_id' => $this->event_id ?? null, + 'product_category_id' => $this->product_category_id ?? null, 'title' => $this->title ?? null, 'sale_start_date' => $this->sale_start_date ?? null, 'sale_end_date' => $this->sale_end_date ?? null, @@ -106,6 +109,17 @@ public function getEventId(): int return $this->event_id; } + public function setProductCategoryId(?int $product_category_id): self + { + $this->product_category_id = $product_category_id; + return $this; + } + + public function getProductCategoryId(): ?int + { + return $this->product_category_id; + } + public function setTitle(string $title): self { $this->title = $title; diff --git a/backend/app/DomainObjects/OrderDomainObject.php b/backend/app/DomainObjects/OrderDomainObject.php index eb6bdd60..2d80581c 100644 --- a/backend/app/DomainObjects/OrderDomainObject.php +++ b/backend/app/DomainObjects/OrderDomainObject.php @@ -2,6 +2,7 @@ namespace HiEvents\DomainObjects; +use HiEvents\DomainObjects\Enums\ProductType; use HiEvents\DomainObjects\Interfaces\IsSortable; use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts; use HiEvents\DomainObjects\Status\OrderPaymentStatus; @@ -66,6 +67,28 @@ public function getFullName(): string return $this->getFirstName() . ' ' . $this->getLastName(); } + public function getProductOrderItems(): Collection + { + if ($this->getOrderItems() === null) { + return new Collection(); + } + + return $this->getOrderItems()->filter(static function (OrderItemDomainObject $orderItem) { + return $orderItem->getProductType() === ProductType::GENERAL->name; + }); + } + + public function getTicketOrderItems(): Collection + { + if ($this->getOrderItems() === null) { + return new Collection(); + } + + return $this->getOrderItems()->filter(static function (OrderItemDomainObject $orderItem) { + return $orderItem->getProductType() === ProductType::TICKET->name; + }); + } + public function setOrderItems(?Collection $orderItems): OrderDomainObject { $this->orderItems = $orderItems; diff --git a/backend/app/DomainObjects/OrderItemDomainObject.php b/backend/app/DomainObjects/OrderItemDomainObject.php index a1df31f7..164b1d9c 100644 --- a/backend/app/DomainObjects/OrderItemDomainObject.php +++ b/backend/app/DomainObjects/OrderItemDomainObject.php @@ -10,6 +10,8 @@ class OrderItemDomainObject extends Generated\OrderItemDomainObjectAbstract public ?ProductDomainObject $product = null; + public ?OrderDomainObject $order = null; + public function getTotalBeforeDiscount(): float { return Currency::round($this->getPriceBeforeDiscount() * $this->getQuantity()); @@ -38,4 +40,16 @@ public function setProduct(?ProductDomainObject $product): self return $this; } + + public function getOrder(): ?OrderDomainObject + { + return $this->order; + } + + public function setOrder(?OrderDomainObject $order): self + { + $this->order = $order; + + return $this; + } } diff --git a/backend/app/DomainObjects/ProductCategoryDomainObject.php b/backend/app/DomainObjects/ProductCategoryDomainObject.php new file mode 100644 index 00000000..592d3dcb --- /dev/null +++ b/backend/app/DomainObjects/ProductCategoryDomainObject.php @@ -0,0 +1,20 @@ +products = $products; + } + + public function getProducts(): ?Collection + { + return $this->products; + } +} diff --git a/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php b/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php index b0af8664..41abc43f 100644 --- a/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php +++ b/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php @@ -10,6 +10,8 @@ class QuestionAndAnswerViewDomainObject extends AbstractDomainObject final public const SINGULAR_NAME = 'question_and_answer_view'; final public const PLURAL_NAME = 'question_and_answer_views'; + private ?int $product_id; + private ?string $product_title; private int $question_id; private ?int $order_id; private string $title; @@ -131,6 +133,28 @@ public function setEventId(int $event_id): QuestionAndAnswerViewDomainObject return $this; } + public function getProductId(): ?int + { + return $this->product_id; + } + + public function setProductId(?int $product_id): QuestionAndAnswerViewDomainObject + { + $this->product_id = $product_id; + return $this; + } + + public function getProductTitle(): ?string + { + return $this->product_title; + } + + public function setProductTitle(?string $product_title): QuestionAndAnswerViewDomainObject + { + $this->product_title = $product_title; + return $this; + } + public function toArray(): array { return [ diff --git a/backend/app/DomainObjects/QuestionDomainObject.php b/backend/app/DomainObjects/QuestionDomainObject.php index 5f1c011f..5b544cc9 100644 --- a/backend/app/DomainObjects/QuestionDomainObject.php +++ b/backend/app/DomainObjects/QuestionDomainObject.php @@ -20,11 +20,13 @@ public function getProducts(): ?Collection return $this->products; } - public function isMultipleChoice(): bool + public function isPreDefinedChoice(): bool { return in_array($this->getType(), [ - QuestionTypeEnum::MULTI_SELECT_DROPDOWN, - QuestionTypeEnum::CHECKBOX, + QuestionTypeEnum::MULTI_SELECT_DROPDOWN->name, + QuestionTypeEnum::CHECKBOX->name, + QuestionTypeEnum::RADIO->name, + QuestionTypeEnum::DROPDOWN->name, ], true); } diff --git a/backend/app/Helper/Url.php b/backend/app/Helper/Url.php index e52777fd..aff22c54 100644 --- a/backend/app/Helper/Url.php +++ b/backend/app/Helper/Url.php @@ -11,7 +11,7 @@ class Url public const ACCEPT_INVITATION = 'app.frontend_urls.accept_invitation'; public const CONFIRM_EMAIL_ADDRESS = 'app.frontend_urls.confirm_email_address'; public const EVENT_HOMEPAGE = 'app.frontend_urls.event_homepage'; - public const ATTENDEE_PRODUCT = 'app.frontend_urls.attendee_product'; + public const ATTENDEE_TICKET = 'app.frontend_urls.attendee_product'; public const ORDER_SUMMARY = 'app.frontend_urls.order_summary'; public const ORGANIZER_ORDER_SUMMARY = 'app.frontend_urls.organizer_order_summary'; diff --git a/backend/app/Http/Actions/Events/GetEventAction.php b/backend/app/Http/Actions/Events/GetEventAction.php index 5eea274d..f1ca7611 100644 --- a/backend/app/Http/Actions/Events/GetEventAction.php +++ b/backend/app/Http/Actions/Events/GetEventAction.php @@ -6,6 +6,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; +use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; @@ -31,10 +32,12 @@ public function __invoke(int $eventId): JsonResponse $event = $this->eventRepository ->loadRelation(new Relationship(domainObject: OrganizerDomainObject::class, name: 'organizer')) ->loadRelation( - new Relationship(ProductDomainObject::class, [ - new Relationship(ProductPriceDomainObject::class), - new Relationship(TaxAndFeesDomainObject::class), - ]), + new Relationship(ProductCategoryDomainObject::class, [ + new Relationship(ProductDomainObject::class, [ + new Relationship(ProductPriceDomainObject::class), + new Relationship(TaxAndFeesDomainObject::class), + ]), + ]) ) ->findById($eventId); diff --git a/backend/app/Http/Actions/Events/GetEventPublicAction.php b/backend/app/Http/Actions/Events/GetEventPublicAction.php index 14472026..4fc6c173 100644 --- a/backend/app/Http/Actions/Events/GetEventPublicAction.php +++ b/backend/app/Http/Actions/Events/GetEventPublicAction.php @@ -15,7 +15,7 @@ class GetEventPublicAction extends BaseAction { public function __construct( - private readonly GetPublicEventHandler $handler, + private readonly GetPublicEventHandler $getPublicEventHandler, private readonly LoggerInterface $logger, ) { @@ -23,7 +23,7 @@ public function __construct( public function __invoke(int $eventId, Request $request): Response|JsonResponse { - $event = $this->handler->handle(GetPublicEventDTO::fromArray([ + $event = $this->getPublicEventHandler->handle(GetPublicEventDTO::fromArray([ 'eventId' => $eventId, 'ipAddress' => $this->getClientIp($request), 'promoCode' => strtolower($request->string('promo_code')), diff --git a/backend/app/Http/Actions/Orders/CompleteOrderActionPublic.php b/backend/app/Http/Actions/Orders/CompleteOrderActionPublic.php index 619c1dcb..acfd466e 100644 --- a/backend/app/Http/Actions/Orders/CompleteOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/CompleteOrderActionPublic.php @@ -33,7 +33,7 @@ public function __invoke(CompleteOrderRequest $request, int $eventId, string $or ? $request->input('order.questions') : null, ]), - 'attendees' => $request->input('attendees'), + 'products' => $request->input('products'), ])); } catch (ResourceConflictException $e) { return $this->errorResponse($e->getMessage(), Response::HTTP_CONFLICT); diff --git a/backend/app/Http/Actions/ProductCategories/CreateProductCategoryAction.php b/backend/app/Http/Actions/ProductCategories/CreateProductCategoryAction.php new file mode 100644 index 00000000..8bbd6ba1 --- /dev/null +++ b/backend/app/Http/Actions/ProductCategories/CreateProductCategoryAction.php @@ -0,0 +1,39 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $productCategory = $this->handler->handle(new UpsertProductCategoryDTO( + name: $request->validated('name'), + description: $request->validated('description'), + is_hidden: $request->validated('is_hidden'), + event_id: $eventId, + )); + + return $this->resourceResponse( + resource: ProductCategoryResource::class, + data: $productCategory, + statusCode: ResponseCodes::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Actions/ProductCategories/DeleteProductCategoryAction.php b/backend/app/Http/Actions/ProductCategories/DeleteProductCategoryAction.php new file mode 100644 index 00000000..80ec3e46 --- /dev/null +++ b/backend/app/Http/Actions/ProductCategories/DeleteProductCategoryAction.php @@ -0,0 +1,46 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $this->deleteProductCategoryHandler->handle( + productCategoryId: $productCategoryId, + eventId: $eventId, + ); + } catch (CannotDeleteEntityException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: Response::HTTP_CONFLICT, + ); + } + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Actions/ProductCategories/EditProductCategoryAction.php b/backend/app/Http/Actions/ProductCategories/EditProductCategoryAction.php new file mode 100644 index 00000000..ff680c83 --- /dev/null +++ b/backend/app/Http/Actions/ProductCategories/EditProductCategoryAction.php @@ -0,0 +1,41 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $request->merge([ + 'event_id' => $eventId, + 'account_id' => $this->getAuthenticatedAccountId(), + 'product_category_id' => $productCategoryId, + ]); + + $productCategory = $this->editProductCategoryHandler->handle(new UpsertProductCategoryDTO( + name: $request->validated('name'), + description: $request->validated('description'), + is_hidden: $request->validated('is_hidden'), + event_id: $eventId, + product_category_id: $productCategoryId, + )); + + return $this->resourceResponse(ProductCategoryResource::class, $productCategory); + } +} diff --git a/backend/app/Http/Actions/ProductCategories/GetProductCategoriesAction.php b/backend/app/Http/Actions/ProductCategories/GetProductCategoriesAction.php new file mode 100644 index 00000000..ac37d246 --- /dev/null +++ b/backend/app/Http/Actions/ProductCategories/GetProductCategoriesAction.php @@ -0,0 +1,30 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $categories = $this->getProductCategoriesHandler->handle($eventId); + + return $this->resourceResponse( + resource: ProductCategoryResource::class, + data: $categories, + ); + } +} diff --git a/backend/app/Http/Actions/ProductCategories/GetProductCategoryAction.php b/backend/app/Http/Actions/ProductCategories/GetProductCategoryAction.php new file mode 100644 index 00000000..d4ca961b --- /dev/null +++ b/backend/app/Http/Actions/ProductCategories/GetProductCategoryAction.php @@ -0,0 +1,30 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $category = $this->getProductCategoryHandler->handle($eventId, $productCategoryId); + + return $this->resourceResponse( + resource: ProductCategoryResource::class, + data: $category, + ); + } +} diff --git a/backend/app/Http/Request/Product/UpsertProductRequest.php b/backend/app/Http/Request/Product/UpsertProductRequest.php index d14b7c27..50fb1bf8 100644 --- a/backend/app/Http/Request/Product/UpsertProductRequest.php +++ b/backend/app/Http/Request/Product/UpsertProductRequest.php @@ -39,6 +39,7 @@ public function rules(): array 'type' => ['required', Rule::in(ProductPriceType::valuesArray())], 'product_type' => ['required', Rule::in(ProductType::valuesArray())], 'tax_and_fee_ids' => 'array', + 'product_category_id' => ['required', 'integer'], ]; } diff --git a/backend/app/Http/Request/ProductCategory/UpsertProductCategoryRequest.php b/backend/app/Http/Request/ProductCategory/UpsertProductCategoryRequest.php new file mode 100644 index 00000000..c981b1d3 --- /dev/null +++ b/backend/app/Http/Request/ProductCategory/UpsertProductCategoryRequest.php @@ -0,0 +1,17 @@ + ['string', 'required', 'max:50'], + 'description' => ['string', 'max:255', 'nullable'], + 'is_hidden' => ['boolean', 'required'], + ]; + } +} diff --git a/backend/app/Mail/Attendee/AttendeeTicketMail.php b/backend/app/Mail/Attendee/AttendeeTicketMail.php index 90d90a5f..722d91ac 100644 --- a/backend/app/Mail/Attendee/AttendeeTicketMail.php +++ b/backend/app/Mail/Attendee/AttendeeTicketMail.php @@ -45,14 +45,14 @@ public function envelope(): Envelope public function content(): Content { return new Content( - markdown: 'emails.orders.attendee-product', + markdown: 'emails.orders.attendee-ticket', with: [ 'event' => $this->event, 'attendee' => $this->attendee, 'eventSettings' => $this->eventSettings, 'organizer' => $this->organizer, - 'productUrl' => sprintf( - Url::getFrontEndUrlFromConfig(Url::ATTENDEE_PRODUCT), + 'ticketUrl' => sprintf( + Url::getFrontEndUrlFromConfig(Url::ATTENDEE_TICKET), $this->event->getId(), $this->attendee->getShortId(), ) diff --git a/backend/app/Models/Event.php b/backend/app/Models/Event.php index 2c092fd1..d87972ee 100644 --- a/backend/app/Models/Event.php +++ b/backend/app/Models/Event.php @@ -24,6 +24,11 @@ public function products(): HasMany return $this->hasMany(Product::class)->orderBy('order'); } + public function product_categories(): HasMany + { + return $this->hasMany(ProductCategory::class)->orderBy('order'); + } + public function attendees(): HasMany { return $this->hasMany(Attendee::class); diff --git a/backend/app/Models/Product.php b/backend/app/Models/Product.php index 46bb9d6a..a8208578 100644 --- a/backend/app/Models/Product.php +++ b/backend/app/Models/Product.php @@ -5,6 +5,7 @@ namespace HiEvents\Models; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -47,4 +48,9 @@ public function check_in_lists(): BelongsToMany { return $this->belongsToMany(CheckInList::class, 'product_check_in_lists'); } + + public function product_category(): BelongsTo + { + return $this->belongsTo(ProductCategory::class); + } } diff --git a/backend/app/Models/ProductCategory.php b/backend/app/Models/ProductCategory.php new file mode 100644 index 00000000..b96826a2 --- /dev/null +++ b/backend/app/Models/ProductCategory.php @@ -0,0 +1,35 @@ +hasMany(Product::class); + } +} diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 7ff4caf5..046f41a3 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -21,6 +21,7 @@ use HiEvents\Repository\Eloquent\OrganizerRepository; use HiEvents\Repository\Eloquent\PasswordResetRepository; use HiEvents\Repository\Eloquent\PasswordResetTokenRepository; +use HiEvents\Repository\Eloquent\ProductCategoryRepository; use HiEvents\Repository\Eloquent\PromoCodeRepository; use HiEvents\Repository\Eloquent\QuestionAnswerRepository; use HiEvents\Repository\Eloquent\QuestionRepository; @@ -47,6 +48,7 @@ use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; use HiEvents\Repository\Interfaces\PasswordResetRepositoryInterface; use HiEvents\Repository\Interfaces\PasswordResetTokenRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductCategoryRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; @@ -90,6 +92,7 @@ class RepositoryServiceProvider extends ServiceProvider StripeCustomerRepositoryInterface::class => StripeCustomerRepository::class, CheckInListRepositoryInterface::class => CheckInListRepository::class, AttendeeCheckInRepositoryInterface::class => AttendeeCheckInRepository::class, + ProductCategoryRepositoryInterface::class => ProductCategoryRepository::class, ]; public function register(): void diff --git a/backend/app/Repository/Eloquent/BaseRepository.php b/backend/app/Repository/Eloquent/BaseRepository.php index c781a44b..1be0bc05 100644 --- a/backend/app/Repository/Eloquent/BaseRepository.php +++ b/backend/app/Repository/Eloquent/BaseRepository.php @@ -128,10 +128,25 @@ public function findFirst(int $id, array $columns = self::DEFAULT_COLUMNS): ?Dom return $this->handleSingleResult($this->model->findOrFail($id, $columns)); } - public function findWhere(array $where, array $columns = self::DEFAULT_COLUMNS): Collection + public function findWhere( + array $where, + array $columns = self::DEFAULT_COLUMNS, + array $orderAndDirections = [], + ): Collection { $this->applyConditions($where); + + if ($orderAndDirections) { + foreach ($orderAndDirections as $orderAndDirection) { + $this->model = $this->model->orderBy( + $orderAndDirection->getOrder(), + $orderAndDirection->getDirection() + ); + } + } + $model = $this->model->get($columns); + $this->resetModel(); return $this->handleResults($model); diff --git a/backend/app/Repository/Eloquent/ProductCategoryRepository.php b/backend/app/Repository/Eloquent/ProductCategoryRepository.php new file mode 100644 index 00000000..6a1546c4 --- /dev/null +++ b/backend/app/Repository/Eloquent/ProductCategoryRepository.php @@ -0,0 +1,50 @@ +model + ->where('event_id', $eventId) + ->with(['products']); + + // Apply filters from QueryParamsDTO, if needed + if (!empty($queryParamsDTO->filter_fields)) { + foreach ($queryParamsDTO->filter_fields as $filter) { + $query->where($filter->field, $filter->operator ?? '=', $filter->value); + } + } + + // Apply sorting from QueryParamsDTO + if (!empty($queryParamsDTO->sort_by)) { + $query->orderBy($queryParamsDTO->sort_by, $queryParamsDTO->sort_direction ?? 'asc'); + } + + return $query->get(); + } + + public function getNextOrder(int $eventId) + { + return $this->model + ->where('event_id', $eventId) + ->max('order') + 1; + } +} diff --git a/backend/app/Repository/Eloquent/ProductRepository.php b/backend/app/Repository/Eloquent/ProductRepository.php index 533a7e20..64fc826c 100644 --- a/backend/app/Repository/Eloquent/ProductRepository.php +++ b/backend/app/Repository/Eloquent/ProductRepository.php @@ -8,6 +8,7 @@ use HiEvents\DomainObjects\CapacityAssignmentDomainObject; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Models\CapacityAssignment; @@ -208,6 +209,15 @@ public function sortProducts(int $eventId, array $orderedProductIds): void $this->db->update($query, $parameters); } + public function hasAssociatedOrders(int $productId): bool + { + return $this->db->table('order_items') + ->join('orders', 'order_items.order_id', '=', 'orders.id') + ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::CANCELLED->name]) + ->where('order_items.product_id', $productId) + ->exists(); + } + public function getModel(): string { return Product::class; diff --git a/backend/app/Repository/Eloquent/Value/OrderAndDirection.php b/backend/app/Repository/Eloquent/Value/OrderAndDirection.php new file mode 100644 index 00000000..4a6058ef --- /dev/null +++ b/backend/app/Repository/Eloquent/Value/OrderAndDirection.php @@ -0,0 +1,36 @@ +validate(); + } + + public function getOrder(): string + { + return $this->order; + } + + public function getDirection(): string + { + return $this->direction; + } + + private function validate(): void + { + if (!in_array($this->direction, ['asc', 'desc'])) { + throw new InvalidArgumentException(__('Invalid direction. Must be either asc or desc')); + } + } +} diff --git a/backend/app/Repository/Eloquent/Value/Relationship.php b/backend/app/Repository/Eloquent/Value/Relationship.php index 857173ca..6dfcb761 100644 --- a/backend/app/Repository/Eloquent/Value/Relationship.php +++ b/backend/app/Repository/Eloquent/Value/Relationship.php @@ -2,18 +2,27 @@ namespace HiEvents\Repository\Eloquent\Value; -readonly class Relationship +use HiEvents\DomainObjects\Interfaces\DomainObjectInterface; +use InvalidArgumentException; + +class Relationship { public function __construct( - private string $domainObject, + private readonly string $domainObject, /** * @var Relationship[]|null */ - private ?array $nested = [], + private readonly ?array $nested = [], + + private readonly ?string $name = null, - private ?string $name = null, + /** + * @var OrderAndDirection[] + */ + private readonly array $orderAndDirections = [], ) { + $this->validate(); } public function getName(): string @@ -31,13 +40,23 @@ public function getDomainObject(): string return $this->domainObject; } + public function getOrderAndDirections(): array + { + return $this->orderAndDirections; + } + public function buildLaravelEagerLoadArray(): array { - if (!$this->nested) { - return [$this->getName()]; + $results = [ + $this->getName() => $this->buildOrderAndDirectionEloquentCallback() + ]; + + // If there are nested relationships, build them and merge into the results array + if ($this->nested) { + $results = array_merge($results, $this->buildNested($this, '')); } - return $this->buildNested($this, ''); + return $results; } private function buildNested(Relationship $relationship, string $prefix): array @@ -47,11 +66,51 @@ private function buildNested(Relationship $relationship, string $prefix): array if ($relationship->nested) { foreach ($relationship->nested as $nested) { $nestedPrefix = $prefix === '' ? $relationship->getName() : $prefix . '.' . $relationship->getName(); - $results[] = $nestedPrefix . '.' . $nested->getName(); + $results[$nestedPrefix . '.' . $nested->getName()] = $nested->buildOrderAndDirectionEloquentCallback(); $results = array_merge($results, $this->buildNested($nested, $nestedPrefix)); } } return $results; } + + private function buildOrderAndDirectionEloquentCallback(): callable|array + { + if ($this->getOrderAndDirections() === []) { + return []; + } + + return function ($query) { + foreach ($this->orderAndDirections as $orderAndDirection) { + $query->orderBy($orderAndDirection->getOrder(), $orderAndDirection->getDirection()); + } + }; + } + + private function validate(): void + { + if (!is_subclass_of($this->domainObject, DomainObjectInterface::class)) { + throw new InvalidArgumentException( + __('DomainObject must be a valid :interface.', [ + 'interface' => DomainObjectInterface::class, + ]), + ); + } + + foreach ($this->nested as $nested) { + if (!is_a($nested, __CLASS__)) { + throw new InvalidArgumentException( + __('Nested relationships must be an array of Relationship objects.'), + ); + } + } + + foreach ($this->orderAndDirections as $orderAndDirection) { + if (!is_a($orderAndDirection, OrderAndDirection::class)) { + throw new InvalidArgumentException( + __('OrderAndDirections must be an array of OrderAndDirection objects.'), + ); + } + } + } } diff --git a/backend/app/Repository/Interfaces/ProductCategoryRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductCategoryRepositoryInterface.php new file mode 100644 index 00000000..593fb5d7 --- /dev/null +++ b/backend/app/Repository/Interfaces/ProductCategoryRepositoryInterface.php @@ -0,0 +1,18 @@ + + */ +interface ProductCategoryRepositoryInterface extends RepositoryInterface +{ + public function findByEventId(int $eventId, QueryParamsDTO $queryParamsDTO): Collection; + + public function getNextOrder(int $eventId); +} diff --git a/backend/app/Repository/Interfaces/ProductRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductRepositoryInterface.php index f7c3e085..cce7389b 100644 --- a/backend/app/Repository/Interfaces/ProductRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/ProductRepositoryInterface.php @@ -86,4 +86,6 @@ public function removeCapacityAssignmentFromProducts(int $capacityAssignmentId): * @return void */ public function sortProducts(int $eventId, array $orderedProductIds): void; + + public function hasAssociatedOrders(int $productId): bool; } diff --git a/backend/app/Repository/Interfaces/RepositoryInterface.php b/backend/app/Repository/Interfaces/RepositoryInterface.php index 5481dbc8..dfa9c2c7 100644 --- a/backend/app/Repository/Interfaces/RepositoryInterface.php +++ b/backend/app/Repository/Interfaces/RepositoryInterface.php @@ -4,6 +4,7 @@ use Exception; use HiEvents\DomainObjects\Interfaces\DomainObjectInterface; +use HiEvents\Repository\Eloquent\Value\OrderAndDirection; use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Pagination\LengthAwarePaginator; @@ -17,6 +18,9 @@ interface RepositoryInterface /** @var array */ public const DEFAULT_COLUMNS = ['*']; + /** @var string */ + public const DEFAULT_ORDER_DIRECTION = 'asc'; + /** @var int */ public const DEFAULT_PAGINATE_LIMIT = 20; @@ -100,9 +104,15 @@ public function findFirst(int $id, array $columns = self::DEFAULT_COLUMNS): ?Dom /** * @param array $where * @param array $columns + * @param OrderAndDirection[] $orderAndDirections * @return Collection */ - public function findWhere(array $where, array $columns = self::DEFAULT_COLUMNS): Collection; + public function findWhere( + array $where, + array $columns = self::DEFAULT_COLUMNS, + /** @var OrderAndDirection[] */ + array $orderAndDirections = [], + ): Collection; /** * @param array $where diff --git a/backend/app/Resources/Event/EventResource.php b/backend/app/Resources/Event/EventResource.php index ef581326..1a150524 100644 --- a/backend/app/Resources/Event/EventResource.php +++ b/backend/app/Resources/Event/EventResource.php @@ -7,6 +7,7 @@ use HiEvents\Resources\Image\ImageResource; use HiEvents\Resources\Organizer\OrganizerResource; use HiEvents\Resources\Product\ProductResource; +use HiEvents\Resources\ProductCategory\ProductCategoryResource; use Illuminate\Http\Request; /** @@ -27,21 +28,28 @@ public function toArray(Request $request): array 'currency' => $this->getCurrency(), 'timezone' => $this->getTimezone(), 'slug' => $this->getSlug(), - 'products' => $this->when((bool)$this->getProducts(), fn() => ProductResource::collection($this->getProducts())), + 'products' => $this->when( + condition: (bool)$this->getProducts(), + value: fn() => ProductResource::collection($this->getProducts()), + ), + 'product_categories' => $this->when( + condition: (bool)$this->getProductCategories(), + value: fn() => ProductCategoryResource::collection($this->getProductCategories()), + ), 'attributes' => $this->when((bool)$this->getAttributes(), fn() => $this->getAttributes()), 'images' => $this->when((bool)$this->getImages(), fn() => ImageResource::collection($this->getImages())), 'location_details' => $this->when((bool)$this->getLocationDetails(), fn() => $this->getLocationDetails()), 'settings' => $this->when( - !is_null($this->getEventSettings()), - fn() => new EventSettingsResource($this->getEventSettings()) + condition: !is_null($this->getEventSettings()), + value: fn() => new EventSettingsResource($this->getEventSettings()) ), 'organizer' => $this->when( - !is_null($this->getOrganizer()), - fn() => new OrganizerResource($this->getOrganizer()) + condition: !is_null($this->getOrganizer()), + value: fn() => new OrganizerResource($this->getOrganizer()) ), 'statistics' => $this->when( - !is_null($this->getEventStatistics()), - fn() => new EventStatisticsResource($this->getEventStatistics()) + condition: !is_null($this->getEventStatistics()), + value: fn() => new EventStatisticsResource($this->getEventStatistics()) ), ]; } diff --git a/backend/app/Resources/Event/EventResourcePublic.php b/backend/app/Resources/Event/EventResourcePublic.php index aeeb4bdf..5beba56d 100644 --- a/backend/app/Resources/Event/EventResourcePublic.php +++ b/backend/app/Resources/Event/EventResourcePublic.php @@ -6,8 +6,9 @@ use HiEvents\Resources\BaseResource; use HiEvents\Resources\Image\ImageResource; use HiEvents\Resources\Organizer\OrganizerResourcePublic; -use HiEvents\Resources\Question\QuestionResource; use HiEvents\Resources\Product\ProductResourcePublic; +use HiEvents\Resources\ProductCategory\ProductCategoryResourcePublic; +use HiEvents\Resources\Question\QuestionResource; use Illuminate\Http\Request; /** @@ -38,30 +39,29 @@ public function toArray(Request $request): array 'lifecycle_status' => $this->getLifecycleStatus(), 'timezone' => $this->getTimezone(), 'location_details' => $this->when((bool)$this->getLocationDetails(), fn() => $this->getLocationDetails()), - - 'products' => $this->when( - !is_null($this->getProducts()), - fn() => ProductResourcePublic::collection($this->getProducts()) + 'product_categories' => $this->when( + condition: !is_null($this->getProductCategories()), + value: fn() => ProductCategoryResourcePublic::collection($this->getProductCategories()), ), 'settings' => $this->when( - !is_null($this->getEventSettings()), - fn() => new EventSettingsResourcePublic($this->getEventSettings(), $this->includePostCheckoutData), + condition: !is_null($this->getEventSettings()), + value: fn() => new EventSettingsResourcePublic($this->getEventSettings(), $this->includePostCheckoutData), ), // @TODO - public question resource 'questions' => $this->when( - !is_null($this->getQuestions()), - fn() => QuestionResource::collection($this->getQuestions()) + condition: !is_null($this->getQuestions()), + value: fn() => QuestionResource::collection($this->getQuestions()) ), 'attributes' => $this->when( - !is_null($this->getAttributes()), - fn() => collect($this->getAttributes())->reject(fn($attribute) => !$attribute['is_public'])), + condition: !is_null($this->getAttributes()), + value: fn() => collect($this->getAttributes())->reject(fn($attribute) => !$attribute['is_public'])), 'images' => $this->when( - !is_null($this->getImages()), - fn() => ImageResource::collection($this->getImages()) + condition: !is_null($this->getImages()), + value: fn() => ImageResource::collection($this->getImages()) ), 'organizer' => $this->when( - !is_null($this->getOrganizer()), - fn() => new OrganizerResourcePublic($this->getOrganizer()), + condition: !is_null($this->getOrganizer()), + value: fn() => new OrganizerResourcePublic($this->getOrganizer()), ), ]; } diff --git a/backend/app/Resources/Event/EventStatisticsResource.php b/backend/app/Resources/Event/EventStatisticsResource.php index 23055834..1b9d5c14 100644 --- a/backend/app/Resources/Event/EventStatisticsResource.php +++ b/backend/app/Resources/Event/EventStatisticsResource.php @@ -21,6 +21,7 @@ public function toArray(Request $request): array 'sales_total_before_additions' => $this->getSalesTotalBeforeAdditions(), 'total_fee' => $this->getTotalFee(), 'products_sold' => $this->getProductsSold(), + 'attendees_registered' => $this->getAttendeesRegistered(), 'total_refunded' => $this->getTotalRefunded(), ]; } diff --git a/backend/app/Resources/Order/OrderResource.php b/backend/app/Resources/Order/OrderResource.php index b127d90d..cec43992 100644 --- a/backend/app/Resources/Order/OrderResource.php +++ b/backend/app/Resources/Order/OrderResource.php @@ -2,7 +2,6 @@ namespace HiEvents\Resources\Order; -use HiEvents\DomainObjects\Enums\QuestionBelongsTo; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\Resources\Attendee\AttendeeResource; use HiEvents\Resources\BaseResource; @@ -49,10 +48,7 @@ public function toArray(Request $request): array ), 'question_answers' => $this->when( !is_null($this->getQuestionAndAnswerViews()), - fn() => QuestionAnswerViewResource::collection( - $this->getQuestionAndAnswerViews() - ?->filter(fn($qav) => $qav->getBelongsTo() === QuestionBelongsTo::ORDER->name) - ) + fn() => QuestionAnswerViewResource::collection($this->getQuestionAndAnswerViews()), ), ]; } diff --git a/backend/app/Resources/Product/ProductMinimalResourcePublic.php b/backend/app/Resources/Product/ProductMinimalResourcePublic.php index c71e7918..daadaab8 100644 --- a/backend/app/Resources/Product/ProductMinimalResourcePublic.php +++ b/backend/app/Resources/Product/ProductMinimalResourcePublic.php @@ -22,6 +22,7 @@ public function toArray(Request $request): array (bool)$this->getProductPrices(), fn() => ProductPriceResourcePublic::collection($this->getProductPrices()), ), + 'product_category_id' => $this->getProductCategoryId(), ]; } } diff --git a/backend/app/Resources/Product/ProductResource.php b/backend/app/Resources/Product/ProductResource.php index 2fc6fecb..765a6497 100644 --- a/backend/app/Resources/Product/ProductResource.php +++ b/backend/app/Resources/Product/ProductResource.php @@ -57,6 +57,7 @@ public function toArray(Request $request): array (bool)$this->getProductPrices(), fn() => ProductPriceResource::collection($this->getProductPrices()) ), + 'product_category_id' => $this->getProductCategoryId(), ]; } } diff --git a/backend/app/Resources/Product/ProductResourcePublic.php b/backend/app/Resources/Product/ProductResourcePublic.php index 9239fa4d..d2876611 100644 --- a/backend/app/Resources/Product/ProductResourcePublic.php +++ b/backend/app/Resources/Product/ProductResourcePublic.php @@ -47,6 +47,7 @@ public function toArray(Request $request): array 'is_available' => $this->isAvailable(), 'is_sold_out' => $this->isSoldOut(), ]), + 'product_category_id' => $this->getProductCategoryId(), ]; } } diff --git a/backend/app/Resources/ProductCategory/ProductCategoryResource.php b/backend/app/Resources/ProductCategory/ProductCategoryResource.php new file mode 100644 index 00000000..4b08433e --- /dev/null +++ b/backend/app/Resources/ProductCategory/ProductCategoryResource.php @@ -0,0 +1,27 @@ + $this->getId(), + 'name' => $this->getName(), + 'description' => $this->getDescription(), + 'is_hidden' => $this->getIsHidden(), + 'order' => $this->getOrder(), + $this->mergeWhen((bool)$this->getProducts(), fn() => [ + 'products' => ProductResource::collection($this->getProducts()), + ]), + ]; + } +} diff --git a/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php b/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php new file mode 100644 index 00000000..20297478 --- /dev/null +++ b/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php @@ -0,0 +1,27 @@ + $this->getId(), + 'name' => $this->getName(), + 'description' => $this->getDescription(), + 'is_hidden' => $this->getIsHidden(), + 'order' => $this->getOrder(), + $this->mergeWhen((bool)$this->getProducts(), fn() => [ + 'products' => ProductResource::collection($this->getProducts()), + ]), + ]; + } +} diff --git a/backend/app/Resources/Question/QuestionAnswerViewResource.php b/backend/app/Resources/Question/QuestionAnswerViewResource.php index 6b58307d..7c3fae4f 100644 --- a/backend/app/Resources/Question/QuestionAnswerViewResource.php +++ b/backend/app/Resources/Question/QuestionAnswerViewResource.php @@ -16,6 +16,8 @@ class QuestionAnswerViewResource extends JsonResource public function toArray(Request $request): array { return [ + 'product_id' => $this->getProductId(), + 'product_title' => $this->getProductTitle(), 'question_id' => $this->getQuestionId(), 'title' => $this->getTitle(), 'answer' => $this->getAnswer(), diff --git a/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentTicketAssociationService.php b/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentProductAssociationService.php similarity index 100% rename from backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentTicketAssociationService.php rename to backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentProductAssociationService.php diff --git a/backend/app/Services/Domain/CheckInList/CheckInListTicketAssociationService.php b/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php similarity index 100% rename from backend/app/Services/Domain/CheckInList/CheckInListTicketAssociationService.php rename to backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php diff --git a/backend/app/Services/Domain/Event/CreateEventImageService.php b/backend/app/Services/Domain/Event/CreateEventImageService.php index a2b6eb73..7c9f8557 100644 --- a/backend/app/Services/Domain/Event/CreateEventImageService.php +++ b/backend/app/Services/Domain/Event/CreateEventImageService.php @@ -43,7 +43,7 @@ public function createImage( image: $image, entityId: $eventId, entityType: EventDomainObject::class, - imageType: EventImageType::EVENT_COVER->name, + imageType: $type->name, ); }); } diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index 1ff21f1e..cec3866d 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -13,6 +13,7 @@ use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; +use HiEvents\Services\Domain\ProductCategory\CreateProductCategoryService; use HTMLPurifier; use Illuminate\Database\DatabaseManager; use Throwable; @@ -26,6 +27,7 @@ public function __construct( private readonly DatabaseManager $databaseManager, private readonly EventStatisticRepositoryInterface $eventStatisticsRepository, private readonly HTMLPurifier $purifier, + private readonly CreateProductCategoryService $createProductCategoryService, ) { } @@ -55,6 +57,8 @@ public function createEvent( $this->createEventStatistics($event); + $this->createDefaultProductCategory($event); + $this->databaseManager->commit(); return $event; @@ -143,4 +147,14 @@ private function createEventSettings( 'support_email' => $organizer->getEmail(), ]); } + + private function createDefaultProductCategory(EventDomainObject $event): void + { + $this->createProductCategoryService->createCategory( + name: __('Tickets'), + isHidden: false, + eventId: $event->getId(), + description: null, + ); + } } diff --git a/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php b/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php index 180eaad7..e15c8fd1 100644 --- a/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php +++ b/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php @@ -11,6 +11,9 @@ public function __construct( public float $total_sales_gross, public int $products_sold, public int $orders_created, + public int $attendees_registered, + public float $total_refunded, + ) { } diff --git a/backend/app/Services/Domain/Event/EventStatsFetchService.php b/backend/app/Services/Domain/Event/EventStatsFetchService.php index d22d8681..ce4263eb 100644 --- a/backend/app/Services/Domain/Event/EventStatsFetchService.php +++ b/backend/app/Services/Domain/Event/EventStatsFetchService.php @@ -30,7 +30,10 @@ public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResp SUM(es.sales_total_gross) AS total_gross_sales, SUM(es.total_tax) AS total_tax, SUM(es.total_fee) AS total_fees, - SUM(es.total_views) AS total_views + SUM(es.total_views) AS total_views, + SUM(es.total_refunded) AS total_refunded, + SUM(es.attendees_registered) AS attendees_registered + FROM event_statistics es WHERE es.event_id = :eventId AND es.deleted_at IS NULL; @@ -46,12 +49,13 @@ public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResp end_date: $requestData->end_date, check_in_stats: $this->getCheckedInStats($eventId), total_products_sold: $totalsResult->total_products_sold ?? 0, + total_attendees_registered: $totalsResult->attendees_registered ?? 0, total_orders: $totalsResult->total_orders ?? 0, total_gross_sales: $totalsResult->total_gross_sales ?? 0, total_fees: $totalsResult->total_fees ?? 0, total_tax: $totalsResult->total_tax ?? 0, total_views: $totalsResult->total_views ?? 0, - + total_refunded: $totalsResult->total_refunded ?? 0, ); } @@ -77,7 +81,9 @@ public function getDailyEventStats(EventStatsRequestDTO $requestData): Collectio COALESCE(SUM(eds.total_tax), 0) AS total_tax, COALESCE(SUM(eds.sales_total_gross), 0) AS total_sales_gross, COALESCE(SUM(eds.orders_created), 0) AS orders_created, - COALESCE(SUM(eds.products_sold), 0) AS products_sold + COALESCE(SUM(eds.products_sold), 0) AS products_sold, + COALESCE(SUM(eds.attendees_registered), 0) AS attendees_registered, + COALESCE(SUM(eds.total_refunded), 0) AS total_refunded FROM date_series ds LEFT JOIN event_daily_statistics eds ON ds.date = eds.date AND eds.deleted_at IS NULL AND eds.event_id = :eventId GROUP BY ds.date @@ -102,6 +108,8 @@ public function getDailyEventStats(EventStatsRequestDTO $requestData): Collectio total_sales_gross: $result->total_sales_gross, products_sold: $result->products_sold, orders_created: $result->orders_created, + attendees_registered: $result->attendees_registered, + total_refunded: $result->total_refunded, ); }); } diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsUpdateService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsUpdateService.php index 5fbf9b48..f62b1ede 100644 --- a/backend/app/Services/Domain/EventStatistics/EventStatisticsUpdateService.php +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsUpdateService.php @@ -2,16 +2,16 @@ namespace HiEvents\Services\Domain\EventStatistics; -use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; +use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Exceptions\EventStatisticsVersionMismatchException; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; -use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; +use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Values\MoneyValue; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Carbon; @@ -155,6 +155,8 @@ private function updateEventStats(OrderDomainObject $order): void 'event_id' => $order->getEventId(), 'products_sold' => $order->getOrderItems() ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), + 'attendees_registered' => $order->getTicketOrderItems() + ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), 'sales_total_gross' => $order->getTotalGross(), 'sales_total_before_additions' => $order->getTotalBeforeAdditions(), 'total_tax' => $order->getTotalTax(), @@ -169,6 +171,8 @@ private function updateEventStats(OrderDomainObject $order): void attributes: [ 'products_sold' => $eventStatistics->getProductsSold() + $order->getOrderItems() ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), + 'attendees_registered' => $eventStatistics->getAttendeesRegistered() + $order->getTicketOrderItems() + ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), 'sales_total_gross' => $eventStatistics->getSalesTotalGross() + $order->getTotalGross(), 'sales_total_before_additions' => $eventStatistics->getSalesTotalBeforeAdditions() + $order->getTotalBeforeAdditions(), 'total_tax' => $eventStatistics->getTotalTax() + $order->getTotalTax(), @@ -209,6 +213,7 @@ private function updateEventDailyStats(OrderDomainObject $order): void 'event_id' => $order->getEventId(), 'date' => (new Carbon($order->getCreatedAt()))->format('Y-m-d'), 'products_sold' => $order->getOrderItems()?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), + 'attendees_registered' => $order->getTicketOrderItems()?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), 'sales_total_gross' => $order->getTotalGross(), 'sales_total_before_additions' => $order->getTotalBeforeAdditions(), 'total_tax' => $order->getTotalTax(), @@ -220,6 +225,7 @@ private function updateEventDailyStats(OrderDomainObject $order): void $update = $this->eventDailyStatisticRepository->updateWhere( attributes: [ + 'attendees_registered' => $eventDailyStatistic->getAttendeesRegistered() + $order->getTicketOrderItems()->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), 'products_sold' => $eventDailyStatistic->getProductsSold() + $order->getOrderItems()->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()), 'sales_total_gross' => $eventDailyStatistic->getSalesTotalGross() + $order->getTotalGross(), 'sales_total_before_additions' => $eventDailyStatistic->getSalesTotalBeforeAdditions() + $order->getTotalBeforeAdditions(), diff --git a/backend/app/Services/Domain/Order/OrderItemProcessingService.php b/backend/app/Services/Domain/Order/OrderItemProcessingService.php index 8536ecee..3f7bf38a 100644 --- a/backend/app/Services/Domain/Order/OrderItemProcessingService.php +++ b/backend/app/Services/Domain/Order/OrderItemProcessingService.php @@ -5,16 +5,16 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; -use HiEvents\DomainObjects\PromoCodeDomainObject; -use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; +use HiEvents\DomainObjects\PromoCodeDomainObject; +use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\Helper\Currency; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; -use HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService; use HiEvents\Services\Domain\Product\DTO\OrderProductPriceDTO; use HiEvents\Services\Domain\Product\ProductPriceService; +use HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService; use HiEvents\Services\Handlers\Order\DTO\ProductOrderDetailsDTO; use Illuminate\Support\Collection; use Symfony\Component\Routing\Exception\ResourceNotFoundException; @@ -57,7 +57,7 @@ public function process( if ($product === null) { throw new ResourceNotFoundException( - __('Product with id :id not found', ['id' => $productOrderDetail->product_id]) + __('Product with id :id not found', ['id' => $productOrderDetail->product_id]) ); } @@ -93,6 +93,7 @@ private function calculateOrderItemData( ); return [ + 'product_type' => $product->getProductType(), 'product_id' => $product->getId(), 'product_price_id' => $productPriceDetails->price_id, 'quantity' => $productPriceDetails->quantity, diff --git a/backend/app/Services/Domain/Product/CreateProductService.php b/backend/app/Services/Domain/Product/CreateProductService.php index 608d8e15..3c8d11fd 100644 --- a/backend/app/Services/Domain/Product/CreateProductService.php +++ b/backend/app/Services/Domain/Product/CreateProductService.php @@ -17,12 +17,13 @@ class CreateProductService { public function __construct( - private readonly ProductRepositoryInterface $productRepository, - private readonly DatabaseManager $databaseManager, + private readonly ProductRepositoryInterface $productRepository, + private readonly DatabaseManager $databaseManager, private readonly TaxAndProductAssociationService $taxAndProductAssociationService, - private readonly ProductPriceCreateService $priceCreateService, - private readonly HTMLPurifier $purifier, - private readonly EventRepositoryInterface $eventRepository, + private readonly ProductPriceCreateService $priceCreateService, + private readonly HTMLPurifier $purifier, + private readonly EventRepositoryInterface $eventRepository, + private readonly ProductOrderingService $productOrderingService, ) { } @@ -55,7 +56,10 @@ private function persistProduct(ProductDomainObject $productsData): ProductDomai 'title' => $productsData->getTitle(), 'type' => $productsData->getType(), 'product_type' => $productsData->getProductType(), - 'order' => $productsData->getOrder(), + 'order' => $this->productOrderingService->getOrderForNewProduct( + eventId: $productsData->getEventId(), + productCategoryId: $productsData->getProductCategoryId(), + ), 'sale_start_date' => $productsData->getSaleStartDate() ? DateHelper::convertToUTC($productsData->getSaleStartDate(), $event->getTimezone()) : null, @@ -72,6 +76,7 @@ private function persistProduct(ProductDomainObject $productsData): ProductDomai 'show_quantity_remaining' => $productsData->getShowQuantityRemaining(), 'is_hidden_without_promo_code' => $productsData->getIsHiddenWithoutPromoCode(), 'event_id' => $productsData->getEventId(), + 'product_category_id' => $productsData->getProductCategoryId(), ]); } diff --git a/backend/app/Services/Domain/Product/DeleteProductService.php b/backend/app/Services/Domain/Product/DeleteProductService.php new file mode 100644 index 00000000..6fee0580 --- /dev/null +++ b/backend/app/Services/Domain/Product/DeleteProductService.php @@ -0,0 +1,60 @@ +databaseManager->transaction(function () use ($productId, $eventId) { + if ($this->productRepository->hasAssociatedOrders($productId)) { + throw new CannotDeleteEntityException( + __('You cannot delete this product because it has orders associated with it. You can hide it instead.') + ); + } + + $this->productRepository->deleteWhere( + [ + ProductDomainObjectAbstract::EVENT_ID => $eventId, + ProductDomainObjectAbstract::ID => $productId, + ] + ); + + $this->productPriceRepository->deleteWhere( + [ + ProductPriceDomainObjectAbstract::PRODUCT_ID => $productId, + ] + ); + }); + + $this->logger->info( + sprintf('Product with id %d was deleted from event with id %d', $productId, $eventId), + [ + 'product_id' => $productId, + 'event_id' => $eventId, + ] + ); + } +} diff --git a/backend/app/Services/Domain/Product/ProductFilterService.php b/backend/app/Services/Domain/Product/ProductFilterService.php index 9554980a..71ca50f2 100644 --- a/backend/app/Services/Domain/Product/ProductFilterService.php +++ b/backend/app/Services/Domain/Product/ProductFilterService.php @@ -4,12 +4,13 @@ use HiEvents\Constants; use HiEvents\DomainObjects\CapacityAssignmentDomainObject; -use HiEvents\DomainObjects\PromoCodeDomainObject; +use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; +use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\Helper\Currency; -use HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService; use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO; +use HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService; use Illuminate\Support\Collection; class ProductFilterService @@ -23,29 +24,39 @@ public function __construct( } /** - * @param Collection $products + * @param Collection $productsCategories * @param PromoCodeDomainObject|null $promoCode * @param bool $hideSoldOutProducts * @return Collection */ public function filter( - Collection $products, + Collection $productsCategories, ?PromoCodeDomainObject $promoCode = null, bool $hideSoldOutProducts = true, ): Collection { - if ($products->isEmpty()) { - return $products; + if ($productsCategories->isEmpty()) { + return $productsCategories; } + $products = $productsCategories + ->flatMap(fn(ProductCategoryDomainObject $category) => $category->getProducts()); + $productQuantities = $this ->fetchAvailableProductQuantitiesService ->getAvailableProductQuantities($products->first()->getEventId()); - return $products + $filteredProducts = $products ->map(fn(ProductDomainObject $product) => $this->processProduct($product, $productQuantities->productQuantities, $promoCode)) ->reject(fn(ProductDomainObject $product) => $this->filterProduct($product, $promoCode, $hideSoldOutProducts)) ->each(fn(ProductDomainObject $product) => $this->processProductPrices($product, $hideSoldOutProducts)); + + return $productsCategories + ->map(fn(ProductCategoryDomainObject $category) => $category->setProducts( + $filteredProducts->where( + static fn(ProductDomainObject $product) => $product->getProductCategoryId() === $category->getId() + ) + )); } private function isHiddenByPromoCode(ProductDomainObject $product, ?PromoCodeDomainObject $promoCode): bool diff --git a/backend/app/Services/Domain/Product/ProductOrderingService.php b/backend/app/Services/Domain/Product/ProductOrderingService.php new file mode 100644 index 00000000..11e9498a --- /dev/null +++ b/backend/app/Services/Domain/Product/ProductOrderingService.php @@ -0,0 +1,24 @@ +productRepository->findWhere([ + 'event_id' => $eventId, + 'product_category_id' => $productCategoryId, + ]) + ->max((static fn(ProductDomainObject $product) => $product->getOrder())) ?? 0) + 1; + } +} diff --git a/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php b/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php new file mode 100644 index 00000000..3c38dadb --- /dev/null +++ b/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php @@ -0,0 +1,31 @@ +productCategoryRepository->create([ + 'name' => $name, + 'description' => $description, + 'is_hidden' => $isHidden, + 'event_id' => $eventId, + 'order' => $this->productCategoryRepository->getNextOrder($eventId), + ]); + } +} diff --git a/backend/app/Services/Domain/ProductCategory/DeleteProductCategoryService.php b/backend/app/Services/Domain/ProductCategory/DeleteProductCategoryService.php new file mode 100644 index 00000000..45ffc662 --- /dev/null +++ b/backend/app/Services/Domain/ProductCategory/DeleteProductCategoryService.php @@ -0,0 +1,119 @@ +databaseManager->transaction(function () use ($productCategoryId, $eventId) { + $this->handleDeletion($productCategoryId, $eventId); + }); + } + + /** + * @throws Throwable + * @throws CannotDeleteEntityException + */ + private function handleDeletion(int $productCategoryId, int $eventId): void + { + $this->validateCanDeleteProductCategory($eventId); + + $this->deleteCategoryProducts($productCategoryId, $eventId); + + $this->deleteCategory($productCategoryId, $eventId); + } + + /** + * @throws CannotDeleteEntityException + * @throws Throwable + */ + private function deleteCategoryProducts(int $productCategoryId, int $eventId): void + { + $productsToDelete = $this->productRepository->findWhere( + [ + ProductCategoryDomainObjectAbstract::ID => $productCategoryId, + ProductCategoryDomainObjectAbstract::EVENT_ID => $eventId, + ] + ); + + + $productsWhichCanNotBeDeleted = new Collection(); + + foreach ($productsToDelete as $product) { + try { + $this->deleteProductService->deleteProduct($product->getId(), $eventId); + } catch (CannotDeleteEntityException) { + + $productsWhichCanNotBeDeleted->push($product); + } + } + + if ($productsWhichCanNotBeDeleted->isNotEmpty()) { + throw new CannotDeleteEntityException( + __('You cannot delete this product category because products :products are associated with it, ' . + 'and they have orders associated with them. Please move the :product_name to another category.', [ + 'products' => $productsWhichCanNotBeDeleted->pluck('name')->implode(', '), + 'product_name' => $productsWhichCanNotBeDeleted->count() > 1 ? __('products') : __('product'), + ]) + ); + } + } + + private function deleteCategory(int $productCategoryId, int $eventId): void + { + $this->productCategoryRepository->deleteWhere( + [ + ProductCategoryDomainObjectAbstract::ID => $productCategoryId, + ProductCategoryDomainObjectAbstract::EVENT_ID => $eventId, + ] + ); + + $this->logger->info(__('Product category :productCategoryId has been deleted.', [ + 'product_category_id' => $productCategoryId, + 'event_id' => $eventId, + ])); + } + + /** + * @throws CannotDeleteEntityException + */ + private function validateCanDeleteProductCategory(int $eventId): void + { + $existingRelatedCategories = $this->productCategoryRepository->findWhere( + [ + ProductCategoryDomainObjectAbstract::EVENT_ID => $eventId, + ] + ); + + if ($existingRelatedCategories->count() === 1) { + throw new CannotDeleteEntityException( + __('You cannot delete the last product category. Please create another category before deleting this one.') + ); + } + } +} diff --git a/backend/app/Services/Domain/ProductCategory/GetProductCategoryService.php b/backend/app/Services/Domain/ProductCategory/GetProductCategoryService.php new file mode 100644 index 00000000..ae03e4dd --- /dev/null +++ b/backend/app/Services/Domain/ProductCategory/GetProductCategoryService.php @@ -0,0 +1,47 @@ +productCategoryRepository + ->loadRelation(new Relationship( + domainObject: ProductDomainObject::class, + orderAndDirections: [ + new OrderAndDirection( + order: ProductCategoryDomainObjectAbstract::ORDER, + ), + ], + )) + ->findFirstWhere( + where: [ + 'id' => $categoryId, + 'event_id' => $eventId, + ] + ); + + if (!$category) { + throw new ResourceNotFoundException( + __('The product category with ID :id was not found.', ['id' => $categoryId]) + ); + } + + return $category; + } +} diff --git a/backend/app/Services/Handlers/Event/DTO/EventStatsResponseDTO.php b/backend/app/Services/Handlers/Event/DTO/EventStatsResponseDTO.php index ca3a4e9d..f6cf90d5 100644 --- a/backend/app/Services/Handlers/Event/DTO/EventStatsResponseDTO.php +++ b/backend/app/Services/Handlers/Event/DTO/EventStatsResponseDTO.php @@ -19,11 +19,14 @@ public function __construct( public EventCheckInStatsResponseDTO $check_in_stats, public int $total_products_sold, + public int $total_attendees_registered, + public int $total_orders, public float $total_gross_sales, public float $total_fees, public float $total_tax, public float $total_views, + public float $total_refunded, ) { } diff --git a/backend/app/Services/Handlers/Event/GetPublicEventHandler.php b/backend/app/Services/Handlers/Event/GetPublicEventHandler.php index 6f309447..f52038e6 100644 --- a/backend/app/Services/Handlers/Event/GetPublicEventHandler.php +++ b/backend/app/Services/Handlers/Event/GetPublicEventHandler.php @@ -7,9 +7,10 @@ use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; use HiEvents\DomainObjects\ImageDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; -use HiEvents\DomainObjects\TaxAndFeesDomainObject; +use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; +use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; @@ -32,9 +33,11 @@ public function handle(GetPublicEventDTO $data): EventDomainObject { $event = $this->eventRepository ->loadRelation( - new Relationship(ProductDomainObject::class, [ - new Relationship(ProductPriceDomainObject::class), - new Relationship(TaxAndFeesDomainObject::class) + new Relationship(ProductCategoryDomainObject::class, [ + new Relationship(ProductDomainObject::class, [ + new Relationship(ProductPriceDomainObject::class), + new Relationship(TaxAndFeesDomainObject::class), + ]), ]) ) ->loadRelation(new Relationship(EventSettingDomainObject::class)) @@ -55,6 +58,9 @@ public function handle(GetPublicEventDTO $data): EventDomainObject $this->eventPageViewIncrementService->increment($data->eventId, $data->ipAddress); } - return $event->setProducts($this->productFilterService->filter($event->getProducts(), $promoCodeDomainObject)); + return $event->setProducts($this->productFilterService->filter( + productsCategories: $event->getProductCategories(), + promoCode: $promoCodeDomainObject + )); } } diff --git a/backend/app/Services/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Handlers/Order/CompleteOrderHandler.php index 801d6cd9..7a17de93 100644 --- a/backend/app/Services/Handlers/Order/CompleteOrderHandler.php +++ b/backend/app/Services/Handlers/Order/CompleteOrderHandler.php @@ -7,28 +7,31 @@ use Carbon\Carbon; use Exception; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\ProductType; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\Generated\ProductPriceDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\DomainObjects\Status\OrderPaymentStatus; use HiEvents\DomainObjects\Status\OrderStatus; -use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\Events\OrderStatusChangedEvent; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Helper\IdHelper; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; -use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; +use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\PaymentIntentSucceededHandler; use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; -use HiEvents\Services\Handlers\Order\DTO\CompleteOrderAttendeeDTO; use HiEvents\Services\Handlers\Order\DTO\CompleteOrderDTO; use HiEvents\Services\Handlers\Order\DTO\CompleteOrderOrderDTO; +use HiEvents\Services\Handlers\Order\DTO\CompleteOrderProductDataDTO; +use HiEvents\Services\Handlers\Order\DTO\CreatedProductDataDTO; use HiEvents\Services\Handlers\Order\DTO\OrderQuestionsDTO; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -62,7 +65,7 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order $updatedOrder = $this->updateOrder($order, $orderDTO); - $this->createAttendees($orderData->attendees, $order); + $this->createAttendees($orderData->products, $order); if ($orderData->order->questions) { $this->createOrderQuestions($orderDTO->questions, $order); @@ -85,24 +88,39 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order } /** + * @param Collection $orderProducts * @throws Exception */ - private function createAttendees(Collection $attendees, OrderDomainObject $order): void + private function createAttendees(Collection $orderProducts, OrderDomainObject $order): void { $inserts = []; + $createdProductData = collect(); $productsPrices = $this->productPriceRepository->findWhereIn( field: ProductPriceDomainObjectAbstract::ID, - values: $attendees->pluck('product_price_id')->toArray(), + values: $orderProducts->pluck('product_price_id')->toArray(), ); $this->validateProductPriceIdsMatchOrder($order, $productsPrices); - $this->validateAttendees($order, $attendees); + $this->validateTicketProductsCount($order, $orderProducts); - foreach ($attendees as $attendee) { + foreach ($orderProducts as $attendee) { $productId = $productsPrices->first( fn(ProductPriceDomainObject $productPrice) => $productPrice->getId() === $attendee->product_price_id) ->getProductId(); + $productType = $this->getProductTypeFromPriceId($attendee->product_price_id, $order->getOrderItems()); + + // If it's not a ticket, skip, as we only want to create attendees for tickets + if ($productType !== ProductType::TICKET->name) { + $createdProductData->push(new CreatedProductDataDTO( + productRequestData: $attendee, + shortId: null, + )); + + continue; + } + + $shortId = IdHelper::shortId(IdHelper::ATTENDEE_PREFIX); $inserts[] = [ AttendeeDomainObjectAbstract::EVENT_ID => $order->getEventId(), @@ -114,20 +132,25 @@ private function createAttendees(Collection $attendees, OrderDomainObject $order AttendeeDomainObjectAbstract::LAST_NAME => $attendee->last_name, AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(), AttendeeDomainObjectAbstract::PUBLIC_ID => IdHelper::publicId(IdHelper::ATTENDEE_PREFIX), - AttendeeDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::ATTENDEE_PREFIX), + AttendeeDomainObjectAbstract::SHORT_ID => $shortId, AttendeeDomainObjectAbstract::LOCALE => $order->getLocale(), ]; + + $createdProductData->push(new CreatedProductDataDTO( + productRequestData: $attendee, + shortId: $shortId, + )); } if (!$this->attendeeRepository->insert($inserts)) { throw new RuntimeException(__('Failed to create attendee')); } - $insertedAttendees = $this->attendeeRepository->findWhere([ - AttendeeDomainObjectAbstract::ORDER_ID => $order->getId() - ]); - - $this->createAttendeeQuestions($attendees, $insertedAttendees, $order, $productsPrices); + $this->createProductQuestions( + createdAttendees: $createdProductData, + order: $order, + productPrices: $productsPrices, + ); } private function createOrderQuestions(Collection $questions, OrderDomainObject $order): void @@ -144,32 +167,39 @@ private function createOrderQuestions(Collection $questions, OrderDomainObject $ }); } - private function createAttendeeQuestions( - Collection $attendees, - Collection $insertedAttendees, + /** + * @param Collection $createdAttendees + * @param Collection $productPrices + * @throws ResourceConflictException|Exception + */ + private function createProductQuestions( + Collection $createdAttendees, OrderDomainObject $order, - Collection $productPrices, + Collection $productPrices ): void { - $insertedIds = []; - /** @var CompleteOrderAttendeeDTO $attendee */ - foreach ($attendees as $attendee) { - $productId = $productPrices->first( - fn(ProductPriceDomainObject $productPrice) => $productPrice->getId() === $attendee->product_price_id) - ->getProductId(); + $newAttendees = $this->attendeeRepository->findWhereIn( + field: AttendeeDomainObjectAbstract::SHORT_ID, + values: $createdAttendees->pluck('shortId')->toArray(), + ); - $attendeeIterator = $insertedAttendees->filter( - fn(AttendeeDomainObject $insertedAttendee) => $insertedAttendee->getProductId() === $productId - && !in_array($insertedAttendee->getId(), $insertedIds, true) - )->getIterator(); + foreach ($createdAttendees as $createdAttendee) { + $productRequestData = $createdAttendee->productRequestData; - if ($attendee->questions === null) { + if ($productRequestData->questions === null) { continue; } - foreach ($attendee->questions as $question) { - $attendeeId = $attendeeIterator->current()->getId(); + $productId = $productPrices->first( + fn(ProductPriceDomainObject $productPrice) => $productPrice->getId() === $productRequestData->product_price_id + )->getProductId(); + // This will be null for non-ticket products + $insertedAttendee = $newAttendees->first( + fn(AttendeeDomainObject $attendee) => $attendee->getShortId() === $createdAttendee->shortId, + ); + + foreach ($productRequestData->questions as $question) { if (empty($question->response)) { continue; } @@ -179,10 +209,8 @@ private function createAttendeeQuestions( 'answer' => $question->response['answer'] ?? $question->response, 'order_id' => $order->getId(), 'product_id' => $productId, - 'attendee_id' => $attendeeId + 'attendee_id' => $insertedAttendee?->getId(), ]); - - $insertedIds[] = $attendeeId; } } } @@ -214,7 +242,7 @@ private function getOrder(string $orderShortId): OrderDomainObject ->loadRelation( new Relationship( domainObject: OrderItemDomainObject::class, - nested: [new Relationship(TicketDomainObject::class, name: 'ticket')] + nested: [new Relationship(ProductDomainObject::class, name: 'product')] )) ->findByShortId($orderShortId); @@ -267,14 +295,30 @@ private function validateProductPriceIdsMatchOrder(OrderDomainObject $order, Col /** * @throws ResourceConflictException */ - private function validateAttendees(OrderDomainObject $order, Collection $attendees): void + private function validateTicketProductsCount(OrderDomainObject $order, Collection $attendees): void { - $orderAttendeeCount = $order->getOrderItems()->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()); - - if ($orderAttendeeCount !== $attendees->count()) { + $orderAttendeeCount = $order->getOrderItems() + ?->filter(fn(OrderItemDomainObject $orderItem) => $orderItem->getProductType() === ProductType::TICKET->name) + ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()); + + $ticketAttendeeCount = $attendees + ->filter( + fn(CompleteOrderProductDataDTO $attendee) => $this->getProductTypeFromPriceId( + $attendee->product_price_id, + $order->getOrderItems() + ) === ProductType::TICKET->name) + ->count(); + + if ($orderAttendeeCount !== $ticketAttendeeCount) { throw new ResourceConflictException( __('The number of attendees does not match the number of tickets in the order') ); } } + + private function getProductTypeFromPriceId(int $priceId, Collection $orderItems): string + { + return $orderItems->first(fn(OrderItemDomainObject $orderItem) => $orderItem->getProductPriceId() === $priceId) + ->getProductType(); + } } diff --git a/backend/app/Services/Handlers/Order/DTO/CompleteOrderAttendeeDTO.php b/backend/app/Services/Handlers/Order/DTO/CompleteOrderAttendeeDTO.php deleted file mode 100644 index 28c3deb9..00000000 --- a/backend/app/Services/Handlers/Order/DTO/CompleteOrderAttendeeDTO.php +++ /dev/null @@ -1,21 +0,0 @@ - $attendees + * @param Collection $products */ public function __construct( public CompleteOrderOrderDTO $order, - #[CollectionOf(CompleteOrderAttendeeDTO::class)] - public Collection $attendees + #[CollectionOf(CompleteOrderProductDataDTO::class)] + public Collection $products ) { } diff --git a/backend/app/Services/Handlers/Order/DTO/CompleteOrderProductDataDTO.php b/backend/app/Services/Handlers/Order/DTO/CompleteOrderProductDataDTO.php new file mode 100644 index 00000000..a624feb7 --- /dev/null +++ b/backend/app/Services/Handlers/Order/DTO/CompleteOrderProductDataDTO.php @@ -0,0 +1,30 @@ +first_name !== null + && $this->last_name !== null + && $this->email !== null; + } +} diff --git a/backend/app/Services/Handlers/Order/DTO/CreatedProductDataDTO.php b/backend/app/Services/Handlers/Order/DTO/CreatedProductDataDTO.php new file mode 100644 index 00000000..9a113745 --- /dev/null +++ b/backend/app/Services/Handlers/Order/DTO/CreatedProductDataDTO.php @@ -0,0 +1,15 @@ + $price->is_hidden, ])); + $category = $this->getProductCategoryService->getCategory( + categoryId: $productsData->product_category_id, + eventId: $productsData->event_id + ); + return $this->productCreateService->createProduct( product: (new ProductDomainObject()) ->setTitle($productsData->title) @@ -53,7 +60,8 @@ public function handle(UpsertProductDTO $productsData): ProductDomainObject ->setIsHiddenWithoutPromoCode($productsData->is_hidden_without_promo_code) ->setProductPrices($productPrices) ->setEventId($productsData->event_id) - ->setProductType($productsData->product_type->name), + ->setProductType($productsData->product_type->name) + ->setProductCategoryId($category->getId()), accountId: $productsData->account_id, taxAndFeeIds: $productsData->tax_and_fee_ids, ); diff --git a/backend/app/Services/Handlers/Product/DTO/UpsertProductDTO.php b/backend/app/Services/Handlers/Product/DTO/UpsertProductDTO.php index 70fd5066..6b4ced8d 100644 --- a/backend/app/Services/Handlers/Product/DTO/UpsertProductDTO.php +++ b/backend/app/Services/Handlers/Product/DTO/UpsertProductDTO.php @@ -14,6 +14,7 @@ class UpsertProductDTO extends BaseDTO public function __construct( public readonly int $account_id, public readonly int $event_id, + public readonly int $product_category_id, public readonly string $title, public readonly ProductPriceType $type, public readonly ProductType $product_type, diff --git a/backend/app/Services/Handlers/Product/DeleteProductHandler.php b/backend/app/Services/Handlers/Product/DeleteProductHandler.php index 26f54e17..04fad6bd 100644 --- a/backend/app/Services/Handlers/Product/DeleteProductHandler.php +++ b/backend/app/Services/Handlers/Product/DeleteProductHandler.php @@ -2,25 +2,14 @@ namespace HiEvents\Services\Handlers\Product; -use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; -use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; -use HiEvents\DomainObjects\Generated\ProductPriceDomainObjectAbstract; use HiEvents\Exceptions\CannotDeleteEntityException; -use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; -use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; -use HiEvents\Repository\Interfaces\ProductRepositoryInterface; -use Illuminate\Database\DatabaseManager; -use Psr\Log\LoggerInterface; +use HiEvents\Services\Domain\Product\DeleteProductService; use Throwable; -readonly class DeleteProductHandler +class DeleteProductHandler { public function __construct( - private ProductRepositoryInterface $productRepository, - private AttendeeRepositoryInterface $attendeeRepository, - private ProductPriceRepositoryInterface $productPriceRepository, - private LoggerInterface $logger, - private DatabaseManager $databaseManager, + private readonly DeleteProductService $deleteProductService, ) { } @@ -31,45 +20,6 @@ public function __construct( */ public function handle(int $productId, int $eventId): void { - $this->databaseManager->transaction(function () use ($productId, $eventId) { - $this->deleteProduct($productId, $eventId); - }); - } - - /** - * @throws CannotDeleteEntityException - */ - private function deleteProduct(int $productId, int $eventId): void - { - $attendees = $this->attendeeRepository->findWhere( - [ - AttendeeDomainObjectAbstract::EVENT_ID => $eventId, - AttendeeDomainObjectAbstract::PRODUCT_ID => $productId, - ] - ); - - if ($attendees->count() > 0) { - throw new CannotDeleteEntityException( - __('You cannot delete this product because it has orders associated with it. You can hide it instead.') - ); - } - - $this->productRepository->deleteWhere( - [ - ProductDomainObjectAbstract::EVENT_ID => $eventId, - ProductDomainObjectAbstract::ID => $productId, - ] - ); - - $this->productPriceRepository->deleteWhere( - [ - ProductPriceDomainObjectAbstract::PRODUCT_ID => $productId, - ] - ); - - $this->logger->info(sprintf('Product %d was deleted from event %d', $productId, $eventId), [ - 'productId' => $productId, - 'eventId' => $eventId, - ]); + $this->deleteProductService->deleteProduct($productId, $eventId); } } diff --git a/backend/app/Services/Handlers/Product/EditProductHandler.php b/backend/app/Services/Handlers/Product/EditProductHandler.php index 43448551..d7bf8ebb 100644 --- a/backend/app/Services/Handlers/Product/EditProductHandler.php +++ b/backend/app/Services/Handlers/Product/EditProductHandler.php @@ -12,9 +12,11 @@ use HiEvents\Helper\DateHelper; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; +use HiEvents\Services\Domain\Product\ProductOrderingService; +use HiEvents\Services\Domain\Product\ProductPriceUpdateService; +use HiEvents\Services\Domain\ProductCategory\GetProductCategoryService; use HiEvents\Services\Domain\Tax\DTO\TaxAndProductAssociateParams; use HiEvents\Services\Domain\Tax\TaxAndProductAssociationService; -use HiEvents\Services\Domain\Product\ProductPriceUpdateService; use HiEvents\Services\Handlers\Product\DTO\UpsertProductDTO; use HTMLPurifier; use Illuminate\Database\DatabaseManager; @@ -26,12 +28,14 @@ class EditProductHandler { public function __construct( - private readonly ProductRepositoryInterface $productRepository, + private readonly ProductRepositoryInterface $productRepository, private readonly TaxAndProductAssociationService $taxAndProductAssociationService, - private readonly DatabaseManager $databaseManager, - private readonly ProductPriceUpdateService $priceUpdateService, - private readonly HTMLPurifier $purifier, - private readonly EventRepositoryInterface $eventRepository, + private readonly DatabaseManager $databaseManager, + private readonly ProductPriceUpdateService $priceUpdateService, + private readonly HTMLPurifier $purifier, + private readonly EventRepositoryInterface $eventRepository, + private readonly ProductOrderingService $productOrderingService, + private readonly GetProductCategoryService $getProductCategoryService, ) { } @@ -73,11 +77,19 @@ private function updateProduct(UpsertProductDTO $productsData, array $where): Pr $this->validateChangeInProductType($productsData); + $productCategory = $this->getProductCategoryService->getCategory( + $productsData->product_category_id, + $productsData->event_id, + ); + $this->productRepository->updateWhere( attributes: [ 'title' => $productsData->title, 'type' => $productsData->type->name, - 'order' => $productsData->order, + 'order' => $this->productOrderingService->getOrderForNewProduct( + eventId: $productsData->event_id, + productCategoryId: $productCategory->getId(), + ), 'sale_start_date' => $productsData->sale_start_date ? DateHelper::convertToUTC($productsData->sale_start_date, $event->getTimezone()) : null, diff --git a/backend/app/Services/Handlers/Product/GetProductsHandler.php b/backend/app/Services/Handlers/Product/GetProductsHandler.php index 0f0a1918..4bd22258 100644 --- a/backend/app/Services/Handlers/Product/GetProductsHandler.php +++ b/backend/app/Services/Handlers/Product/GetProductsHandler.php @@ -26,7 +26,7 @@ public function handle(int $eventId, QueryParamsDTO $queryParamsDTO): LengthAwar ->findByEventId($eventId, $queryParamsDTO); $filteredProducts = $this->productFilterService->filter( - products: $productPaginator->getCollection(), + productsCategories: $productPaginator->getCollection(), hideSoldOutProducts: false, ); diff --git a/backend/app/Services/Handlers/ProductCategory/CreateProductCategoryHandler.php b/backend/app/Services/Handlers/ProductCategory/CreateProductCategoryHandler.php new file mode 100644 index 00000000..ce5cd661 --- /dev/null +++ b/backend/app/Services/Handlers/ProductCategory/CreateProductCategoryHandler.php @@ -0,0 +1,26 @@ +productCategoryService->createCategory( + name: $dto->name, + isHidden: $dto->is_hidden, + eventId: $dto->event_id, + description: $dto->description, + ); + } +} diff --git a/backend/app/Services/Handlers/ProductCategory/DTO/UpsertProductCategoryDTO.php b/backend/app/Services/Handlers/ProductCategory/DTO/UpsertProductCategoryDTO.php new file mode 100644 index 00000000..fe0626c0 --- /dev/null +++ b/backend/app/Services/Handlers/ProductCategory/DTO/UpsertProductCategoryDTO.php @@ -0,0 +1,18 @@ +deleteProductCategoryService->deleteProductCategory($productCategoryId, $eventId); + } +} diff --git a/backend/app/Services/Handlers/ProductCategory/EditProductCategoryHandler.php b/backend/app/Services/Handlers/ProductCategory/EditProductCategoryHandler.php new file mode 100644 index 00000000..7263bfe3 --- /dev/null +++ b/backend/app/Services/Handlers/ProductCategory/EditProductCategoryHandler.php @@ -0,0 +1,33 @@ +productCategoryRepository->updateWhere( + attributes: [ + 'name' => $dto->name, + 'is_hidden' => $dto->is_hidden, + 'description' => $dto->description, + ], + where: [ + 'id' => $dto->product_category_id, + 'event_id' => $dto->event_id, + ], + ); + + return $this->productCategoryRepository->findById($dto->product_category_id); + } +} diff --git a/backend/app/Services/Handlers/ProductCategory/GetProductCategoriesHandler.php b/backend/app/Services/Handlers/ProductCategory/GetProductCategoriesHandler.php new file mode 100644 index 00000000..c9d8e8fd --- /dev/null +++ b/backend/app/Services/Handlers/ProductCategory/GetProductCategoriesHandler.php @@ -0,0 +1,50 @@ +productCategoryRepository + ->loadRelation(new Relationship( + domainObject: ProductDomainObject::class, + nested: [ + new Relationship(ProductPriceDomainObject::class), + new Relationship(TaxAndFeesDomainObject::class), + ], + orderAndDirections: [ + new OrderAndDirection( + order: ProductDomainObjectAbstract::ORDER, + ), + ], + )) + ->findWhere( + where: [ + 'event_id' => $eventId, + ], + orderAndDirections: [ + new OrderAndDirection( + order: ProductCategoryDomainObjectAbstract::ORDER, + ), + ], + ); + } +} diff --git a/backend/app/Services/Handlers/ProductCategory/GetProductCategoryHandler.php b/backend/app/Services/Handlers/ProductCategory/GetProductCategoryHandler.php new file mode 100644 index 00000000..485443b7 --- /dev/null +++ b/backend/app/Services/Handlers/ProductCategory/GetProductCategoryHandler.php @@ -0,0 +1,44 @@ +productCategoryRepository + ->loadRelation(new Relationship( + domainObject: ProductDomainObject::class, + nested: [ + new Relationship(ProductPriceDomainObject::class), + new Relationship(TaxAndFeesDomainObject::class), + ], + orderAndDirections: [ + new OrderAndDirection( + order: ProductDomainObjectAbstract::ORDER, + ), + ], + )) + ->findFirstWhere( + where: [ + 'event_id' => $eventId, + 'id' => $productCategoryId, + ] + ); + } +} diff --git a/backend/app/Validators/CompleteOrderValidator.php b/backend/app/Validators/CompleteOrderValidator.php index fcf8bf6c..35029f92 100644 --- a/backend/app/Validators/CompleteOrderValidator.php +++ b/backend/app/Validators/CompleteOrderValidator.php @@ -5,16 +5,16 @@ namespace HiEvents\Validators; use HiEvents\DomainObjects\Enums\QuestionBelongsTo; -use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; -use HiEvents\DomainObjects\QuestionDomainObject; +use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; +use HiEvents\DomainObjects\QuestionDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; -use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; -use HiEvents\Validators\Rules\AttendeeQuestionRule; +use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; use HiEvents\Validators\Rules\OrderQuestionRule; +use HiEvents\Validators\Rules\ProductQuestionRule; use Illuminate\Routing\Route; class CompleteOrderValidator extends BaseValidator @@ -56,18 +56,7 @@ public function rules(): array 'order.last_name' => ['required', 'string', 'max:40'], 'order.questions' => new OrderQuestionRule($orderQuestions, $products), 'order.email' => 'required|email', - 'attendees.*.first_name' => ['required', 'string', 'max:40'], - 'attendees.*.last_name' => ['required', 'string', 'max:40'], - 'attendees.*.email' => ['required', 'email'], - 'attendees' => new AttendeeQuestionRule($productQuestions, $products), - - // Address validation is intentionally not strict, as we want to support all countries - 'order.address.address_line_1' => ['string', 'max:255'], - 'order.address.address_line_2' => ['string', 'max:255', 'nullable'], - 'order.address.city' => ['string', 'max:85'], - 'order.address.state_or_region' => ['string', 'max:85'], - 'order.address.zip_or_postal_code' => ['string', 'max:85'], - 'order.address.country' => ['string', 'max:2'], + 'products' => new ProductQuestionRule($productQuestions, $products), ]; } @@ -77,9 +66,6 @@ public function messages(): array 'order.first_name' => __('First name is required'), 'order.last_name' => __('Last name is required'), 'order.email' => __('A valid email is required'), - 'attendees.*.first_name' => __('First name is required'), - 'attendees.*.last_name' => __('Last name is required'), - 'attendees.*.email' => __('A valid email is required'), ]; } } diff --git a/backend/app/Validators/Rules/BaseQuestionRule.php b/backend/app/Validators/Rules/BaseQuestionRule.php index b64da09f..f30ce6e0 100644 --- a/backend/app/Validators/Rules/BaseQuestionRule.php +++ b/backend/app/Validators/Rules/BaseQuestionRule.php @@ -54,10 +54,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void { $this->validateRequiredQuestionArePresent(collect($value)); - $validationMessages = $this->validateQuestions($value); + $questionValidationMessages = $this->validateQuestions($value); - if ($validationMessages) { - $this->validator->messages()->merge($validationMessages); + if ($questionValidationMessages) { + $this->validator->messages()->merge($questionValidationMessages); } } @@ -87,7 +87,7 @@ protected function getProductIdFromProductPriceId(int $productPriceId): int protected function isAnswerValid(QuestionDomainObject $questionDomainObject, mixed $response): bool { - if (!$questionDomainObject->isMultipleChoice()) { + if (!$questionDomainObject->isPreDefinedChoice()) { return true; } @@ -96,7 +96,7 @@ protected function isAnswerValid(QuestionDomainObject $questionDomainObject, mix } if (is_string($response['answer'])) { - return in_array($response, $questionDomainObject->getOptions(), true); + return in_array($response['answer'], $questionDomainObject->getOptions(), true); } return array_diff((array)$response['answer'], $questionDomainObject->getOptions()) === []; @@ -160,4 +160,9 @@ protected function validateResponseLength( return $validationMessages; } + + protected function getProductDomainObject(int $id): ?ProductDomainObject + { + return $this->products->filter(fn($product) => $product->getId() === $id)?->first(); + } } diff --git a/backend/app/Validators/Rules/AttendeeQuestionRule.php b/backend/app/Validators/Rules/ProductQuestionRule.php similarity index 54% rename from backend/app/Validators/Rules/AttendeeQuestionRule.php rename to backend/app/Validators/Rules/ProductQuestionRule.php index caf5dea6..534dd92e 100644 --- a/backend/app/Validators/Rules/AttendeeQuestionRule.php +++ b/backend/app/Validators/Rules/ProductQuestionRule.php @@ -2,20 +2,22 @@ namespace HiEvents\Validators\Rules; +use HiEvents\DomainObjects\Enums\ProductType; use HiEvents\DomainObjects\QuestionDomainObject; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; -class AttendeeQuestionRule extends BaseQuestionRule +class ProductQuestionRule extends BaseQuestionRule { /** * @throws ValidationException */ - protected function validateRequiredQuestionArePresent(Collection $orderAttendees): void + protected function validateRequiredQuestionArePresent(Collection $orderProducts): void { - foreach ($orderAttendees as $attendee) { - $productId = $this->getProductIdFromProductPriceId($attendee['product_price_id']); - $questions = $attendee['questions'] ?? []; + foreach ($orderProducts as $productData) { + $productId = $this->getProductIdFromProductPriceId($productData['product_price_id']); + $questions = $productData['questions'] ?? []; $requiredQuestionIds = $this->questions ->filter(function (QuestionDomainObject $question) use ($productId) { @@ -33,15 +35,29 @@ protected function validateRequiredQuestionArePresent(Collection $orderAttendees } } - protected function validateQuestions(mixed $attendees): array + protected function validateQuestions(mixed $products): array { $validationMessages = []; - foreach ($attendees as $attendeeIndex => $attendee) { - $questions = $attendee['questions'] ?? []; + foreach ($products as $productIndex => $productRequestData) { + $productDomainObject = $this->getProductDomainObject($productRequestData['product_id']); + + if (!$productDomainObject) { + $validationMessages['products.' . $productIndex][] = __('This product is outdated. Please reload the page.'); + continue; + } + + if ($productDomainObject->getProductType() === ProductType::TICKET->name) { + $validationMessages = [ + ...$validationMessages, + ...$this->validateBasicTicketFields($productRequestData, $productIndex), + ]; + } + + $questions = $productRequestData['questions'] ?? []; foreach ($questions as $questionIndex => $question) { $questionDomainObject = $this->getQuestionDomainObject($question['question_id'] ?? null); - $key = 'attendees.' . $attendeeIndex . '.questions.' . $questionIndex . '.response'; + $key = 'products.' . $productIndex . '.questions.' . $questionIndex . '.response'; $response = empty($question['response']) ? null : $question['response']; if (!$questionDomainObject) { @@ -67,4 +83,25 @@ protected function validateQuestions(mixed $attendees): array return $validationMessages; } + + private function validateBasicTicketFields(mixed $productRequestData, int|string $productIndex): array + { + $validationMessages = []; + + $validator = Validator::make($productRequestData, [ + 'first_name' => ['required', 'string', 'min:1', 'max:100'], + 'last_name' => ['required', 'string', 'min:1', 'max:100'], + 'email' => ['required', 'string', 'email', 'max:100'], + ]); + + if ($validator->fails()) { + foreach ($validator->errors()->messages() as $field => $messages) { + foreach ($messages as $message) { + $validationMessages["products.$productIndex.$field"][] = $message; + } + } + } + + return $validationMessages; + } } diff --git a/backend/database/migrations/2024_09_23_032009_add_product_categories_table.php b/backend/database/migrations/2024_09_23_032009_add_product_categories_table.php new file mode 100644 index 00000000..5a4b2f88 --- /dev/null +++ b/backend/database/migrations/2024_09_23_032009_add_product_categories_table.php @@ -0,0 +1,68 @@ +id(); + $table->string('name'); + $table->string('no_products_message')->nullable(); + $table->string('description')->nullable(); + $table->boolean('is_hidden')->default(false); + $table->tinyInteger('order')->default(0); + + $table->unsignedBigInteger('event_id'); + $table->foreign('event_id')->references('id')->on('events')->onDelete('cascade'); + + $table->timestamps(); + $table->softDeletes(); + + $table->index('event_id'); + $table->index('is_hidden'); + $table->index('order'); + }); + + Schema::table('products', static function (Blueprint $table) { + $table->unsignedBigInteger('product_category_id')->nullable(); + $table->foreign('product_category_id')->references('id')->on('product_categories')->onDelete('set null'); + }); + + $events = DB::table('events')->get(); + + foreach ($events as $event) { + $categoryId = DB::table('product_categories')->insertGetId([ + 'name' => __('Tickets'), + 'event_id' => $event->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('products') + ->where('event_id', $event->id) + ->update(['product_category_id' => $categoryId]); + } + + DB::table('questions') + ->where('belongs_to', 'TICKET') + ->update(['belongs_to' => 'PRODUCT']); + } + + public function down(): void + { + Schema::table('products', static function (Blueprint $table) { + $table->dropForeign(['product_category_id']); + $table->dropColumn('product_category_id'); + }); + + Schema::dropIfExists('product_categories'); + + DB::table('questions') + ->where('belongs_to', 'PRODUCT') + ->update(['belongs_to' => 'TICKET']); + } +}; diff --git a/backend/database/migrations/2024_09_29_053757_add_product_type_to_order_items_table.php b/backend/database/migrations/2024_09_29_053757_add_product_type_to_order_items_table.php new file mode 100644 index 00000000..79f7a26e --- /dev/null +++ b/backend/database/migrations/2024_09_29_053757_add_product_type_to_order_items_table.php @@ -0,0 +1,22 @@ +string('product_type')->default(ProductType::TICKET->name); + }); + } + + public function down(): void + { + Schema::table('order_items', function (Blueprint $table) { + $table->dropColumn('product_type'); + }); + } +}; diff --git a/backend/database/migrations/2024_10_01_003655_update_question_and_answer_views_view.php b/backend/database/migrations/2024_10_01_003655_update_question_and_answer_views_view.php new file mode 100644 index 00000000..4f0db80e --- /dev/null +++ b/backend/database/migrations/2024_10_01_003655_update_question_and_answer_views_view.php @@ -0,0 +1,54 @@ +unsignedInteger('attendees_registered')->default(0); + }); + Schema::table('event_daily_statistics', static function (Blueprint $table) { + $table->unsignedInteger('attendees_registered')->default(0); + }); + } + + public function down(): void + { + Schema::table('event_statistics_tables', static function (Blueprint $table) { + $table->dropColumn('attendees_registered'); + }); + + Schema::table('event_daily_statistics', static function (Blueprint $table) { + $table->dropColumn('attendees_registered'); + }); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index ef2909c2..92dd90eb 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -70,6 +70,11 @@ use HiEvents\Http\Actions\Organizers\GetOrganizerAction; use HiEvents\Http\Actions\Organizers\GetOrganizerEventsAction; use HiEvents\Http\Actions\Organizers\GetOrganizersAction; +use HiEvents\Http\Actions\ProductCategories\CreateProductCategoryAction; +use HiEvents\Http\Actions\ProductCategories\DeleteProductCategoryAction; +use HiEvents\Http\Actions\ProductCategories\EditProductCategoryAction; +use HiEvents\Http\Actions\ProductCategories\GetProductCategoriesAction; +use HiEvents\Http\Actions\ProductCategories\GetProductCategoryAction; use HiEvents\Http\Actions\PromoCodes\CreatePromoCodeAction; use HiEvents\Http\Actions\PromoCodes\DeletePromoCodeAction; use HiEvents\Http\Actions\PromoCodes\GetPromoCodeAction; @@ -171,12 +176,19 @@ function (Router $router): void { $router->put('/events/{event_id}/status', UpdateEventStatusAction::class); $router->post('/events/{event_id}/duplicate', DuplicateEventAction::class); + $router->post('/events/{event_id}/product-categories', CreateProductCategoryAction::class); + $router->get('/events/{event_id}/product-categories', GetProductCategoriesAction::class); + $router->get('/events/{event_id}/product-categories/{category_id}', GetProductCategoryAction::class); + $router->put('/events/{event_id}/product-categories/{category_id}', EditProductCategoryAction::class); + $router->delete('/events/{event_id}/product-categories/{category_id}', DeleteProductCategoryAction::class); + $router->post('/events/{event_id}/products', CreateProductAction::class); $router->post('/events/{event_id}/products/sort', SortProductsAction::class); $router->put('/events/{event_id}/products/{ticket_id}', EditProductAction::class); $router->get('/events/{event_id}/products/{ticket_id}', GetProductAction::class); $router->delete('/events/{event_id}/products/{ticket_id}', DeleteProductAction::class); $router->get('/events/{event_id}/products', GetProductsAction::class); + $router->get('/events/{event_id}/check_in_stats', GetEventCheckInStatsAction::class); $router->get('/events/{event_id}/stats', GetEventStatsAction::class); diff --git a/backend/tests/Unit/Services/Handlers/Order/CompleteOrderHandlerTest.php b/backend/tests/Unit/Services/Handlers/Order/CompleteOrderHandlerTest.php index a68f595e..f42a26f9 100644 --- a/backend/tests/Unit/Services/Handlers/Order/CompleteOrderHandlerTest.php +++ b/backend/tests/Unit/Services/Handlers/Order/CompleteOrderHandlerTest.php @@ -16,7 +16,7 @@ use HiEvents\Repository\Interfaces\TicketPriceRepositoryInterface; use HiEvents\Services\Domain\Ticket\TicketQuantityUpdateService; use HiEvents\Services\Handlers\Order\CompleteOrderHandler; -use HiEvents\Services\Handlers\Order\DTO\CompleteOrderAttendeeDTO; +use HiEvents\Services\Handlers\Order\DTO\CompleteOrderProductDataDTO; use HiEvents\Services\Handlers\Order\DTO\CompleteOrderDTO; use HiEvents\Services\Handlers\Order\DTO\CompleteOrderOrderDTO; use Illuminate\Support\Collection; @@ -242,7 +242,7 @@ private function createMockCompleteOrderDTO(): CompleteOrderDTO questions: null, ); - $attendeeDTO = new CompleteOrderAttendeeDTO( + $attendeeDTO = new CompleteOrderProductDataDTO( first_name: 'John', last_name: 'Doe', email: 'john@example.com', @@ -251,7 +251,7 @@ private function createMockCompleteOrderDTO(): CompleteOrderDTO return new CompleteOrderDTO( order: $orderDTO, - attendees: new Collection([$attendeeDTO]) + products: new Collection([$attendeeDTO]) ); } diff --git a/frontend/src/api/capacity-assignment.client.ts b/frontend/src/api/capacity-assignment.client.ts index 2819cbbf..43a324e7 100644 --- a/frontend/src/api/capacity-assignment.client.ts +++ b/frontend/src/api/capacity-assignment.client.ts @@ -4,7 +4,8 @@ import { CapacityAssignmentRequest, GenericDataResponse, GenericPaginatedResponse, - IdParam, QueryFilters, + IdParam, + QueryFilters, } from "../types"; import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; diff --git a/frontend/src/api/product-category.client.ts b/frontend/src/api/product-category.client.ts new file mode 100644 index 00000000..38489384 --- /dev/null +++ b/frontend/src/api/product-category.client.ts @@ -0,0 +1,45 @@ +import { api } from "./client"; +import { + ProductCategory, + GenericDataResponse, + IdParam, +} from "../types"; + +export const productCategoryClient = { + create: async (eventId: IdParam, productCategory: ProductCategory) => { + const response = await api.post>( + `events/${eventId}/product-categories`, + productCategory + ); + return response.data; + }, + + update: async (eventId: IdParam, productCategoryId: IdParam, productCategory: ProductCategory) => { + const response = await api.put>( + `events/${eventId}/product-categories/${productCategoryId}`, + productCategory + ); + return response.data; + }, + + all: async (eventId: IdParam) => { + const response = await api.get>( + `events/${eventId}/product-categories` + ); + return response.data; + }, + + get: async (eventId: IdParam, productCategoryId: IdParam) => { + const response = await api.get>( + `events/${eventId}/product-categories/${productCategoryId}` + ); + return response.data; + }, + + delete: async (eventId: IdParam, productCategoryId: IdParam) => { + const response = await api.delete>( + `events/${eventId}/product-categories/${productCategoryId}` + ); + return response.data; + }, +}; diff --git a/frontend/src/api/product.client.ts b/frontend/src/api/product.client.ts new file mode 100644 index 00000000..490a800c --- /dev/null +++ b/frontend/src/api/product.client.ts @@ -0,0 +1,46 @@ +import {api} from "./client"; +import { + GenericDataResponse, + GenericPaginatedResponse, + IdParam, + QueryFilters, SortableItem, + Product, +} from "../types"; +import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; +import {publicApi} from "./public-client.ts"; + +export const productClient = { + findById: async (eventId: IdParam, productId: IdParam) => { + const response = await api.get>(`/events/${eventId}/products/${productId}`); + return response.data; + }, + all: async (eventId: IdParam, pagination: QueryFilters) => { + const response = await api.get>( + `/events/${eventId}/products` + queryParamsHelper.buildQueryString(pagination) + ); + return response.data; + }, + create: async (eventId: IdParam, product: Product) => { + const response = await api.post>(`events/${eventId}/products`, product); + return response.data; + }, + update: async (eventId: IdParam, productId: IdParam, product: Product) => { + const response = await api.put>(`events/${eventId}/products/${productId}`, product); + return response.data; + }, + delete: async (eventId: IdParam, productId: IdParam) => { + const response = await api.delete>(`/events/${eventId}/products/${productId}`); + return response.data; + }, + sortProducts: async (eventId: IdParam, productSort: SortableItem[]) => { + return await api.post(`/events/${eventId}/products/sort`, productSort); + } +} + +export const productClientPublic = { + findByEventId: async (eventId: IdParam) => { + const response = await publicApi.get>(`/events/${eventId}/products`); + return response.data; + }, +} + diff --git a/frontend/src/components/common/AttendeeList/AttendeeList.module.scss b/frontend/src/components/common/AttendeeList/AttendeeList.module.scss index 3cb228aa..1e06574d 100644 --- a/frontend/src/components/common/AttendeeList/AttendeeList.module.scss +++ b/frontend/src/components/common/AttendeeList/AttendeeList.module.scss @@ -26,10 +26,11 @@ flex: 1; display: flex; place-content: flex-end; + align-items: center; a { align-self: flex-end; } } } -} \ No newline at end of file +} diff --git a/frontend/src/components/common/AttendeeList/index.tsx b/frontend/src/components/common/AttendeeList/index.tsx index 81329328..f75d91d4 100644 --- a/frontend/src/components/common/AttendeeList/index.tsx +++ b/frontend/src/components/common/AttendeeList/index.tsx @@ -2,7 +2,7 @@ import {ActionIcon, Avatar, Tooltip} from "@mantine/core"; import {getInitials} from "../../../utilites/helpers.ts"; import Truncate from "../Truncate"; import {NavLink} from "react-router-dom"; -import {IconEye} from "@tabler/icons-react"; +import {IconExternalLink, IconEye} from "@tabler/icons-react"; import classes from './AttendeeList.module.scss'; import {Order, Product} from "../../../types.ts"; import {t} from "@lingui/macro"; @@ -25,8 +25,8 @@ export const AttendeeList = ({order, products}: { order: Order, products: Produc
- - + + @@ -35,4 +35,4 @@ export const AttendeeList = ({order, products}: { order: Order, products: Produc ))}
) -} \ No newline at end of file +} diff --git a/frontend/src/components/common/CapacityAssignmentList/index.tsx b/frontend/src/components/common/CapacityAssignmentList/index.tsx index 8dd3ab93..ccf060ff 100644 --- a/frontend/src/components/common/CapacityAssignmentList/index.tsx +++ b/frontend/src/components/common/CapacityAssignmentList/index.tsx @@ -46,12 +46,12 @@ export const CapacityAssignmentList = ({capacityAssignments, openCreateModal}: C

- Capacity assignments let you manage capacity across products or an entire event. Ideal + Capacity assignments let you manage capacity across tickets or an entire event. Ideal for multi-day events, workshops, and more, where controlling attendance is crucial.

For instance, you can associate a capacity assignment with Day One and All - Days product. Once the capacity is reached, both products will automatically stop + Days ticket. Once the capacity is reached, both tickets will automatically stop being available for sale.

diff --git a/frontend/src/components/common/Card/index.tsx b/frontend/src/components/common/Card/index.tsx index 6b18866c..32f8d22d 100644 --- a/frontend/src/components/common/Card/index.tsx +++ b/frontend/src/components/common/Card/index.tsx @@ -1,17 +1,23 @@ import classes from './Card.module.scss'; -import React, {CSSProperties} from "react"; +import React, {CSSProperties, LegacyRef} from "react"; + +export type CardVariant = 'default' | 'lightGray' | 'noStyle' | 'lightGradient'; interface CardInterface { children: React.ReactNode; className?: string; style?: CSSProperties | undefined; - variant?: 'default' | 'lightGray' | 'noStyle' | 'lightGradient'; + variant?: CardVariant; + ref?: LegacyRef | undefined; } -export const Card = ({children, className = '', style = {}, variant = 'default'}: CardInterface) => { +export const Card = ({children, className = '', style = {}, variant = 'default', ref = null}: CardInterface) => { return ( -
+
{children}
); -} \ No newline at end of file +} diff --git a/frontend/src/components/common/CheckInListList/index.tsx b/frontend/src/components/common/CheckInListList/index.tsx index 14bf1454..4b8165e6 100644 --- a/frontend/src/components/common/CheckInListList/index.tsx +++ b/frontend/src/components/common/CheckInListList/index.tsx @@ -1,16 +1,7 @@ import {CheckInList, IdParam} from "../../../types"; import {Badge, Button, Progress} from "@mantine/core"; import {t, Trans} from "@lingui/macro"; -import { - IconCopy, - IconExternalLink, - IconHelp, - IconLink, - IconPencil, - IconPlus, - IconTrash, - IconUsers -} from "@tabler/icons-react"; +import {IconCopy, IconExternalLink, IconHelp, IconPencil, IconPlus, IconTrash, IconUsers} from "@tabler/icons-react"; import Truncate from "../Truncate"; import {NoResultsSplash} from "../NoResultsSplash"; import classes from './CheckInListList.module.scss'; @@ -23,7 +14,7 @@ import {EditCheckInListModal} from "../../modals/EditCheckInListModal"; import {useDeleteCheckInList} from "../../../mutations/useDeleteCheckInList"; import {showError, showSuccess} from "../../../utilites/notifications.tsx"; import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; -import { useParams } from "react-router-dom"; +import {useParams} from "react-router-dom"; interface CheckInListListProps { checkInLists: CheckInList[]; @@ -58,7 +49,7 @@ export const CheckInListList = ({checkInLists, openCreateModal}: CheckInListList

Check-in lists help manage attendee entry for your event. You can associate multiple - products with a check-in list and ensure only those with valid products can enter. + tickets with a check-in list and ensure only those with valid tickets can enter.

diff --git a/frontend/src/components/common/CheckoutQuestion/index.tsx b/frontend/src/components/common/CheckoutQuestion/index.tsx index a937768d..0658f933 100644 --- a/frontend/src/components/common/CheckoutQuestion/index.tsx +++ b/frontend/src/components/common/CheckoutQuestion/index.tsx @@ -224,7 +224,7 @@ export const CheckoutProductQuestions = ({ questions, form, product, - index: attendeeIndex + index: productIndex }: CheckoutProductQuestionProps) => { let questionIndex = 0; return ( @@ -234,8 +234,8 @@ export const CheckoutProductQuestions = ({ return; } - const name = `attendees.${attendeeIndex}.questions.${questionIndex++}.response`; - return + const name = `products.${productIndex}.questions.${questionIndex++}.response`; + return })} ) diff --git a/frontend/src/components/common/NumberSelector/index.tsx b/frontend/src/components/common/NumberSelector/index.tsx index 4c795dbb..579e3658 100644 --- a/frontend/src/components/common/NumberSelector/index.tsx +++ b/frontend/src/components/common/NumberSelector/index.tsx @@ -29,12 +29,12 @@ export const NumberSelector = ({formInstance, fieldName, min, max, sharedValues} }, [value]); useEffect(() => { - // to handle application promo code after updating the quanity + // to handle application promo code after updating the quantity const formValue = _.get(formInstance.values, fieldName) if (formValue !== value) { formInstance.setFieldValue(fieldName, value); } - }, [formInstance]); + }, [formInstance.values]); const increment = () => { // Adjust from 0 to minValue on the first increment, if minValue is greater than 0 diff --git a/frontend/src/components/common/OrderDetails/index.tsx b/frontend/src/components/common/OrderDetails/index.tsx index 65294b36..5b5b30f8 100644 --- a/frontend/src/components/common/OrderDetails/index.tsx +++ b/frontend/src/components/common/OrderDetails/index.tsx @@ -2,14 +2,18 @@ import {Anchor, Tooltip} from "@mantine/core"; import {prettyDate, relativeDate} from "../../../utilites/dates.ts"; import {OrderStatusBadge} from "../OrderStatusBadge"; import {Currency} from "../Currency"; -import {Card} from "../Card"; +import {Card, CardVariant} from "../Card"; import {Event, Order} from "../../../types.ts"; import classes from "./OrderDetails.module.scss"; import {t} from "@lingui/macro"; -export const OrderDetails = ({order, event}: { order: Order, event: Event }) => { +export const OrderDetails = ({order, event, cardVariant = 'lightGray'}: { + order: Order, + event: Event, + cardVariant?: CardVariant +}) => { return ( - +
{t`Name`} @@ -43,7 +47,7 @@ export const OrderDetails = ({order, event}: { order: Order, event: Event }) => {t`Status`}
- +
@@ -64,4 +68,4 @@ export const OrderDetails = ({order, event}: { order: Order, event: Event }) =>
); -} \ No newline at end of file +} diff --git a/frontend/src/components/common/ProductSelector/index.tsx b/frontend/src/components/common/ProductSelector/index.tsx new file mode 100644 index 00000000..5c452ee0 --- /dev/null +++ b/frontend/src/components/common/ProductSelector/index.tsx @@ -0,0 +1,44 @@ +import {MultiSelect} from "@mantine/core"; +import {IconTicket} from "@tabler/icons-react"; +import {UseFormReturnType} from "@mantine/form"; +import {ProductCategory, ProductType} from "../../../types.ts"; +import React from "react"; + +interface ProductSelectorProps { + label: string; + placeholder: string; + icon?: React.ReactNode; + data: ProductCategory[]; + form: UseFormReturnType; + fieldName: string; + includedProductTypes?: ProductType[]; +} + +export const ProductSelector = ({ + label, + placeholder, + icon = , + data, + form, + fieldName, + includedProductTypes = [ProductType.Ticket, ProductType.General], + }: ProductSelectorProps) => { + return ( + ({ + group: category.name, + items: category.products + ?.filter((product) => includedProductTypes.includes(product.product_type)) + ?.map((product) => ({ + value: String(product.id), + label: product.title, + })) || [], + }))} + leftSection={icon} + {...form.getInputProps(fieldName)} + /> + ); +}; diff --git a/frontend/src/components/common/ProductsTable/ProductsBlankSlate/index.tsx b/frontend/src/components/common/ProductsTable/ProductsBlankSlate/index.tsx new file mode 100644 index 00000000..cfbb4abd --- /dev/null +++ b/frontend/src/components/common/ProductsTable/ProductsBlankSlate/index.tsx @@ -0,0 +1,69 @@ +import {NoResultsSplash} from "../../NoResultsSplash"; +import {Button} from "../../Button"; +import {IconPlus} from "@tabler/icons-react"; +import {t, Trans} from "@lingui/macro"; + +interface ProductsBlankSlateProps { + openCreateModal: () => void; + productCategories: any; + searchTerm: string; +} + +export const ProductsBlankSlate = ({openCreateModal, productCategories, searchTerm}: ProductsBlankSlateProps) => { + const showLargeBlankSlate = productCategories + .every((category: any) => category.products.length === 0) && productCategories.length === 1; + + if (searchTerm) { + return ( + +

+ + We couldn't find any tickets matching {searchTerm ? + {searchTerm} : 'your search'} + +

+ + )} + /> + ); + } + + if (showLargeBlankSlate) { + return ( + +

+ {t`You'll need at least one product to get started. Free, paid or let the user decide what to pay.`} +

+ + + )} + /> + ); + } + + return ( +

+ {t`This category doesn't have any products yet.`} +

+ +
+ ) +} diff --git a/frontend/src/components/common/ProductsTable/ProductsTable.module.scss b/frontend/src/components/common/ProductsTable/ProductsTable.module.scss index cffd3ac5..5a377066 100644 --- a/frontend/src/components/common/ProductsTable/ProductsTable.module.scss +++ b/frontend/src/components/common/ProductsTable/ProductsTable.module.scss @@ -1,152 +1,255 @@ @import "../../../styles/mixins.scss"; +.sortableCategory { + margin-bottom: 20px; + transition: transform 250ms ease; + border-radius: var(--tk-radius-sm); + padding: var(--tk-spacing-lg); + box-shadow: 0 3px 0 #dddddd; + border: 1px solid #e3e3e3; + background-color: #FFFFFF; +} + +.categoryHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + gap: 10px; +} + +.categoryActions { + display: flex; + gap: 5px; +} + +.categoryAction { + margin-left: auto; +} + +.categoryTitle { + margin: 0; + display: flex; + align-items: center; + gap: 10px; +} + +.categoryDragHandle { + cursor: grab; + + &:active { + cursor: grabbing; + } +} + +.dragHandle { + touch-action: none; + margin-top: 5px; +} + +.dragHandleDisabled { + cursor: not-allowed; + opacity: 0.5; +} + +.categoryContent { + min-height: 50px; +} + +.isOver { + background-color: #efefef; +} + +.isDragging { + opacity: 0.5; + z-index: 1000; +} + +.dragOverlay { + .sortableCategory, .productCard { + transform: scale(1.05); + box-shadow: 0 0 20px rgba(0, 0, 0, 0.2); + } +} + .cards { display: flex; flex-direction: column; +} - .productCard { - display: grid; - padding: 20px; - margin-bottom: 20px; - //border-top: 3px solid var(--tk-color-money-green) !important; - position: relative; - gap: 10px; +.productCard { + box-sizing: border-box; + border-radius: var(--tk-radius-sm); + border: 1px solid #e3e3e3; + background-color: #FFFFFF; + display: grid; + padding: 20px; + margin-bottom: 20px; + position: relative; + gap: 10px; + transition: transform 250ms ease; + grid-template-areas: "dragHanlde productInfo action"; + grid-template-columns: 40px 1fr 40px; + + @include respond-below(lg) { + grid-template-areas: "dragHanlde productInfo" + "dragHanlde action"; + } - grid-template-areas: "dragHanlde productInfo action"; - grid-template-columns: 40px 1fr 40px; + .halfCircle { + width: 20px; + height: 10px; + background-color: #fff; + border-top-left-radius: 110px; + border-top-right-radius: 110px; + border: 1px solid #ddd; + border-bottom: 0; + transform: rotate(90deg); + position: absolute; + left: -6px; + top: 44%; + } - @include respond-below(lg) { - grid-template-areas: "dragHanlde productInfo" - "dragHanlde action"; - } + .halfCircle.right { + left: auto; + right: -6px; + transform: rotate(270deg); + } - .halfCircle { - width: 20px; - height: 10px; - background-color: #fbfafb; - border-top-left-radius: 110px; - border-top-right-radius: 110px; - border: 1px solid #ddd; - border-bottom: 0; - transform: rotate(90deg); - position: absolute; - left: -6px; - top: 44%; - } + .dragHandle { + display: flex; + justify-content: center; + align-items: center; + cursor: move; + grid-area: dragHanlde; + touch-action: none; + } - .halfCircle.right { - left: auto; - right: -6px; - transform: rotate(270deg); - } + .dragHandleDisabled { + cursor: not-allowed; + opacity: 0.5; + } - .dragHandle { - display: flex; - justify-content: center; - align-items: center; - cursor: move; - grid-area: dragHanlde; - touch-action: none; - } + .productInfo { + grid-area: productInfo; - .dragHandleDisabled { - cursor: not-allowed; - opacity: 0.5; - } + .productDetails { + display: grid; + width: 100%; + align-items: center; + gap: 15px; + flex-wrap: wrap; + grid-template-columns: 1fr 1fr 1fr 1fr; - .productInfo { - grid-area: productInfo; + @include respond-below(lg) { + flex-direction: column; + align-items: flex-start; + grid-template-columns: 1fr 1fr; + gap: 20px; + } - .productDetails { - display: grid; - width: 100%; - align-items: center; - gap: 15px; - flex-wrap: wrap; + @include respond-below(sm) { + gap: 10px; + } - grid-template-columns: 1fr 1fr 1fr 1fr; + @include respond-below(xs) { + gap: 20px; + grid-template-columns: 1fr; + } - @include respond-below(lg) { - flex-direction: column; - align-items: flex-start; - grid-template-columns: 1fr 1fr; - gap: 20px; - } + > div { + flex: 1; + min-width: 125px; @include respond-below(sm) { - gap: 10px; - } - - @include respond-below(xs) { - gap: 20px; - grid-template-columns: 1fr; + min-width: 100px; } + } - > div { - flex: 1; - min-width: 125px; + .heading { + text-transform: uppercase; + color: #9ca3af; + font-size: .8em; + } - @include respond-below(sm) { - min-width: 100px; - } - } + .status { + max-width: 120px; + cursor: pointer; + } - .heading { - text-transform: uppercase; - color: #9ca3af; - font-size: .8em; - } + .title { + text-wrap: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } - .status { - max-width: 120px; - cursor: pointer; - } + .price { + color: var(--tk-color-money-green); - .title { + .priceAmount { + font-weight: 600; text-wrap: nowrap; overflow: hidden; text-overflow: ellipsis; } + } - .price { - color: var(--tk-color-money-green); - - .priceAmount { - font-weight: 600; - text-wrap: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .availability { - } + .availability { } } + } - .action { - display: flex; - grid-area: action; + .action { + display: flex; + grid-area: action; - @include respond-below(lg) { - margin-top: 10px; - } + @include respond-below(lg) { + margin-top: 10px; + } - .desktopAction { - @include respond-below(lg) { - display: none; - } + .desktopAction { + @include respond-below(lg) { + display: none; } + } - .mobileAction { - display: none; - @include respond-below(lg) { - display: block; - } + .mobileAction { + display: none; + @include respond-below(lg) { + display: block; } } } } +.dragPreview { + background-color: #fff; + border: 1px solid #e3e3e3; + border-radius: var(--tk-radius-sm); + padding: 10px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + + h3 { + margin: 0 0 5px; + } + + p { + margin: 0; + color: #666; + } +} +.moreProducts { + background-color: #f0f0f0; + border-radius: var(--tk-radius-sm); + padding: 10px; + margin-top: 10px; + font-size: 14px; + color: #666; + text-align: center; +} +.isDragging { + opacity: 0.6; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +} diff --git a/frontend/src/components/common/ProductsTable/SortableCategory/index.tsx b/frontend/src/components/common/ProductsTable/SortableCategory/index.tsx new file mode 100644 index 00000000..0914366a --- /dev/null +++ b/frontend/src/components/common/ProductsTable/SortableCategory/index.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import {useSortable} from '@dnd-kit/sortable'; +import {CSS} from '@dnd-kit/utilities'; +import {IconEyeOff, IconGripVertical, IconPencil, IconTrash, IconTrashOff} from "@tabler/icons-react"; +import classes from "../ProductsTable.module.scss"; +import classNames from "classnames"; +import {ActionIcon, Popover} from "@mantine/core"; +import {useDisclosure} from "@mantine/hooks"; +import {EditProductCategoryModal} from "../../../modals/EditProductCategoryModal"; +import {ProductCategory} from "../../../../types.ts"; +import {t} from "@lingui/macro"; +import {useDeleteProductCategory} from "../../../../mutations/useDeleteProductCategory.ts"; +import {useParams} from "react-router-dom"; +import {showError} from "../../../../utilites/notifications.tsx"; +import {AxiosError} from "axios"; + +interface SortableCategoryProps { + category: ProductCategory; + children: React.ReactNode; + isLastCategory: boolean; + enableSorting: boolean; + isOver: boolean; + isDragging: boolean; +} + +export const SortableCategory: React.FC = ({ + category, + children, + isLastCategory, + enableSorting = true, + isOver = false, + isDragging = false + }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({id: category.id}); + const [isEditModalOpen, editModal] = useDisclosure(false); + const {eventId} = useParams(); + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + const deleteMutation = useDeleteProductCategory(); + + const handleDelete = () => { + if (isLastCategory) { + showError(t`You cannot delete the last category.`); + return; + } + + deleteMutation.mutate({productCategoryId: category.id, eventId: eventId}, { + onSuccess: () => { + editModal.close(); + }, + onError: (error: AxiosError) => { + if (error?.response?.status && error.response.status === 409 && error?.response?.data?.message) { + showError(error?.response?.data.message); + return; + } else { + showError(t`We couldn't delete the category. Please try again.`); + } + } + }); + } + + return ( + <> +
+
+

+ {category.name} + {category.is_hidden && ( + + + + + + {t`This category is hidden from public view`} + + + )} +

+ +
+ + + + + {isLastCategory ? : } + +
+ +
+
+
+
+ {children} +
+ {isDragging && ( +
+

{category.name}

+ {category?.products &&

{category?.products?.length} products

} +
+ )} +
+ {isEditModalOpen && ( + + )} + + ); + } +; diff --git a/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx b/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx index 9ddc2c1f..fb03c5f5 100644 --- a/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx +++ b/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx @@ -1,46 +1,43 @@ -import {IdParam, MessageType, Product, ProductPrice, ProductPriceType} from "../../../../types.ts"; -import {useSortable} from "@dnd-kit/sortable"; +import {useSortable} from '@dnd-kit/sortable'; +import {CSS} from '@dnd-kit/utilities'; +import {IconDotsVertical, IconEyeOff, IconGripVertical, IconPencil, IconSend, IconTrash} from "@tabler/icons-react"; +import classes from "../ProductsTable.module.scss"; +import classNames from "classnames"; +import {Badge, Button, Group, Menu, Popover} from "@mantine/core"; +import Truncate from "../../Truncate"; +import {t} from "@lingui/macro"; +import {relativeDate} from "../../../../utilites/dates.ts"; +import {formatCurrency} from "../../../../utilites/currency.ts"; +import {IdParam, MessageType, Product, ProductPrice, ProductPriceType, ProductType} from "../../../../types.ts"; import {useDisclosure} from "@mantine/hooks"; import {useState} from "react"; import {useDeleteProduct} from "../../../../mutations/useDeleteProduct.ts"; -import {CSS} from "@dnd-kit/utilities"; import {showError, showSuccess} from "../../../../utilites/notifications.tsx"; -import {t} from "@lingui/macro"; -import {relativeDate} from "../../../../utilites/dates.ts"; -import {formatCurrency} from "../../../../utilites/currency.ts"; -import {Card} from "../../Card"; -import classes from "../ProductsTable.module.scss"; -import classNames from "classnames"; -import {IconDotsVertical, IconEyeOff, IconGripVertical, IconPencil, IconSend, IconTrash} from "@tabler/icons-react"; -import Truncate from "../../Truncate"; -import {Badge, Button, Group, Menu, Popover} from "@mantine/core"; import {EditProductModal} from "../../../modals/EditProductModal"; import {SendMessageModal} from "../../../modals/SendMessageModal"; -import {UniqueIdentifier} from "@dnd-kit/core"; -export const SortableProduct = ({product, enableSorting, currencyCode}: {product: Product, enableSorting: boolean, currencyCode: string }) => { - const uniqueId = product.id as UniqueIdentifier; + +interface SortableProductProps { + product: Product; + enableSorting: boolean; + currencyCode: string; + isOver: boolean; + isDragging: boolean; +} + +export const SortableProduct = ({product, enableSorting, currencyCode, isOver, isDragging}: SortableProductProps) => { const { attributes, listeners, setNodeRef, transform, - transition - } = useSortable( - { - id: uniqueId, - } - ); + transition, + } = useSortable({id: product.id}); const [isEditModalOpen, editModal] = useDisclosure(false); const [isMessageModalOpen, messageModal] = useDisclosure(false); const [productId, setProductId] = useState(); const deleteMutation = useDeleteProduct(); - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - const handleModalClick = (productId: IdParam, modal: { open: () => void }) => { setProductId(productId); modal.open(); @@ -60,6 +57,11 @@ export const SortableProduct = ({product, enableSorting, currencyCode}: {product }); } + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + const getProductStatus = (product: Product) => { if (product.is_sold_out) { return t`Sold Out`; @@ -107,21 +109,27 @@ export const SortableProduct = ({product, enableSorting, currencyCode}: {product return ( <> -
- -
- -
-
-
-
-
{t`Title`}
- {(product.is_hidden_without_promo_code || product.is_hidden) && ( +
+
+ +
+
+
+
+
{t`Title`} {product.id}
+ + {(product.is_hidden_without_promo_code || product.is_hidden) && ( @@ -133,82 +141,82 @@ export const SortableProduct = ({product, enableSorting, currencyCode}: {product )} +
+
+
{t`Status`}
+ + + + {product.is_available ? t`On Sale` : t`Not On Sale`} + + + + {getProductStatus(product)} + + +
+
+
{t`Price`}
+
+ {getPriceRange(product)}
-
-
{t`Status`}
- - - - {product.is_available ? t`On Sale` : t`Not On Sale`} - - - - {getProductStatus(product)} - - - -
-
-
{t`Price`}
-
- {getPriceRange(product)} -
-
-
-
{t`Attendees`}
- {Number(product.quantity_sold)} -
+
+
+
{product.product_type === ProductType.Ticket ? t`Attendees` : t`Quantity Sold`}
+ {Number(product.quantity_sold)}
-
- - - -
-
- -
-
- -
+
+
+ + + +
+
+
- - - - {t`Actions`} - handleModalClick(product.id, messageModal)} - leftSection={}>{t`Message Attendees`} - handleModalClick(product.id, editModal)} - leftSection={}>{t`Edit Product`} - - {t`Danger zone`} - handleDeleteProduct(product.id, product.event_id)} - color="red" - leftSection={} - > - {t`Delete product`} - - -
-
-
-
-
- +
+ +
+
+ + + + {t`Actions`} + handleModalClick(product.id, messageModal)} + leftSection={}>{t`Message Attendees`} + handleModalClick(product.id, editModal)} + leftSection={}>{t`Edit Product`} + + {t`Danger zone`} + handleDeleteProduct(product.id, product.event_id)} + color="red" + leftSection={} + > + {t`Delete product`} + + +
+
+
+ + {product.product_type === ProductType.Ticket &&
} + +
- {isEditModalOpen && } {isMessageModalOpen && void; + onCreateOpen: (categoryId: IdParam) => void; + searchTerm: string; } -export const ProductsTable = ({products, event, openCreateModal, enableSorting = false}: ProductCardProps) => { - const {eventId} = useParams(); - const sortProductsMutation = useSortProducts(); - const {items, setItems, handleDragEnd} = useDragItemsHandler({ - initialItemIds: products.map((product) => Number(product.id)), - onSortEnd: (newArray) => { - sortProductsMutation.mutate({ - sortedProducts: newArray.map((id, index) => { - return {id, order: index + 1}; - }), - eventId: eventId, - }, { - onSuccess: () => { - showSuccess(t`Products sorted successfully`); - }, - onError: () => { - showError(t`An error occurred while sorting the products. Please try again or refresh the page`); - } - }) - }, - }); +interface ItemWithId { + id: UniqueIdentifier; +} + +export const ProductCategoryList: React.FC = ({ + initialCategories, + event, + enableSorting, + onCreateOpen, + searchTerm + }) => { + const [categories, setCategories] = useState(initialCategories); + const [activeId, setActiveId] = useState(null); + const [activeCategoryId, setActiveCategoryId] = useState(null); + const [filteredCategories, setFilteredCategories] = useState(initialCategories); const sensors = useSensors( - useSensor(PointerSensor), - useSensor(TouchSensor) + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) ); - useEffect(() => { - setItems(products.map((product) => Number(product.id))); - }, [products]); - - if (products.length === 0) { - return -

- {t`You'll need at least one product to get started. Free, paid or let the user decide what to pay.`} -

- - - )} - />; + if (!categories || categories.length === 0 || !event) { + return <>no categories or event; } - const handleDragStart = (event: any) => { - if (!enableSorting) { - showError(t`Please remove filters and set sorting to "Homepage order" to enable sorting`); - event.cancel(); + console.log(categories); + + const handleDragStart = (event: DragStartEvent) => { + const {active} = event; + + if (!active) { + showError(t`Error moving item`); + return; } - } - return ( - - -
- {items.map((productId) => { - const product = products.find((t) => t.id === productId); + setActiveId(active.id); + setActiveCategoryId(categories.find(cat => cat.products.some(prod => prod.id === active.id))?.id || null); + }; + + const handleDragOver = (event: DragOverEvent) => { + const {active, over} = event; + if (!over) return; + + const activeCategory = categories.find(cat => cat.products.some(prod => prod.id === active.id)); + const overCategory = categories.find(cat => cat.id === over.id || cat.products.some(prod => prod.id === over.id)); + + if (!activeCategory || !overCategory || activeCategory === overCategory) return; + + setCategories(prevCategories => { + const activeIndex = activeCategory?.products.findIndex(prod => prod.id === active.id); + return prevCategories.map(cat => { + if (cat.id === activeCategory.id) { + return { + ...cat, + products: cat.products.filter(prod => prod.id !== active.id) + }; + } + if (cat.id === overCategory.id) { + const overIndex = cat.products.findIndex(prod => prod.id === over.id); + const newIndex = overIndex === -1 ? cat.products.length : overIndex; + return { + ...cat, + products: [ + ...cat.products.slice(0, newIndex), + activeCategory.products[activeIndex], + ...cat.products.slice(newIndex) + ] + }; + } + return cat; + }); + }); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const {active, over} = event; - if (!product) { - return null; + if (!active || !over) { + showError(t`Error moving item`); + return; + } + + if (active.id !== over.id) { + setCategories((prevCategories) => { + const oldIndex = prevCategories.findIndex((cat) => cat.id === active.id); + const newIndex = prevCategories.findIndex((cat) => cat.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + // Category was moved + return arrayMove(prevCategories, oldIndex, newIndex); + } else { + // Product was moved + const newCategories = [...prevCategories]; + const sourceCategory = newCategories.find(cat => cat.products.some((prod: ItemWithId) => prod.id === active.id)); + const destCategory = newCategories.find(cat => cat.id === over.id || cat.products.some((prod: ItemWithId) => prod.id === over.id)); + + if (sourceCategory && destCategory) { + const [movedProduct] = sourceCategory.products.splice(sourceCategory.products.findIndex((prod: ItemWithId) => prod.id === active.id), 1); + const overIndex = destCategory.products.findIndex((prod: ItemWithId) => prod.id === over.id); + + if (overIndex !== -1) { + destCategory.products.splice(overIndex, 0, movedProduct); + } else { + destCategory.products.push(movedProduct); } + } - return ( - { + for (const category of categories) { + if (category.id === id) return category; + const product = category?.products?.find(p => p.id === id); + if (product) return product; + } + }; + + useEffect(() => { + if (searchTerm) { + const filtered = initialCategories + .map(category => ({ + ...category, + products: category.products.filter(product => product.title.toLowerCase().includes(searchTerm.toLowerCase())) + })) + .filter(category => category.products.length > 0); + + setFilteredCategories(filtered); + } else { + setFilteredCategories(initialCategories); + } + }, [searchTerm, initialCategories]); + + return ( + + {filteredCategories.length > 0 ? ( + cat.id)} strategy={verticalListSortingStrategy}> +
+ {filteredCategories.map((category) => ( + - ); - })} -
-
+ isOver={category.id === activeCategoryId} + isLastCategory={categories.length === 1} + > + {category.products.length === 0 && ( + onCreateOpen(category.id)}/> + )} + {category.products.length > 0 && ( + prod.id)} + strategy={verticalListSortingStrategy}> +
+ {category.products.map((product: Product) => ( + + ))} +
+
+ )} + + ))} +
+
+ ) : ( + + )} + + {activeId ? ( + (() => { + const item = findItemById(activeId); + if (item && 'products' in item) { + // It's a category + return ( +
+
+

{item.name}

+
+
+
+ {item.products.slice(0, 2).map((product: Product) => ( + + ))} + {item.products.length > 2 && ( +
+ +{item.products.length - 2} more products +
+ )} +
+
+
+ ); + } else if (item) { + // It's a product + return ( + + ); + } + return null; + })() + ) : null} +
); }; diff --git a/frontend/src/components/common/QuestionAndAnswerList/index.tsx b/frontend/src/components/common/QuestionAndAnswerList/index.tsx index c92dabc4..b6477b71 100644 --- a/frontend/src/components/common/QuestionAndAnswerList/index.tsx +++ b/frontend/src/components/common/QuestionAndAnswerList/index.tsx @@ -1,21 +1,61 @@ -import {Card} from "../Card"; -import {QuestionAnswer} from "../../../types.ts"; +import { Card } from "../Card"; +import { QuestionAnswer } from "../../../types.ts"; +import { Table } from '@mantine/core'; interface QuestionAndAnswerListProps { - questionAnswers: QuestionAnswer[] + questionAnswers: QuestionAnswer[]; + belongsToFilter?: string[]; // Array of filter values } -export const QuestionAndAnswerList = ({questionAnswers}: QuestionAndAnswerListProps) => { - return ( - - {questionAnswers.map((answer, index) => ( -
- {answer.title} -

- {answer.text_answer} -

-
- ))} +export const QuestionAndAnswerList = ({ questionAnswers, belongsToFilter }: QuestionAndAnswerListProps) => { + // Filter questionAnswers by 'belongs_to' array if the filter is applied + const filteredQuestions = belongsToFilter && belongsToFilter.length > 0 + ? questionAnswers.filter(qa => belongsToFilter.includes(qa.belongs_to)) + : questionAnswers; + + // Categorize the questions + const productQuestions = filteredQuestions.filter(qa => qa.belongs_to === 'PRODUCT' && !qa.attendee_id); + const attendeeQuestions = filteredQuestions.filter(qa => qa.belongs_to === 'PRODUCT' && qa.attendee_id); + const orderQuestions = filteredQuestions.filter(qa => qa.belongs_to === 'ORDER'); + + // Function to render a table for a given category of questions + const renderTable = (title: string, questions: QuestionAnswer[], showProductColumn = true) => ( + +

{title}

+ {questions.length > 0 ? ( + + + + {showProductColumn && } + + + {title === "Attendee Answers" && } + + + + {questions.map((qa, index) => ( + + {showProductColumn && } + + + {title === "Attendee Answers" && ( + + )} + + ))} + +
ProductQuestionAnswerAttendee
{qa.product_title || 'N/A'}{qa.title}{Array.isArray(qa.answer) ? qa.answer.join(", ") : qa.answer}{qa.first_name ? `${qa.first_name} ${qa.last_name}` : 'N/A'}
+ ) : ( +

No {title.toLowerCase()} questions available.

+ )}
); -} \ No newline at end of file + + return ( +
+ {renderTable('Attendee Answers', attendeeQuestions)} + {renderTable('Order Answers', orderQuestions, false)} + {renderTable('Products Answers', productQuestions)} +
+ ); +} diff --git a/frontend/src/components/common/QuestionsTable/index.tsx b/frontend/src/components/common/QuestionsTable/index.tsx index 058688a9..cfa0f8ce 100644 --- a/frontend/src/components/common/QuestionsTable/index.tsx +++ b/frontend/src/components/common/QuestionsTable/index.tsx @@ -304,7 +304,7 @@ export const QuestionsTable = ({questions}: QuestionsTableProp) => { )}
-

{t`Attendee questions`}

+

{t`Product questions`}

{ { number: formatNumber(eventStats?.total_products_sold as number), description: t`Products sold`, - icon: + icon: + }, + { + number: formatNumber(eventStats?.total_attendees_registered as number), + description: t`Attendees`, + icon: + }, + { + number: formatCurrency(eventStats?.total_refunded as number || 0, event?.currency), + description: t`Refunded`, + icon: }, { number: formatCurrency(eventStats?.total_gross_sales || 0, event?.currency), description: t`Gross sales`, - icon: + icon: }, { number: formatNumber(eventStats?.total_views as number), description: t`Page views`, - icon: + icon: }, { number: formatNumber(eventStats?.total_orders as number), description: t`Orders Created`, - icon: + icon: } ]; diff --git a/frontend/src/components/common/ToolBar/index.tsx b/frontend/src/components/common/ToolBar/index.tsx index 60ab4465..f8c5a61c 100644 --- a/frontend/src/components/common/ToolBar/index.tsx +++ b/frontend/src/components/common/ToolBar/index.tsx @@ -11,13 +11,13 @@ export const ToolBar = ({searchComponent, children}: ToolBarProps) => { return (
-
+ {searchComponent &&
{searchComponent && searchComponent()} -
+
}
{children}
) -} \ No newline at end of file +} diff --git a/frontend/src/components/forms/CapaciyAssigmentForm/index.tsx b/frontend/src/components/forms/CapaciyAssigmentForm/index.tsx index 18171b01..576ca3f2 100644 --- a/frontend/src/components/forms/CapaciyAssigmentForm/index.tsx +++ b/frontend/src/components/forms/CapaciyAssigmentForm/index.tsx @@ -1,17 +1,18 @@ import {InputGroup} from "../../common/InputGroup"; -import {MultiSelect, NumberInput, Switch, TextInput} from "@mantine/core"; +import {NumberInput, TextInput} from "@mantine/core"; import {t} from "@lingui/macro"; import {UseFormReturnType} from "@mantine/form"; -import {CapacityAssignmentRequest, Product} from "../../../types.ts"; +import {CapacityAssignmentRequest, ProductCategory} from "../../../types.ts"; import {CustomSelect, ItemProps} from "../../common/CustomSelect"; -import {IconCheck, IconTicket, IconX} from "@tabler/icons-react"; +import {IconCheck, IconX} from "@tabler/icons-react"; +import {ProductSelector} from "../../common/ProductSelector"; -interface CapaciyAssigmentFormProps { +interface CapacityAssigmentFormProps { form: UseFormReturnType; - products: Product[], + productsCategories: ProductCategory[], } -export const CapaciyAssigmentForm = ({form, products}: CapaciyAssigmentFormProps) => { +export const CapacityAssigmentForm = ({form, productsCategories}: CapacityAssigmentFormProps) => { const statusOptions: ItemProps[] = [ { icon: , @@ -43,18 +44,12 @@ export const CapaciyAssigmentForm = ({form, products}: CapaciyAssigmentFormProps /> - { - return { - value: String(product.id), - label: product.title, - } - })} - leftSection={} - {...form.getInputProps('product_ids')} + data={productsCategories} + form={form} + fieldName={'product_ids'} /> ; - products: Product[], + productCategories: ProductCategory[], } -export const CheckInListForm = ({form, products}: CheckInListFormProps) => { +export const CheckInListForm = ({form, productCategories}: CheckInListFormProps) => { return ( <> { placeholder={t`VIP check-in list`} /> - { - return { - value: String(product.id), - label: product.title, - } - })} - required - leftSection={} - {...form.getInputProps('product_ids')} +