diff --git a/.gitignore b/.gitignore index 9dd75a97bf..20e182f472 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /.idea/** frontend/.env backend/.env +docker/all-in-one/.env todo.md CLAUDE.md /prompts/** diff --git a/backend/app/DomainObjects/Enums/AttendeeDetailsCollectionMethod.php b/backend/app/DomainObjects/Enums/AttendeeDetailsCollectionMethod.php new file mode 100644 index 0000000000..f992d8e99b --- /dev/null +++ b/backend/app/DomainObjects/Enums/AttendeeDetailsCollectionMethod.php @@ -0,0 +1,11 @@ + $this->deleted_at ?? null, 'name' => $this->name ?? null, 'email' => $this->email ?? null, + 'stripe_account_id' => $this->stripe_account_id ?? null, 'short_id' => $this->short_id ?? null, + 'stripe_connect_setup_complete' => $this->stripe_connect_setup_complete ?? null, 'account_verified_at' => $this->account_verified_at ?? null, + 'stripe_connect_account_type' => $this->stripe_connect_account_type ?? null, 'is_manually_verified' => $this->is_manually_verified ?? null, ]; } @@ -153,6 +162,17 @@ public function getEmail(): string return $this->email; } + public function setStripeAccountId(?string $stripe_account_id): self + { + $this->stripe_account_id = $stripe_account_id; + return $this; + } + + public function getStripeAccountId(): ?string + { + return $this->stripe_account_id; + } + public function setShortId(string $short_id): self { $this->short_id = $short_id; @@ -164,6 +184,17 @@ public function getShortId(): string return $this->short_id; } + public function setStripeConnectSetupComplete(?bool $stripe_connect_setup_complete): self + { + $this->stripe_connect_setup_complete = $stripe_connect_setup_complete; + return $this; + } + + public function getStripeConnectSetupComplete(): ?bool + { + return $this->stripe_connect_setup_complete; + } + public function setAccountVerifiedAt(?string $account_verified_at): self { $this->account_verified_at = $account_verified_at; @@ -175,6 +206,17 @@ public function getAccountVerifiedAt(): ?string return $this->account_verified_at; } + public function setStripeConnectAccountType(?string $stripe_connect_account_type): self + { + $this->stripe_connect_account_type = $stripe_connect_account_type; + return $this; + } + + public function getStripeConnectAccountType(): ?string + { + return $this->stripe_connect_account_type; + } + public function setIsManuallyVerified(bool $is_manually_verified): self { $this->is_manually_verified = $is_manually_verified; diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index ad91a0ffb6..ddfad6abe7 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -59,6 +59,8 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const INVOICE_PAYMENT_TERMS_DAYS = 'invoice_payment_terms_days'; final public const INVOICE_NOTES = 'invoice_notes'; final public const TICKET_DESIGN_SETTINGS = 'ticket_design_settings'; + final public const ATTENDEE_DETAILS_COLLECTION_METHOD = 'attendee_details_collection_method'; + final public const SHOW_MARKETING_OPT_IN = 'show_marketing_opt_in'; protected int $id; protected int $event_id; @@ -109,6 +111,8 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected ?int $invoice_payment_terms_days = null; protected ?string $invoice_notes = null; protected array|string|null $ticket_design_settings = null; + protected string $attendee_details_collection_method = 'PER_TICKET'; + protected bool $show_marketing_opt_in = true; public function toArray(): array { @@ -162,6 +166,8 @@ public function toArray(): array 'invoice_payment_terms_days' => $this->invoice_payment_terms_days ?? null, 'invoice_notes' => $this->invoice_notes ?? null, 'ticket_design_settings' => $this->ticket_design_settings ?? null, + 'attendee_details_collection_method' => $this->attendee_details_collection_method ?? null, + 'show_marketing_opt_in' => $this->show_marketing_opt_in ?? null, ]; } @@ -704,4 +710,26 @@ public function getTicketDesignSettings(): array|string|null { return $this->ticket_design_settings; } + + public function setAttendeeDetailsCollectionMethod(string $attendee_details_collection_method): self + { + $this->attendee_details_collection_method = $attendee_details_collection_method; + return $this; + } + + public function getAttendeeDetailsCollectionMethod(): string + { + return $this->attendee_details_collection_method; + } + + public function setShowMarketingOptIn(bool $show_marketing_opt_in): self + { + $this->show_marketing_opt_in = $show_marketing_opt_in; + return $this; + } + + public function getShowMarketingOptIn(): bool + { + return $this->show_marketing_opt_in; + } } diff --git a/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php index 66d7acdad1..f31510daae 100644 --- a/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php @@ -43,6 +43,7 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const PAYMENT_PROVIDER = 'payment_provider'; final public const NOTES = 'notes'; final public const STATISTICS_DECREMENTED_AT = 'statistics_decremented_at'; + final public const OPTED_INTO_MARKETING_AT = 'opted_into_marketing_at'; protected int $id; protected int $event_id; @@ -77,6 +78,7 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac protected ?string $payment_provider = null; protected ?string $notes = null; protected ?string $statistics_decremented_at = null; + protected ?string $opted_into_marketing_at = null; public function toArray(): array { @@ -114,6 +116,7 @@ public function toArray(): array 'payment_provider' => $this->payment_provider ?? null, 'notes' => $this->notes ?? null, 'statistics_decremented_at' => $this->statistics_decremented_at ?? null, + 'opted_into_marketing_at' => $this->opted_into_marketing_at ?? null, ]; } @@ -479,4 +482,15 @@ public function getStatisticsDecrementedAt(): ?string { return $this->statistics_decremented_at; } + + public function setOptedIntoMarketingAt(?string $opted_into_marketing_at): self + { + $this->opted_into_marketing_at = $opted_into_marketing_at; + return $this; + } + + public function getOptedIntoMarketingAt(): ?string + { + return $this->opted_into_marketing_at; + } } diff --git a/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php index 6aed55c16c..d7e55a0292 100644 --- a/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrganizerSettingDomainObjectAbstract.php @@ -25,6 +25,8 @@ abstract class OrganizerSettingDomainObjectAbstract extends \HiEvents\DomainObje final public const UPDATED_AT = 'updated_at'; final public const DELETED_AT = 'deleted_at'; final public const LOCATION_DETAILS = 'location_details'; + final public const DEFAULT_ATTENDEE_DETAILS_COLLECTION_METHOD = 'default_attendee_details_collection_method'; + final public const DEFAULT_SHOW_MARKETING_OPT_IN = 'default_show_marketing_opt_in'; protected int $id; protected int $organizer_id; @@ -41,6 +43,8 @@ abstract class OrganizerSettingDomainObjectAbstract extends \HiEvents\DomainObje protected ?string $updated_at = null; protected ?string $deleted_at = null; protected array|string|null $location_details = null; + protected string $default_attendee_details_collection_method = 'PER_TICKET'; + protected bool $default_show_marketing_opt_in = true; public function toArray(): array { @@ -60,6 +64,8 @@ public function toArray(): array 'updated_at' => $this->updated_at ?? null, 'deleted_at' => $this->deleted_at ?? null, 'location_details' => $this->location_details ?? null, + 'default_attendee_details_collection_method' => $this->default_attendee_details_collection_method ?? null, + 'default_show_marketing_opt_in' => $this->default_show_marketing_opt_in ?? null, ]; } @@ -227,4 +233,27 @@ public function getLocationDetails(): array|string|null { return $this->location_details; } + + public function setDefaultAttendeeDetailsCollectionMethod( + string $default_attendee_details_collection_method, + ): self { + $this->default_attendee_details_collection_method = $default_attendee_details_collection_method; + return $this; + } + + public function getDefaultAttendeeDetailsCollectionMethod(): string + { + return $this->default_attendee_details_collection_method; + } + + public function setDefaultShowMarketingOptIn(bool $default_show_marketing_opt_in): self + { + $this->default_show_marketing_opt_in = $default_show_marketing_opt_in; + return $this; + } + + public function getDefaultShowMarketingOptIn(): bool + { + return $this->default_show_marketing_opt_in; + } } diff --git a/backend/app/Exports/OrdersExport.php b/backend/app/Exports/OrdersExport.php index d6dc8c57da..71ac6e1948 100644 --- a/backend/app/Exports/OrdersExport.php +++ b/backend/app/Exports/OrdersExport.php @@ -66,6 +66,7 @@ public function headings(): array __('Billing Address'), __('Notes'), __('Promo Code'), + __('Opted In To Marketing'), ], $questionTitles); } @@ -109,6 +110,7 @@ public function map($order): array $order->getBillingAddressString(), $order->getNotes(), $order->getPromoCode(), + $order->getOptedIntoMarketingAt() ? 'Yes' : 'No', ], $answers->toArray()); } diff --git a/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php index a1be5515d9..e34a362644 100644 --- a/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php @@ -32,6 +32,7 @@ public function __invoke(CompleteOrderRequest $request, int $eventId, string $or 'questions' => $request->has('order.questions') ? $request->input('order.questions') : null, + 'opted_into_marketing' => $request->boolean('order.opted_into_marketing'), ]), 'products' => $request->input('products'), 'event_id' => $eventId, diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 8dcf38728f..2706cb913a 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -2,6 +2,7 @@ namespace HiEvents\Http\Request\EventSettings; +use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\Enums\PriceDisplayMode; @@ -22,6 +23,7 @@ public function rules(): array 'continue_button_text' => ['string', 'nullable', 'max:100'], 'support_email' => ['email', 'nullable'], 'require_attendee_details' => ['boolean'], + 'attendee_details_collection_method' => [Rule::in(AttendeeDetailsCollectionMethod::valuesArray())], 'order_timeout_in_minutes' => ['numeric', "min:1", "max:120"], 'homepage_background_color' => ['nullable', ...RulesHelper::HEX_COLOR], @@ -83,6 +85,9 @@ public function rules(): array 'ticket_design_settings.footer_text' => ['nullable', 'string', 'max:500'], 'ticket_design_settings.layout_type' => ['nullable', 'string', Rule::in(['default', 'modern'])], 'ticket_design_settings.enabled' => ['boolean'], + + // Marketing settings + 'show_marketing_opt_in' => ['boolean'], ]; } diff --git a/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php index a7404a3c70..fd9d352a73 100644 --- a/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php +++ b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php @@ -2,6 +2,7 @@ namespace HiEvents\Http\Request\Organizer\Settings; +use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; use HiEvents\DomainObjects\Enums\OrganizerHomepageVisibility; use HiEvents\Http\Request\BaseRequest; @@ -13,6 +14,10 @@ class PartialUpdateOrganizerSettingsRequest extends BaseRequest public static function rules(): array { return [ + // Event defaults + 'default_attendee_details_collection_method' => ['sometimes', 'nullable', Rule::in(AttendeeDetailsCollectionMethod::valuesArray())], + 'default_show_marketing_opt_in' => ['sometimes', 'nullable', 'boolean'], + // Social handles 'facebook_handle' => ['sometimes', 'nullable', 'string', 'max:255'], 'instagram_handle' => ['sometimes', 'nullable', 'string', 'max:255'], diff --git a/backend/app/Models/Order.php b/backend/app/Models/Order.php index 5fc024b607..e69cdad97e 100644 --- a/backend/app/Models/Order.php +++ b/backend/app/Models/Order.php @@ -63,7 +63,8 @@ protected function getCastMap(): array 'point_in_time_data' => 'array', 'address' => 'array', 'taxes_and_fees_rollup' => 'array', - 'statistics_decremented_at' => 'datetime' + 'statistics_decremented_at' => 'datetime', + 'opted_into_marketing_at' => 'datetime', ]; } } diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index 4d6fb296ea..ceaeab3599 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -18,6 +18,7 @@ public function toArray($request): array 'product_page_message' => $this->getProductPageMessage(), 'continue_button_text' => $this->getContinueButtonText(), 'required_attendee_details' => $this->getRequireAttendeeDetails(), + 'attendee_details_collection_method' => $this->getAttendeeDetailsCollectionMethod(), 'email_footer_message' => $this->getEmailFooterMessage(), 'support_email' => $this->getSupportEmail(), 'order_timeout_in_minutes' => $this->getOrderTimeoutInMinutes(), @@ -66,6 +67,9 @@ public function toArray($request): array 'invoice_tax_details' => $this->getInvoiceTaxDetails(), 'invoice_notes' => $this->getInvoiceNotes(), 'invoice_payment_terms_days' => $this->getInvoicePaymentTermsDays(), + + // Marketing settings + 'show_marketing_opt_in' => $this->getShowMarketingOptIn(), ]; } } diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index fa290dc0fd..8cd0447a46 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -34,6 +34,7 @@ public function toArray($request): array 'product_page_message' => $this->getProductPageMessage(), 'continue_button_text' => $this->getContinueButtonText(), 'required_attendee_details' => $this->getRequireAttendeeDetails(), + 'attendee_details_collection_method' => $this->getAttendeeDetailsCollectionMethod(), 'email_footer_message' => $this->getEmailFooterMessage(), 'support_email' => $this->getSupportEmail(), 'order_timeout_in_minutes' => $this->getOrderTimeoutInMinutes(), @@ -72,6 +73,9 @@ public function toArray($request): array // Invoice settings 'require_billing_address' => $this->getRequireBillingAddress(), 'invoice_label' => $this->getInvoiceLabel(), + + // Marketing settings + 'show_marketing_opt_in' => $this->getShowMarketingOptIn(), ]; } } diff --git a/backend/app/Resources/Organizer/OrganizerSettingsResource.php b/backend/app/Resources/Organizer/OrganizerSettingsResource.php index d29f50e3c9..ead73151c3 100644 --- a/backend/app/Resources/Organizer/OrganizerSettingsResource.php +++ b/backend/app/Resources/Organizer/OrganizerSettingsResource.php @@ -15,6 +15,8 @@ public function toArray($request): array return [ 'id' => $this->getId(), 'organizer_id' => $this->getOrganizerId(), + 'default_attendee_details_collection_method' => $this->getDefaultAttendeeDetailsCollectionMethod(), + 'default_show_marketing_opt_in' => $this->getDefaultShowMarketingOptIn(), 'social_media_handles' => $this->getSocialMediaHandles(), 'homepage_theme_settings' => $this->getHomepageThemeSettings(), 'homepage_visibility' => $this->getHomepageVisibility(), diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index cd9ab7bc8b..9212de50d8 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -4,6 +4,7 @@ use HiEvents\DataTransferObjects\AddressDTO; use HiEvents\DataTransferObjects\BaseDTO; +use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\Enums\PriceDisplayMode; @@ -31,6 +32,7 @@ public function __construct( public readonly ?HomepageBackgroundType $homepage_background_type, public readonly bool $require_attendee_details, + public readonly AttendeeDetailsCollectionMethod $attendee_details_collection_method, public readonly int $order_timeout_in_minutes, public readonly ?string $website_url, public readonly ?string $maps_url, @@ -69,6 +71,9 @@ public function __construct( // Ticket design settings public readonly ?array $ticket_design_settings = null, + + // Marketing settings + public readonly bool $show_marketing_opt_in = true, ) { } @@ -95,6 +100,7 @@ public static function createWithDefaults( homepage_body_background_color: '#7a5eb9', homepage_background_type: HomepageBackgroundType::COLOR, require_attendee_details: false, + attendee_details_collection_method: AttendeeDetailsCollectionMethod::PER_TICKET, order_timeout_in_minutes: 0, website_url: null, maps_url: null, @@ -133,6 +139,9 @@ public static function createWithDefaults( 'layout_type' => 'classic', 'enabled' => true, ], + + // Marketing defaults + show_marketing_opt_in: true, ); } } diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index 67bb45602d..ed04b056a6 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -52,6 +52,7 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe 'email_footer_message' => $eventSettingsDTO->settings['email_footer_message'] ?? $existingSettings->getEmailFooterMessage(), 'support_email' => $eventSettingsDTO->settings['support_email'] ?? $existingSettings->getSupportEmail(), 'require_attendee_details' => $eventSettingsDTO->settings['require_attendee_details'] ?? $existingSettings->getRequireAttendeeDetails(), + 'attendee_details_collection_method' => $eventSettingsDTO->settings['attendee_details_collection_method'] ?? $existingSettings->getAttendeeDetailsCollectionMethod(), 'continue_button_text' => array_key_exists('continue_button_text', $eventSettingsDTO->settings) ? $eventSettingsDTO->settings['continue_button_text'] : $existingSettings->getContinueButtonText(), @@ -121,7 +122,10 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe // Ticket design settings 'ticket_design_settings' => array_key_exists('ticket_design_settings', $eventSettingsDTO->settings) ? $eventSettingsDTO->settings['ticket_design_settings'] - : $existingSettings->getTicketDesignSettings() + : $existingSettings->getTicketDesignSettings(), + + // Marketing settings + 'show_marketing_opt_in' => $eventSettingsDTO->settings['show_marketing_opt_in'] ?? $existingSettings->getShowMarketingOptIn(), ]), ); } diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index 443f135ba7..b37911c025 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -35,6 +35,7 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje ?? $this->purifier->purify($settings->email_footer_message), 'support_email' => $settings->support_email, 'require_attendee_details' => $settings->require_attendee_details, + 'attendee_details_collection_method' => $settings->attendee_details_collection_method->name, 'continue_button_text' => trim($settings->continue_button_text), 'homepage_background_color' => $settings->homepage_background_color, @@ -81,6 +82,9 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje // Ticket design settings 'ticket_design_settings' => $settings->ticket_design_settings, + + // Marketing settings + 'show_marketing_opt_in' => $settings->show_marketing_opt_in, ], where: [ 'event_id' => $settings->event_id, diff --git a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php index 468ba5df90..4daf4c2e27 100644 --- a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php @@ -7,6 +7,7 @@ use Carbon\Carbon; use Exception; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod; use HiEvents\DomainObjects\Enums\ProductType; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract; @@ -67,14 +68,19 @@ public function __construct( */ public function handle(string $orderShortId, CompleteOrderDTO $orderData): OrderDomainObject { - $updatedOrder = DB::transaction(function () use ($orderData, $orderShortId) { + /** @var EventSettingDomainObject $eventSettings */ + $eventSettings = $this->eventSettingsRepository->findFirstWhere([ + 'event_id' => $orderData->event_id, + ]); + + $updatedOrder = DB::transaction(function () use ($orderData, $orderShortId, $eventSettings) { $orderDTO = $orderData->order; $order = $this->getOrder($orderShortId); $updatedOrder = $this->updateOrder($order, $orderDTO); - $this->createAttendees($orderData->products, $order); + $this->createAttendees($orderData->products, $order, $orderDTO, $eventSettings); if ($orderData->order->questions) { $this->createOrderQuestions($orderDTO->questions, $order); @@ -93,11 +99,6 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order return $updatedOrder; }); - /** @var EventSettingDomainObject $eventSettings */ - $eventSettings = $this->eventSettingsRepository->findFirstWhere([ - 'event_id' => $orderData->event_id, - ]); - event(new OrderStatusChangedEvent( order: $updatedOrder, sendEmails: true, @@ -120,7 +121,12 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order * @param Collection $orderProducts * @throws Exception */ - private function createAttendees(Collection $orderProducts, OrderDomainObject $order): void + private function createAttendees( + Collection $orderProducts, + OrderDomainObject $order, + CompleteOrderOrderDTO $orderDTO, + EventSettingDomainObject $eventSettings, + ): void { $inserts = []; $createdProductData = collect(); @@ -131,6 +137,8 @@ private function createAttendees(Collection $orderProducts, OrderDomainObject $o ); $this->validateProductPriceIdsMatchOrder($order, $productsPrices); + + $isPerOrderCollection = $eventSettings->getAttendeeDetailsCollectionMethod() === AttendeeDetailsCollectionMethod::PER_ORDER->name; $this->validateTicketProductsCount($order, $orderProducts); foreach ($orderProducts as $attendee) { @@ -158,9 +166,9 @@ private function createAttendees(Collection $orderProducts, OrderDomainObject $o AttendeeDomainObjectAbstract::STATUS => $order->isPaymentRequired() ? AttendeeStatus::AWAITING_PAYMENT->name : AttendeeStatus::ACTIVE->name, - AttendeeDomainObjectAbstract::EMAIL => $attendee->email, - AttendeeDomainObjectAbstract::FIRST_NAME => $attendee->first_name, - AttendeeDomainObjectAbstract::LAST_NAME => $attendee->last_name, + AttendeeDomainObjectAbstract::EMAIL => $isPerOrderCollection ? $orderDTO->email : $attendee->email, + AttendeeDomainObjectAbstract::FIRST_NAME => $isPerOrderCollection ? $orderDTO->first_name : $attendee->first_name, + AttendeeDomainObjectAbstract::LAST_NAME => $isPerOrderCollection ? $orderDTO->last_name : $attendee->last_name, AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(), AttendeeDomainObjectAbstract::PUBLIC_ID => IdHelper::publicId(IdHelper::ATTENDEE_PREFIX), AttendeeDomainObjectAbstract::SHORT_ID => $shortId, @@ -303,6 +311,9 @@ private function updateOrder(OrderDomainObject $order, CompleteOrderOrderDTO $or OrderDomainObjectAbstract::STATUS => $order->isPaymentRequired() ? OrderStatus::RESERVED->name : OrderStatus::COMPLETED->name, + OrderDomainObjectAbstract::OPTED_INTO_MARKETING_AT => $orderDTO->opted_into_marketing + ? Carbon::now() + : null, ] ); diff --git a/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderOrderDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderOrderDTO.php index 91c0f97aeb..d44442c2a1 100644 --- a/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderOrderDTO.php +++ b/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderOrderDTO.php @@ -14,6 +14,7 @@ class CompleteOrderOrderDTO extends BaseDTO * @param string $email * @param Collection|null $questions * @param array|null $address + * @param bool $opted_into_marketing */ public function __construct( public readonly string $first_name, @@ -22,6 +23,7 @@ public function __construct( #[CollectionOf(OrderQuestionsDTO::class)] public readonly ?Collection $questions, public readonly ?array $address = [], + public readonly bool $opted_into_marketing = false, ) { } diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php index d1c7c30ffe..1ec781169d 100644 --- a/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php @@ -4,6 +4,7 @@ use HiEvents\DataTransferObjects\AddressDTO; use HiEvents\DataTransferObjects\BaseDataObject; +use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; use HiEvents\DomainObjects\Enums\OrganizerHomepageVisibility; use Spatie\LaravelData\Attributes\MapInputName; @@ -16,59 +17,64 @@ class PartialUpdateOrganizerSettingsDTO extends BaseDataObject { public function __construct( - public readonly int $organizerId, - public readonly string $accountId, + public readonly int $organizerId, + public readonly string $accountId, + + // Event defaults + #[WithCast(EnumCast::class, AttendeeDetailsCollectionMethod::class)] + public readonly AttendeeDetailsCollectionMethod|Optional|null $defaultAttendeeDetailsCollectionMethod, + public readonly bool|Optional|null $defaultShowMarketingOptIn, // Social - public readonly string|Optional|null $facebookHandle, - public readonly string|Optional|null $instagramHandle, - public readonly string|Optional|null $twitterHandle, - public readonly string|Optional|null $linkedinHandle, - public readonly string|Optional|null $discordHandle, - public readonly string|Optional|null $tiktokHandle, - public readonly string|Optional|null $youtubeHandle, - public readonly string|Optional|null $snapchatHandle, - public readonly string|Optional|null $twitchHandle, - public readonly string|Optional|null $redditHandle, - public readonly string|Optional|null $pinterestHandle, - public readonly string|Optional|null $whatsappHandle, - public readonly string|Optional|null $telegramHandle, - public readonly string|Optional|null $vkHandle, - public readonly string|Optional|null $weiboHandle, - public readonly string|Optional|null $wechatHandle, - public readonly string|Optional|null $flickrHandle, - public readonly string|Optional|null $tumblrHandle, - public readonly string|Optional|null $quoraHandle, - public readonly string|Optional|null $vimeoHandle, - public readonly string|Optional|null $githubHandle, + public readonly string|Optional|null $facebookHandle, + public readonly string|Optional|null $instagramHandle, + public readonly string|Optional|null $twitterHandle, + public readonly string|Optional|null $linkedinHandle, + public readonly string|Optional|null $discordHandle, + public readonly string|Optional|null $tiktokHandle, + public readonly string|Optional|null $youtubeHandle, + public readonly string|Optional|null $snapchatHandle, + public readonly string|Optional|null $twitchHandle, + public readonly string|Optional|null $redditHandle, + public readonly string|Optional|null $pinterestHandle, + public readonly string|Optional|null $whatsappHandle, + public readonly string|Optional|null $telegramHandle, + public readonly string|Optional|null $vkHandle, + public readonly string|Optional|null $weiboHandle, + public readonly string|Optional|null $wechatHandle, + public readonly string|Optional|null $flickrHandle, + public readonly string|Optional|null $tumblrHandle, + public readonly string|Optional|null $quoraHandle, + public readonly string|Optional|null $vimeoHandle, + public readonly string|Optional|null $githubHandle, // Website - public readonly string|Optional|null $websiteUrl, + public readonly string|Optional|null $websiteUrl, // Location details - public readonly AddressDTO|Optional|null $locationDetails, + public readonly AddressDTO|Optional|null $locationDetails, // Homepage settings - public readonly OrganizerHomepageVisibility|Optional|null $homepageVisibility, + public readonly OrganizerHomepageVisibility|Optional|null $homepageVisibility, - public readonly string|Optional|null $homepageBackgroundColor, - public readonly string|Optional|null $homepageContentBackgroundColor, - public readonly string|Optional|null $homepagePrimaryColor, - public readonly string|Optional|null $homepagePrimaryTextColor, - public readonly string|Optional|null $homepageSecondaryColor, - public readonly string|Optional|null $homepageSecondaryTextColor, + public readonly string|Optional|null $homepageBackgroundColor, + public readonly string|Optional|null $homepageContentBackgroundColor, + public readonly string|Optional|null $homepagePrimaryColor, + public readonly string|Optional|null $homepagePrimaryTextColor, + public readonly string|Optional|null $homepageSecondaryColor, + public readonly string|Optional|null $homepageSecondaryTextColor, - #[WithCast(EnumCast::class)] - public readonly HomepageBackgroundType|Optional|null $homepageBackgroundType, + #[WithCast(EnumCast::class, HomepageBackgroundType::class)] + public readonly HomepageBackgroundType|Optional|null $homepageBackgroundType, // SEO - public readonly string|Optional|null $seoKeywords, - public readonly string|Optional|null $seoTitle, - public readonly string|Optional|null $seoDescription, - public readonly bool|Optional|null $allowSearchEngineIndexing, + public readonly string|Optional|null $seoKeywords, + public readonly string|Optional|null $seoTitle, + public readonly string|Optional|null $seoDescription, + public readonly bool|Optional|null $allowSearchEngineIndexing, // Password - public readonly string|Optional|null $homepagePassword, + public readonly string|Optional|null $homepagePassword, ) { } diff --git a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php index 20010af03a..114e47587d 100644 --- a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php @@ -42,6 +42,16 @@ public function handle(PartialUpdateOrganizerSettingsDTO $dto): OrganizerSetting } $this->organizerSettingsRepository->updateWhere([ + 'default_attendee_details_collection_method' => $dto->getProvided( + 'defaultAttendeeDetailsCollectionMethod', + $organizerSettings->getDefaultAttendeeDetailsCollectionMethod() + )?->name ?? $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(), + + 'default_show_marketing_opt_in' => $dto->getProvided( + 'defaultShowMarketingOptIn', + $organizerSettings->getDefaultShowMarketingOptIn() + ), + 'social_media_handles' => array_filter([ 'facebook' => $dto->getProvided('facebookHandle', $organizerSettings->getSocialMediaHandle('facebook')), 'instagram' => $dto->getProvided('instagramHandle', $organizerSettings->getSocialMediaHandle('instagram')), diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index 72c441c5b4..69b5ef9d99 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -223,6 +223,9 @@ private function createEventSettings( 'organization_name' => $organizer->getName(), 'organization_address' => null, 'invoice_tax_details' => null, + + 'attendee_details_collection_method' => $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(), + 'show_marketing_opt_in' => $organizerSettings->getDefaultShowMarketingOptIn(), ]); } } diff --git a/backend/app/Validators/CompleteOrderValidator.php b/backend/app/Validators/CompleteOrderValidator.php index af67340074..860ce4e296 100644 --- a/backend/app/Validators/CompleteOrderValidator.php +++ b/backend/app/Validators/CompleteOrderValidator.php @@ -76,7 +76,12 @@ public function rules(): array 'order.last_name' => ['required', 'string', 'max:40'], 'order.questions' => new OrderQuestionRule($orderQuestions, $products), 'order.email' => 'required|email', - 'products' => new ProductQuestionRule($productQuestions, $products), + 'order.email_confirmation' => 'required|email|same:order.email', + 'products' => new ProductQuestionRule( + $productQuestions, + $products, + $eventSettings->getAttendeeDetailsCollectionMethod(), + ), ...$addressRules ]; } @@ -89,6 +94,8 @@ public function messages(): array 'order.first_name.required' => __('First name is required'), 'order.last_name.required' => __('Last name is required'), 'order.email' => __('A valid email is required'), + 'order.email_confirmation.required' => __('Please confirm your email address'), + 'order.email_confirmation.same' => __('Email addresses do not match'), 'order.address.address_line_1.required' => __('Address line 1 is required'), 'order.address.city.required' => __('City is required'), 'order.address.zip_or_postal_code.required' => __('Zip or postal code is required'), diff --git a/backend/app/Validators/Rules/ProductQuestionRule.php b/backend/app/Validators/Rules/ProductQuestionRule.php index 56479d954f..2722c7a5e3 100644 --- a/backend/app/Validators/Rules/ProductQuestionRule.php +++ b/backend/app/Validators/Rules/ProductQuestionRule.php @@ -2,6 +2,7 @@ namespace HiEvents\Validators\Rules; +use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod; use HiEvents\DomainObjects\Enums\ProductType; use HiEvents\DomainObjects\QuestionDomainObject; use Illuminate\Support\Collection; @@ -10,6 +11,17 @@ class ProductQuestionRule extends BaseQuestionRule { + private bool $skipBasicAttendeeValidation = false; + + public function __construct( + Collection $questions, + Collection $products, + ?string $attendeeDetailsCollectionMethod = null, + ) { + parent::__construct($questions, $products); + + $this->skipBasicAttendeeValidation = $attendeeDetailsCollectionMethod === AttendeeDetailsCollectionMethod::PER_ORDER->name; + } /** * @throws ValidationException */ @@ -47,7 +59,7 @@ protected function validateQuestions(mixed $products): array continue; } - if ($productDomainObject->getProductType() === ProductType::TICKET->name) { + if ($productDomainObject->getProductType() === ProductType::TICKET->name && !$this->skipBasicAttendeeValidation) { $validationMessages = [ ...$validationMessages, ...$this->validateBasicTicketFields($productRequestData, $productIndex), @@ -93,6 +105,10 @@ private function validateBasicTicketFields(mixed $productRequestData, int|string 'first_name' => ['required', 'string', 'min:1', 'max:100'], 'last_name' => ['required', 'string', 'min:1', 'max:100'], 'email' => ['required', 'string', 'email', 'max:100'], + 'email_confirmation' => ['required', 'string', 'email', 'max:100', 'same:email'], + ], [ + 'email_confirmation.required' => __('Please confirm the email address'), + 'email_confirmation.same' => __('Email addresses do not match'), ]); if ($validator->fails()) { diff --git a/backend/database/migrations/2025_11_25_120000_add_attendee_details_collection_method.php b/backend/database/migrations/2025_11_25_120000_add_attendee_details_collection_method.php new file mode 100644 index 0000000000..bc74168b66 --- /dev/null +++ b/backend/database/migrations/2025_11_25_120000_add_attendee_details_collection_method.php @@ -0,0 +1,34 @@ +string('attendee_details_collection_method') + ->default(AttendeeDetailsCollectionMethod::PER_TICKET->name) + ->after('require_attendee_details'); + }); + + Schema::table('organizer_settings', static function (Blueprint $table) { + $table->string('default_attendee_details_collection_method') + ->default(AttendeeDetailsCollectionMethod::PER_TICKET->name) + ->after('organizer_id'); + }); + } + + public function down(): void + { + Schema::table('event_settings', static function (Blueprint $table) { + $table->dropColumn('attendee_details_collection_method'); + }); + + Schema::table('organizer_settings', static function (Blueprint $table) { + $table->dropColumn('default_attendee_details_collection_method'); + }); + } +}; diff --git a/backend/database/migrations/2025_11_26_120000_add_marketing_opt_in_fields.php b/backend/database/migrations/2025_11_26_120000_add_marketing_opt_in_fields.php new file mode 100644 index 0000000000..4c022f60b3 --- /dev/null +++ b/backend/database/migrations/2025_11_26_120000_add_marketing_opt_in_fields.php @@ -0,0 +1,38 @@ +timestamp('opted_into_marketing_at')->nullable()->after('notes'); + }); + + Schema::table('event_settings', function (Blueprint $table) { + $table->boolean('show_marketing_opt_in')->default(true)->after('ticket_design_settings'); + }); + + Schema::table('organizer_settings', function (Blueprint $table) { + $table->boolean('default_show_marketing_opt_in')->default(true)->after('default_attendee_details_collection_method'); + }); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn('opted_into_marketing_at'); + }); + + Schema::table('event_settings', function (Blueprint $table) { + $table->dropColumn('show_marketing_opt_in'); + }); + + Schema::table('organizer_settings', function (Blueprint $table) { + $table->dropColumn('default_show_marketing_opt_in'); + }); + } +}; diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php index b331f4c747..709ed69e54 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php @@ -116,6 +116,7 @@ public function testHandleThrowsResourceNotFoundExceptionWhenOrderNotFound(): vo $orderShortId = 'NONEXISTENT'; $orderData = $this->createMockCompleteOrderDTO(); + $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($this->createMockEventSetting()); $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturnNull(); $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); @@ -134,6 +135,7 @@ public function testHandleThrowsResourceConflictExceptionWhenOrderAlreadyProcess $order->setEmail('d@d.com'); $order->setTotalGross(0); + $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($this->createMockEventSetting()); $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); @@ -151,6 +153,7 @@ public function testHandleThrowsResourceConflictExceptionWhenOrderExpired(): voi $order->setReservedUntil(Carbon::now()->subHour()->toDateTimeString()); $order->setTotalGross(100); + $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($this->createMockEventSetting()); $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); @@ -226,6 +229,7 @@ public function testHandleThrowsExceptionWhenAttendeeInsertFails(): void $order = $this->createMockOrder(); $updatedOrder = $this->createMockOrder(); + $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($this->createMockEventSetting()); $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); @@ -249,6 +253,7 @@ public function testExceptionIsThrowWhenAttendeeCountDoesNotMatchOrderItemsCount $order->getOrderItems()->first()->setQuantity(2); + $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($this->createMockEventSetting()); $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); diff --git a/docker/all-in-one/.env b/docker/all-in-one/.env.example similarity index 93% rename from docker/all-in-one/.env rename to docker/all-in-one/.env.example index cf601a89ad..3a7b4f6130 100644 --- a/docker/all-in-one/.env +++ b/docker/all-in-one/.env.example @@ -23,6 +23,8 @@ APP_DISABLE_REGISTRATION=false APP_SAAS_MODE_ENABLED=false APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=0 APP_SAAS_STRIPE_APPLICATION_FEE_FIXED=0 +APP_EMAIL_LOGO_URL= +APP_EMAIL_LOGO_LINK_URL= # Email settings (Using log mailer for local testing) MAIL_MAILER=log @@ -40,6 +42,9 @@ FILESYSTEM_PUBLIC_DISK=public FILESYSTEM_PRIVATE_DISK=local # Database settings +POSTGRES_DB=hi-events +POSTGRES_USER=postgres +POSTGRES_PASSWORD=secret DATABASE_URL=postgresql://postgres:secret@postgres:5432/hi-events # Stripe settings (Replace with valid test keys if necessary) diff --git a/docker/all-in-one/README.md b/docker/all-in-one/README.md index 4b7fdc7318..cab5878ac9 100644 --- a/docker/all-in-one/README.md +++ b/docker/all-in-one/README.md @@ -16,7 +16,13 @@ git clone git@github.com:HiEventsDev/hi.events.git cd hi.events/docker/all-in-one ``` -### Step 2: Generate the `APP_KEY` and `JWT_SECRET` +### Step 2: Copy the Environment File + +```bash +cp .env.example .env +``` + +### Step 3: Generate the `APP_KEY` and `JWT_SECRET` Generate the keys using the following commands: @@ -38,7 +44,7 @@ for /f "tokens=*" %i in ('openssl rand -base64 32') do @echo JWT_SECRET=%i [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) # For JWT_SECRET ``` -### Step 3: Update the `.env` File +### Step 4: Update the `.env` File Update the `.env` file located in `./docker/all-in-one/.env` with the generated `APP_KEY` and `JWT_SECRET`: @@ -47,13 +53,13 @@ APP_KEY=your_generated_app_key JWT_SECRET=your_generated_jwt_secret ``` -### Step 4: Start the Docker Containers +### Step 5: Start the Docker Containers ```bash docker compose up -d ``` -### Step 5: Create an Account +### Step 6: Create an Account Visit [http://localhost:8123/auth/register](http://localhost:8123/auth/register) to create an account. diff --git a/docker/all-in-one/docker-compose.yml b/docker/all-in-one/docker-compose.yml index 4a88c24785..7f881ad462 100644 --- a/docker/all-in-one/docker-compose.yml +++ b/docker/all-in-one/docker-compose.yml @@ -3,11 +3,9 @@ services: build: context: ./../../ dockerfile: Dockerfile.all-in-one - container_name: all-in-one + restart: unless-stopped ports: - "8123:80" - networks: - - hi-events-network environment: - VITE_FRONTEND_URL=${VITE_FRONTEND_URL} - VITE_API_URL_CLIENT=${VITE_API_URL_CLIENT} @@ -37,7 +35,7 @@ services: - MAIL_FROM_NAME=${MAIL_FROM_NAME} - FILESYSTEM_PUBLIC_DISK=${FILESYSTEM_PUBLIC_DISK} - FILESYSTEM_PRIVATE_DISK=${FILESYSTEM_PRIVATE_DISK} - - DATABASE_URL=postgresql://postgres:secret@postgres:5432/hi-events + - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-secret}@postgres:5432/${POSTGRES_DB:-hi-events} - REDIS_HOST=redis - REDIS_PASSWORD= - REDIS_PORT=6379 @@ -53,43 +51,31 @@ services: condition: service_healthy redis: - image: redis:latest - container_name: redis - networks: - - hi-events-network + image: redis:7-alpine + restart: unless-stopped healthcheck: test: [ "CMD", "redis-cli", "ping" ] interval: 10s timeout: 5s retries: 5 - ports: - - "6379:6379" volumes: - redisdata:/data postgres: - image: postgres:latest - container_name: postgres - networks: - - hi-events-network + image: postgres:17-alpine + restart: unless-stopped healthcheck: test: [ "CMD-SHELL", "pg_isready -U postgres" ] interval: 10s timeout: 5s retries: 5 environment: - POSTGRES_DB: hi-events - POSTGRES_USER: postgres - POSTGRES_PASSWORD: secret + POSTGRES_DB: ${POSTGRES_DB:-hi-events} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secret} volumes: - pgdata:/var/lib/postgresql/data -networks: - hi-events-network: - driver: bridge - volumes: pgdata: - driver: local redisdata: - driver: local diff --git a/frontend/src/components/common/AddEventToCalendarButton/index.tsx b/frontend/src/components/common/AddEventToCalendarButton/index.tsx index d2cd5cb244..5148fc5bf9 100644 --- a/frontend/src/components/common/AddEventToCalendarButton/index.tsx +++ b/frontend/src/components/common/AddEventToCalendarButton/index.tsx @@ -1,143 +1,21 @@ -import {ActionIcon, Button, Popover, Stack, Text, Tooltip} from '@mantine/core'; -import {IconBrandGoogle, IconCalendarPlus, IconDownload} from '@tabler/icons-react'; +import {ActionIcon, Tooltip} from '@mantine/core'; +import {IconCalendarPlus} from '@tabler/icons-react'; import {t} from "@lingui/macro"; - -interface LocationDetails { - venue_name?: string; - - [key: string]: any; -} - -interface EventSettings { - location_details?: LocationDetails; -} - -interface Event { - title: string; - description_preview?: string; - description?: string; - start_date: string; - end_date?: string; - settings?: EventSettings; -} +import {Event} from "../../../types.ts"; +import {CalendarOptionsPopover} from "../CalendarOptionsPopover"; interface AddToCalendarProps { event: Event; } -const eventLocation = (event: Event): string => { - if (event.settings?.location_details) { - const details = event.settings.location_details; - const addressParts = []; - - if (details.street_address) addressParts.push(details.street_address); - if (details.street_address_2) addressParts.push(details.street_address_2); - if (details.city) addressParts.push(details.city); - if (details.state) addressParts.push(details.state); - if (details.postal_code) addressParts.push(details.postal_code); - if (details.country) addressParts.push(details.country); - - const address = addressParts.join(', '); - - if (details.venue_name) { - return `${details.venue_name}, ${address}`; - } - - return address; - } - - return ''; -}; - -const createICSContent = (event: Event): string => { - const formatDate = (date: string): string => { - return new Date(date).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); - }; - - const stripHtml = (html: string): string => { - const tmp = document.createElement('div'); - tmp.innerHTML = html || ''; - return tmp.textContent || tmp.innerText || ''; - }; - - return [ - 'BEGIN:VCALENDAR', - 'VERSION:2.0', - 'PRODID:-//Hi.Events//NONSGML Event Calendar//EN', - 'CALSCALE:GREGORIAN', - 'BEGIN:VEVENT', - `DTSTART:${formatDate(event.start_date)}`, - `DTEND:${formatDate(event.end_date || event.start_date)}`, - `SUMMARY:${event.title.replace(/\n/g, '\\n')}`, - `DESCRIPTION:${stripHtml(event.description_preview || '').replace(/\n/g, '\\n')}`, - `LOCATION:${eventLocation(event)}`, - `DTSTAMP:${formatDate(new Date().toISOString())}`, - `UID:${crypto.randomUUID()}@hi.events`, - 'END:VEVENT', - 'END:VCALENDAR' - ].join('\r\n'); -}; - -const downloadICSFile = (event: Event): void => { - const content = createICSContent(event); - const blob = new Blob([content], {type: 'text/calendar;charset=utf-8'}); - const link = document.createElement('a'); - link.href = window.URL.createObjectURL(blob); - link.setAttribute('download', `${event.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.ics`); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -}; - -const createGoogleCalendarUrl = (event: Event): string => { - const formatGoogleDate = (date: string): string => { - return new Date(date).toISOString().replace(/-|:|\.\d{3}/g, ''); - }; - - const params = new URLSearchParams({ - action: 'TEMPLATE', - text: event.title, - details: event.description_preview || '', - location: eventLocation(event), - dates: `${formatGoogleDate(event.start_date)}/${formatGoogleDate(event.end_date || event.start_date)}` - }); - - return `https://calendar.google.com/calendar/render?${params.toString()}`; -}; - export const AddToEventCalendarButton = ({event}: AddToCalendarProps) => { return ( - - - - - - - - - - - {t`Add to Calendar`} - - - - - + + + + + + + ); }; diff --git a/frontend/src/components/common/AddToCalendarCTA/AddToCalendarCTA.module.scss b/frontend/src/components/common/AddToCalendarCTA/AddToCalendarCTA.module.scss new file mode 100644 index 0000000000..8329aeb1cf --- /dev/null +++ b/frontend/src/components/common/AddToCalendarCTA/AddToCalendarCTA.module.scss @@ -0,0 +1,52 @@ +@use "../../../styles/mixins"; + +.container { + display: flex; + align-items: center; + gap: 16px; + background: #EEF2FF; + border: 1px solid #E0E7FF; + border-radius: 12px; + padding: 20px; + margin-top: 32px; + margin-bottom: 32px; + + @include mixins.respond-below(sm) { + flex-direction: column; + text-align: center; + } +} + +.iconContainer { + width: 40px; + height: 40px; + background: #E0E7FF; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: #4F46E5; + flex-shrink: 0; +} + +.content { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + + @include mixins.respond-below(sm) { + align-items: center; + } +} + +.title { + font-size: 15px; + font-weight: 500; + color: #111827; +} + +.subtitle { + font-size: 14px; + color: #6B7280; +} diff --git a/frontend/src/components/common/AddToCalendarCTA/index.tsx b/frontend/src/components/common/AddToCalendarCTA/index.tsx new file mode 100644 index 0000000000..0ba32735dd --- /dev/null +++ b/frontend/src/components/common/AddToCalendarCTA/index.tsx @@ -0,0 +1,29 @@ +import {t} from "@lingui/macro"; +import {Button} from "@mantine/core"; +import {IconCalendar} from "@tabler/icons-react"; +import {Event} from "../../../types.ts"; +import {CalendarOptionsPopover} from "../CalendarOptionsPopover"; +import classes from './AddToCalendarCTA.module.scss'; + +interface AddToCalendarCTAProps { + event: Event; +} + +export const AddToCalendarCTA = ({event}: AddToCalendarCTAProps) => { + return ( +
+
+ +
+
+ {t`Don't forget!`} + {t`Add this event to your calendar`} +
+ + + +
+ ); +}; diff --git a/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss b/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss index 3c91bc746f..ed81c95e80 100644 --- a/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss +++ b/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss @@ -311,16 +311,6 @@ max-width: 250px; max-height: 200px; object-fit: contain; - - //@include mixins.respond-below(sm) { - // max-width: 250px; - // max-height: 80px; - //} - // - //@media print { - // max-width: 250px; - // max-height: 80px; - //} } .qrContainer { @@ -348,31 +338,107 @@ } } -.statusOverlay { +// QR Placeholder States (Awaiting Payment / Cancelled) +.qrPlaceholder { + position: relative; + width: 160px; + height: 160px; + border-radius: 12px; + border: 2px solid; + overflow: hidden; + + @include mixins.respond-below(sm) { + width: 140px; + height: 140px; + } + + @media print { + width: 160px; + height: 160px; + } +} + +.qrPlaceholderPending { + border-color: var(--mantine-primary-color-light-hover); + background-color: var(--mantine-primary-color-light); +} + +.qrPlaceholderCancelled { + border-color: var(--mantine-color-red-3); + background-color: var(--mantine-color-red-0); +} + +// Faded QR pattern background +.qrPatternBackground { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 2px; + padding: 12px; + opacity: 0.1; +} + +.qrPatternCell { + border-radius: 1px; +} + +.qrPlaceholderPending .qrPatternCellFilled { + background-color: var(--mantine-primary-color-filled); +} + +.qrPlaceholderCancelled .qrPatternCellFilled { + background-color: var(--mantine-color-red-9); +} + +// Status content overlay +.qrPlaceholderContent { position: absolute; inset: 0; display: flex; + flex-direction: column; align-items: center; justify-content: center; - background: rgba(255, 255, 255, 0.95); - border-radius: 3px; + gap: 8px; } -.cancelled { - color: #dc2626; - font-weight: 600; +.statusIconCircle { + width: 44px; + height: 44px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + + @include mixins.respond-below(sm) { + width: 40px; + height: 40px; + } +} + +.statusIconPending { + background-color: var(--mantine-primary-color-filled); +} + +.statusIconCancelled { + background-color: var(--mantine-color-red-6); +} + +.statusText { font-size: 14px; - text-transform: uppercase; - letter-spacing: 0.05em; + font-weight: 700; + + @include mixins.respond-below(sm) { + font-size: 13px; + } } -.pending { - color: #ea580c; - font-weight: 600; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.05em; - text-align: center; +.statusTextPending { + color: var(--mantine-primary-color-filled); +} + +.statusTextCancelled { + color: var(--mantine-color-red-7); } .ticketId { @@ -443,7 +509,6 @@ } } - .actions { display: flex; gap: 12px; diff --git a/frontend/src/components/common/AttendeeTicket/index.tsx b/frontend/src/components/common/AttendeeTicket/index.tsx index 7cb9824495..a30dedcc27 100644 --- a/frontend/src/components/common/AttendeeTicket/index.tsx +++ b/frontend/src/components/common/AttendeeTicket/index.tsx @@ -4,7 +4,7 @@ import {formatCurrency} from "../../../utilites/currency.ts"; import {t} from "@lingui/macro"; import {prettyDate} from "../../../utilites/dates.ts"; import QRCode from "react-qr-code"; -import {IconCopy, IconPrinter} from "@tabler/icons-react"; +import {IconCopy, IconPrinter, IconLock, IconX} from "@tabler/icons-react"; import {Address, Attendee, Event, Product} from "../../../types.ts"; import classes from './AttendeeTicket.module.scss'; import {imageUrl} from "../../../utilites/urlHelper.ts"; @@ -41,6 +41,19 @@ export const AttendeeTicket = ({ const isCancelled = attendee.status === 'CANCELLED'; const isAwaitingPayment = attendee.status === 'AWAITING_PAYMENT'; + // Generate a deterministic pattern based on attendee ID for consistency + const generateQrPattern = () => { + const seed = attendee.public_id || 'default'; + const pattern = []; + for (let i = 0; i < 64; i++) { + const charCode = seed.charCodeAt(i % seed.length); + pattern.push((charCode + i) % 2 === 0); + } + return pattern; + }; + + const qrPattern = generateQrPattern(); + return (
{/* Header */} @@ -110,25 +123,46 @@ export const AttendeeTicket = ({
)} -
- {(isCancelled || isAwaitingPayment) ? ( -
- - {isCancelled ? t`Cancelled` : t`Awaiting Payment`} + {/* QR Code or Status Placeholder */} + {(isCancelled || isAwaitingPayment) ? ( +
+ {/* Faded QR Pattern Background */} +
+ {qrPattern.map((filled, i) => ( +
+ ))} +
+ + {/* Status Content Overlay */} +
+
+ {isCancelled ? ( + + ) : ( + + )} +
+ + {isCancelled ? t`Cancelled` : t`Pay to unlock`}
- ) : ( +
+ ) : ( +
- )} -
+
+ )}
{t`Ticket ID`}
@@ -159,7 +193,7 @@ export const AttendeeTicket = ({ onClick={() => window?.open(`/product/${event.id}/${attendee.short_id}/print`, '_blank')} leftSection={} > - {t`Print`} + {t`Print to PDF`} { + return ( + + + {children} + + + + {t`Choose calendar`} + + + + + + ); +}; diff --git a/frontend/src/components/common/Card/Card.module.scss b/frontend/src/components/common/Card/Card.module.scss index 3e25aecfbc..f7907b7dd4 100644 --- a/frontend/src/components/common/Card/Card.module.scss +++ b/frontend/src/components/common/Card/Card.module.scss @@ -14,8 +14,9 @@ } &.default { - box-shadow: 0 3px 0 #dddddd; - border: 1px solid #e3e3e3; background-color: #FFFFFF; + border: 1px solid #E5E7EB; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); + transition: box-shadow 0.15s ease; } } diff --git a/frontend/src/components/common/EventCard/index.tsx b/frontend/src/components/common/EventCard/index.tsx index 54285c15c7..f7c78fffde 100644 --- a/frontend/src/components/common/EventCard/index.tsx +++ b/frontend/src/components/common/EventCard/index.tsx @@ -27,7 +27,7 @@ import {showError, showSuccess} from "../../../utilites/notifications.tsx"; import {useUpdateEventStatus} from "../../../mutations/useUpdateEventStatus.ts"; import {formatCurrency} from "../../../utilites/currency.ts"; import {formatNumber} from "../../../utilites/helpers.ts"; -import {formatDate} from "../../../utilites/dates.ts"; +import {formatDateWithLocale} from "../../../utilites/dates.ts"; const placeholderEmojis = ['🎉', '🎪', '🎸', '🎨', '🌟', '🎭', '🎯', '🎮', '🎲', '🎳']; @@ -137,15 +137,15 @@ export function EventCard({event}: EventCardProps) {
{formatDate(event.start_date, 'MMM', event.timezone)} - {formatDate(event.start_date, 'D', event.timezone)} + className={classes.month}>{formatDateWithLocale(event.start_date, 'monthShort', event.timezone)} + {formatDateWithLocale(event.start_date, 'dayOfMonth', event.timezone)}
{formatDate(event.start_date, 'h:mm A', event.timezone)} + className={classes.time}>{formatDateWithLocale(event.start_date, 'timeOnly', event.timezone)} {event.end_date && ( - {formatDate(event.end_date, 'h:mm A', event.timezone)} + className={classes.endTime}>- {formatDateWithLocale(event.end_date, 'timeOnly', event.timezone)} )}
diff --git a/frontend/src/components/common/EventDateRange/index.tsx b/frontend/src/components/common/EventDateRange/index.tsx index 64411fb771..89c2ba9fbb 100644 --- a/frontend/src/components/common/EventDateRange/index.tsx +++ b/frontend/src/components/common/EventDateRange/index.tsx @@ -1,32 +1,35 @@ -import {Event} from "../../../types.ts"; -import {formatDate} from "../../../utilites/dates.ts"; +import { Event } from "../../../types.ts"; +import { formatDateWithLocale } from "../../../utilites/dates.ts"; interface EventDateRangeProps { - event: Event + event: Event; } -export const EventDateRange = ({event}: EventDateRangeProps) => { - const startDateFormatted = formatDate(event.start_date, "ddd, MMM D, YYYY h:mm A", event.timezone); - const endDateFormatted = event.end_date ? formatDate(event.end_date, "ddd, MMM D, YYYY h:mm A", event.timezone) : null; - const sameDayFormatted = formatDate(event.start_date, "dddd, MMMM D", event.timezone); - const startTimeFormatted = formatDate(event.start_date, "h:mm A", event.timezone); - const endTimeFormatted = event.end_date ? formatDate(event.end_date, "h:mm A", event.timezone) : null; - const timezone = formatDate(event.start_date, "z", event.timezone); - +export const EventDateRange = ({ event }: EventDateRangeProps) => { const isSameDay = event.end_date && event.start_date.substring(0, 10) === event.end_date.substring(0, 10); + const timezone = formatDateWithLocale(event.start_date, "timezone", event.timezone); + + if (isSameDay) { + const dayFormatted = formatDateWithLocale(event.start_date, "dayName", event.timezone); + const startTime = formatDateWithLocale(event.start_date, "timeOnly", event.timezone); + const endTime = formatDateWithLocale(event.end_date!, "timeOnly", event.timezone); + + return ( + + {dayFormatted} · {startTime} - {endTime} {timezone} + + ); + } + + const startDateFormatted = formatDateWithLocale(event.start_date, "fullDateTime", event.timezone); + const endDateFormatted = event.end_date + ? formatDateWithLocale(event.end_date, "fullDateTime", event.timezone) + : null; return ( - <> - {isSameDay ? ( - - {sameDayFormatted} · {startTimeFormatted} - {endTimeFormatted} {timezone} - - ) : ( - - {startDateFormatted} - {endDateFormatted && ` - ${endDateFormatted}`} {timezone} - - )} - + + {startDateFormatted} + {endDateFormatted && ` - ${endDateFormatted}`} {timezone} + ); } diff --git a/frontend/src/components/common/HomepageInfoMessage/HomepageInfoMessage.module.scss b/frontend/src/components/common/HomepageInfoMessage/HomepageInfoMessage.module.scss index 43d248c987..9c37659aa2 100644 --- a/frontend/src/components/common/HomepageInfoMessage/HomepageInfoMessage.module.scss +++ b/frontend/src/components/common/HomepageInfoMessage/HomepageInfoMessage.module.scss @@ -1,28 +1,93 @@ -.checkoutStatus { +@use "../../../styles/mixins"; + +.container { display: flex; + flex-direction: column; + align-items: center; justify-content: center; + min-height: calc(100vh - 200px); + padding: 20px; +} + +.card { + width: 100%; + max-width: 420px; + background: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 16px; + padding: 40px 32px; + text-align: center; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); + + @include mixins.respond-below(sm) { + padding: 32px 24px; + } +} + +.emojiContainer { + width: 80px; + height: 80px; + border-radius: 50%; + display: flex; align-items: center; - flex-direction: column; - margin-top: 100px; + justify-content: center; + margin: 0 auto 24px; + background: var(--mantine-primary-color-light); - h3 { - text-align: center; - font-weight: 100; + @include mixins.respond-below(sm) { + width: 64px; + height: 64px; } +} + +.emoji { + font-size: 36px; + line-height: 1; - .iconContainer { - background-color: #fff; - border-radius: 130px; - border: 2px solid var(--hi-primary); - width: 180px; - height: 180px; - display: flex; - justify-content: center; - align-items: center; - - img { - width: 140px; - } + @include mixins.respond-below(sm) { + font-size: 28px; } } +.title { + font-size: 20px; + font-weight: 600; + color: #111827; + margin: 0 0 8px 0; + line-height: 1.4; + + @include mixins.respond-below(sm) { + font-size: 18px; + } +} + +.subtitle { + font-size: 15px; + color: #6B7280; + margin: 0 0 24px 0; + line-height: 1.5; +} + +.button { + width: 100%; + height: 44px; + border-radius: 10px; + font-weight: 500; + font-size: 15px; + margin-top: 16px; +} + +.helpText { + margin-top: 24px; + font-size: 14px; + color: #6B7280; +} + +.helpLink { + color: var(--mantine-primary-color-filled); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} diff --git a/frontend/src/components/common/HomepageInfoMessage/index.tsx b/frontend/src/components/common/HomepageInfoMessage/index.tsx index 8e29937c10..ab53342a78 100644 --- a/frontend/src/components/common/HomepageInfoMessage/index.tsx +++ b/frontend/src/components/common/HomepageInfoMessage/index.tsx @@ -1,36 +1,77 @@ -import classes from './HomepageInfoMessage.module.scss'; import {Button} from "@mantine/core"; -import {IconLink} from "@tabler/icons-react"; +import {IconArrowRight} from "@tabler/icons-react"; +import classes from './HomepageInfoMessage.module.scss'; import React from "react"; -interface CheckoutStatusProps { - message: React.ReactNode +type StatusType = + | 'info' + | 'processing' + | 'success' + | 'warning' + | 'error' + | 'expired' + | 'cancelled' + | 'not_found' + | 'awaiting_payment' + | 'offline_payment'; + +const getStatusEmoji = (status: StatusType): string => { + const emojis: Record = { + info: '💬', + processing: '⏳', + success: '🎉', + warning: '⚠️', + error: '❌', + expired: '⏰', + cancelled: '😔', + not_found: '🔍', + awaiting_payment: '💳', + offline_payment: '🏦', + }; + return emojis[status] || emojis.info; +}; + +interface HomepageInfoMessageProps { + message: React.ReactNode; + subtitle?: string; link?: string; linkText?: string; - iconType?: 'info' | 'processing'; + status?: StatusType; } -export const HomepageInfoMessage = ({message, link, linkText, iconType = 'info'}: CheckoutStatusProps) => { - const icon = () => { - if (iconType === 'info') { - return '/info-icon.svg'; - } - if (iconType === 'processing') { - return '/stopwatch-ticket-icon.svg'; - } - } +export const HomepageInfoMessage = ({ + message, + subtitle, + link, + linkText, + status = 'info', + }: HomepageInfoMessageProps) => { + const emoji = getStatusEmoji(status); return ( -
-
- {''} +
+
+
+ {emoji} +
+ +

{message}

+ + {subtitle && ( +

{subtitle}

+ )} + + {(link && linkText) && ( + + )}
-

{message}

- {(link && linkText) && ( - - )}
); -} +}; diff --git a/frontend/src/components/common/InlineOrderSummary/InlineOrderSummary.module.scss b/frontend/src/components/common/InlineOrderSummary/InlineOrderSummary.module.scss new file mode 100644 index 0000000000..cdec29ed48 --- /dev/null +++ b/frontend/src/components/common/InlineOrderSummary/InlineOrderSummary.module.scss @@ -0,0 +1,294 @@ +@use "../../../styles/mixins"; + +.inlineOrderSummary { + background: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 12px; + margin-bottom: 24px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); + overflow: visible; +} + +// Header +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + cursor: pointer; + user-select: none; + transition: background-color 0.15s ease; +} + +.headerTitle { + font-weight: 600; + font-size: 16px; + color: #111827; +} + +.headerRight { + display: flex; + align-items: center; + gap: 12px; +} + +.headerTotal { + font-weight: 600; + font-size: 16px; + color: #111827; +} + +.chevron { + color: #6B7280; + transition: transform 0.15s ease; +} + +.chevronRotated { + transform: rotate(180deg); +} + +// Content +.content { + padding: 0 20px 20px 20px; + border-top: 1px solid #E5E7EB; +} + +// Event Info +.eventInfo { + display: flex; + gap: 12px; + padding: 16px 0; +} + +.eventImage { + width: 56px; + height: 56px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.eventImagePlaceholder { + width: 100%; + height: 100%; + background: #F3F4F6; + display: flex; + align-items: center; + justify-content: center; + color: #9CA3AF; +} + +.eventDetails { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; +} + +.eventTitle { + font-weight: 500; + font-size: 14px; + color: #111827; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.eventMeta { + font-size: 13px; + color: #6B7280; + margin-top: 2px; + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +// Divider +.divider { + height: 1px; + background-color: #F3F4F6; +} + +// Line Items +.lineItems { + padding: 16px 0; +} + +.lineItem { + display: flex; + justify-content: space-between; + align-items: flex-start; + + &:not(:last-child) { + margin-bottom: 12px; + } +} + +.lineItemLeft { + display: flex; + align-items: baseline; + gap: 6px; + flex: 1; + min-width: 0; +} + +.lineItemName { + font-size: 14px; + color: #374151; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lineItemQuantity { + font-size: 14px; + color: #6B7280; + flex-shrink: 0; +} + +.lineItemPriceWrapper { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: 16px; +} + +.lineItemPriceOriginal { + font-size: 14px; + color: #9CA3AF; + text-decoration: line-through; +} + +.lineItemPrice { + font-size: 14px; + color: #111827; + font-weight: 500; +} + +// Promo Code +.promoCode { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #FEF3C7; + border: 1px solid #FDE68A; + border-radius: 8px; + padding: 10px 12px; + margin: 16px 0; +} + +.promoCodeLeft { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + color: #92400E; +} + +.promoCodeDiscount { + font-size: 14px; + font-weight: 600; + color: #059669; +} + +// Totals +.totals { + padding: 16px 0 0 0; +} + +.totalsRow { + display: flex; + justify-content: space-between; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } +} + +.totalsLabel { + font-size: 14px; + color: #6B7280; +} + +.totalsValue { + font-size: 14px; + color: #111827; +} + +.totalsValueFree { + color: #10B981; +} + +.totalsRowFinal { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #F3F4F6; +} + +.totalsFinalLabel { + font-size: 16px; + font-weight: 600; + color: #111827; +} + +.totalsFinalValue { + font-size: 18px; + font-weight: 600; + color: #111827; +} + +.totalsCurrency { + font-size: 14px; + font-weight: 400; + color: #6B7280; + margin-left: 4px; +} + +// Buyer Protection +.buyerProtection { + display: flex; + align-items: flex-start; + gap: 12px; + background-color: #ECFDF5; + border: 1px solid #D1FAE5; + border-radius: 8px; + padding: 12px 14px; + margin-top: 16px; +} + +.buyerProtectionIcon { + color: #059669; + flex-shrink: 0; + margin-top: 1px; +} + +.buyerProtectionText { + display: flex; + flex-direction: column; + gap: 2px; +} + +.buyerProtectionTitle { + font-size: 14px; + font-weight: 500; + color: #065F46; +} + +.buyerProtectionSubtitle { + font-size: 12px; + color: #047857; +} diff --git a/frontend/src/components/common/InlineOrderSummary/index.tsx b/frontend/src/components/common/InlineOrderSummary/index.tsx new file mode 100644 index 0000000000..1c7ada2ac0 --- /dev/null +++ b/frontend/src/components/common/InlineOrderSummary/index.tsx @@ -0,0 +1,179 @@ +import {useState} from "react"; +import {Collapse} from "@mantine/core"; +import {IconCalendarEvent, IconChevronDown, IconShieldCheck, IconTag} from "@tabler/icons-react"; +import {t} from "@lingui/macro"; +import classNames from "classnames"; +import {Event, Order} from "../../../types.ts"; +import {formatCurrency} from "../../../utilites/currency.ts"; +import {prettyDate} from "../../../utilites/dates.ts"; +import classes from './InlineOrderSummary.module.scss'; + +interface InlineOrderSummaryProps { + event: Event; + order: Order; + defaultExpanded?: boolean; + showBuyerProtection?: boolean; +} + +export const InlineOrderSummary = ({ + event, + order, + defaultExpanded = true, + showBuyerProtection = true, +}: InlineOrderSummaryProps) => { + const [expanded, setExpanded] = useState(defaultExpanded); + + const totalAmount = order.total_refunded + ? order.total_gross - order.total_refunded + : order.total_gross; + + const coverImage = event?.images?.find((image) => image.type === 'EVENT_COVER'); + const location = event?.settings?.location_details?.city || + event?.settings?.location_details?.venue_name || + null; + + const totalFee = order.taxes_and_fees_rollup?.fees?.reduce((sum, fee) => sum + fee.value, 0) || 0; + const totalTax = order.taxes_and_fees_rollup?.taxes?.reduce((sum, tax) => sum + tax.value, 0) || 0; + const totalDiscount = order.order_items?.reduce((sum, item) => { + if (item.total_before_discount && item.total_before_additions) { + return sum + (item.total_before_discount - item.total_before_additions); + } + return sum; + }, 0) || 0; + + return ( +
+
setExpanded(!expanded)} + role="button" + aria-expanded={expanded} + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && setExpanded(!expanded)} + > + {t`Order Summary`} +
+ + {formatCurrency(totalAmount, order.currency)} {order.currency} + + +
+
+ + +
+
+
+ {coverImage ? ( + {event.title}/ + ) : ( +
+ +
+ )} +
+
+
{event.title}
+
+ {prettyDate(event.start_date, event.timezone, false)} +
+ {location && ( +
{location}
+ )} +
+
+ +
+ +
+ {order.order_items?.map((item) => ( +
+
+ {item.item_name} + {/* eslint-disable-next-line lingui/no-unlocalized-strings */} + × {item.quantity} +
+
+ {!!item.price_before_discount && ( + + {formatCurrency(item.price_before_discount * item.quantity, order.currency)} + + )} + + {formatCurrency(item.price * item.quantity, order.currency)} + +
+
+ ))} +
+ + {order.promo_code && totalDiscount > 0 && ( +
+
+ + {order.promo_code} +
+ + -{formatCurrency(totalDiscount, order.currency)} + +
+ )} + +
+ +
+
+ {t`Subtotal`} + + {formatCurrency(order.total_before_additions, order.currency)} + +
+ +
+ {t`Fees`} + + {formatCurrency(totalFee, order.currency)} + +
+ + {totalTax > 0 && ( +
+ {t`Taxes`} + + {formatCurrency(totalTax, order.currency)} + +
+ )} + +
+ {t`Total`} + + {formatCurrency(totalAmount, order.currency)} + {order.currency} + +
+
+ + {showBuyerProtection && order.is_payment_required && ( +
+ +
+ {t`Secure Checkout`} + + {t`Your payment is protected with bank-level encryption`} + +
+
+ )} +
+ +
+ ); +}; diff --git a/frontend/src/components/common/ProductsTable/ProductsTable.module.scss b/frontend/src/components/common/ProductsTable/ProductsTable.module.scss index 8ef3a901ce..cd24749463 100644 --- a/frontend/src/components/common/ProductsTable/ProductsTable.module.scss +++ b/frontend/src/components/common/ProductsTable/ProductsTable.module.scss @@ -1,26 +1,53 @@ @use "../../../styles/mixins.scss"; +// ========================================== +// Variables & Design Tokens +// ========================================== + +$card-radius: var(--hi-radius-md, 8px); +$card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06); +$card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04); +$transition-fast: 150ms ease; +$transition-base: 200ms ease; + + +$color-text-primary: #1f2937; +$color-text-secondary: #6b7280; +$color-text-muted: #9ca3af; +$color-border: #e5e7eb; +$color-border-light: #f3f4f6; +$color-success: #10b981; +$color-money: #059669; + +// ========================================== +// Category Container Styles +// ========================================== + .sortableCategory { - margin-bottom: 20px; - transition: transform 250ms ease; - border-radius: var(--hi-radius-sm); + margin-bottom: 24px; + transition: transform $transition-base, box-shadow $transition-base; + border-radius: $card-radius; padding: var(--hi-spacing-lg); - box-shadow: 0 3px 0 #dddddd; - border: 1px solid #e3e3e3; + box-shadow: $card-shadow; + border: 1px solid $color-border; background-color: #ffffff; + + &:hover { + box-shadow: $card-shadow-hover; + } } .categoryHeader { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 10px; - gap: 10px; + margin-bottom: 16px; + gap: 12px; } .categoryActions { display: flex; - gap: 5px; + gap: 8px; } .categoryAction { @@ -31,11 +58,19 @@ margin: 0; display: flex; align-items: center; - gap: 10px; + gap: 12px; + font-weight: 600; + color: $color-text-primary; } .categoryDragHandle { cursor: grab; + color: $color-text-muted; + transition: color $transition-fast; + + &:hover { + color: $color-text-secondary; + } &:active { cursor: grabbing; @@ -49,7 +84,7 @@ .dragHandleDisabled { cursor: not-allowed; - opacity: 0.5; + opacity: 0.4; } .categoryContent { @@ -57,202 +92,427 @@ } .isOver { - background-color: #efefef; + background-color: $color-border-light; } .isDragging { - opacity: 0.5; + opacity: 0.6; z-index: 1000; } .dragOverlay { .sortableCategory, .productCard { - transform: scale(1.05); - box-shadow: 0 0 20px rgba(0, 0, 0, 0.2); + transform: scale(1.02); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); } } .cards { display: flex; flex-direction: column; + gap: 12px; } +// ========================================== +// Product Card Styles +// ========================================== + .productCard { + position: relative; box-sizing: border-box; - border-radius: var(--hi-radius-sm); - border: 1px solid #e3e3e3; + border-radius: $card-radius; + border: 1px solid $color-border; 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; + padding: 0; + overflow: hidden; + transition: + transform $transition-base, + box-shadow $transition-base, + border-color $transition-base; + grid-template-areas: "sort content action"; + grid-template-columns: 44px 1fr auto; + + &:hover { + box-shadow: $card-shadow-hover; + border-color: darken($color-border, 5%); + + .actionButton { + background-color: $color-border-light; + } + } @include mixins.respond-below(lg) { grid-template-areas: - "dragHanlde productInfo" - "dragHanlde action"; + "sort content" + "sort action"; + grid-template-columns: 40px 1fr; } - .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 mixins.respond-below(sm) { + grid-template-areas: + "content" + "action"; + grid-template-columns: 1fr; + + .sortControls { + display: none; + } + } + + &.soldOut { + opacity: 0.7; + + &::after { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 10px, + rgba(0, 0, 0, 0.02) 10px, + rgba(0, 0, 0, 0.02) 20px + ); + pointer-events: none; + } + } +} + + +// Sort controls +.sortControls { + grid-area: sort; + display: flex; + justify-content: center; + align-items: center; + padding: 12px 0; + border-right: 1px solid $color-border-light; + background-color: #fafafa; +} + +// Main content area +.productContent { + grid-area: content; + display: flex; + flex-direction: column; + padding: 16px 20px; + gap: 14px; + min-width: 0; + + @include mixins.respond-below(sm) { + padding: 14px 16px; + gap: 12px; } +} + +// Header section +.productHeader { + display: flex; + flex-direction: column; + gap: 8px; +} + +.badgeRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} - .halfCircle.right { - left: auto; - right: -6px; - transform: rotate(270deg); +.typeBadges { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.productTitle { + margin: 0; + font-size: 16px; + font-weight: 600; + color: $color-text-primary; + line-height: 1.4; + + @include mixins.respond-below(sm) { + font-size: 15px; } +} + +.statusBadge { + flex-shrink: 0; + font-weight: 500; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.5px; +} + +// Details grid +.detailsGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; - .dragHandle { - display: flex; - justify-content: center; - align-items: center; - cursor: move; - grid-area: dragHanlde; - touch-action: none; + @include mixins.respond-below(lg) { + grid-template-columns: repeat(3, 1fr); + gap: 16px; } - .dragHandleDisabled { - cursor: not-allowed; - opacity: 0.5; + @include mixins.respond-below(md) { + grid-template-columns: repeat(2, 1fr); + gap: 14px; } - .productInfo { - grid-area: productInfo; - display: flex; - - .productDetails { - display: grid; - width: 100%; - align-items: center; - gap: 15px; - flex-wrap: wrap; - grid-template-columns: 1fr 1fr 1fr 1fr; - - @include mixins.respond-below(lg) { - flex-direction: column; - align-items: flex-start; - grid-template-columns: 1fr 1fr; - gap: 20px; - } - - @include mixins.respond-below(sm) { - gap: 10px; - } - - @include mixins.respond-below(xs) { - gap: 20px; - grid-template-columns: 1fr; - } - - > div { - flex: 1; - min-width: 125px; - - @include mixins.respond-below(sm) { - min-width: 100px; - } - } - - .heading { - text-transform: uppercase; - color: #9ca3af; - font-size: 0.8em; - } - - .status { - max-width: 120px; - cursor: pointer; - } - - .title { - text-wrap: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .price { - color: var(--hi-color-money-green); - - .priceAmount { - font-weight: 600; - text-wrap: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .availability { - } - } + @include mixins.respond-below(xs) { + grid-template-columns: 1fr; + gap: 12px; } +} - .action { - display: flex; - grid-area: action; +.detailItem { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} - @include mixins.respond-below(lg) { - margin-top: 10px; - } +.detailLabel { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: $color-text-muted; +} - .desktopAction { - @include mixins.respond-below(lg) { - display: none; - } - } +// Price styling +.priceValue { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} - .mobileAction { - display: none; - @include mixins.respond-below(lg) { - display: block; - } - } +.priceAmount { + font-size: 16px; + font-weight: 700; + color: $color-money; + font-variant-numeric: tabular-nums; + + @include mixins.respond-below(sm) { + font-size: 15px; } } +.freePrice { + color: $color-success; +} + +.taxIndicator { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background-color: #fef3c7; + color: #92400e; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + cursor: help; + transition: background-color $transition-fast; + + &:hover { + background-color: #fde68a; + } + + svg { + flex-shrink: 0; + } +} + +// Sales styling +.salesValue { + display: flex; + flex-direction: column; + gap: 4px; +} + +.salesWithProgress { + display: flex; + flex-direction: column; + gap: 6px; +} + +.salesCount { + font-size: 16px; + font-weight: 600; + color: $color-text-primary; + display: flex; + align-items: baseline; + gap: 4px; + + @include mixins.respond-below(sm) { + font-size: 15px; + } +} + +.salesTotal { + font-size: 13px; + font-weight: 400; + color: $color-text-muted; +} + +.unlimited { + font-size: 12px; + font-weight: 400; + color: $color-text-muted; + margin-left: 4px; +} + +.salesProgress { + max-width: 120px; + + @include mixins.respond-below(sm) { + max-width: 100%; + } +} + +.lowStock { + font-size: 11px; + font-weight: 500; + color: #ea580c; + display: flex; + align-items: center; + gap: 4px; + + &::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background-color: #ea580c; + animation: pulse 2s infinite; + } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +// Date styling +.dateValue { + display: flex; + align-items: center; +} + +.dateRange { + display: flex; + flex-direction: column; + gap: 2px; +} + +.dateItem { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: $color-text-secondary; + cursor: help; + + svg { + color: $color-text-muted; + flex-shrink: 0; + } +} + +.alwaysAvailable, +.noEndDate { + font-size: 13px; + color: $color-text-muted; + font-style: italic; +} + +// Action section +.actionSection { + grid-area: action; + display: flex; + align-items: center; + justify-content: center; + padding: 0 12px; + border-left: 1px solid $color-border-light; + background-color: #fafafa; + + @include mixins.respond-below(lg) { + border-left: none; + border-top: 1px solid $color-border-light; + padding: 12px 16px; + justify-content: flex-start; + } +} + +.actionButton { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + border-radius: 6px; + transition: background-color $transition-fast; + + @include mixins.respond-below(lg) { + padding: 6px 12px; + } +} + +.actionButtonText { + @include mixins.respond-above(lg) { + display: none; + } +} + +.actionButtonIcon { + @include mixins.respond-below(lg) { + display: none; + } +} + + +// ========================================== +// Drag Preview +// ========================================== + .dragPreview { background-color: #fff; - border: 1px solid #e3e3e3; - border-radius: var(--hi-radius-sm); - padding: 10px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + border: 1px solid $color-border; + border-radius: $card-radius; + padding: 12px 16px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); h3 { - margin: 0 0 5px; + margin: 0 0 4px; + font-size: 14px; + font-weight: 600; } p { margin: 0; - color: #666; + color: $color-text-secondary; + font-size: 13px; } } .moreProducts { - background-color: #f0f0f0; - border-radius: var(--hi-radius-sm); - padding: 10px; - margin-top: 10px; - font-size: 14px; - color: #666; + background-color: $color-border-light; + border-radius: $card-radius; + padding: 12px 16px; + margin-top: 8px; + font-size: 13px; + color: $color-text-secondary; text-align: center; } .isDragging { - opacity: 0.6; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + opacity: 0.5; + box-shadow: $card-shadow; } diff --git a/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx b/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx index 352bdeb5ce..e7723f71c7 100644 --- a/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx +++ b/frontend/src/components/common/ProductsTable/SortableProduct/index.tsx @@ -1,10 +1,23 @@ import {useState} from 'react'; -import {IconCopyPlus, IconDotsVertical, IconEyeOff, IconPencil, IconSend, IconTrash} from "@tabler/icons-react"; +import { + IconCalendarEvent, + IconClock, + IconCopyPlus, + IconDotsVertical, + IconEyeOff, + IconLock, + IconPackage, + IconPencil, + IconReceipt, + IconSend, + IconTicket, + 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 {Badge, Button, Group, Menu, Progress, Tooltip} from "@mantine/core"; import Truncate from "../../Truncate"; -import {t} from "@lingui/macro"; +import {t, Trans} from "@lingui/macro"; import {relativeDate} from "../../../../utilites/dates.ts"; import {formatCurrency} from "../../../../utilites/currency.ts"; import { @@ -62,37 +75,43 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S }); } - const getProductStatus = (product: Product) => { + const getStatusInfo = (product: Product) => { if (product.is_sold_out) { - return t`Sold Out`; + return {label: t`Sold Out`, color: 'red', variant: 'filled' as const}; } - if (product.is_before_sale_start_date) { - return t`On sale` + ' ' + relativeDate(product.sale_start_date as string); + return {label: t`Scheduled`, color: 'blue', variant: 'light' as const}; } - if (product.is_after_sale_end_date) { - return t`Sale ended ` + ' ' + relativeDate(product.sale_end_date as string); + return {label: t`Ended`, color: 'gray', variant: 'light' as const}; } - if (product.is_hidden) { - return t`Hidden from public view`; + return {label: t`Hidden`, color: 'gray', variant: 'outline' as const}; } + return product.is_available + ? {label: t`On Sale`, color: 'green', variant: 'light' as const} + : {label: t`Paused`, color: 'orange', variant: 'light' as const}; + } - return product.is_available ? t`On Sale` : t`Not On Sale`; + const getStatusTooltip = (product: Product) => { + if (product.is_sold_out) return t`This product is sold out`; + if (product.is_before_sale_start_date) return t`On sale ${relativeDate(product.sale_start_date as string)}`; + if (product.is_after_sale_end_date) return t`Sale ended ${relativeDate(product.sale_end_date as string)}`; + if (product.is_hidden) return t`Hidden from public view`; + return product.is_available ? t`Currently available for purchase` : t`Sales are paused`; } const getPriceRange = (product: Product) => { const productPrices: ProductPrice[] = product.prices as ProductPrice[]; if (!Array.isArray(productPrices) || productPrices.length === 0) { - return t`Price not set`; + return {display: t`Price not set`, isFree: false}; } if (product.type !== ProductPriceType.Tiered) { if (productPrices[0].price <= 0) { - return t`Free`; + return {display: t`Free`, isFree: true}; } - return formatCurrency(productPrices[0].price, currencyCode); + return {display: formatCurrency(productPrices[0].price, currencyCode), isFree: false}; } const prices = productPrices.map(productPrice => productPrice.price); @@ -100,20 +119,54 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S const maxPrice = Math.max(...prices); if (minPrice <= 0 && maxPrice <= 0) { - return t`Free`; + return {display: t`Free`, isFree: true}; } if (minPrice === maxPrice) { - return formatCurrency(minPrice, currencyCode); + return {display: formatCurrency(minPrice, currencyCode), isFree: false}; } - return `${formatCurrency(minPrice, currencyCode)} - ${formatCurrency(maxPrice, currencyCode)}`; + return { + display: `${formatCurrency(minPrice, currencyCode)} – ${formatCurrency(maxPrice, currencyCode)}`, + isFree: false + }; + } + + const hasTaxesOrFees = () => { + return (product.taxes_and_fees && product.taxes_and_fees.length > 0) || + (product.tax_and_fee_ids && product.tax_and_fee_ids.length > 0); + } + + const getTaxFeeTooltip = () => { + if (!product.taxes_and_fees || product.taxes_and_fees.length === 0) { + return t`Taxes & fees applied`; + } + return product.taxes_and_fees.map(tf => tf.name).join(', '); + } + + const getSalesProgress = () => { + const sold = Number(product.quantity_sold) || 0; + const initial = product.initial_quantity_available; + + if (!initial || initial <= 0) { + return null; // Unlimited + } + + const percentage = Math.min((sold / initial) * 100, 100); + const remaining = initial - sold; + + return { + sold, + total: initial, + remaining, + percentage, + isLow: remaining > 0 && remaining <= 10, + }; } const handleSort = (productId: IdParam, direction: 'up' | 'down') => { if (!category?.products?.length || !product.event_id) return; - // Find current category index in all categories const categoryIndex = categories.findIndex(cat => cat.id === category.id); const currentIndex = category.products.findIndex(p => p.id === productId); @@ -121,13 +174,11 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S let updatedCategories = [...categories]; - // Handle moving to different category if ((direction === 'up' && currentIndex === 0) || (direction === 'down' && currentIndex === category.products.length - 1)) { const targetCategoryIndex = direction === 'up' ? categoryIndex - 1 : categoryIndex + 1; - // Check if target category exists if (targetCategoryIndex < 0 || targetCategoryIndex >= categories.length) return; const sourceProducts = [...category.products]; @@ -136,7 +187,6 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S const targetCategory = categories[targetCategoryIndex]; const targetProducts = [...(targetCategory.products || [])]; - // Insert at end if moving up, start if moving down const targetPosition = direction === 'up' ? targetProducts.length : 0; targetProducts.splice(targetPosition, 0, movedProduct); @@ -150,7 +200,6 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S return cat; }); } else { - // Handle moving within same category const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; if (newIndex < 0 || newIndex >= category.products.length) return; @@ -188,10 +237,19 @@ export const SortableProduct = ({product, currencyCode, category, categories}: S const canMoveDown = currentIndex < currentProducts.length - 1 || currentCategoryIndex < categories.length - 1; + const isTicket = product.product_type === ProductType.Ticket; + const statusInfo = getStatusInfo(product); + const priceInfo = getPriceRange(product); + const salesProgress = getSalesProgress(); + return ( <> -
-
+
+ {/* Sort controls */} +
-
-
-
-
{t`Title`}
- - {(product.is_hidden_without_promo_code || product.is_hidden) && ( - - - - - - {product.is_hidden - ? t`This product is hidden from public view` - : t`This product is hidden unless targeted by a Promo Code`} - - - )} -
-
-
{t`Status`}
- - - - {product.is_available ? t`On Sale` : t`Not On Sale`} + + {/* Main content */} +
+ {/* Header row with badges */} +
+
+
+ {isTicket ? ( + } + variant="light" + color="violet" + size="sm" + > + {t`Ticket`} + + ) : ( + } + variant="light" + color="cyan" + size="sm" + > + {t`Product`} + + )} + {product.type === ProductPriceType.Donation && ( + + {t`Donation`} - - - {getProductStatus(product)} - - + )} + {(product.is_hidden_without_promo_code || product.is_hidden) && ( + + : } + > + {product.is_hidden_without_promo_code ? t`Promo Only` : t`Hidden`} + + + )} +
+ + + {statusInfo.label} + +
-
-
{t`Price`}
-
- {getPriceRange(product)} +

+ +

+
+ + {/* Details grid */} +
+ {/* Price */} +
+ {t`Price`} +
+ + {priceInfo.display} + + {hasTaxesOrFees() && ( + +
+ + {t`+Tax/Fees`} +
+
+ )}
-
-
- {product.product_type === ProductType.Ticket ? t`Attendees` : t`Quantity Sold`} + + {/* Sales / Quantity */} +
+ + {isTicket ? t`Attendees` : t`Sold`} + +
+ {salesProgress ? ( +
+ + {salesProgress.sold} + / {salesProgress.total} + + = 100 ? 'red' : salesProgress.isLow ? 'orange' : 'green'} + className={classes.salesProgress} + /> + {salesProgress.isLow && salesProgress.remaining > 0 && ( + + {t`${salesProgress.remaining} left`} + + )} +
+ ) : ( + + {Number(product.quantity_sold)} + {t`Unlimited`} + + )} +
+
+ + {/* Sale period */} +
+ {t`Sale Period`} +
+ {product.sale_start_date || product.sale_end_date ? ( +
+ {product.is_before_sale_start_date && product.sale_start_date && ( + +
+ + {relativeDate(product.sale_start_date as string)} +
+
+ )} + {!product.is_before_sale_start_date && product.sale_end_date && ( + +
+ + {product.is_after_sale_end_date ? t`Ended` : relativeDate(product.sale_end_date as string)} +
+
+ )} + {!product.is_before_sale_start_date && !product.sale_end_date && ( + {t`No end date`} + )} +
+ ) : ( + {t`Always available`} + )}
- {Number(product.quantity_sold)}
-
+ + {/* Actions */} +
- +
-
- -
-
- -
+
{t`Actions`} - {product.product_type === ProductType.Ticket && ( + {isTicket && ( handleModalClick(product.id, messageModal)} - leftSection={}> + leftSection={} + > {t`Message Attendees`} )} handleModalClick(product.id, editModal)} - leftSection={}> - {t`Edit Product`} + leftSection={} + > + Edit {isTicket ? t`Ticket` : t`Product`} handleModalClick(product.id, duplicateModal)} - leftSection={}> - {t`Duplicate Product`} + leftSection={} + > + {t`Duplicate`} + + {t`Danger zone`} handleDeleteProduct(product.id, product.event_id)} color="red" - leftSection={}> - {t`Delete product`} + leftSection={} + > + {t`Delete`}
- {product.product_type === ProductType.Ticket && ( - <> -
-
- - )} -
+ {isDuplicateModalOpen && } {isEditModalOpen && } diff --git a/frontend/src/components/common/ProgressStepper/ProgressStepper.module.scss b/frontend/src/components/common/ProgressStepper/ProgressStepper.module.scss new file mode 100644 index 0000000000..c40c1b4d9f --- /dev/null +++ b/frontend/src/components/common/ProgressStepper/ProgressStepper.module.scss @@ -0,0 +1,71 @@ +@use "../../../styles/mixins"; + +.stepper { + display: flex; + align-items: center; + justify-content: center; + gap: 0; +} + +.stepContainer { + display: flex; + align-items: center; +} + +.stepItem { + display: flex; + align-items: center; + gap: 8px; +} + +.circle { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + background-color: #E5E7EB; + color: #6B7280; + transition: all 0.15s ease; + + &.active { + background-color: var(--mantine-primary-color-filled); + color: #ffffff; + } +} + +.label { + font-size: 14px; + font-weight: 500; + color: #6B7280; + transition: color 0.15s ease; + + @include mixins.respond-below(sm) { + display: none; + } + + &.activeLabel { + color: #111827; + } +} + +.connector { + width: 40px; + height: 2px; + background-color: #D1D5DB; + margin: 0 8px; + border-radius: 1px; + transition: background-color 0.15s ease; + + @include mixins.respond-below(sm) { + width: 28px; + margin: 0 6px; + } + + &.activeConnector { + background-color: var(--mantine-primary-color-filled); + } +} diff --git a/frontend/src/components/common/ProgressStepper/index.tsx b/frontend/src/components/common/ProgressStepper/index.tsx new file mode 100644 index 0000000000..ee0d431258 --- /dev/null +++ b/frontend/src/components/common/ProgressStepper/index.tsx @@ -0,0 +1,59 @@ +import {t} from "@lingui/macro"; +import {IconCheck} from "@tabler/icons-react"; +import classes from './ProgressStepper.module.scss'; + +interface Step { + label: string; + key: string; +} + +interface ProgressStepperProps { + isPaymentRequired: boolean; + currentStep: 'details' | 'payment' | 'summary'; +} + +export const ProgressStepper = ({isPaymentRequired, currentStep}: ProgressStepperProps) => { + const steps: Step[] = isPaymentRequired + ? [ + {label: t`Details`, key: 'details'}, + {label: t`Payment`, key: 'payment'}, + {label: t`Summary`, key: 'summary'}, + ] + : [ + {label: t`Details`, key: 'details'}, + {label: t`Summary`, key: 'summary'}, + ]; + + const currentStepIndex = steps.findIndex(step => step.key === currentStep); + + return ( +
+ {steps.map((step, index) => { + const isCompleted = index < currentStepIndex; + const isActive = index === currentStepIndex; + + return ( +
+
+
+ {isCompleted ? ( + + ) : ( + {index + 1} + )} +
+ + {step.label} + +
+ {index < steps.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/frontend/src/components/common/QuestionsTable/QuestionsTable.module.scss b/frontend/src/components/common/QuestionsTable/QuestionsTable.module.scss index b63fdff4d9..81d4b962fb 100644 --- a/frontend/src/components/common/QuestionsTable/QuestionsTable.module.scss +++ b/frontend/src/components/common/QuestionsTable/QuestionsTable.module.scss @@ -66,6 +66,12 @@ width: 100%; height: 100%; } + + .perOrderNote { + color: var(--mantine-color-dimmed); + font-style: italic; + margin: 10px 0; + } } } } diff --git a/frontend/src/components/common/QuestionsTable/index.tsx b/frontend/src/components/common/QuestionsTable/index.tsx index 445708fd1c..1350cd13e3 100644 --- a/frontend/src/components/common/QuestionsTable/index.tsx +++ b/frontend/src/components/common/QuestionsTable/index.tsx @@ -43,6 +43,7 @@ import {useSortQuestions} from "../../../mutations/useSortQuestions.ts"; import classNames from "classnames"; import {Popover} from "../Popover"; import {useExportAnswers} from "../../../mutations/useExportAnswers.ts"; +import {useGetEventSettings} from "../../../queries/useGetEventSettings.ts"; interface QuestionsTableProp { questions: Partial[]; @@ -214,16 +215,25 @@ const DefaultQuestions = () => ( /> - + + + + ); export const QuestionsTable = ({questions}: QuestionsTableProp) => { + const {eventId} = useParams(); const productQuestions = questions.filter(question => question.belongs_to === "PRODUCT"); const orderQuestions = questions.filter(question => question.belongs_to === "ORDER"); const form = useForm(); @@ -231,6 +241,8 @@ export const QuestionsTable = ({questions}: QuestionsTableProp) => { const [editModalOpen, {open: openEditModal, close: closeEditModal}] = useDisclosure(false); const [questionId, setQuestionId] = useState(); const [showHiddenQuestions, setShowHiddenQuestions] = useState(false); + const eventSettingsQuery = useGetEventSettings(eventId); + const isPerOrderCollection = eventSettingsQuery.data?.attendee_details_collection_method === 'PER_ORDER'; // This disables the input fields in the preview form.getInputProps = (name: string) => ({ @@ -346,7 +358,10 @@ export const QuestionsTable = ({questions}: QuestionsTableProp) => { {t`Preview`} + title={isPerOrderCollection + ? t`Attendee information collection is set to "Per order". Attendee details will be copied from the order information.` + : t`First Name, Last Name, and Email Address are default questions and are always included in the checkout process.` + }> @@ -367,7 +382,7 @@ export const QuestionsTable = ({questions}: QuestionsTableProp) => { ))}

{t`Attendee questions`}

- + {!isPerOrderCollection && } {productQuestions .filter(question => showHiddenQuestions || !question.is_hidden) .map(question => ( @@ -377,6 +392,11 @@ export const QuestionsTable = ({questions}: QuestionsTableProp) => { form={form} /> ))} + {isPerOrderCollection && productQuestions.filter(question => showHiddenQuestions || !question.is_hidden).length === 0 && ( +

+ {t`Attendee details will be copied from order information.`} +

+ )}
diff --git a/frontend/src/components/forms/ProductForm/ProductForm.module.scss b/frontend/src/components/forms/ProductForm/ProductForm.module.scss index 46cf44d98a..01c2dd194b 100644 --- a/frontend/src/components/forms/ProductForm/ProductForm.module.scss +++ b/frontend/src/components/forms/ProductForm/ProductForm.module.scss @@ -1,26 +1,95 @@ .priceTierCard { position: relative; padding: var(--hi-spacing-md) var(--hi-spacing-lg); + margin-bottom: var(--hi-spacing-md); + border-left: 3px solid var(--mantine-color-blue-5); - .mantine-InputWrapper-root { - margin-bottom: 0 !important; - } - - .mantine-SimpleGrid-root { - margin: 0 !important; + h3 { + margin: 0 0 var(--hi-spacing-sm) 0; + font-size: 0.95rem; + font-weight: 600; + color: var(--mantine-color-gray-7); } +} - .removeTier { - position: absolute; - top: 0; - right: 0; - margin: 10px; - cursor: pointer; - } +.removeTier { + position: absolute; + top: 0; + right: 0; + margin: 10px; + cursor: pointer; - .disabled { + &.disabled { svg { color: #b4b4b4; } } } + +.priceTiers { + .addTierButton { + margin-top: var(--hi-spacing-xs); + } +} + +.visibilityOptions { + display: flex; + flex-direction: column; + gap: var(--hi-spacing-sm); +} + +.additionalToggle { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--hi-spacing-xs); + padding: var(--hi-spacing-sm) var(--hi-spacing-md); + margin-top: 0; + margin-bottom: var(--hi-spacing-xl); + cursor: pointer; + background: var(--mantine-color-gray-0); + border: 1px dashed var(--mantine-color-gray-3); + border-radius: var(--mantine-radius-md); + transition: background 0.15s ease, border-color 0.15s ease; + + &:hover { + background: var(--mantine-color-gray-1); + border-color: var(--mantine-color-gray-4); + } + + svg { + flex-shrink: 0; + color: var(--mantine-color-gray-5); + } + + .toggleLabel { + font-size: 0.875rem; + font-weight: 500; + color: var(--mantine-color-gray-6); + } + + .toggleDescription { + flex-basis: 100%; + font-size: 0.8rem; + color: var(--mantine-color-gray-5); + margin-left: calc(16px + var(--hi-spacing-xs)); + margin-top: 2px; + } +} + +.additionalOptionsContent { + display: flex; + flex-direction: column; + gap: var(--hi-spacing-lg); + margin-bottom: var(--hi-spacing-xl); +} + +.fieldsetLegend { + display: inline-flex; + align-items: center; + gap: var(--hi-spacing-xs); + + svg { + flex-shrink: 0; + } +} diff --git a/frontend/src/components/forms/ProductForm/index.tsx b/frontend/src/components/forms/ProductForm/index.tsx index 73a0a677e4..cadb341516 100644 --- a/frontend/src/components/forms/ProductForm/index.tsx +++ b/frontend/src/components/forms/ProductForm/index.tsx @@ -4,11 +4,9 @@ import {Event, Product, ProductPriceType, TaxAndFee, TaxAndFeeCalculationType, T import { ActionIcon, Alert, - Anchor, Button, Collapse, ComboboxItem, - Group, MultiSelect, NumberInput, Select, @@ -16,12 +14,19 @@ import { TextInput } from "@mantine/core"; import { + IconCalendar, IconCash, + IconChevronDown, + IconChevronUp, IconCoinOff, IconCoins, + IconEye, IconHeartDollar, IconInfoCircle, + IconPlus, + IconReceipt, IconShirt, + IconShoppingCart, IconTicket, IconTrash, IconTrashOff, @@ -69,7 +74,8 @@ const ProductPriceTierForm = ({form, product, event}: ProductFormProps) => { { { return ( <> -
- {Number(product?.quantity_sold) > 0 && ( - } mb={20} color={'blue'}> - {t`You cannot change the product type as there are attendees associated with this product.`} - - )} + {Number(product?.quantity_sold) > 0 && ( + } mb={20} color={'blue'}> + {t`You cannot change the product type as there are attendees associated with this product.`} + + )} - 0} - label={t`Product Type`} - required - form={form} - name={'product_type'} - optionList={productTypeOptions} - /> - {form.errors.product_type && ( - - {form.errors.product_type} - - )} + 0} + label={t`Product Type`} + required + form={form} + name={'product_type'} + optionList={productTypeOptions} + /> - 0} - label={t`Price Type`} - required - form={form} - name={'type'} - optionList={productPriceOptions} - /> + {form.errors.product_type && ( + + {form.errors.product_type} + + )} - {form.errors.type && ( - - {form.errors.type} - - )} + 0} + label={t`Price Type`} + required + form={form} + name={'type'} + optionList={productPriceOptions} + /> - {form.values.type === ProductPriceType.Tiered && ( - - - Tiered products allow you to offer multiple price options for the same product. - This is perfect for early bird products, or offering different price - options for different groups of people. - - - )} + {form.errors.type && ( + + {form.errors.type} + + )} - + {form.values.type === ProductPriceType.Tiered && ( + }> + + Tiered products allow you to offer multiple price options for the same product. + This is perfect for early bird products, or offering different price + options for different groups of people. + + + )} - form.setFieldValue('description', value)} - /> + - } + placeholder={t`Select category...`} + data={event?.product_categories?.map((category) => ({ + value: String(category.id), + label: category.name, + }))} + /> + + {form.values.type !== ProductPriceType.Tiered && ( + + +

+ Please enter the price excluding taxes and fees. +

+

+ Taxes and fees can be added below. +

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

+ The number of products available for this product +

+

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

+ + )} + />} + /> +
+ )} {form.values.type === ProductPriceType.Tiered && ( -
- - +
+
+ - +
)} - - {opened ? t`Hide` : t`Show`} {t`Additional Options`} - +
e.key === 'Enter' && toggle()} + > + {opened ? : } + {opened ? t`Hide Additional Options` : t`Show Additional Options`} + {!opened && {t`Taxes, fees, sale period, order limits, and visibility settings`}} +
-
- - {t`The taxes and fees to apply to this product. You can create new taxes and fees on the`}{' '} - {t`Taxes and Fees`} {t`page.`} - {' '} - +
+
+ + {t`Taxes and Fees`} + + }> + + {t`The taxes and fees to apply to this product. You can create new taxes and fees on the`}{' '} + {t`Taxes and Fees`} {t`page.`} + + )} + data={[{ + group: t`Taxes`, + items: taxAndFeeOptions(TaxAndFeeType.Tax), + }, { + group: t`Fees`, + items: taxAndFeeOptions(TaxAndFeeType.Fee), + }]} + /> + + {(form.values.type === ProductPriceType.Free && !!form.values.tax_and_fee_ids?.length) && ( + +

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

+ +
)} - data={[{ - group: t`Taxes`, - items: taxAndFeeOptions(TaxAndFeeType.Tax), - }, { - group: t`Fees`, - items: taxAndFeeOptions(TaxAndFeeType.Fee), - }]} - /> +
- {(form.values.type === ProductPriceType.Free && !!form.values.tax_and_fee_ids?.length) && ( - -

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

- -
- )} +
+ + {t`Order Limits`} + + }> + + + + +
- - - - +
+ + {t`Sale Period`} + + }> + + + + +
- - - - -

- {t`Visibility`} -

- - - - - - {t`You can create a promo code which targets this product on the`} {t`Promo Code page`}} - mt={20} - {...form.getInputProps('is_hidden_without_promo_code', {type: 'checkbox'})} - label={t`Hide product unless user has applicable promo code`} - /> - -
+
+ + {t`Visibility`} + + }> +
+ + + + + + {t`You can create a promo code which targets this product on the`} {t`Promo Code page`}} + {...form.getInputProps('is_hidden_without_promo_code', {type: 'checkbox'})} + label={t`Hide product unless user has applicable promo code`} + /> + +
+
+
); diff --git a/frontend/src/components/forms/StripeCheckoutForm/index.tsx b/frontend/src/components/forms/StripeCheckoutForm/index.tsx index 30dc534714..35bb02b4f0 100644 --- a/frontend/src/components/forms/StripeCheckoutForm/index.tsx +++ b/frontend/src/components/forms/StripeCheckoutForm/index.tsx @@ -90,8 +90,10 @@ export default function StripeCheckoutForm({setSubmitHandler}: { if (order?.payment_status === 'PAYMENT_RECEIVED') { return ( ); @@ -100,8 +102,10 @@ export default function StripeCheckoutForm({setSubmitHandler}: { if (order?.payment_status !== 'AWAITING_PAYMENT' && order?.payment_status !== 'PAYMENT_FAILED') { return ( ); @@ -111,8 +115,8 @@ export default function StripeCheckoutForm({setSubmitHandler}: { layout: { type: "accordion", defaultCollapsed: false, - radios: true, - spacedAccordionItems: true, + radios: false, + spacedAccordionItems: false, }, }; diff --git a/frontend/src/components/layouts/Checkout/Checkout.module.scss b/frontend/src/components/layouts/Checkout/Checkout.module.scss index 76b41531f8..5a7a64871d 100644 --- a/frontend/src/components/layouts/Checkout/Checkout.module.scss +++ b/frontend/src/components/layouts/Checkout/Checkout.module.scss @@ -3,13 +3,7 @@ .container { display: flex; background-color: #3c2b5c05 !important; - height: 100vh; - - .sidebar { - @include mixins.respond-below(md) { - display: none; - } - } + min-height: 100vh; .countdown { color: #0ca678; @@ -19,6 +13,16 @@ color: #ad2f26; } + .timerGroup { + flex-shrink: 0; + } + + .timerLabel { + @include mixins.respond-below(sm) { + display: none; + } + } + .subTitle { font-size: 0.95em; margin-top: 10px; @@ -26,8 +30,6 @@ .mainContent { flex: 1; - border-right: 1px solid #e0e0e0; - //height: 100vh; .header { height: 60px; @@ -52,7 +54,8 @@ h1 { font-size: 1.3em; - width: calc(100vw - 350px); + width: 100%; + max-width: 700px; padding: 0 15px; overflow: hidden; @@ -60,32 +63,9 @@ text-overflow: ellipsis; @include mixins.respond-below(md) { - width: calc(100vw - 60px); font-size: 1em; } } } - - .main { - position: relative; - height: calc(100vh - 90px); - overflow-y: auto; - padding: 20px 60px; - @include mixins.scrollbar(); - - @include mixins.respond-below(md) { - padding: 20px; - } - - .innerContainer { - max-width: 700px; - margin: 0 auto; - } - } - - .footer { - height: 100px; - background-color: #ffffff; - } } } diff --git a/frontend/src/components/layouts/Checkout/CheckoutContent/CheckoutContent.module.scss b/frontend/src/components/layouts/Checkout/CheckoutContent/CheckoutContent.module.scss index a89db2eaea..9bde324e64 100644 --- a/frontend/src/components/layouts/Checkout/CheckoutContent/CheckoutContent.module.scss +++ b/frontend/src/components/layouts/Checkout/CheckoutContent/CheckoutContent.module.scss @@ -2,22 +2,15 @@ .main { position: relative; - height: calc(100vh - 123px); + min-height: calc(100vh - 60px); + height: auto; overflow-y: auto; - padding: 20px 60px; + padding: 20px 60px 40px; @include mixins.scrollbar(); - &.hasFooter { - height: calc(100vh - 60px); - - @include mixins.respond-below(md) { - height: calc(100vh - 110px); - } - } - @include mixins.respond-below(md) { - padding: 20px; + padding: 20px 20px 40px; } .innerContainer { @@ -25,8 +18,3 @@ margin: 0 auto; } } - -.footer { - height: 100px; - background-color: #ffffff; -} diff --git a/frontend/src/components/layouts/Checkout/CheckoutContent/index.tsx b/frontend/src/components/layouts/Checkout/CheckoutContent/index.tsx index d82ae8e29c..afd88527b3 100644 --- a/frontend/src/components/layouts/Checkout/CheckoutContent/index.tsx +++ b/frontend/src/components/layouts/Checkout/CheckoutContent/index.tsx @@ -1,18 +1,16 @@ import classes from "./CheckoutContent.module.scss"; import React from "react"; -import classNames from "classnames"; interface MainContentProps { children: React.ReactNode; - hasFooter?: boolean; } -export const CheckoutContent = ({children, hasFooter}: MainContentProps) => { +export const CheckoutContent = ({children}: MainContentProps) => { return ( -
+
{children}
) -} \ No newline at end of file +} diff --git a/frontend/src/components/layouts/Checkout/CheckoutFooter/CheckoutFooter.module.scss b/frontend/src/components/layouts/Checkout/CheckoutFooter/CheckoutFooter.module.scss deleted file mode 100644 index 38c513e8ef..0000000000 --- a/frontend/src/components/layouts/Checkout/CheckoutFooter/CheckoutFooter.module.scss +++ /dev/null @@ -1,67 +0,0 @@ -@use "../../../../styles/mixins"; - -.sidebar { - width: auto !important; - border-radius: var(--hi-radius-md); - margin: 20px; - overflow: hidden; -} - -.overlay { - background-color: #00000035; - padding: 20px; - box-shadow: 0px -2px 4px rgba(0, 0, 0, 0.1); - position: fixed; - bottom: 0; - left: 0; - right: 0; - z-index: 1; - height: 100vh; - width: 100vw; -} - -.footer { - position: sticky; - bottom: 0; - z-index: 2; - - @include mixins.respond-below(md) { - width: 100%; - } - - .buttons { - display: flex; - gap: 10px; - justify-content: center; - align-items: center; - - background-color: var(--hi-color-white); - padding: 10px; - box-shadow: 0px -5px 5px 0 rgba(0, 0, 0, 0.0392156863); - border-top: 1px solid #e0e0e0; - - .orderSummaryToggle { - display: block; - - @include mixins.respond-above(md) { - display: none; - } - } - - .continueButton { - margin-top: 20px; - flex: 1; - max-width: 700px; - } - - .continueButton { - margin-top: 0; - } - } -} - -.orderComplete { - @include mixins.respond-above(md) { - display: none !important; - } -} diff --git a/frontend/src/components/layouts/Checkout/CheckoutFooter/index.tsx b/frontend/src/components/layouts/Checkout/CheckoutFooter/index.tsx deleted file mode 100644 index d447245de0..0000000000 --- a/frontend/src/components/layouts/Checkout/CheckoutFooter/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import {ActionIcon, Button} from "@mantine/core"; -import {t} from "@lingui/macro"; -import {IconShoppingCartDown, IconShoppingCartUp} from "@tabler/icons-react"; -import classes from "./CheckoutFooter.module.scss"; -import {Event, Order} from "../../../../types.ts"; -import {CheckoutSidebar} from "../CheckoutSidebar"; -import {ReactNode, useState} from "react"; -import classNames from "classnames"; - -interface ContinueButtonProps { - isLoading: boolean; - buttonContent?: ReactNode; - order: Order; - event: Event; - isOrderComplete?: boolean; - onClick?: () => void; -} - -export const CheckoutFooter = ({isLoading, buttonContent, event, order, onClick, isOrderComplete = false}: ContinueButtonProps) => { - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - - return ( - <> - {isSidebarOpen &&
setIsSidebarOpen(false)}/>} - -
- {isSidebarOpen && } - -
- {!isOrderComplete && ( - - )} - setIsSidebarOpen(!isSidebarOpen)} - variant={'transparent'} - size={'md'} - className={classes.orderSummaryToggle} - > - {isSidebarOpen && } - {!isSidebarOpen && } - -
-
- - ); -} diff --git a/frontend/src/components/layouts/Checkout/CheckoutSidebar/CheckoutSidebar.module.scss b/frontend/src/components/layouts/Checkout/CheckoutSidebar/CheckoutSidebar.module.scss deleted file mode 100644 index c51b71704d..0000000000 --- a/frontend/src/components/layouts/Checkout/CheckoutSidebar/CheckoutSidebar.module.scss +++ /dev/null @@ -1,25 +0,0 @@ -@use "../../../../styles/mixins.scss"; - -.sidebar { - width: 350px; - min-width: 250px; - background-color: #ffffff; - max-height: 100vh; - overflow-y: auto; - @include mixins.scrollbar(); - - .coverImage { - img { - width: 100%; - } - } - - .checkoutSummary { - position: relative; - padding: 20px; - - h4 { - margin-top: 0; - } - } -} diff --git a/frontend/src/components/layouts/Checkout/CheckoutSidebar/index.tsx b/frontend/src/components/layouts/Checkout/CheckoutSidebar/index.tsx deleted file mode 100644 index 555b57967a..0000000000 --- a/frontend/src/components/layouts/Checkout/CheckoutSidebar/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import {t} from "@lingui/macro"; -import {OrderSummary} from "../../../common/OrderSummary"; -import {LoadingMask} from "../../../common/LoadingMask"; -import {Event, Order} from "../../../../types.ts"; -import classes from './CheckoutSidebar.module.scss'; -import classNames from "classnames"; - -interface SidebarProps { - event: Event; - order: Order; - className?: string; -} - -export const CheckoutSidebar = ({event, order, className = ''}: SidebarProps) => { - const coverImage = event?.images?.find((image) => image.type === 'EVENT_COVER'); - - return ( - - ); -} diff --git a/frontend/src/components/layouts/Checkout/index.tsx b/frontend/src/components/layouts/Checkout/index.tsx index a40ca128a4..6636fd093f 100644 --- a/frontend/src/components/layouts/Checkout/index.tsx +++ b/frontend/src/components/layouts/Checkout/index.tsx @@ -1,14 +1,14 @@ -import {Outlet, useBlocker, useNavigate, useParams} from "react-router"; +import {Outlet, useBlocker, useLocation, useNavigate, useParams} from "react-router"; import classes from './Checkout.module.scss'; import {useGetOrderPublic} from "../../../queries/useGetOrderPublic.ts"; import {t} from "@lingui/macro"; import {Countdown} from "../../common/Countdown"; -import {CheckoutSidebar} from "./CheckoutSidebar"; import {ActionIcon, Button, Group, Modal, Tooltip} from "@mantine/core"; import {IconArrowLeft, IconPrinter, IconReceipt} from "@tabler/icons-react"; import {eventHomepagePath, eventHomepageUrl} from "../../../utilites/urlHelper.ts"; import {ShareComponent} from "../../common/ShareIcon"; import {AddToEventCalendarButton} from "../../common/AddEventToCalendarButton"; +import {ProgressStepper} from "../../common/ProgressStepper"; import {useMediaQuery} from "@mantine/hooks"; import {useEffect, useState} from "react"; import {Invoice} from "../../../types.ts"; @@ -24,6 +24,7 @@ const Checkout = () => { const {data: order} = useGetOrderPublic(eventId, orderShortId, ['event']); const event = order?.event; const navigate = useNavigate(); + const location = useLocation(); const orderIsCompleted = order?.status === 'COMPLETED'; const orderIsReserved = order?.status === 'RESERVED'; const orderIsAwaitingOfflinePayment = order?.status === 'AWAITING_OFFLINE_PAYMENT'; @@ -35,6 +36,14 @@ const Checkout = () => { const [isAbandoning, setIsAbandoning] = useState(false); const abandonOrderMutation = useAbandonOrderPublic(); + const getCurrentStep = (): 'details' | 'payment' | 'summary' => { + const pathname = location.pathname; + if (pathname.includes('/payment')) return 'payment'; + if (pathname.includes('/summary')) return 'summary'; + return 'details'; + }; + const currentStep = getCurrentStep(); + const isOrderReservedAndNotExpired = orderIsReserved && order?.reserved_until && isDateInFuture(order.reserved_until); @@ -146,14 +155,22 @@ const Checkout = () => { {!isMobile && t`Event Homepage`} - - {order.status === 'RESERVED' && t`Checkout`} - {order.status === 'COMPLETED' && t`Your Order`} - + {orderIsReserved && ( + + )} + + {(orderIsCompleted || orderIsAwaitingOfflinePayment) && ( + + {t`Your Order`} + + )} {orderIsReserved && ( - - + + {t`Time left:`} {
- - {(order && event) && }
= ({event, primaryColor = '#8b5 const placeholderEmoji = placeholderEmojis[emojiIndex]; // Format dates using the event's timezone - const startMonth = formatDate(event.start_date, "MMM", event.timezone); - const startDay = formatDate(event.start_date, "D", event.timezone); - const startTime = formatDate(event.start_date, "h:mm A", event.timezone); - const endTime = event.end_date ? formatDate(event.end_date, "h:mm A", event.timezone) : null; - const prettyTimezone = formatDate(event.start_date, "z", event.timezone); + const startMonth = formatDateWithLocale(event.start_date, "monthShort", event.timezone); + const startDay = formatDateWithLocale(event.start_date, "dayOfMonth", event.timezone); + const startTime = formatDateWithLocale(event.start_date, "timeOnly", event.timezone); + const endTime = event.end_date ? formatDateWithLocale(event.end_date, "timeOnly", event.timezone) : null; + const prettyTimezone = formatDateWithLocale(event.start_date, "timezone", event.timezone); const isSameDay = event.end_date && event.start_date.substring(0, 10) === event.end_date.substring(0, 10); - const endMonth = event.end_date ? formatDate(event.end_date, "MMM", event.timezone) : null; - const endDay = event.end_date ? formatDate(event.end_date, "D", event.timezone) : null; + const endMonth = event.end_date ? formatDateWithLocale(event.end_date, "monthShort", event.timezone) : null; + const endDay = event.end_date ? formatDateWithLocale(event.end_date, "dayOfMonth", event.timezone) : null; const coverImage = event.images?.find(img => img.type === 'EVENT_COVER'); const location = event?.settings?.location_details?.city || event?.settings?.location_details?.venue_name; diff --git a/frontend/src/components/routes/event/EventDashboard/index.tsx b/frontend/src/components/routes/event/EventDashboard/index.tsx index 8cd14b5f94..0e5d04d27b 100644 --- a/frontend/src/components/routes/event/EventDashboard/index.tsx +++ b/frontend/src/components/routes/event/EventDashboard/index.tsx @@ -10,7 +10,7 @@ import {Card} from "../../../common/Card"; import classes from "./EventDashboard.module.scss"; import {useGetEventStats} from "../../../../queries/useGetEventStats.ts"; import {formatCurrency} from "../../../../utilites/currency.ts"; -import {formatDate} from "../../../../utilites/dates.ts"; +import {formatDateWithLocale} from "../../../../utilites/dates.ts"; import {Button, Skeleton} from "@mantine/core"; import {useMediaQuery} from "@mantine/hooks"; import {IconAlertCircle, IconX} from "@tabler/icons-react"; @@ -87,7 +87,7 @@ export const EventDashboard = () => { } const dateRange = (eventStats && event) - ? `${formatDate(eventStats.start_date, 'MMM DD', event?.timezone)} - ${formatDate(eventStats.end_date, 'MMM DD', event?.timezone)}` + ? `${formatDateWithLocale(eventStats.start_date, 'chartDate', event?.timezone)} - ${formatDateWithLocale(eventStats.end_date, 'chartDate', event?.timezone)}` : ''; const shouldShowChecklist = (isChecklistVisible && event && accountIsFetched && account?.is_saas_mode_enabled) && ( @@ -255,7 +255,7 @@ export const EventDashboard = () => { ({ - date: formatDate(stat.date, 'MMM DD', event.timezone), + date: formatDateWithLocale(stat.date, 'chartDate', event.timezone), orders_created: stat.orders_created, products_sold: stat.products_sold, attendees_registered: stat.attendees_registered, @@ -289,7 +289,7 @@ export const EventDashboard = () => { h={300} data={eventStats?.daily_stats.map(stat => { return ({ - date: formatDate(stat.date, 'MMM DD', event.timezone), + date: formatDateWithLocale(stat.date, 'chartDate', event.timezone), total_fees: stat.total_fees, total_sales_gross: stat.total_sales_gross, total_tax: stat.total_tax, diff --git a/frontend/src/components/routes/event/Reports/DailySalesReport/index.tsx b/frontend/src/components/routes/event/Reports/DailySalesReport/index.tsx index a78b188b4b..632e23f0c0 100644 --- a/frontend/src/components/routes/event/Reports/DailySalesReport/index.tsx +++ b/frontend/src/components/routes/event/Reports/DailySalesReport/index.tsx @@ -1,7 +1,7 @@ import {useParams} from "react-router"; import {useGetEvent} from "../../../../../queries/useGetEvent.ts"; import {formatCurrency} from "../../../../../utilites/currency.ts"; -import {formatDate} from "../../../../../utilites/dates.ts"; +import {formatDateWithLocale} from "../../../../../utilites/dates.ts"; import ReportTable from "../../../../common/ReportTable"; export const DailySalesReport = () => { @@ -18,7 +18,7 @@ export const DailySalesReport = () => { key: 'date' as const, label: 'Date', sortable: true, - render: (value: string) => formatDate(value, 'MMM D, YYYY', event?.timezone) + render: (value: string) => formatDateWithLocale(value, 'shortDate', event?.timezone) }, { key: 'sales_total_gross' as const, diff --git a/frontend/src/components/routes/event/Reports/PromoCodesReport/index.tsx b/frontend/src/components/routes/event/Reports/PromoCodesReport/index.tsx index d23b13ed6c..f9d849a8e2 100644 --- a/frontend/src/components/routes/event/Reports/PromoCodesReport/index.tsx +++ b/frontend/src/components/routes/event/Reports/PromoCodesReport/index.tsx @@ -3,7 +3,7 @@ import {useGetEvent} from "../../../../../queries/useGetEvent.ts"; import {formatCurrency} from "../../../../../utilites/currency.ts"; import ReportTable from "../../../../common/ReportTable"; import {t} from "@lingui/macro"; -import {formatDate} from "../../../../../utilites/dates.ts"; +import {formatDateWithLocale} from "../../../../../utilites/dates.ts"; const PromoCodesReport = () => { const {eventId} = useParams(); @@ -63,13 +63,13 @@ const PromoCodesReport = () => { key: 'first_used_at' as const, label: t`First Used`, sortable: true, - render: (value: string) => value ? formatDate(value, 'MMM D, YYYY', event.timezone) : '-' + render: (value: string) => value ? formatDateWithLocale(value, 'shortDate', event.timezone) : '-' }, { key: 'last_used_at' as const, label: t`Last Used`, sortable: true, - render: (value: string) => value ? formatDate(value, 'MMM D, YYYY', event.timezone) : '-' + render: (value: string) => value ? formatDateWithLocale(value, 'shortDate', event.timezone) : '-' }, { key: 'max_allowed_usages' as const, diff --git a/frontend/src/components/routes/event/Settings/Sections/HomepageAndCheckoutSettings/index.tsx b/frontend/src/components/routes/event/Settings/Sections/HomepageAndCheckoutSettings/index.tsx index 590ca5973b..76fba0c3c2 100644 --- a/frontend/src/components/routes/event/Settings/Sections/HomepageAndCheckoutSettings/index.tsx +++ b/frontend/src/components/routes/event/Settings/Sections/HomepageAndCheckoutSettings/index.tsx @@ -1,5 +1,5 @@ import {t} from "@lingui/macro"; -import {Button, NumberInput} from "@mantine/core"; +import {Button, NumberInput, Switch} from "@mantine/core"; import {useForm} from "@mantine/form"; import {useParams} from "react-router"; import {useEffect} from "react"; @@ -12,6 +12,8 @@ import {useGetEventSettings} from "../../../../../../queries/useGetEventSettings import {Editor} from "../../../../../common/Editor"; import {HeadingWithDescription} from "../../../../../common/Card/CardHeading"; import {isEmptyHtml} from "../../../../../../utilites/helpers.ts"; +import {CustomSelect, ItemProps} from "../../../../../common/CustomSelect"; +import {IconUser, IconUsers} from "@tabler/icons-react"; export const HomepageAndCheckoutSettings = () => { const {eventId} = useParams(); @@ -22,6 +24,8 @@ export const HomepageAndCheckoutSettings = () => { pre_checkout_message: '', post_checkout_message: '', order_timeout_in_minutes: 15, + attendee_details_collection_method: 'PER_TICKET' as 'PER_TICKET' | 'PER_ORDER', + show_marketing_opt_in: true, }, transformValues: (values) => ({ ...values, @@ -29,6 +33,21 @@ export const HomepageAndCheckoutSettings = () => { post_checkout_message: isEmptyHtml(values.post_checkout_message) ? null : values.post_checkout_message, }), }); + + const attendeeCollectionOptions: ItemProps[] = [ + { + icon: , + label: t`Per ticket`, + value: 'PER_TICKET', + description: t`Collect attendee details for each ticket purchased.`, + }, + { + icon: , + label: t`Per order`, + value: 'PER_ORDER', + description: t`Use order details for all attendees. Attendee names and emails will match the buyer's information.`, + }, + ]; const formErrorHandle = useFormErrorResponseHandler(); useEffect(() => { @@ -37,6 +56,8 @@ export const HomepageAndCheckoutSettings = () => { pre_checkout_message: eventSettingsQuery.data.pre_checkout_message, post_checkout_message: eventSettingsQuery.data.post_checkout_message, order_timeout_in_minutes: eventSettingsQuery.data.order_timeout_in_minutes, + attendee_details_collection_method: eventSettingsQuery.data.attendee_details_collection_method || 'PER_TICKET', + show_marketing_opt_in: eventSettingsQuery.data.show_marketing_opt_in ?? true, }); } }, [eventSettingsQuery.isFetched]); @@ -86,12 +107,27 @@ export const HomepageAndCheckoutSettings = () => { onChange={(value) => form.setFieldValue('post_checkout_message', value)} /> + + + + diff --git a/frontend/src/components/routes/organizer/Reports/CheckInSummaryReport/index.tsx b/frontend/src/components/routes/organizer/Reports/CheckInSummaryReport/index.tsx index 0de4c32d3f..612c0c20de 100644 --- a/frontend/src/components/routes/organizer/Reports/CheckInSummaryReport/index.tsx +++ b/frontend/src/components/routes/organizer/Reports/CheckInSummaryReport/index.tsx @@ -1,6 +1,6 @@ import {Link, useParams} from "react-router"; import {useGetOrganizer} from "../../../../../queries/useGetOrganizer.ts"; -import {formatDate} from "../../../../../utilites/dates.ts"; +import {formatDateWithLocale} from "../../../../../utilites/dates.ts"; import OrganizerReportTable from "../../../../common/OrganizerReportTable"; import {t} from "@lingui/macro"; @@ -28,7 +28,7 @@ const CheckInSummaryReport = () => { key: 'start_date' as const, label: t`Event Date`, sortable: true, - render: (value: string) => value ? formatDate(value, 'MMM D, YYYY', organizer?.timezone) : '-' + render: (value: string) => value ? formatDateWithLocale(value, 'shortDate', organizer?.timezone || 'UTC') : '-' }, { key: 'total_attendees' as const, diff --git a/frontend/src/components/routes/organizer/Reports/EventsPerformanceReport/index.tsx b/frontend/src/components/routes/organizer/Reports/EventsPerformanceReport/index.tsx index ecce2b6d61..4229ce47de 100644 --- a/frontend/src/components/routes/organizer/Reports/EventsPerformanceReport/index.tsx +++ b/frontend/src/components/routes/organizer/Reports/EventsPerformanceReport/index.tsx @@ -2,7 +2,7 @@ import {Link, useParams} from "react-router"; import {useGetOrganizer} from "../../../../../queries/useGetOrganizer.ts"; import {useGetOrganizerStats} from "../../../../../queries/useGetOrganizerStats.ts"; import {formatCurrency} from "../../../../../utilites/currency.ts"; -import {formatDate} from "../../../../../utilites/dates.ts"; +import {formatDateWithLocale} from "../../../../../utilites/dates.ts"; import OrganizerReportTable from "../../../../common/OrganizerReportTable"; import {t} from "@lingui/macro"; import {Badge} from "@mantine/core"; @@ -55,7 +55,7 @@ const EventsPerformanceReport = () => { key: 'start_date' as const, label: t`Date`, sortable: true, - render: (value: string) => value ? formatDate(value, 'MMM D, YYYY', organizer?.timezone) : '-' + render: (value: string) => value ? formatDateWithLocale(value, 'shortDate', organizer?.timezone || 'UTC') : '-' }, { key: 'event_state' as const, diff --git a/frontend/src/components/routes/organizer/Reports/RevenueSummaryReport/index.tsx b/frontend/src/components/routes/organizer/Reports/RevenueSummaryReport/index.tsx index 59f1f91833..f66285b82a 100644 --- a/frontend/src/components/routes/organizer/Reports/RevenueSummaryReport/index.tsx +++ b/frontend/src/components/routes/organizer/Reports/RevenueSummaryReport/index.tsx @@ -2,7 +2,7 @@ import {useParams} from "react-router"; import {useGetOrganizer} from "../../../../../queries/useGetOrganizer.ts"; import {useGetOrganizerStats} from "../../../../../queries/useGetOrganizerStats.ts"; import {formatCurrency} from "../../../../../utilites/currency.ts"; -import {formatDate} from "../../../../../utilites/dates.ts"; +import {formatDateWithLocale} from "../../../../../utilites/dates.ts"; import OrganizerReportTable from "../../../../common/OrganizerReportTable"; import {t} from "@lingui/macro"; @@ -23,7 +23,7 @@ const RevenueSummaryReport = () => { key: 'date' as const, label: t`Date`, sortable: true, - render: (value: string) => formatDate(value, 'MMM D, YYYY', organizer?.timezone) + render: (value: string) => formatDateWithLocale(value, 'shortDate', organizer?.timezone || 'UTC') }, { key: 'gross_sales' as const, diff --git a/frontend/src/components/routes/organizer/Settings/Sections/EventDefaults/index.tsx b/frontend/src/components/routes/organizer/Settings/Sections/EventDefaults/index.tsx new file mode 100644 index 0000000000..2c346abaae --- /dev/null +++ b/frontend/src/components/routes/organizer/Settings/Sections/EventDefaults/index.tsx @@ -0,0 +1,100 @@ +import {useParams} from "react-router"; +import {useForm} from "@mantine/form"; +import {useFormErrorResponseHandler} from "../../../../../../hooks/useFormErrorResponseHandler.tsx"; +import {useEffect} from "react"; +import {showSuccess} from "../../../../../../utilites/notifications.tsx"; +import {t} from "@lingui/macro"; +import {Card} from "../../../../../common/Card"; +import {HeadingWithDescription} from "../../../../../common/Card/CardHeading"; +import {Button, Switch} from "@mantine/core"; +import {useGetOrganizerSettings} from "../../../../../../queries/useGetOrganizerSettings.ts"; +import {useUpdateOrganizerSettings} from "../../../../../../mutations/useUpdateOrganizerSettings.ts"; +import {CustomSelect, ItemProps} from "../../../../../common/CustomSelect"; +import {IconUser, IconUsers} from "@tabler/icons-react"; + +export const EventDefaults = () => { + const {organizerId} = useParams(); + const organizerSettingsQuery = useGetOrganizerSettings(organizerId); + const updateMutation = useUpdateOrganizerSettings(); + + const form = useForm({ + initialValues: { + default_attendee_details_collection_method: 'PER_TICKET' as 'PER_TICKET' | 'PER_ORDER', + default_show_marketing_opt_in: true, + } + }); + + const attendeeCollectionOptions: ItemProps[] = [ + { + icon: , + label: t`Per ticket`, + value: 'PER_TICKET', + description: t`Collect attendee details for each ticket purchased.`, + }, + { + icon: , + label: t`Per order`, + value: 'PER_ORDER', + description: t`Use order details for all attendees. Attendee names and emails will match the buyer's information.`, + }, + ]; + + const formErrorHandle = useFormErrorResponseHandler(); + + useEffect(() => { + if (organizerSettingsQuery?.isFetched && organizerSettingsQuery?.data) { + form.setValues({ + default_attendee_details_collection_method: organizerSettingsQuery.data.default_attendee_details_collection_method || 'PER_TICKET', + default_show_marketing_opt_in: organizerSettingsQuery.data.default_show_marketing_opt_in ?? true, + }); + } + }, [organizerSettingsQuery.isFetched]); + + const handleSubmit = (values: { default_attendee_details_collection_method: 'PER_TICKET' | 'PER_ORDER'; default_show_marketing_opt_in: boolean }) => { + updateMutation.mutate({ + organizerSettings: values, + organizerId: organizerId, + }, { + onSuccess: () => { + showSuccess(t`Successfully Updated Event Defaults`); + }, + onError: (error) => { + formErrorHandle(form, error); + } + }); + } + + return ( + + +
+
+ + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/routes/organizer/Settings/index.tsx b/frontend/src/components/routes/organizer/Settings/index.tsx index 3f3da5236f..e05f96469c 100644 --- a/frontend/src/components/routes/organizer/Settings/index.tsx +++ b/frontend/src/components/routes/organizer/Settings/index.tsx @@ -3,11 +3,12 @@ import BasicSettings from "./Sections/BasicSettings"; import {SocialLinks} from "./Sections/SocialLinks"; import {AddressSettings} from "./Sections/AddressSettings"; import EmailTemplateSettings from "./Sections/EmailTemplateSettings"; +import {EventDefaults} from "./Sections/EventDefaults"; import {PageBody} from "../../../common/PageBody"; import {PageTitle} from "../../../common/PageTitle"; import {t} from "@lingui/macro"; import {Box, Group, NavLink as MantineNavLink, Stack} from "@mantine/core"; -import {IconBrandGoogleAnalytics, IconInfoCircle, IconMapPin, IconShare, IconMail} from "@tabler/icons-react"; +import {IconBrandGoogleAnalytics, IconInfoCircle, IconMapPin, IconShare, IconMail, IconCalendarEvent} from "@tabler/icons-react"; import {useMediaQuery} from "@mantine/hooks"; import {useState} from "react"; import {Card} from "../../../common/Card"; @@ -23,6 +24,12 @@ const Settings = () => { icon: IconInfoCircle, component: BasicSettings }, + { + id: 'event-defaults', + label: t`Event Defaults`, + icon: IconCalendarEvent, + component: EventDefaults + }, { id: 'address-settings', label: t`Address`, diff --git a/frontend/src/components/routes/product-widget/CollectInformation/CollectInformation.module.scss b/frontend/src/components/routes/product-widget/CollectInformation/CollectInformation.module.scss index e69de29bb2..758adae5a7 100644 --- a/frontend/src/components/routes/product-widget/CollectInformation/CollectInformation.module.scss +++ b/frontend/src/components/routes/product-widget/CollectInformation/CollectInformation.module.scss @@ -0,0 +1,152 @@ +@use "../../../../styles/mixins"; + +.sectionHeading { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 8px; + color: #111827; +} + +.sectionHelper { + font-size: 14px; + color: #6B7280; + margin-bottom: 20px; +} + +.copyDetailsSection { + padding-top: 16px; + border-top: 1px solid #F3F4F6; +} + +.copyDetailsMultiple { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.copyLabel { + flex-shrink: 0; +} + +.ticketSection { + margin-bottom: 24px; +} + +.ticketTypeHeader { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + margin-top: 24px; + + h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: #111827; + } +} + +.ticketCountBadge { + background: var(--mantine-primary-color-light); + color: var(--mantine-primary-color-filled); + font-size: 12px; + padding: 2px 10px; + border-radius: 12px; + font-weight: 500; +} + +.attendeeCard { + position: relative; +} + +.attendeeCardHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 16px; +} + +.attendeeHeaderLeft { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.attendeeNumber { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--mantine-primary-color-light); + color: var(--mantine-primary-color-filled); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; + flex-shrink: 0; +} + +.attendeeInfo { + h4 { + margin: 0 0 2px 0; + font-size: 15px; + font-weight: 500; + color: #111827; + } +} + +.attendeeTicketType { + font-size: 13px; + color: #6B7280; +} + +.copiedBadge { + background: #D1FAE5; + color: #065F46; + font-size: 12px; + padding: 4px 8px; + border-radius: 12px; + font-weight: 500; + white-space: nowrap; +} + +.checkoutActions { + margin-top: 32px; + margin-bottom: 24px; +} + +.continueButton { + width: 100%; + height: 48px; + border-radius: 8px; + font-weight: 600; + font-size: 16px; + transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease; + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + &:active:not(:disabled) { + transform: translateY(0); + } +} + +.tosNotice { + text-align: center; + margin-top: 12px; + font-size: 12px; + color: #6B7280; + + a { + color: var(--mantine-primary-color-filled); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx index 1af50d45fb..8f43f4246d 100644 --- a/frontend/src/components/routes/product-widget/CollectInformation/index.tsx +++ b/frontend/src/components/routes/product-widget/CollectInformation/index.tsx @@ -1,26 +1,37 @@ import {useMutation} from "@tanstack/react-query"; import {FinaliseOrderPayload, orderClientPublic} from "../../../../api/order.client.ts"; import {useNavigate, useParams} from "react-router"; -import {Button, Group, NativeSelect, Skeleton, TextInput} from "@mantine/core"; +import { + Button, + Checkbox, + Group, + NativeSelect, + SegmentedControl, + Skeleton, + Text, + TextInput, + Tooltip +} from "@mantine/core"; +import {IconArrowRight, IconCheck, IconCircleCheck} from "@tabler/icons-react"; +import {t, Trans} from "@lingui/macro"; import {useForm} from "@mantine/form"; import {notifications} from "@mantine/notifications"; import {useGetOrderPublic} from "../../../../queries/useGetOrderPublic.ts"; import {useGetEventPublic} from "../../../../queries/useGetEventPublic.ts"; import {useGetEventQuestionsPublic} from "../../../../queries/useGetEventQuestionsPublic.ts"; import {CheckoutOrderQuestions, CheckoutProductQuestions} from "../../../common/CheckoutQuestion"; -import {Event, IdParam, Order, Question} from "../../../../types.ts"; -import {useEffect} from "react"; -import {t} from "@lingui/macro"; +import {Event, IdParam, Question} from "../../../../types.ts"; +import {useEffect, useState} from "react"; import {InputGroup} from "../../../common/InputGroup"; import {Card} from "../../../common/Card"; -import {IconCopy} from "@tabler/icons-react"; -import {CheckoutFooter} from "../../../layouts/Checkout/CheckoutFooter"; import {CheckoutContent} from "../../../layouts/Checkout/CheckoutContent"; +import {getConfig} from "../../../../utilites/config.ts"; import {HomepageInfoMessage} from "../../../common/HomepageInfoMessage"; +import {InlineOrderSummary} from "../../../common/InlineOrderSummary"; import {eventCheckoutPath, eventHomepagePath} from "../../../../utilites/urlHelper.ts"; -import {formatCurrency} from "../../../../utilites/currency.ts"; import {showInfo} from "../../../../utilites/notifications.tsx"; import countries from "../../../../../data/countries.json"; +import classes from "./CollectInformation.module.scss"; const LoadingSkeleton = () => ( @@ -56,6 +67,17 @@ export const CollectInformation = () => { const orderQuestions = questions?.filter(question => question.belongs_to === "ORDER"); const products = productCategories?.flatMap(category => category.products); const requireBillingAddress = event?.settings?.require_billing_address; + const isPerOrderCollection = event?.settings?.attendee_details_collection_method === 'PER_ORDER'; + const [copyOption, setCopyOption] = useState<'none' | 'first' | 'all'>('none'); + + const isEmailValid = (email: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const EmailCheckIcon = () => ( + + ); let productIndex = 0; @@ -65,24 +87,42 @@ export const CollectInformation = () => { first_name: "", last_name: "", email: "", + email_confirmation: "", address: {}, questions: {}, + opted_into_marketing: false, }, products: [{ first_name: "", last_name: "", email: "", + email_confirmation: "", product_price_id: "", product_id: "", questions: {}, }], }, + validate: { + order: { + email_confirmation: (value, values) => + value !== values.order.email ? t`Email addresses do not match` : null, + }, + products: { + email_confirmation: (value, values, path) => { + const index = parseInt(path.split('.')[1]); + const product = values.products[index]; + if (product && product.email !== value) { + return t`Email addresses do not match`; + } + return null; + }, + }, + }, + validateInputOnBlur: true, }); - const copyDetailsToAllAttendees = () => { - if (!products) { - return; - } + const getTicketAttendeeIndices = (): number[] => { + if (!products || !form.values.products) return []; const attendeeProductIds = new Set( products @@ -90,14 +130,52 @@ export const CollectInformation = () => { .map(product => product.id) ); - const updatedProducts = form.values.products.map(product => { - if (attendeeProductIds.has(product.product_id)) { - return { - ...product, - first_name: form.values.order.first_name, - last_name: form.values.order.last_name, - email: form.values.order.email, - }; + return form.values.products + .map((product, index) => attendeeProductIds.has(product.product_id) ? index : -1) + .filter(index => index !== -1); + }; + + const getFirstTicketAttendeeIndex = (): number => { + const indices = getTicketAttendeeIndices(); + return indices.length > 0 ? indices[0] : -1; + }; + + const totalTicketAttendees = getTicketAttendeeIndices().length; + + const areOrderDetailsComplete = () => { + const {first_name, last_name, email} = form.values.order; + return first_name.trim() !== '' && last_name.trim() !== '' && isEmailValid(email); + }; + + const copyDetailsToAttendees = (option: 'none' | 'first' | 'all') => { + if (!products) return; + + const ticketIndices = getTicketAttendeeIndices(); + if (ticketIndices.length === 0) return; + + const updatedProducts = form.values.products.map((product, index) => { + const isTicketAttendee = ticketIndices.includes(index); + const isFirst = index === ticketIndices[0]; + const shouldCopy = option === 'all' ? isTicketAttendee : (option === 'first' && isFirst); + + if (isTicketAttendee) { + if (shouldCopy) { + return { + ...product, + first_name: form.values.order.first_name, + last_name: form.values.order.last_name, + email: form.values.order.email, + email_confirmation: form.values.order.email, + }; + } else { + return { + ...product, + first_name: "", + last_name: "", + email: "", + email_confirmation: "", + }; + } } return product; }); @@ -108,6 +186,26 @@ export const CollectInformation = () => { }); }; + const handleCopyOptionChange = (value: string) => { + const option = value as 'none' | 'first' | 'all'; + + // Only allow copying if order details are complete + if (option !== 'none' && !areOrderDetailsComplete()) { + return; + } + + setCopyOption(option); + copyDetailsToAttendees(option); + }; + + // Reset copy option if order details become incomplete + useEffect(() => { + if (copyOption !== 'none' && !areOrderDetailsComplete()) { + setCopyOption('none'); + copyDetailsToAttendees('none'); + } + }, [form.values.order.first_name, form.values.order.last_name, form.values.order.email]); + const mutation = useMutation({ mutationFn: (orderData: FinaliseOrderPayload) => orderClientPublic.finaliseOrder(Number(eventId), String(orderShortId), orderData), @@ -159,6 +257,7 @@ export const CollectInformation = () => { first_name: "", last_name: "", email: "", + email_confirmation: "", questions: productIdToQuestionMap.get(orderItem?.product_id)?.map((question: Question) => { return { question_id: question.id, @@ -218,57 +317,65 @@ export const CollectInformation = () => { if (order?.status === 'ABANDONED') { return ; } if (order?.payment_status === 'AWAITING_PAYMENT') { return ; } if (order?.status === 'COMPLETED') { return ; } if (order?.status === 'CANCELLED') { return ; } if (isOrderError && orderError?.response?.status === 404) { return ( - <> - - + ); } if (isOrderError || isEventError || isQuestionsError) { return ( - <> - - + ); } @@ -280,9 +387,16 @@ export const CollectInformation = () => { return (
-

+ {(event && order) && ( + + )} + +

{t`Your Details`}

+

+ {t`We'll send your tickets to this email`} +

@@ -300,19 +414,68 @@ export const CollectInformation = () => { /> - - - {orderRequiresAttendeeDetails && ( - + + : null} + {...form.getInputProps("order.email")} + /> + : null} + {...form.getInputProps("order.email_confirmation")} + /> + + + {orderRequiresAttendeeDetails && !isPerOrderCollection && totalTicketAttendees > 0 && ( +
+ {totalTicketAttendees === 1 ? ( + +
+ handleCopyOptionChange(e.currentTarget.checked ? 'first' : 'none')} + /> +
+
+ ) : ( +
+ {t`Copy my details to:`} + + + +
+ )} +
)} {requireBillingAddress && ( @@ -368,11 +531,19 @@ export const CollectInformation = () => { )} {orderQuestions && } + + {event?.settings?.show_marketing_opt_in && ( + + )}
{orderItems?.map(orderItem => { const product = products?.find(product => product!.id === orderItem.product_id); - const productRequiresDetails = product?.product_type === 'TICKET'; + const productRequiresDetails = product?.product_type === 'TICKET' && !isPerOrderCollection; const productHasQuestions = productQuestions?.some(question => question.product_ids?.includes(orderItem.product_id)); if (!product) { @@ -384,50 +555,104 @@ export const CollectInformation = () => { } return ( -
-

{orderItem?.item_name}

+
+
+

{orderItem?.item_name}

+ + {orderItem.quantity === 1 + ? t`1 ticket` + : t`${orderItem.quantity} tickets`} + +
{Array.from(Array(orderItem?.quantity)).map((_, index) => { + const currentProductIndex = productIndex; + const ticketIndices = getTicketAttendeeIndices(); + const isTicketAttendee = ticketIndices.includes(currentProductIndex); + const isFirstTicketAttendee = currentProductIndex === getFirstTicketAttendeeIndex(); + const isCopied = isTicketAttendee && ( + copyOption === 'all' || (copyOption === 'first' && isFirstTicketAttendee) + ); + + // Check if current values still match the order details + const currentProduct = form.values.products[currentProductIndex]; + const valuesMatchOrder = currentProduct && + currentProduct.first_name === form.values.order.first_name && + currentProduct.last_name === form.values.order.last_name && + currentProduct.email === form.values.order.email; + + // Only show badge if copied AND values still match + const showCopiedBadge = isCopied && productRequiresDetails && valuesMatchOrder; + const productInputs = ( - <> - -

- {product.product_type === 'TICKET' ? t`Attendee` : t`Item`} {index + 1} {t`Details`} -

- - {productRequiresDetails && ( - <> - - - - + +
+
+
+ {index + 1} +
+
+

+ {product.product_type === 'TICKET' ? t`Attendee` : t`Item`} {index + 1} +

+ + {orderItem?.item_name} + +
+
+ {showCopiedBadge && ( + + {t`Copied from above`} + + )} +
+ {productRequiresDetails && ( + <> + + + + + + : null} + {...form.getInputProps(`products.${currentProductIndex}.email`)} /> - - )} - - {productQuestions && - } -
- + : null} + {...form.getInputProps(`products.${currentProductIndex}.email_confirmation`)} + /> + + + )} + + {productQuestions && + } +
); productIndex++; @@ -443,25 +668,42 @@ export const CollectInformation = () => {
)} + +
+ + {!!getConfig('VITE_TOS_URL') && ( +

+ + By continuing, you agree to the{' '} + + {getConfig('VITE_APP_NAME', 'Hi.Events')} Terms of Service + + +

+ )} +
+ - -
- {t`Continue`} -
-
- {formatCurrency(order.total_gross, order.currency)} -
-
- {order.currency} -
- - ) : t`Complete Order`} - event={event as Event} - order={order as Order} - /> ); } diff --git a/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/OrderSummaryAndProducts.module.scss b/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/OrderSummaryAndProducts.module.scss index fb63396371..ab20cd6c71 100644 --- a/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/OrderSummaryAndProducts.module.scss +++ b/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/OrderSummaryAndProducts.module.scss @@ -1,22 +1,80 @@ @use "../../../../styles/mixins"; .welcomeHeader { - margin-bottom: 20px; - padding: 20px; - font-size: 1.5rem; + margin-bottom: 24px; + padding: 32px 20px 24px; text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.confettiIcon { + font-size: 48px; + line-height: 1; + margin-bottom: 8px; + animation: bounceIn 0.6s ease-out, subtleBounce 2s ease-in-out 0.6s infinite; + + span { + display: inline-block; + } +} + +@keyframes bounceIn { + 0% { + transform: scale(0); + opacity: 0; + } + 50% { + transform: scale(1.2); + } + 70% { + transform: scale(0.9); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes subtleBounce { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } +} + +.welcomeMessage { + font-size: 24px; + font-weight: 600; + color: #111827; + margin-bottom: 4px; +} + +.confirmationText { + font-size: 15px; + color: #6B7280; + + strong { + font-weight: 500; + color: #374151; + } } .actionBar { - background-color: #800080; + background-color: var(--mantine-primary-color-filled); padding: var(--hi-spacing-md); color: #fff; } .heading { - font-size: 1.5rem; + font-size: 1.25rem; font-weight: 600; - margin-top: 20px; + margin-top: 24px; + margin-bottom: 0; } .subHeading { @@ -26,8 +84,8 @@ .orderSummaryContainer { padding: var(--hi-spacing-lg); border-radius: var(--hi-radius-lg); - background-color: #fff9ff; - border: 1px solid #800080; + background-color: var(--mantine-primary-color-light); + border: 1px solid var(--mantine-primary-color-filled); } .detailItem { diff --git a/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/index.tsx b/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/index.tsx index 9d760ef484..c85bbc38ef 100644 --- a/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/index.tsx +++ b/frontend/src/components/routes/product-widget/OrderSummaryAndProducts/index.tsx @@ -1,4 +1,3 @@ -import React from "react"; import {t} from "@lingui/macro"; import {NavLink, useNavigate, useParams} from "react-router"; import {Badge, Button, Group, SimpleGrid, Text} from "@mantine/core"; @@ -28,8 +27,9 @@ import {AttendeeTicket} from "../../../common/AttendeeTicket"; import {PoweredByFooter} from "../../../common/PoweredByFooter"; import {EventDateRange} from "../../../common/EventDateRange"; import {OnlineEventDetails} from "../../../common/OnlineEventDetails"; +import {AddToCalendarCTA} from "../../../common/AddToCalendarCTA"; +import {InlineOrderSummary} from "../../../common/InlineOrderSummary"; import {CheckoutContent} from "../../../layouts/Checkout/CheckoutContent"; -import {CheckoutFooter} from "../../../layouts/Checkout/CheckoutFooter"; import {Event, Order, Product} from "../../../../types.ts"; import classes from './OrderSummaryAndProducts.module.scss'; @@ -89,14 +89,53 @@ const DetailItem = ({icon: Icon, label, value}: { icon: any, label: string, valu ); const WelcomeHeader = ({order, event}: { order: Order; event: Event }) => { + const isCompleted = order.status === 'COMPLETED'; + const isAwaitingPayment = order.status === 'AWAITING_OFFLINE_PAYMENT'; + const isCancelled = order.status === 'CANCELLED'; + const message = { - 'COMPLETED': t`You're going to ${event.title}! 🎉`, + 'COMPLETED': t`You're going to ${event.title}!`, 'CANCELLED': t`Your order has been cancelled`, 'RESERVED': null, - 'AWAITING_OFFLINE_PAYMENT': t`Your order is awaiting payment 🏦` + 'AWAITING_OFFLINE_PAYMENT': t`Your order is awaiting payment`, + 'ABANDONED': null, }[order.status]; - return message ?
{message}
: null; + if (!message) return null; + + return ( +
+ {isCompleted && ( +
+ {/* eslint-disable-next-line lingui/no-unlocalized-strings */} + 🎉 +
+ )} + {isAwaitingPayment && ( +
+ {/* eslint-disable-next-line lingui/no-unlocalized-strings */} + +
+ )} + {isCancelled && ( +
+ {/* eslint-disable-next-line lingui/no-unlocalized-strings */} + 😔 +
+ )} +
{message}
+ {isCompleted && ( +
+ {t`Confirmation sent to`} {order.email} +
+ )} + {isCancelled && ( +
+ {t`A cancellation notice has been sent to`} {order.email} +
+ )} +
+ ); }; const OrderDetails = ({order, event}: { order: Order, event: Event }) => ( @@ -201,17 +240,33 @@ const EventDetails = ({event}: { event: Event }) => { }; const OrderStatus = ({order}: { order: Order }) => { - let message = t`This order is processing.`; - - if (order?.payment_status === 'AWAITING_PAYMENT') { - message = t`This order is processing.`; - } else if (order?.status === 'CANCELLED') { - message = t`This order has been cancelled.`; - } else if (order?.status === 'COMPLETED') { - message = t`This order is complete.`; + if (order?.status === 'CANCELLED') { + return ( + + ); } - return ; + if (order?.status === 'COMPLETED') { + return ( + + ); + } + + return ( + + ); }; const PostCheckoutMessage = ({ message }: { message: string }) => ( @@ -257,9 +312,16 @@ export const OrderSummaryAndProducts = () => { return ( <> - + + + {order?.status === 'AWAITING_OFFLINE_PAYMENT' && } @@ -276,6 +338,8 @@ export const OrderSummaryAndProducts = () => {

{t`Event Details`}

+ {order.status === 'COMPLETED' && } + {(order?.attendees && order.attendees.length > 0) && (

{t`Guests`}

@@ -302,12 +366,6 @@ export const OrderSummaryAndProducts = () => {
- ); }; diff --git a/frontend/src/components/routes/product-widget/Payment/Payment.module.scss b/frontend/src/components/routes/product-widget/Payment/Payment.module.scss index 94420db572..6b4f6e29b0 100644 --- a/frontend/src/components/routes/product-widget/Payment/Payment.module.scss +++ b/frontend/src/components/routes/product-widget/Payment/Payment.module.scss @@ -2,4 +2,83 @@ max-width: 500px; margin: 0 auto; position: relative; +} + +.paymentMethodSelector { + margin-top: 20px; + margin-bottom: 4px; +} + +.paymentMethodLabel { + margin-bottom: 8px; +} + +.paymentMethodTabs { + display: flex; + gap: 8px; +} + +.paymentMethodTab { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + color: #6B7280; + transition: all 0.15s ease; + + &:hover { + border-color: #D1D5DB; + background: #F9FAFB; + } + + &.active { + border-color: var(--mantine-primary-color-filled); + background: var(--mantine-primary-color-light); + color: var(--mantine-primary-color-filled); + } +} + +.checkoutActions { + margin-top: 32px; + margin-bottom: 24px; +} + +.continueButton { + width: 100%; + height: 48px; + border-radius: 8px; + font-weight: 600; + font-size: 16px; + transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease; + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + &:active:not(:disabled) { + transform: translateY(0); + } +} + +.tosNotice { + text-align: center; + margin-top: 12px; + font-size: 12px; + color: #6B7280; + + a { + color: var(--mantine-primary-color-filled); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } } \ No newline at end of file diff --git a/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Stripe/index.tsx b/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Stripe/index.tsx index 1c01f20087..a18c9a06e5 100644 --- a/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Stripe/index.tsx +++ b/frontend/src/components/routes/product-widget/Payment/PaymentMethods/Stripe/index.tsx @@ -44,9 +44,11 @@ export const StripePaymentMethod = ({enabled, setSubmitHandler}: StripePaymentMe return ( ); @@ -56,10 +58,12 @@ export const StripePaymentMethod = ({enabled, setSubmitHandler}: StripePaymentMe return ( ); diff --git a/frontend/src/components/routes/product-widget/Payment/index.tsx b/frontend/src/components/routes/product-widget/Payment/index.tsx index 44ceb77a28..bd014d78d3 100644 --- a/frontend/src/components/routes/product-widget/Payment/index.tsx +++ b/frontend/src/components/routes/product-widget/Payment/index.tsx @@ -4,17 +4,20 @@ import {useGetEventPublic} from "../../../../queries/useGetEventPublic.ts"; import {CheckoutContent} from "../../../layouts/Checkout/CheckoutContent"; import {StripePaymentMethod} from "./PaymentMethods/Stripe"; import {OfflinePaymentMethod} from "./PaymentMethods/Offline"; -import {Event, Order} from "../../../../types.ts"; -import {CheckoutFooter} from "../../../layouts/Checkout/CheckoutFooter"; -import {Group} from "@mantine/core"; +import {Event} from "../../../../types.ts"; +import {Button, Group, Text} from "@mantine/core"; +import {IconBuildingBank, IconLock, IconWallet} from "@tabler/icons-react"; import {formatCurrency} from "../../../../utilites/currency.ts"; -import {t} from "@lingui/macro"; +import {t, Trans} from "@lingui/macro"; import {useGetOrderPublic} from "../../../../queries/useGetOrderPublic.ts"; import { useTransitionOrderToOfflinePaymentPublic } from "../../../../mutations/useTransitionOrderToOfflinePaymentPublic.ts"; import {Card} from "../../../common/Card"; +import {InlineOrderSummary} from "../../../common/InlineOrderSummary"; import {showError} from "../../../../utilites/notifications.tsx"; +import {getConfig} from "../../../../utilites/config.ts"; +import classes from "./Payment.module.scss"; const Payment = () => { const navigate = useNavigate(); @@ -41,6 +44,11 @@ const Payment = () => { } }, [isStripeEnabled, isOfflineEnabled]); + React.useEffect(() => { + // Scroll to top when payment page loads + window?.scrollTo(0, 0); + }, []); + const handleParentSubmit = () => { if (submitHandler) { setIsPaymentLoading(true); @@ -82,6 +90,9 @@ const Payment = () => { return ( <> + {(event && order) && ( + + )} {isStripeEnabled && (
@@ -95,41 +106,60 @@ const Payment = () => { )} {(isStripeEnabled && isOfflineEnabled) && ( -
- setActivePaymentMethod( - activePaymentMethod === 'STRIPE' ? 'OFFLINE' : 'STRIPE' - )} - style={{cursor: 'pointer'}} - > - {activePaymentMethod === 'STRIPE' - ? t`I would like to pay using an offline method` - : t`I would like to pay using an online method (credit card etc.)` - } - +
+ + {t`Payment method`} + +
+ + +
)} - - -
- {t`Place Order`} -
-
- {formatCurrency(order.total_gross, order.currency)} -
-
- {order.currency} -
- - ) : t`Complete Payment`} - /> +
+ + {getConfig('VITE_TOS_URL') && ( +

+ + By continuing, you agree to the{' '} + + {getConfig('VITE_APP_NAME', 'Hi.Events')} Terms of Service + + +

+ )} +
+ ); } diff --git a/frontend/src/components/routes/product-widget/PaymentReturn/index.tsx b/frontend/src/components/routes/product-widget/PaymentReturn/index.tsx index 76a7a80468..fb2b5a7071 100644 --- a/frontend/src/components/routes/product-widget/PaymentReturn/index.tsx +++ b/frontend/src/components/routes/product-widget/PaymentReturn/index.tsx @@ -71,17 +71,23 @@ export const PaymentReturn = () => {
{!cannotConfirmPayment && ( {(!shouldPoll && paymentIntentQuery.isFetched) && t`We could not process your payment. Please try again or contact support.`} - {(!shouldPoll && !paymentIntentQuery.isFetched) && t`Almost there! We're just waiting for your payment to be processed. This should only take a few seconds..`} + {(!shouldPoll && !paymentIntentQuery.isFetched) && t`Almost there! We're just waiting for your payment to be processed. This should only take a few seconds.`} {shouldPoll && t`We're processing your order. Please wait...`} - )}/> + )} + /> )} - {cannotConfirmPayment && t`We were unable to confirm your payment. Please try again or contact support.`} + {cannotConfirmPayment && ( + + )}
); diff --git a/frontend/src/components/routes/profile/ManageProfile/index.tsx b/frontend/src/components/routes/profile/ManageProfile/index.tsx index b1c2457a62..a12c53e1e3 100644 --- a/frontend/src/components/routes/profile/ManageProfile/index.tsx +++ b/frontend/src/components/routes/profile/ManageProfile/index.tsx @@ -1,7 +1,7 @@ import {Card} from "../../../common/Card"; import {useForm, UseFormReturnType} from "@mantine/form"; import {useGetMe} from "../../../../queries/useGetMe.ts"; -import {Alert, Button, PasswordInput, Select, Tabs, TextInput} from "@mantine/core"; +import {Alert, Button, NativeSelect, PasswordInput, Select, Tabs, TextInput} from "@mantine/core"; import classes from "./ManageProfile.module.scss"; import {useEffect, useState} from "react"; import {IconInfoCircle, IconPassword, IconUser} from "@tabler/icons-react"; @@ -13,7 +13,12 @@ import {useCancelEmailChange} from "../../../../mutations/useCancelEmailChange.t import {useFormErrorResponseHandler} from "../../../../hooks/useFormErrorResponseHandler.tsx"; import {t, Trans} from "@lingui/macro"; import {useResendEmailConfirmation} from "../../../../mutations/useResendEmailConfirmation.ts"; -import {getLocaleName, localeToFlagEmojiMap, localeToNameMap, SupportedLocales} from "../../../../locales.ts"; +import {localeToFlagEmojiMap, localeToNameMap, SupportedLocales} from "../../../../locales.ts"; + +const localeSelectData = Object.keys(localeToNameMap).map(locale => ({ + value: locale, + label: `${localeToFlagEmojiMap[locale as SupportedLocales]} ${localeToNameMap[locale as SupportedLocales]}`, +})); export const ManageProfile = () => { const {data: me, isFetching} = useGetMe(); @@ -162,15 +167,12 @@ export const ManageProfile = () => { placeholder={t`UTC`} /> -