diff --git a/backend/app/DomainObjects/Enums/ImageType.php b/backend/app/DomainObjects/Enums/ImageType.php index 6f50bd585..df38a0472 100644 --- a/backend/app/DomainObjects/Enums/ImageType.php +++ b/backend/app/DomainObjects/Enums/ImageType.php @@ -15,6 +15,7 @@ enum ImageType // Event images case EVENT_COVER; + case TICKET_LOGO; // Organizer images case ORGANIZER_LOGO; @@ -24,6 +25,7 @@ public static function eventImageTypes(): array { return [ self::EVENT_COVER, + self::TICKET_LOGO, ]; } @@ -47,6 +49,7 @@ public static function getMinimumDimensionsMap(ImageType $imageType): array $map = [ self::GENERIC->name => [50, 50], self::EVENT_COVER->name => [600, 50], + self::TICKET_LOGO->name => [100, 100], self::ORGANIZER_LOGO->name => [100, 100], self::ORGANIZER_COVER->name => [600, 50], ]; diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index ed4088fd0..ad91a0ffb 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -58,6 +58,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const ALLOW_ORDERS_AWAITING_OFFLINE_PAYMENT_TO_CHECK_IN = 'allow_orders_awaiting_offline_payment_to_check_in'; 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'; protected int $id; protected int $event_id; @@ -107,6 +108,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected bool $allow_orders_awaiting_offline_payment_to_check_in = false; protected ?int $invoice_payment_terms_days = null; protected ?string $invoice_notes = null; + protected array|string|null $ticket_design_settings = null; public function toArray(): array { @@ -159,6 +161,7 @@ public function toArray(): array 'allow_orders_awaiting_offline_payment_to_check_in' => $this->allow_orders_awaiting_offline_payment_to_check_in ?? null, 'invoice_payment_terms_days' => $this->invoice_payment_terms_days ?? null, 'invoice_notes' => $this->invoice_notes ?? null, + 'ticket_design_settings' => $this->ticket_design_settings ?? null, ]; } @@ -690,4 +693,15 @@ public function getInvoiceNotes(): ?string { return $this->invoice_notes; } + + public function setTicketDesignSettings(array|string|null $ticket_design_settings): self + { + $this->ticket_design_settings = $ticket_design_settings; + return $this; + } + + public function getTicketDesignSettings(): array|string|null + { + return $this->ticket_design_settings; + } } diff --git a/backend/app/Http/Actions/Events/GetEventAction.php b/backend/app/Http/Actions/Events/GetEventAction.php index f1ca76116..5df8dd1cc 100644 --- a/backend/app/Http/Actions/Events/GetEventAction.php +++ b/backend/app/Http/Actions/Events/GetEventAction.php @@ -5,6 +5,7 @@ namespace HiEvents\Http\Actions\Events; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\ImageDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\TaxAndFeesDomainObject; @@ -31,6 +32,7 @@ public function __invoke(int $eventId): JsonResponse $event = $this->eventRepository ->loadRelation(new Relationship(domainObject: OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(new Relationship(ImageDomainObject::class)) ->loadRelation( new Relationship(ProductCategoryDomainObject::class, [ new Relationship(ProductDomainObject::class, [ diff --git a/backend/app/Http/Actions/Events/Images/GetEventImagesAction.php b/backend/app/Http/Actions/Events/Images/GetEventImagesAction.php index 208a15dd0..c32b7b0ad 100644 --- a/backend/app/Http/Actions/Events/Images/GetEventImagesAction.php +++ b/backend/app/Http/Actions/Events/Images/GetEventImagesAction.php @@ -2,7 +2,6 @@ namespace HiEvents\Http\Actions\Events\Images; -use HiEvents\DomainObjects\Enums\ImageType; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Interfaces\ImageRepositoryInterface; @@ -22,7 +21,6 @@ public function __invoke(int $eventId): JsonResponse $images = $this->imageRepository->findWhere([ 'entity_id' => $eventId, 'entity_type' => EventDomainObject::class, - 'type' => ImageType::EVENT_COVER->name, ]); return $this->resourceResponse(ImageResource::class, $images); diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 74416cbaf..8dcf38728 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -75,6 +75,14 @@ public function rules(): array 'invoice_tax_details' => ['nullable', 'string'], 'invoice_notes' => ['nullable', 'string'], 'invoice_payment_terms_days' => ['nullable', 'integer', 'gte:0', 'lte:1000'], + + // Ticket design settings + 'ticket_design_settings' => ['nullable', 'array'], + 'ticket_design_settings.accent_color' => ['nullable', 'string', ...RulesHelper::HEX_COLOR], + 'ticket_design_settings.logo_image_id' => ['nullable', 'integer'], + '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'], ]; } @@ -106,6 +114,11 @@ public function messages(): array 'organization_name.required_if' => __('The organization name is required when invoicing is enabled.'), 'organization_address.required_if' => __('The organization address is required when invoicing is enabled.'), 'invoice_start_number.min' => __('The invoice start number must be at least 1.'), + + // Ticket design messages + 'ticket_design_settings.accent_color' => $colorMessage, + 'ticket_design_settings.footer_text.max' => __('The footer text may not be greater than 500 characters.'), + 'ticket_design_settings.layout_type.in' => __('The layout type must be default or modern.'), ]; } } diff --git a/backend/app/Models/EventSetting.php b/backend/app/Models/EventSetting.php index 98ce2114f..ebc40f249 100644 --- a/backend/app/Models/EventSetting.php +++ b/backend/app/Models/EventSetting.php @@ -13,6 +13,7 @@ protected function getCastMap(): array return [ 'location_details' => 'array', 'payment_providers' => 'array', + 'ticket_design_settings' => 'array', ]; } } diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index 84e323d20..4d6fb296e 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -47,6 +47,9 @@ public function toArray($request): array 'price_display_mode' => $this->getPriceDisplayMode(), 'hide_getting_started_page' => $this->getHideGettingStartedPage(), + // Ticket design settings + 'ticket_design_settings' => $this->getTicketDesignSettings(), + // Payment settings 'payment_providers' => $this->getPaymentProviders(), 'offline_payment_instructions' => $this->getOfflinePaymentInstructions(), diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index 11792cd9c..fa290dc0f 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -53,6 +53,9 @@ public function toArray($request): array 'location_details' => $this->getLocationDetails(), 'is_online_event' => $this->getIsOnlineEvent(), + // Ticket design settings + 'ticket_design_settings' => $this->getTicketDesignSettings(), + // SEO settings 'seo_title' => $this->getSeoTitle(), 'seo_description' => $this->getSeoDescription(), diff --git a/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php index 7ef12805e..e8380b176 100644 --- a/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; +use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\Exceptions\ResourceConflictException; @@ -32,7 +33,9 @@ public function __construct( public function handle(ResendAttendeeTicketDTO $resendAttendeeProductDTO): void { $attendee = $this->attendeeRepository - ->loadRelation(new Relationship(OrderDomainObject::class, name: 'order')) + ->loadRelation(new Relationship(OrderDomainObject::class, nested: [ + new Relationship(OrderItemDomainObject::class), + ], name: 'order')) ->findFirstWhere([ 'id' => $resendAttendeeProductDTO->attendeeId, 'event_id' => $resendAttendeeProductDTO->eventId, diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index fb7721096..cd9ab7bc8 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -66,6 +66,9 @@ public function __construct( public readonly ?string $invoice_tax_details = null, public readonly ?string $invoice_notes = null, public readonly ?int $invoice_payment_terms_days = null, + + // Ticket design settings + public readonly ?array $ticket_design_settings = null, ) { } @@ -121,6 +124,15 @@ public static function createWithDefaults( invoice_tax_details: null, invoice_notes: null, invoice_payment_terms_days: null, + + // Ticket design defaults + ticket_design_settings: [ + 'accent_color' => '#333333', + 'logo_image_id' => null, + 'footer_text' => null, + 'layout_type' => 'classic', + 'enabled' => true, + ], ); } } diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index 817d82776..67bb45602 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -10,11 +10,11 @@ use HiEvents\Services\Application\Handlers\EventSettings\DTO\UpdateEventSettingsDTO; use Throwable; -readonly class PartialUpdateEventSettingsHandler +class PartialUpdateEventSettingsHandler { public function __construct( - private UpdateEventSettingsHandler $eventSettingsHandler, - private EventSettingsRepositoryInterface $eventSettingsRepository, + private readonly UpdateEventSettingsHandler $eventSettingsHandler, + private readonly EventSettingsRepositoryInterface $eventSettingsRepository, ) { } @@ -116,7 +116,12 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe : $existingSettings->getInvoiceNotes(), 'invoice_payment_terms_days' => array_key_exists('invoice_payment_terms_days', $eventSettingsDTO->settings) ? $eventSettingsDTO->settings['invoice_payment_terms_days'] - : $existingSettings->getInvoicePaymentTermsDays() + : $existingSettings->getInvoicePaymentTermsDays(), + + // Ticket design settings + 'ticket_design_settings' => array_key_exists('ticket_design_settings', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['ticket_design_settings'] + : $existingSettings->getTicketDesignSettings() ]), ); } diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index b73a1d17e..443f135ba 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -78,6 +78,9 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje 'invoice_tax_details' => $this->purifier->purify($settings->invoice_tax_details), 'invoice_notes' => $this->purifier->purify($settings->invoice_notes), 'invoice_payment_terms_days' => $settings->invoice_payment_terms_days, + + // Ticket design settings + 'ticket_design_settings' => $settings->ticket_design_settings, ], where: [ 'event_id' => $settings->event_id, diff --git a/backend/app/Services/Application/Handlers/Images/CreateImageHandler.php b/backend/app/Services/Application/Handlers/Images/CreateImageHandler.php index 37a1b85ca..ff74876a6 100644 --- a/backend/app/Services/Application/Handlers/Images/CreateImageHandler.php +++ b/backend/app/Services/Application/Handlers/Images/CreateImageHandler.php @@ -20,6 +20,7 @@ class CreateImageHandler ImageType::ORGANIZER_LOGO, ImageType::ORGANIZER_COVER, ImageType::EVENT_COVER, + ImageType::TICKET_LOGO, ]; public function __construct( diff --git a/backend/app/Services/Domain/Event/CreateEventImageService.php b/backend/app/Services/Domain/Event/CreateEventImageService.php index 33d9094b7..2b448cea6 100644 --- a/backend/app/Services/Domain/Event/CreateEventImageService.php +++ b/backend/app/Services/Domain/Event/CreateEventImageService.php @@ -11,6 +11,9 @@ use Illuminate\Http\UploadedFile; use Throwable; +/** + * @deprecated use CreateImageAction + */ class CreateEventImageService { public function __construct( diff --git a/backend/database/migrations/2025_09_04_071235_add_ticket_design_settings_to_event_settings.php b/backend/database/migrations/2025_09_04_071235_add_ticket_design_settings_to_event_settings.php new file mode 100644 index 000000000..b7ab6b100 --- /dev/null +++ b/backend/database/migrations/2025_09_04_071235_add_ticket_design_settings_to_event_settings.php @@ -0,0 +1,28 @@ +jsonb('ticket_design_settings')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('event_settings', function (Blueprint $table) { + $table->dropColumn('ticket_design_settings'); + }); + } +}; diff --git a/frontend/src/components/common/AttendeeDetails/index.tsx b/frontend/src/components/common/AttendeeDetails/index.tsx index b54f88267..eb0baea1e 100644 --- a/frontend/src/components/common/AttendeeDetails/index.tsx +++ b/frontend/src/components/common/AttendeeDetails/index.tsx @@ -1,5 +1,5 @@ import {Anchor} from "@mantine/core"; -import {Attendee} from "../../../types.ts"; +import {Attendee, Product} from "../../../types.ts"; import classes from "./AttendeeDetails.module.scss"; import {t} from "@lingui/macro"; import {getAttendeeProductTitle} from "../../../utilites/products.ts"; @@ -37,7 +37,7 @@ export const AttendeeDetails = ({attendee}: { attendee: Attendee }) => { {t`Product`}
- {getAttendeeProductTitle(attendee)} + {getAttendeeProductTitle(attendee, attendee.product as Product)}
diff --git a/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss b/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss index 311a9b438..afa98016e 100644 --- a/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss +++ b/frontend/src/components/common/AttendeeTicket/AttendeeTicket.module.scss @@ -1,140 +1,443 @@ @use "../../../styles/mixins"; -.attendee { - display: flex; - justify-content: space-between; - border-radius: 10px; - background-color: #ffffff; - border: 1px solid #ddd; +@media print { + @page { + size: A4; + margin: 0.5in; + } + + body { + font-size: 12pt; + line-height: 1.4; + } +} + +.ticket { + background: #ffffff; + border-radius: var(--mantine-radius-md); + border: 1px solid var(--mantine-color-gray-3); + max-width: 750px; + margin: 0 auto; overflow: hidden; - padding: 0; - margin-bottom: 20px; + display: flex; + flex-direction: column; @media print { + box-shadow: none; + border: none; + border-radius: 0; + max-width: 100%; + width: 100%; + height: auto; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + justify-content: flex-start; + page-break-after: avoid; page-break-inside: avoid; - break-inside: avoid; - margin-bottom: 100px; - - &:last-child { - margin-bottom: 0; - } } +} + +// Header +.header { + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + padding: 24px 32px; @include mixins.respond-below(sm) { - flex-direction: column-reverse; + padding: 20px; } - .attendeeInfo { - display: flex; + @media print { + background: #ffffff; + border-bottom: 2px solid #e0e0e0; + padding: 0 0 30px 0; + margin-bottom: 30px; + } +} + +.headerContent { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + + @include mixins.respond-below(sm) { flex-direction: column; + align-items: flex-start; + gap: 16px; + } +} + +.eventTitle { + font-size: 24px; + font-weight: 600; + color: #1e293b; + margin: 0; + line-height: 1.3; + letter-spacing: -0.01em; + + @include mixins.respond-below(sm) { + font-size: 20px; + } + + @media print { + font-size: 32px; + margin-bottom: 10px; + } +} + +.priceDisplay { + font-size: 18px; + font-weight: 600; + color: var(--accent); + white-space: nowrap; + + @include mixins.respond-below(sm) { + font-size: 16px; + } + + @media print { + font-size: 24px; + color: #1e293b; + font-weight: 700; + } +} + +// Content +.content { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 32px; + padding: 32px; + flex: 1; + align-items: start; + + @include mixins.respond-below(md) { + grid-template-columns: 1fr; + gap: 24px; + padding: 24px 20px; + } + + @media print { + grid-template-columns: 2fr 1fr; + padding: 0; + gap: 50px; + flex: 1; + align-items: center; + } +} + +.contentLeft { + display: flex; + flex-direction: column; + gap: 28px; + + @include mixins.respond-below(sm) { + gap: 20px; + } + + @media print { + gap: 40px; justify-content: center; + } +} + +// Event Details +.eventDetails { + display: flex; + flex-direction: column; + gap: 20px; + + @include mixins.respond-below(sm) { + gap: 18px; + } + + @media print { + gap: 30px; + } +} + +.detailRow { + display: flex; + flex-direction: column; + gap: 6px; +} + +.detailLabel { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + + @media print { + font-size: 14px; + color: #666666; + font-weight: 600; + } +} + +.detailValue { + font-size: 16px; + font-weight: 500; + color: #1e293b; + line-height: 1.4; + + @include mixins.respond-below(sm) { + font-size: 15px; + } + + @media print { + font-size: 20px; + font-weight: 600; + } +} + +// Attendee Section +.attendeeSection { + background: #f1f5f9; + padding: 24px; + border-radius: 4px; + margin-top: auto; + + @include mixins.respond-below(sm) { padding: 20px; - flex: 1; - place-content: space-between; + } - .attendeeNameAndPrice { - place-self: flex-start; - display: flex; - justify-content: space-between; - flex-direction: row; - width: 100%; + @media print { + background: #ffffff; + padding: 35px; + border-radius: 8px; + border: 1px solid #d0d0d0; + } +} - .attendeeName { - flex: 1; - } - - .productName { - font-size: 0.9em; - font-weight: 900; - margin-bottom: 5px; - } - - .productPrice { - .badge { - background-color: #8bc34a; - color: #fff; - padding: 5px 10px; - border-radius: 10px; - font-size: 0.8em; - } - } - - h2 { - margin: 0; - } - } +.attendeeName { + font-size: 18px; + font-weight: 600; + color: #1e293b; + margin: 8px 0 4px; - .eventInfo { - .eventName { - font-weight: 900; - } - } + @include mixins.respond-below(sm) { + font-size: 17px; + } - a { - font-size: 0.9em; - } + @media print { + font-size: 24px; + margin: 12px 0 8px; + font-weight: 700; } +} - .qrCode { - .attendeeCode { - padding: 5px; - margin-bottom: 20px; - font-weight: 900; - font-size: 0.8em; - } +.attendeeEmail { + font-size: 14px; + color: #64748b; - justify-content: flex-end; - align-items: center; - display: flex; + @media print { + font-size: 18px; + color: #666666; + } +} + +// Footer Text +.footerText { + font-size: 14px; + color: #475569; + line-height: 1.5; + flex: 1; + + @include mixins.respond-below(sm) { + font-size: 13px; + } + + @media print { + color: #555555; + font-size: 16px; + line-height: 1.6; + } +} + +// Right Section - QR Code +.contentRight { + display: flex; + justify-content: center; + align-items: flex-start; + + @include mixins.respond-below(md) { + order: -1; + justify-content: center; + padding: 20px 0; + } +} + +.qrSection { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + justify-content: center; + + @media print { + gap: 30px; + justify-content: flex-start; + } +} + +.logoContainer { + display: flex; + justify-content: center; + margin-bottom: 16px; +} + +.logo { + 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 { + position: relative; + padding: 16px; + background: #ffffff; + border: 3px solid var(--accent); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + + @include mixins.respond-below(sm) { + padding: 14px; + } + + @media print { + border-color: var(--accent); + padding: 25px; + border-width: 4px; + } +} + +.statusOverlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.95); + border-radius: 3px; +} + +.cancelled { + color: #dc2626; + font-weight: 600; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.pending { + color: #ea580c; + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + text-align: center; +} + +.ticketId { + text-align: center; + display: flex; + flex-direction: column; + gap: 8px; +} + +.ticketIdValue { + font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 14px; + font-weight: 600; + color: var(--accent); + background: #f8fafc; + padding: 8px 16px; + border-radius: 4px; + letter-spacing: 0.05em; + + @include mixins.respond-below(sm) { + font-size: 13px; + padding: 6px 12px; + } + + @media print { + color: var(--accent); + background: #f0f0f0; + font-size: 18px; + padding: 12px 20px; + font-weight: 700; + } +} + +// Footer +.footer { + background: #f8fafc; + border-top: 1px solid #e2e8f0; + padding: 20px 32px; + + @include mixins.respond-below(sm) { + padding: 16px 20px; + } + + @media print { + background: #ffffff; + border-top: 2px solid #e0e0e0; + padding: 30px 0 0 0; + margin-top: 30px; + } +} + +.footerContent { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + + @include mixins.respond-below(md) { flex-direction: column; - background-color: #f8f8f8; - border-left: 1px solid #ddd; - padding: 15px; + gap: 16px; + text-align: center; + } +} - @include mixins.respond-below(sm) { - border-left: none; - } - .qrImage { - svg { - width: 180px; - height: 180px; - } - - @media print { - svg { - width: 220px; - height: 220px; - } - } - - .cancelled { - height: 140px; - padding: 20px; - font-size: 1.1em; - display: flex; - justify-content: center; - align-items: center; - color: #d64646; - width: 140px; - } - - .awaitingPayment { - font-size: 1em; - display: flex; - justify-content: center; - align-items: center; - color: #e09300; - font-weight: 900; - margin-bottom: 10px; - } - } +.actions { + display: flex; + gap: 12px; + + @include mixins.respond-below(md) { + width: 100%; + justify-content: center; + } - .productButtons { - background: #ffffff; - border-radius: 5px; - margin-top: 20px; - border: 1px solid #d1d1d1; + @include mixins.respond-below(sm) { + flex-direction: column; + max-width: 280px; + gap: 8px; + + button { + width: 100%; } } + + @media print { + display: none; + } } diff --git a/frontend/src/components/common/AttendeeTicket/index.tsx b/frontend/src/components/common/AttendeeTicket/index.tsx index 622ac9b12..cb75a732b 100644 --- a/frontend/src/components/common/AttendeeTicket/index.tsx +++ b/frontend/src/components/common/AttendeeTicket/index.tsx @@ -1,6 +1,5 @@ -import {Card} from "../Card"; import {getAttendeeProductPrice, getAttendeeProductTitle} from "../../../utilites/products.ts"; -import {Anchor, Button, CopyButton} from "@mantine/core"; +import {Button, CopyButton} from "@mantine/core"; import {formatCurrency} from "../../../utilites/currency.ts"; import {t} from "@lingui/macro"; import {prettyDate} from "../../../utilites/dates.ts"; @@ -8,6 +7,7 @@ import QRCode from "react-qr-code"; import {IconCopy, IconPrinter} from "@tabler/icons-react"; import {Attendee, Event, Product} from "../../../types.ts"; import classes from './AttendeeTicket.module.scss'; +import {imageUrl} from "../../../utilites/urlHelper.ts"; interface AttendeeTicketProps { event: Event; @@ -16,85 +16,154 @@ interface AttendeeTicketProps { hideButtons?: boolean; } -export const AttendeeTicket = ({attendee, product, event, hideButtons = false}: AttendeeTicketProps) => { +export const AttendeeTicket = ({ + attendee, + product, + event, + hideButtons = false, + }: AttendeeTicketProps) => { const productPrice = getAttendeeProductPrice(attendee, product); + const hasVenue = event?.settings?.location_details?.venue_name || event?.settings?.location_details?.address_line_1; + + const ticketDesignSettings = event?.settings?.ticket_design_settings; + const accentColor = ticketDesignSettings?.accent_color || '#6B46C1'; + const footerText = ticketDesignSettings?.footer_text; + const logoUrl = imageUrl('TICKET_LOGO', event?.images); + + const ticketStyle = { + '--accent': accentColor, + } as React.CSSProperties; + + const isCancelled = attendee.status === 'CANCELLED'; + const isAwaitingPayment = attendee.status === 'AWAITING_PAYMENT'; return ( - -
-
-
-

- {attendee.first_name} {attendee.last_name} -

-
- {getAttendeeProductTitle(attendee)} -
- - {attendee.email} - -
-
-
- {productPrice > 0 && formatCurrency(productPrice, event?.currency)} - {productPrice === 0 && t`Free`} -
-
-
-
-
- {event?.title} -
-
- {prettyDate(event.start_date, event.timezone)} +
+ {/* Header */} +
+
+

{event?.title}

+
+ {productPrice > 0 ? formatCurrency(productPrice, event?.currency) : t`Free`}
-
-
- {attendee.public_id} -
-
- {attendee.status === 'CANCELLED' && ( -
- {t`Cancelled`} + {/* Main Content */} +
+
+ {/* Event Details */} +
+
+
{t`Date & Time`}
+
+ {prettyDate(event.start_date, event.timezone)} +
- )} - {attendee.status === 'AWAITING_PAYMENT' && ( -
- {t`Awaiting Payment`} + {hasVenue && ( +
+
{t`Venue`}
+
+ {event?.settings?.location_details?.venue_name} + {event?.settings?.location_details?.address_line_1 && ( + <>, {event?.settings?.location_details?.address_line_1} + )} +
+
+ )} + +
+
{t`Ticket Type`}
+
+ {getAttendeeProductTitle(attendee, product)} +
- )} - {attendee.status !== 'CANCELLED' && } +
+ + {/* Attendee Information */} +
+
{t`Attendee`}
+
+ {attendee.first_name} {attendee.last_name} +
+
{attendee.email}
+
- {!hideButtons && ( -
- - - - {({copied, copy}) => ( - + {/* Right Section - Logo & QR Code */} +
+
+ {logoUrl && ( +
+ Event Logo +
+ )} + +
+ {(isCancelled || isAwaitingPayment) ? ( +
+ + {isCancelled ? t`Cancelled` : t`Awaiting Payment`} + +
+ ) : ( + )} - +
+ +
+
{t`Ticket ID`}
+
{attendee.public_id}
+
- )} +
- + + {/* Footer - Only show if there's footer text or buttons */} + {(footerText || !hideButtons) && ( +
+
+ {footerText && ( +
+ {footerText} +
+ )} + + {!hideButtons && ( +
+ + + + {({copied, copy}) => ( + + )} + +
+ )} +
+
+ )} +
); } diff --git a/frontend/src/components/layouts/Event/index.tsx b/frontend/src/components/layouts/Event/index.tsx index d0cbce3de..9ae2507fd 100644 --- a/frontend/src/components/layouts/Event/index.tsx +++ b/frontend/src/components/layouts/Event/index.tsx @@ -108,6 +108,7 @@ const EventLayout = () => { {label: t`Tools`}, {link: 'homepage-designer', label: t`Homepage Designer`, icon: IconPaint}, + {link: 'ticket-designer', label: t`Ticket Design`, icon: IconTicket}, {link: 'widget', label: t`Widget Embed`, icon: IconDeviceTabletCode}, {link: 'webhooks', label: t`Webhooks`, icon: IconWebhook}, ]; diff --git a/frontend/src/components/modals/CreateAttendeeModal/index.tsx b/frontend/src/components/modals/CreateAttendeeModal/index.tsx index f1b831585..9cde645a3 100644 --- a/frontend/src/components/modals/CreateAttendeeModal/index.tsx +++ b/frontend/src/components/modals/CreateAttendeeModal/index.tsx @@ -1,5 +1,5 @@ import {Modal} from "../../common/Modal"; -import {GenericModalProps, ProductCategory, ProductType} from "../../../types.ts"; +import {GenericModalProps, IdParam, ProductCategory, ProductType} from "../../../types.ts"; import {Button} from "../../common/Button"; import {useNavigate, useParams} from "react-router"; import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx"; @@ -73,6 +73,17 @@ export const CreateAttendeeModal = ({onClose}: GenericModalProps) => { } }, [form.values.product_id]); + useEffect(() => { + if (form.values.product_price_id && !form.values.amount_paid) { + form.setFieldValue( + 'amount_paid', + Number(eventProducts + ?.find(product => product.id == form.values.product_id)?.prices + ?.find(productPrice => (productPrice.id as IdParam) = form.values.product_price_id)?.price) + ); + } + }, [form.values.product_price_id]); + const handleSubmit = (values: CreateAttendeeRequest) => { mutation.mutate({ eventId: eventId, diff --git a/frontend/src/components/routes/event/TicketDesigner/TicketDesigner.module.scss b/frontend/src/components/routes/event/TicketDesigner/TicketDesigner.module.scss new file mode 100644 index 000000000..c2f478f97 --- /dev/null +++ b/frontend/src/components/routes/event/TicketDesigner/TicketDesigner.module.scss @@ -0,0 +1,210 @@ +@use "../../../../styles/mixins.scss"; + +.container { + display: flex; + flex-direction: row; + margin: calc(var(--hi-spacing-lg) * -1); + min-height: calc(100vh - 60px); + + h2 { + margin-bottom: 0; + } + + @include mixins.respond-below(lg) { + flex-direction: column; + margin: 0; + min-height: auto; + } +} + +.sidebar { + min-width: 380px; + max-width: 380px; + background-color: #ffffff; + padding: var(--hi-spacing-lg); + height: calc(100vh - 55px); + overflow-y: auto; + position: sticky; + top: 0; + border-right: 1px solid var(--mantine-color-gray-2); + + @include mixins.respond-below(lg) { + width: 100%; + min-width: unset; + max-width: unset; + position: relative; + overflow: auto; + height: auto; + border-right: none; + border-bottom: 1px solid var(--mantine-color-gray-2); + padding: var(--hi-spacing-md); + } +} + +.sticky { + position: sticky; + top: 0; +} + +.header { + margin-bottom: var(--hi-spacing-lg); + padding-bottom: var(--hi-spacing-md); + border-bottom: 1px solid var(--mantine-color-gray-2); + + h2 { + margin: 0 0 var(--hi-spacing-xs) 0; + font-size: 1.375rem; + font-weight: 600; + color: var(--mantine-color-gray-9); + } +} + +.accordion { + margin-bottom: 0; + + .accordionItem { + border: 1px solid var(--mantine-color-gray-3); + border-radius: var(--mantine-radius-md); + overflow: hidden; + + &:not(:last-child) { + margin-bottom: var(--hi-spacing-md); + } + + :global(.mantine-Accordion-control) { + padding: var(--hi-spacing-md); + background: var(--mantine-color-gray-0); + + &:hover { + background: var(--mantine-color-gray-1); + } + + &[data-active] { + border-bottom: 1px solid var(--mantine-color-gray-2); + } + } + + :global(.mantine-Accordion-panel) { + padding: 0; + background: white; + } + + :global(.mantine-Accordion-content) { + padding: var(--hi-spacing-lg); + } + } +} + +.fieldset { + border: none; + padding: 0; + margin: 0; + + // Fix large margins on ColorInput components + :global(.mantine-ColorInput-root) { + margin-bottom: 0; + } + + :global(.mantine-ColorInput-label) { + font-weight: 500; + font-size: 0.875rem; + } + + :global(.mantine-ColorInput-description) { + font-size: 0.8125rem; + margin-top: 0.25rem; + } + + // TextInput styling + :global(.mantine-TextInput-label) { + font-weight: 500; + font-size: 0.875rem; + } + + :global(.mantine-TextInput-description) { + font-size: 0.8125rem; + margin-top: 0.25rem; + } + + &:disabled { + opacity: 0.6; + pointer-events: none; + } +} + +.preview { + height: calc(100vh - 55px); + width: 100%; + overflow: hidden; + min-width: 500px; + display: flex; + flex-direction: column; + background: white; + border-left: 1px solid var(--mantine-color-gray-2); + + @include mixins.respond-below(lg) { + width: 100%; + min-width: unset; + max-width: unset; + position: relative; + overflow: auto; + height: auto; + border-left: none; + border-top: 1px solid var(--mantine-color-gray-2); + padding: 0; + } +} + +.previewHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background: white; + border-bottom: 1px solid var(--mantine-color-gray-3); + + h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + } +} + +.previewContent { + flex: 1; + overflow: auto; + display: flex; + background: var(--mantine-color-gray-0); + min-height: 0; + min-width: 0; + + @include mixins.respond-below(md) { + min-height: 400px; + } + + @include mixins.respond-below(sm) { + min-height: 350px; + } +} + +@media (max-width: 768px) { + .container { + flex-direction: column; + height: auto; + } + + .sidebar { + width: 100%; + overflow-y: visible; + padding-right: 0; + } + + .preview { + min-height: 500px; + padding: 0; + } + + .previewHeader { + padding: 0.75rem 1rem; + } +} \ No newline at end of file diff --git a/frontend/src/components/routes/event/TicketDesigner/TicketDesignerPrint.tsx b/frontend/src/components/routes/event/TicketDesigner/TicketDesignerPrint.tsx new file mode 100644 index 000000000..410819a54 --- /dev/null +++ b/frontend/src/components/routes/event/TicketDesigner/TicketDesignerPrint.tsx @@ -0,0 +1,101 @@ +import {useParams} from 'react-router'; +import {useGetEvent} from '../../../../queries/useGetEvent.ts'; +import {useGetMe} from '../../../../queries/useGetMe.ts'; +import {useGetEventSettings} from '../../../../queries/useGetEventSettings.ts'; +import {useGetEventImages} from '../../../../queries/useGetEventImages.ts'; +import {AttendeeTicket} from '../../../common/AttendeeTicket'; +import {PoweredByFooter} from '../../../common/PoweredByFooter'; +import {t} from '@lingui/macro'; +import {useEffect} from "react"; +import classes from '../../../routes/product-widget/PrintOrder/PrintOrder.module.scss'; + +const TicketDesignerPrint = () => { + const {eventId} = useParams(); + const eventQuery = useGetEvent(eventId); + const meQuery = useGetMe(); + const settingsQuery = useGetEventSettings(eventId); + const imagesQuery = useGetEventImages(eventId); + + const event = eventQuery.data; + const user = meQuery.data; + const settings = settingsQuery.data; + const images = imagesQuery.data; + + useEffect(() => { + if (event && user && settings) { + setTimeout(() => window?.print(), 500); + } + }, [event, user, settings]); + + if (!event || !user || !settings) { + return null; + } + + const mockProduct = { + id: 1, + title: t`General Admission`, + price: 2500, + type: "TICKET" as const, + sale_start_date: null, + sale_end_date: null, + max_per_order: null, + min_per_order: null, + quantity_available: null, + is_hidden: false, + sort_order: 1, + description: "", + is_hidden_without_promo_code: false + }; + + const mockAttendee = { + id: 1, + public_id: "PREVIEW12345", + short_id: "P1234", + first_name: user.first_name || "John", + last_name: user.last_name || "Doe", + email: user.email || "john.doe@example.com", + status: "ACTIVE" as const, + checked_in_at: null, + product_id: mockProduct.id, + product: mockProduct, + product_price_id: 1, + order_id: 1, + order: { + id: 1, + short_id: "ORD123", + public_id: "ORD123", + created_at: new Date().toISOString(), + total_gross: 2500, + currency: event.currency || "USD", + } + }; + + // Merge the ticket design settings and images into the event + const eventWithDesignSettings = { + ...event, + settings: { + ...event.settings, + ticket_design_settings: settings.ticket_design_settings + }, + images: images || [] + }; + + return ( +
+

{t`Ticket Preview for`} {event.title}

+
+ +
+ +
+
+
+ ); +} + +export default TicketDesignerPrint; diff --git a/frontend/src/components/routes/event/TicketDesigner/TicketPreview.module.scss b/frontend/src/components/routes/event/TicketDesigner/TicketPreview.module.scss new file mode 100644 index 000000000..f6569ecc8 --- /dev/null +++ b/frontend/src/components/routes/event/TicketDesigner/TicketPreview.module.scss @@ -0,0 +1,44 @@ +@use "../../../../styles/mixins.scss"; + +.previewWrapper { + display: flex; + justify-content: center; + align-items: flex-start; + padding: 2rem; + width: 100%; + height: 100%; + overflow-x: auto; + overflow-y: auto; + + @include mixins.respond-below(lg) { + padding: 1.5rem; + overflow-x: auto; + min-width: 0; + + // Allow horizontal scroll on tablets when ticket is wider than viewport + > * { + flex-shrink: 0; + } + } + + @include mixins.respond-below(md) { + padding: 1rem; + } + + @include mixins.respond-below(sm) { + padding: 0.5rem; + } +} + +.loadingState { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + color: #6c757d; + font-style: italic; + + p { + margin: 0; + } +} \ No newline at end of file diff --git a/frontend/src/components/routes/event/TicketDesigner/TicketPreview.tsx b/frontend/src/components/routes/event/TicketDesigner/TicketPreview.tsx new file mode 100644 index 000000000..7e9e0dd6a --- /dev/null +++ b/frontend/src/components/routes/event/TicketDesigner/TicketPreview.tsx @@ -0,0 +1,107 @@ +import {useGetEvent} from "../../../../queries/useGetEvent.ts"; +import {useGetMe} from "../../../../queries/useGetMe.ts"; +import {t} from "@lingui/macro"; +import {IdParam} from "../../../../types.ts"; +import {AttendeeTicket} from "../../../common/AttendeeTicket"; +import classes from './TicketPreview.module.scss'; + +interface TicketDesignSettings { + accent_color: string; + logo_image_id: IdParam | null; + footer_text: string | null; + enabled: boolean; +} + +interface TicketPreviewProps { + settings: TicketDesignSettings; + eventId: IdParam; + logoUrl?: string; +} + +export const TicketPreview = ({settings, eventId, logoUrl}: TicketPreviewProps) => { + const eventQuery = useGetEvent(eventId); + const meQuery = useGetMe(); + + const event = eventQuery.data; + const user = meQuery.data; + + if (!event || !user) { + return ( +
+

{t`Loading preview...`}

+
+ ); + } + + const mockProduct = { + id: 1, + title: t`General Admission`, + price: 2500, + type: "TICKET" as const, + sale_start_date: null, + sale_end_date: null, + max_per_order: null, + min_per_order: null, + quantity_available: null, + is_hidden: false, + sort_order: 1, + description: "", + is_hidden_without_promo_code: false + }; + + const mockAttendee = { + id: 1, + public_id: "PREVIEW12345", + short_id: "P1234", + first_name: user.first_name || "John", + last_name: user.last_name || "Doe", + email: user.email || "john.doe@example.com", + status: "ACTIVE" as const, + checked_in_at: null, + product_id: mockProduct.id, + product: mockProduct, + product_price_id: 1, + order_id: 1, + order: { + id: 1, + short_id: "ORD123", + created_at: new Date().toISOString(), + total_gross: 2500, + currency: event.currency || "USD", + } + }; + + const eventWithDesignSettings = { + ...event, + settings: { + ...event.settings, + ticket_design_settings: { + accent_color: settings.accent_color, + logo_image_id: settings.logo_image_id, + footer_text: settings.footer_text, + enabled: settings.enabled + } + }, + images: logoUrl && settings.logo_image_id ? [ + ...((event.images || []).filter(img => img.type !== 'TICKET_LOGO')), + { + id: settings.logo_image_id, + type: 'TICKET_LOGO' as const, + url: logoUrl, + size_bytes: 0, + filename: '' + } + ] : (event.images || []).filter(img => img.type !== 'TICKET_LOGO') + }; + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/components/routes/event/TicketDesigner/index.tsx b/frontend/src/components/routes/event/TicketDesigner/index.tsx new file mode 100644 index 000000000..a9068c689 --- /dev/null +++ b/frontend/src/components/routes/event/TicketDesigner/index.tsx @@ -0,0 +1,220 @@ +import {useEffect, useState} from "react"; +import classes from './TicketDesigner.module.scss'; +import {useParams} from "react-router"; +import {useGetEventSettings} from "../../../../queries/useGetEventSettings.ts"; +import {useUpdateEventSettings} from "../../../../mutations/useUpdateEventSettings.ts"; +import {useFormErrorResponseHandler} from "../../../../hooks/useFormErrorResponseHandler.tsx"; +import {IdParam} from "../../../../types.ts"; +import {showSuccess} from "../../../../utilites/notifications.tsx"; +import {t} from "@lingui/macro"; +import {useForm} from "@mantine/form"; +import {Button, ColorInput, Textarea, Accordion, Stack, Text, Group} from "@mantine/core"; +import {IconColorSwatch, IconHelp, IconPrinter} from "@tabler/icons-react"; +import {Tooltip} from "../../../common/Tooltip"; +import {ImageUploadDropzone} from "../../../common/ImageUploadDropzone"; +import {queryClient} from "../../../../utilites/queryClient.ts"; +import {GET_EVENT_IMAGES_QUERY_KEY, useGetEventImages} from "../../../../queries/useGetEventImages.ts"; +import {LoadingMask} from "../../../common/LoadingMask"; +import {TicketPreview} from "./TicketPreview"; + +interface TicketDesignSettings { + accent_color: string; + logo_image_id: IdParam; + footer_text: string | null; + enabled: boolean; +} + +const TicketDesigner = () => { + const {eventId} = useParams(); + const eventSettingsQuery = useGetEventSettings(eventId); + const eventImagesQuery = useGetEventImages(eventId); + const updateMutation = useUpdateEventSettings(); + + const [accordionValue, setAccordionValue] = useState(['design']); + + const existingLogo = eventImagesQuery.data?.find((image) => image.type === 'TICKET_LOGO'); + + const form = useForm({ + initialValues: { + accent_color: '#333333', + logo_image_id: undefined, + footer_text: '', + enabled: true, + } + }); + + const formErrorHandle = useFormErrorResponseHandler(); + + useEffect(() => { + if (eventSettingsQuery?.isFetched && eventSettingsQuery?.data?.ticket_design_settings) { + const settings = eventSettingsQuery.data.ticket_design_settings; + form.setValues({ + accent_color: settings.accent_color || '#333333', + logo_image_id: settings.logo_image_id || undefined, + footer_text: settings.footer_text || '', + enabled: settings.enabled !== false, + }); + } + }, [eventSettingsQuery.isFetched]); + + useEffect(() => { + if (existingLogo?.id) { + form.setFieldValue('logo_image_id', existingLogo.id); + } else { + form.setFieldValue('logo_image_id', null); + } + }, [existingLogo?.id]); + + const handleSubmit = (values: TicketDesignSettings) => { + updateMutation.mutate( + { + eventSettings: { + ticket_design_settings: { + accent_color: values.accent_color, + logo_image_id: values.logo_image_id, + footer_text: values.footer_text || undefined, + enabled: values.enabled + } + }, + eventId: eventId + }, + { + onSuccess: () => { + showSuccess(t`Ticket design saved successfully`); + }, + onError: (error) => { + formErrorHandle(form, error); + }, + } + ); + }; + + const handleImageChange = () => { + queryClient.invalidateQueries({ + queryKey: [GET_EVENT_IMAGES_QUERY_KEY, eventId] + }); + }; + + if (eventSettingsQuery.isLoading || eventImagesQuery.isLoading) { + return ; + } + + return ( +
+
+
+
+

{t`Ticket Design`}

+ {t`Customize your ticket appearance`} +
+ +
+
+ + + }> + {t`Design Elements`} + + + +
+ +
+ +
+ + {t`Logo`} + + + + + +
+ +
+