diff --git a/CLAUDE.md b/CLAUDE.md index 7141894ff6..14d0e4a469 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,10 @@ cd docker/development ./start-dev.sh --certs=signed # Signed certs with mkcert ``` +### OpenAPI docs (Scramble) + +API docs are auto-generated by `dedoc/scramble` from FormRequest `rules()` and JsonResource `toArray()` — UI at `https://localhost:8443/docs/api` (backend container, local only), spec via `php artisan scramble:export`. After adding or changing endpoints, run `php artisan scramble:analyze` (also enforced by `tests/Feature/OpenApi/OpenApiGenerationTest.php`). Custom inference extensions live in `backend/app/OpenApi/` (BaseAction response helpers, pagination query params, binary downloads); route filtering and the JWT security scheme are in `ScrambleServiceProvider`. `/admin/*`, sitemaps, and `mail-test` are deliberately excluded from the spec. `CreateOrderRequest`/`CompleteOrderRequest` return their real (runtime-dependent) rules only when a route is bound and a static documentation shape otherwise — keep the two in sync when changing checkout validation. Status/type fields in the Order/Event/Attendee/Product resources carry `/** @var 'A'|'B' */` literal-union annotations that render as schema enums — update them when adding enum cases. Outgoing webhook payloads are documented in the overview markdown in `config/scramble.php` — update the table when adding `DomainEventType` cases. + ### API smoke-testing (after backend changes) Unit tests miss wiring bugs — exercise changed endpoints against the dev stack. Base URL `https://localhost:8443/api` (self-signed — `curl -sk`). Endpoints are defined in `backend/routes/api.php`. diff --git a/backend/.gitignore b/backend/.gitignore index 0b22aedcc2..fbd6611f57 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -23,3 +23,4 @@ yarn-error.log .idea /app-back .vapor +/openapi.json diff --git a/backend/app/Console/Commands/Demo/ConferenceDemoEvent.php b/backend/app/Console/Commands/Demo/ConferenceDemoEvent.php new file mode 100644 index 0000000000..d9a805d540 --- /dev/null +++ b/backend/app/Console/Commands/Demo/ConferenceDemoEvent.php @@ -0,0 +1,471 @@ +timezone) + ->addDays(90) + ->next(CarbonImmutable::THURSDAY) + ->setTime(9, 0); + $day2 = $day1->addDay()->setTime(18, 0); + $workshopDay = $day1->subDay(); + + $vatId = $this->ctx->taxOrFeeId($owner->account_id, 'VAT', TaxCalculationType::PERCENTAGE, TaxType::TAX, 23.0, 'Irish VAT at the standard 23% rate, applied to conference passes and workshops.'); + $feeId = $this->ctx->taxOrFeeId($owner->account_id, 'Booking fee', TaxCalculationType::FIXED, TaxType::FEE, 2.50, 'Covers payment processing and badge printing.'); + + $location = $this->ctx->createLocation->handle(new UpsertLocationDTO( + organizer_id: $owner->organizer_id, + account_id: $owner->account_id, + name: 'The Round Room at the Mansion House', + structured_address: new AddressDTO( + venue_name: 'The Round Room at the Mansion House', + address_line_1: 'Dawson Street', + city: 'Dublin', + state_or_region: 'Co. Dublin', + zip_or_postal_code: 'D02 AF30', + country: 'IE', + ), + latitude: 53.3398, + longitude: -6.2578, + )); + + $event = $this->ctx->createEvent->handle(new CreateEventDTO( + title: 'RUNTIME 26 — Two days on the systems behind the systems', + organizer_id: $owner->organizer_id, + account_id: $owner->account_id, + user_id: $owner->user_id, + start_date: $day1->toDateTimeString(), + end_date: $day2->toDateTimeString(), + description: $this->description(), + attributes: collect([ + new AttributesDTO(name: 'Format', value: 'Single track, 32 talks, 25 minutes each', is_public: true), + new AttributesDTO(name: 'Capacity', value: '800', is_public: true), + new AttributesDTO(name: 'Workshops', value: 'The day before, four full-day sessions', is_public: true), + new AttributesDTO(name: 'Recordings', value: 'Free and public within two weeks', is_public: true), + new AttributesDTO(name: 'Accessibility', value: 'Step-free, live captioning, hearing loop, quiet room', is_public: true), + new AttributesDTO(name: 'Internal note', value: 'AV contract signed, catering final numbers due two weeks out', is_public: false), + ]), + timezone: $this->timezone, + currency: $this->currency, + category: EventCategory::TECH, + event_location: new EventLocationData(type: LocationType::IN_PERSON, location_id: $location->getId()), + status: EventStatus::LIVE->name, + type: EventType::SINGLE, + )); + + $eventId = $event->getId(); + + $passesCategory = $this->ctx->renameDefaultCategory($eventId, 'Conference passes', 'Both days, single track, all meals. Prices exclude VAT — your invoice will itemise it.'); + $workshopCategory = $this->ctx->addCategory($eventId, 'Workshops — the day before', 'Full-day, hands-on, capped small so you actually get help. Bring a laptop. Lunch included.', 'Workshops are announced with the second wave of speakers.'); + $extrasCategory = $this->ctx->addCategory($eventId, 'Extras', 'Optional bits. The community fund is the one that matters most.'); + + $tee = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $extrasCategory, + title: 'RUNTIME 26 tee', + type: ProductPriceType::PAID, + product_type: ProductType::GENERAL, + prices: collect([new ProductPriceDTO(price: 32.00, initial_quantity_available: 400)]), + max_per_order: 3, + description: 'Heavyweight organic cotton, screen-printed in Dublin, with the conference dependency graph on the back. Collect from registration on day one — we do not post them.', + min_per_order: 1, + tax_and_fee_ids: [$vatId], + is_addon_only: true, + ))->getId(); + + $dinner = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $extrasCategory, + title: "Speakers' dinner — evening of day one", + type: ProductPriceType::PAID, + product_type: ProductType::GENERAL, + prices: collect([new ProductPriceDTO(price: 65.00, initial_quantity_available: 80)]), + max_per_order: 2, + description: 'Long tables, no seating plan, every speaker in the room and no assigned VIP section. 80 seats, allocated in order of purchase. Dietary requirements come from your attendee form.', + min_per_order: 1, + show_quantity_remaining: true, + tax_and_fee_ids: [$vatId], + is_addon_only: true, + is_highlighted: true, + highlight_message: '80 seats', + ))->getId(); + + $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $extrasCategory, + title: 'Community ticket fund', + type: ProductPriceType::DONATION, + product_type: ProductType::GENERAL, + prices: collect([new ProductPriceDTO(price: 20.00)]), + max_per_order: 1, + description: 'Pay what you like. Every €75 here becomes one community pass for someone whose employer will not pay and who is not going to ask twice. We publish exactly what came in and what went out after the event.', + min_per_order: 1, + )); + + $workshops = []; + foreach ($this->workshops() as $workshop) { + $workshops[] = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $workshopCategory, + title: $workshop['title'], + type: ProductPriceType::PAID, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: $workshop['price'], initial_quantity_available: $workshop['seats'])]), + sale_start_date: $day1->subDays(199)->toDateTimeString(), + sale_end_date: $workshopDay->subDay()->setTime(23, 0)->toDateTimeString(), + max_per_order: 2, + description: $workshop['description'], + min_per_order: 1, + show_quantity_remaining: true, + tax_and_fee_ids: [$vatId], + is_highlighted: $workshop['highlight'] !== null, + highlight_message: $workshop['highlight'], + waitlist_enabled: true, + ))->getId(); + } + + $conferencePass = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $passesCategory, + title: 'Conference pass', + type: ProductPriceType::TIERED, + product_type: ProductType::TICKET, + prices: collect([ + new ProductPriceDTO(price: 195.00, label: 'Blind bird', sale_start_date: $day1->subDays(300)->toDateTimeString(), sale_end_date: $day1->subDays(200)->toDateTimeString(), initial_quantity_available: 100), + new ProductPriceDTO(price: 275.00, label: 'Early bird', sale_start_date: $day1->subDays(199)->toDateTimeString(), sale_end_date: $day1->subDays(120)->toDateTimeString(), initial_quantity_available: 250), + new ProductPriceDTO(price: 345.00, label: 'Standard', sale_start_date: $day1->subDays(119)->toDateTimeString(), sale_end_date: $day1->subDays(27)->toDateTimeString(), initial_quantity_available: 300), + new ProductPriceDTO(price: 425.00, label: 'Final release', sale_start_date: $day1->subDays(26)->toDateTimeString(), sale_end_date: $day1->subDay()->setTime(23, 0)->toDateTimeString(), initial_quantity_available: 150), + ]), + sale_start_date: $day1->subDays(320)->toDateTimeString(), + sale_end_date: $day1->subDay()->setTime(23, 0)->toDateTimeString(), + max_per_order: 4, + description: 'Both days, single track, breakfast, lunch and coffee included, recordings after. Priced in releases — when one sells out the next opens and the price only goes up. Prices exclude VAT.', + min_per_order: 1, + hide_before_sale_start_date: false, + hide_after_sale_end_date: false, + show_quantity_remaining: true, + tax_and_fee_ids: [$vatId, $feeId], + addon_product_ids: [$tee, $dinner], + is_highlighted: true, + highlight_message: 'Standard release — the final release costs more', + waitlist_enabled: true, + ))->getId(); + + $teamPass = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $passesCategory, + title: 'Team pass — 5 seats or more', + type: ProductPriceType::PAID, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: 295.00, initial_quantity_available: 150)]), + sale_start_date: $day1->subDays(320)->toDateTimeString(), + sale_end_date: $day1->subDays(7)->toDateTimeString(), + max_per_order: 25, + description: 'Same pass, a lower price per seat, minimum five in one order. One invoice, one PO, one expense claim for whoever drew the short straw. Attendee names can be filled in later — email us and we will reopen the form up to a week before.', + min_per_order: 5, + tax_and_fee_ids: [$vatId, $feeId], + addon_product_ids: [$tee, $dinner], + ))->getId(); + + $communityPass = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $passesCategory, + title: 'Community pass', + type: ProductPriceType::PAID, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: 75.00, initial_quantity_available: 100)]), + sale_start_date: $day1->subDays(160)->toDateTimeString(), + sale_end_date: $day1->subDays(14)->toDateTimeString(), + max_per_order: 1, + description: 'Funded entirely by the community fund and by people buying solidarity tickets. For students, career changers, people between jobs, and anyone whose employer will not pay. There is no means test — we ask which describes you and we take your word for it.', + min_per_order: 1, + show_quantity_remaining: true, + addon_product_ids: [$tee], + is_highlighted: true, + highlight_message: 'Funded by the community fund', + waitlist_enabled: true, + ))->getId(); + + $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $passesCategory, + title: 'Livestream pass', + type: ProductPriceType::PAID, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: 45.00, initial_quantity_available: 2000)]), + sale_start_date: $day1->subDays(320)->toDateTimeString(), + sale_end_date: $day2->toDateTimeString(), + max_per_order: 10, + description: 'Both days streamed live with captions, plus the Q&A channel and the recordings a week before they go public. No hallway track, which is honestly most of the value — but it is the same talks.', + min_per_order: 1, + tax_and_fee_ids: [$vatId], + )); + + $speakerPass = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $passesCategory, + title: 'Speaker & crew', + type: ProductPriceType::FREE, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: 0.0, initial_quantity_available: 90)]), + sale_end_date: $day1->subDay()->setTime(23, 0)->toDateTimeString(), + max_per_order: 2, + description: 'Speakers, volunteers, AV and the code of conduct team. You will have been sent a code.', + min_per_order: 1, + hide_when_sold_out: true, + is_hidden_without_promo_code: true, + addon_product_ids: [$tee, $dinner], + ))->getId(); + + $badgePasses = [$conferencePass, $teamPass, $communityPass, $speakerPass]; + + $this->question($eventId, 'Name as you want it on your badge', 'This is what gets printed and what 800 people will read across a room. Nicknames, mononyms and handles are all fine — it does not have to match your ID.', QuestionTypeEnum::SINGLE_LINE_TEXT, QuestionBelongsTo::PRODUCT, $badgePasses, required: true); + $this->question($eventId, 'Job title', 'Printed under your name. Leave it blank if you would rather people just talked to you.', QuestionTypeEnum::SINGLE_LINE_TEXT, QuestionBelongsTo::PRODUCT, $badgePasses); + $this->question($eventId, 'Company or organisation', 'Also printed on the badge. Between jobs is a perfectly good answer and several of us have used it.', QuestionTypeEnum::SINGLE_LINE_TEXT, QuestionBelongsTo::PRODUCT, $badgePasses); + $this->question($eventId, 'Pronouns for your badge', 'Optional, and blank is a valid choice rather than a missing one.', QuestionTypeEnum::DROPDOWN, QuestionBelongsTo::PRODUCT, $badgePasses, options: ['she / her', 'he / him', 'they / them', 'she / they', 'he / they', 'Ask me', 'Leave it off the badge']); + $this->question($eventId, 'Dietary requirements', 'Select everything that applies. This goes straight to the caterer as a headcount, and there is a labelled table for allergens rather than one sad tray at the end of the line.', QuestionTypeEnum::MULTI_SELECT_DROPDOWN, QuestionBelongsTo::PRODUCT, array_merge($badgePasses, $workshops), options: ['No requirements', 'Vegetarian', 'Vegan', 'Gluten-free', 'Dairy-free', 'Nut allergy', 'Shellfish allergy', 'Halal', 'Kosher', 'Low FODMAP']); + $this->question($eventId, 'What do you want to get out of this workshop?', 'A sentence is plenty. Instructors read these the week before and rebalance the day around what people actually turned up for.', QuestionTypeEnum::MULTI_LINE_TEXT, QuestionBelongsTo::PRODUCT, $workshops); + $this->question($eventId, 'Which of these describes you?', 'We ask so we can report back to the people funding these passes. We do not verify it and there is nothing to upload.', QuestionTypeEnum::RADIO, QuestionBelongsTo::PRODUCT, [$communityPass], required: true, options: ['Student or apprentice', 'Career changer', 'Between jobs', 'Employer will not cover it', 'Independent, freelance or non-profit', 'Would rather not say']); + $this->question($eventId, 'T-shirt size', 'Unisex fit, true to size. Collect from registration on day one.', QuestionTypeEnum::DROPDOWN, QuestionBelongsTo::PRODUCT, [$tee], required: true, options: ['XS', 'S', 'M', 'L', 'XL', '2XL', '3XL', '4XL']); + $this->question($eventId, 'Code of conduct', 'Please read it in full on the website before ticking. The response team is named there with direct contact details, not a general inbox.', QuestionTypeEnum::CHECKBOX, QuestionBelongsTo::ORDER, [], required: true, options: [ + 'I have read the code of conduct and agree to it', + 'I understand it is enforced, and that removal for harassment does not come with a refund', + ]); + $this->question($eventId, 'Company VAT number', 'For the reverse charge if you are VAT-registered outside Ireland. Leave blank and we will just charge Irish VAT at 23%. It appears on the invoice exactly as you type it, so check it.', QuestionTypeEnum::SINGLE_LINE_TEXT, QuestionBelongsTo::ORDER, []); + $this->question($eventId, 'Accessibility requirements', 'The venue is step-free throughout, the main hall has a hearing loop and live captioning on both screens, and there is a quiet room off the hallway. Tell us anything else — reserved seating near the front, an interpreter, a personal assistant ticket at no charge, a fridge for medication — and we will have it sorted well before you arrive.', QuestionTypeEnum::MULTI_LINE_TEXT, QuestionBelongsTo::ORDER, []); + $this->question($eventId, 'Hiring, or looking?', 'Optional. Gets you a small coloured dot on your badge and a line on the jobs board by the coffee. Nothing is shared with sponsors.', QuestionTypeEnum::RADIO, QuestionBelongsTo::ORDER, [], options: ['Hiring', 'Looking', 'Both, somehow', 'Neither']); + $this->question($eventId, 'Account notes (staff only)', 'Internal field for PO numbers and group-booking context. Not shown to buyers.', QuestionTypeEnum::SINGLE_LINE_TEXT, QuestionBelongsTo::ORDER, [], isHidden: true); + + $this->ctx->createPromoCode->handle($eventId, new UpsertPromoCodeDTO( + code: 'speaker26', + event_id: $eventId, + applicable_product_ids: [$speakerPass], + discount_type: PromoCodeDiscountTypeEnum::NONE, + discount: 0.0, + expiry_date: $day1->subDay()->setTime(23, 0)->toDateTimeString(), + max_allowed_usages: 90, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT, + )); + + $this->ctx->createPromoCode->handle($eventId, new UpsertPromoCodeDTO( + code: 'usergroup', + event_id: $eventId, + applicable_product_ids: [$conferencePass], + discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE, + discount: 15.0, + expiry_date: $day1->subDays(27)->toDateTimeString(), + max_allowed_usages: 120, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT, + )); + + $this->ctx->createPromoCode->handle($eventId, new UpsertPromoCodeDTO( + code: 'alumni25', + event_id: $eventId, + applicable_product_ids: [$conferencePass, $teamPass], + discount_type: PromoCodeDiscountTypeEnum::FIXED, + discount: 50.0, + expiry_date: $day1->subDays(5)->toDateTimeString(), + max_allowed_usages: 200, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT, + )); + + $this->ctx->applySettings($owner->account_id, $eventId, $this->settings()); + $this->ctx->uploadCover($eventId, $owner->account_id, 'conference.jpg'); + + return new SeededDemoEvent( + event_id: $eventId, + title: $event->getTitle(), + slug: $event->getSlug(), + occurrence_count: $this->ctx->occurrenceCount($eventId), + promo_codes: ['speaker26', 'usergroup', 'alumni25'], + ); + } + + private function workshops(): array + { + return [ + [ + 'title' => 'Postgres at 10TB', + 'price' => 195.00, + 'seats' => 30, + 'highlight' => '30 seats', + 'description' => 'A full day in a real 10TB database. Partitioning strategies you can apply without downtime, what autovacuum is actually doing while you sleep, reading query plans that lie to you, and the three connection-pooling mistakes behind most 3am pages. Bring a laptop with Docker.', + ], + [ + 'title' => "Agents that don't hallucinate their way into prod", + 'price' => 195.00, + 'seats' => 30, + 'highlight' => 'Nearly gone', + 'description' => 'Shipping LLM-backed systems you can actually operate: tool-call validation, retry and fallback design, structured output that holds under load, cost and latency budgets, and an eval harness you build on the day and take home. Bring a laptop and an API key.', + ], + [ + 'title' => "Incident command for engineers who'd rather not", + 'price' => 160.00, + 'seats' => 25, + 'highlight' => null, + 'description' => 'Three escalating simulated incidents. You will take the commander role at least once. Covers declaring early, running a channel that stays readable, handing over cleanly at shift change, and writing the review without blame. No laptop needed.', + ], + [ + 'title' => 'The far end of the TypeScript type system', + 'price' => 160.00, + 'seats' => 25, + 'highlight' => null, + 'description' => 'Conditional and mapped types, template literal types, variance, and knowing when to stop. Half the day is spent deleting types that were never earning their keep. Bring a laptop and a codebase you are allowed to show.', + ], + ]; + } + + private function question( + int $eventId, + string $title, + string $description, + QuestionTypeEnum $type, + QuestionBelongsTo $belongsTo, + array $productIds, + bool $required = false, + ?array $options = null, + bool $isHidden = false, + ): void { + $this->ctx->createQuestion->handle(new UpsertQuestionDTO( + title: $title, + type: $type, + required: $required, + options: $options, + event_id: $eventId, + product_ids: $productIds, + is_hidden: $isHidden, + belongs_to: $belongsTo, + description: $description, + )); + } + + private function settings(): array + { + return [ + 'homepage_theme_settings' => [ + 'mode' => 'light', + 'accent' => '#1B36F5', + 'background' => '#F2F0EA', + 'background_type' => 'COLOR', + 'font_family' => 'Plus Jakarta Sans', + ], + 'homepage_background_type' => 'COLOR', + 'homepage_background_color' => '#F2F0EA', + 'homepage_body_background_color' => '#F2F0EA', + 'homepage_primary_color' => '#1B36F5', + 'homepage_primary_text_color' => '#FFFFFF', + 'homepage_secondary_color' => '#14161A', + 'homepage_secondary_text_color' => '#F2F0EA', + 'seo_title' => 'RUNTIME 26 — Single-track engineering conference, Dublin', + 'seo_description' => 'Two days on the systems behind the systems. 32 single-track talks on databases, infrastructure, AI in production and incident response. The Round Room, Dublin.', + 'seo_keywords' => 'engineering conference dublin, software conference ireland, infrastructure conference, postgres, sre, ai infrastructure, developer conference', + 'allow_search_engine_indexing' => true, + 'price_display_mode' => 'EXCLUSIVE', + 'require_attendee_details' => true, + 'attendee_details_collection_method' => 'PER_TICKET', + 'allow_copy_details_to_all_attendees' => true, + 'allow_attendee_self_edit' => true, + 'order_timeout_in_minutes' => 30, + 'continue_button_text' => 'Get your pass', + 'support_email' => 'tickets@runtime.dev', + 'show_marketing_opt_in' => true, + 'notify_organizer_of_new_orders' => true, + 'waitlist_auto_process' => true, + 'waitlist_offer_timeout_minutes' => 2880, + 'enable_invoicing' => true, + 'invoice_label' => 'Invoice', + 'invoice_prefix' => 'RT26', + 'invoice_start_number' => 1001, + 'require_billing_address' => true, + 'invoice_payment_terms_days' => 30, + 'organization_name' => 'Runtime Events Ltd', + 'organization_address' => '6 Fitzwilliam Square, Dublin 2, D02 XE61, Ireland', + 'invoice_tax_details' => 'Runtime Events Ltd · Company no. 719284 · VAT no. IE4392017T', + 'invoice_notes' => 'Payment due within 30 days. Please quote the invoice number on your remittance. For PO numbers or a quote before purchase, email tickets@runtime.dev.', + 'pre_checkout_message' => '

Two things before you pay.

Need a PO raised, a quote first, or an invoice before payment? Stop here and email tickets@runtime.dev — we turn these around the same day.

', + 'post_checkout_message' => '

You\'re in. See you at the Round Room.

Your VAT invoice is attached to the confirmation email — that is the one your finance team wants. Passes are QR codes in the same email; there is nothing to print.

Between now and then

The schedule lands a few weeks out, and every ticket holder gets it a day before it is public.

', + 'email_footer_message' => 'RUNTIME 26 · The Round Room at the Mansion House, Dawson Street, Dublin 2. Runtime Events Ltd, 6 Fitzwilliam Square, Dublin 2. VAT IE4392017T.', + 'ticket_design_settings' => [ + 'enabled' => true, + 'accent_color' => '#1B36F5', + 'layout_type' => 'modern', + 'footer_text' => 'Registration opens 08:15 on day one · Bring this QR · Badge details editable until a week before', + ], + ]; + } + + private function description(): string + { + return '

Two days, one track, 32 talks about the unglamorous parts of software that actually decide whether it works.

' + .'

RUNTIME is a single-track conference for people who operate what they build. No keynote sponsors, no product pitches from the main stage, no talk that could have been a landing page. Everything is 25 minutes, everything is recorded, and every speaker has run the thing they are describing in production and can tell you how it went wrong.

' + .'

We cap it at 800 so the hallway track still works.

' + .'

What the two days look like

Some of who is speaking

The remaining 26 talks come off an open CFP. Blind-reviewed, and we pay every speaker.

' + .'

What is included

Getting a ticket paid for

' + .'

Tick the invoice box at checkout and you will get a proper VAT invoice with 30-day terms — enough for most expense policies. If you need a letter for your manager, a quote before purchase, or a PO raised, email tickets@runtime.dev and we will send one the same day.

' + .'

If your employer will not pay and that is the only thing stopping you, take a community pass. There is no means test and we do not ask.

' + .'

Code of conduct

' + .'

We have one, it is enforced, and the response team is named on the website with direct contact details rather than a general inbox. Harassment gets people removed without a refund.

' + .'

The Round Room at the Mansion House, Dawson Street, Dublin 2. Step-free throughout, hearing loop in the main hall, five minutes from Grafton Street and the Luas green line.

'; + } +} diff --git a/backend/app/Console/Commands/Demo/DemoOwner.php b/backend/app/Console/Commands/Demo/DemoOwner.php new file mode 100644 index 0000000000..53040bd59f --- /dev/null +++ b/backend/app/Console/Commands/Demo/DemoOwner.php @@ -0,0 +1,14 @@ +updateSettings->handle(new PartialUpdateEventSettingsDTO( + account_id: $accountId, + event_id: $eventId, + settings: $settings, + )); + } + + public function uploadCover(int $eventId, int $accountId, string $assetName): void + { + $path = resource_path('demo/covers/'.$assetName); + + if (! is_file($path)) { + throw new RuntimeException('Demo cover image is missing: '.$path); + } + + $this->createImage->handle(new CreateEventImageDTO( + eventId: $eventId, + accountId: $accountId, + image: new UploadedFile($path, basename($path), mime_content_type($path) ?: 'image/jpeg', null, true), + imageType: ImageType::EVENT_COVER, + )); + } + + public function renameDefaultCategory(int $eventId, string $name, ?string $description, ?string $noProductsMessage = null): int + { + $categoryId = $this->defaultCategoryId($eventId); + + $this->editCategory->handle(new UpsertProductCategoryDTO( + name: $name, + description: $description, + is_hidden: false, + event_id: $eventId, + no_products_message: $noProductsMessage, + product_category_id: $categoryId, + )); + + return $categoryId; + } + + public function addCategory(int $eventId, string $name, ?string $description, ?string $noProductsMessage = null): int + { + return $this->createCategory->handle(new UpsertProductCategoryDTO( + name: $name, + description: $description, + is_hidden: false, + event_id: $eventId, + no_products_message: $noProductsMessage, + ))->getId(); + } + + public function defaultCategoryId(int $eventId): int + { + $id = $this->db->table('product_categories') + ->where('event_id', $eventId) + ->orderBy('id') + ->value('id'); + + if ($id === null) { + throw new RuntimeException('Event '.$eventId.' has no default product category.'); + } + + return (int) $id; + } + + public function taxOrFeeId( + int $accountId, + string $name, + TaxCalculationType $calculationType, + TaxType $type, + float $rate, + ?string $description = null, + ): int { + $existing = $this->db->table('taxes_and_fees') + ->where('account_id', $accountId) + ->where('name', $name) + ->whereNull('deleted_at') + ->value('id'); + + if ($existing !== null) { + return (int) $existing; + } + + return $this->createTax->handle(new UpsertTaxDTO( + name: $name, + description: $description, + calculation_type: $calculationType, + type: $type, + rate: $rate, + is_active: true, + is_default: false, + account_id: $accountId, + ))->getId(); + } + + public function firstPriceId(int $productId): int + { + return (int) $this->db->table('product_prices') + ->where('product_id', $productId) + ->orderBy('id') + ->value('id'); + } + + public function occurrenceCount(int $eventId): int + { + return $this->db->table('event_occurrences')->where('event_id', $eventId)->count(); + } + + public function occurrenceIdsExcluding(int $eventId, array $excludedIds): array + { + return $this->db->table('event_occurrences') + ->where('event_id', $eventId) + ->whereNotIn('id', $excludedIds ?: [0]) + ->orderBy('id') + ->pluck('id') + ->map(static fn ($id) => (int) $id) + ->all(); + } + + public function toUtc(string $localDateTime, string $timezone): string + { + return DateHelper::convertToUTC($localDateTime, $timezone); + } +} diff --git a/backend/app/Console/Commands/Demo/FestivalDemoEvent.php b/backend/app/Console/Commands/Demo/FestivalDemoEvent.php new file mode 100644 index 0000000000..6435ff320e --- /dev/null +++ b/backend/app/Console/Commands/Demo/FestivalDemoEvent.php @@ -0,0 +1,486 @@ +timezone); + $gates = $this->gatesOpen($now); + $close = $gates->addDays(2)->setTime(23, 0); + + $location = $this->ctx->createLocation->handle(new UpsertLocationDTO( + organizer_id: $owner->organizer_id, + account_id: $owner->account_id, + name: 'Ardgroom Harbour Farm', + structured_address: new AddressDTO( + venue_name: 'Ardgroom Harbour Farm', + address_line_1: 'Ardgroom', + address_line_2: 'Beara Peninsula', + city: 'Bantry', + state_or_region: 'Co. Cork', + zip_or_postal_code: 'P75 XR62', + country: 'IE', + ), + latitude: 51.7361, + longitude: -9.7736, + )); + + $event = $this->ctx->createEvent->handle(new CreateEventDTO( + title: 'TIDELINE — Three days on the Beara Peninsula', + organizer_id: $owner->organizer_id, + account_id: $owner->account_id, + user_id: $owner->user_id, + start_date: $gates->toDateTimeString(), + end_date: $close->toDateTimeString(), + description: $this->description(), + attributes: collect([ + new AttributesDTO(name: 'Capacity', value: '4,000', is_public: true), + new AttributesDTO(name: 'Stages', value: 'The Harbour, The Sea Church, The Boathouse', is_public: true), + new AttributesDTO(name: 'Camping', value: 'On site — tents, campervans and pre-pitched bell tents', is_public: true), + new AttributesDTO(name: 'Under 12s', value: 'Free with an accompanying adult', is_public: true), + new AttributesDTO(name: 'Getting there', value: 'Return shuttle from Cork city, 2h15', is_public: true), + new AttributesDTO(name: 'Curfew', value: 'Main stages 23:00, Boathouse 03:00', is_public: true), + new AttributesDTO(name: 'Internal note', value: 'Licence renewal due 8 weeks out, water bowser contract not yet signed', is_public: false), + ]), + timezone: $this->timezone, + currency: $this->currency, + category: EventCategory::FESTIVAL, + event_location: new EventLocationData(type: LocationType::IN_PERSON, location_id: $location->getId()), + status: EventStatus::LIVE->name, + type: EventType::SINGLE, + )); + + $eventId = $event->getId(); + + $ticketsCategory = $this->ctx->renameDefaultCategory( + $eventId, + 'Festival tickets', + 'Weekend and day tickets. All of them draw from the same 4,000 site capacity, so when it is gone it is gone.', + ); + $campingCategory = $this->ctx->addCategory($eventId, 'Camping & accommodation', 'Everything on site opens at noon on the Friday and closes at 2pm on the Monday. 1,200 pitches shared across all four options.'); + $extrasCategory = $this->ctx->addCategory($eventId, 'Getting there & extras', 'Add these to any ticket. The shuttle is the single best decision you will make — the road in is one lane.'); + + $shuttle = $this->addon($owner, $eventId, $extrasCategory, 'Return shuttle from Cork city', 'Coach from Parnell Place at 09:00 Friday, back Monday at 11:00. Two hours fifteen each way along the coast road, and it means nobody has to drive home tired.', 25.00, 600, showRemaining: true); + $parking = $this->addon($owner, $eventId, $extrasCategory, 'Car parking pass', 'One car, one pass, valid all weekend in the field above the harbour. Ten minutes downhill to the gate. There is no parking without a pass and the Gardaí do clear the road.', 20.00, 500); + $locker = $this->addon($owner, $eventId, $extrasCategory, 'Charging locker', 'A lockable box with a charging cable, by the main gate. Signal is patchy out here anyway, but at least you will get home.', 15.00, 300); + $tote = $this->addon($owner, $eventId, $extrasCategory, 'TIDELINE tote', 'Screen-printed heavy canvas, made in Cork. Collect from the merch tent by the harbour.', 24.00, 800); + + $ticketAddons = [$shuttle, $parking, $locker, $tote]; + + $weekend = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $ticketsCategory, + title: 'Weekend ticket', + type: ProductPriceType::TIERED, + product_type: ProductType::TICKET, + prices: collect([ + new ProductPriceDTO(price: 189.00, label: 'Tier 1', sale_start_date: $now->subDays(120)->toDateTimeString(), sale_end_date: $now->subDays(45)->toDateTimeString(), initial_quantity_available: 600), + new ProductPriceDTO(price: 215.00, label: 'Tier 2', sale_start_date: $now->subDays(44)->toDateTimeString(), sale_end_date: $now->addDays(40)->toDateTimeString(), initial_quantity_available: 900), + new ProductPriceDTO(price: 239.00, label: 'Tier 3', sale_start_date: $now->addDays(41)->toDateTimeString(), sale_end_date: $now->addDays(100)->toDateTimeString(), initial_quantity_available: 900), + new ProductPriceDTO(price: 265.00, label: 'Final tier', sale_start_date: $now->addDays(101)->toDateTimeString(), sale_end_date: $gates->subDay()->toDateTimeString(), initial_quantity_available: 600), + ]), + sale_end_date: $gates->subDay()->toDateTimeString(), + max_per_order: 6, + description: 'All three days, both nights, every stage. Camping is separate and sells out first, so if you want a pitch add one now.', + min_per_order: 1, + hide_before_sale_start_date: false, + hide_after_sale_end_date: false, + show_quantity_remaining: true, + addon_product_ids: $ticketAddons, + is_highlighted: true, + highlight_message: 'Tier 2 — the price only goes up', + waitlist_enabled: true, + ))->getId(); + + $friday = $this->dayTicket($owner, $eventId, $ticketsCategory, 'Friday day ticket', 'Gates noon, music from 14:00, last act 23:00. No camping — day tickets leave the site by 01:00.', 89.00, 400, $gates, $ticketAddons); + $saturday = $this->dayTicket($owner, $eventId, $ticketsCategory, 'Saturday day ticket', 'The big one. Three stages running from midday, Cormorant closing the harbour at 21:30.', 99.00, 500, $gates, $ticketAddons, highlight: 'Busiest day'); + $sunday = $this->dayTicket($owner, $eventId, $ticketsCategory, 'Sunday day ticket', 'Slower, quieter, and the one the locals come to. Sea Church sessions all afternoon and the Long Table at 17:00.', 89.00, 400, $gates, $ticketAddons); + + $underTwelves = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $ticketsCategory, + title: 'Under 12s', + type: ProductPriceType::FREE, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: 0.0, initial_quantity_available: 400)]), + sale_end_date: $gates->subDay()->toDateTimeString(), + max_per_order: 4, + description: 'Free, but they still need a ticket so we know how many are on site. Must be with a named adult at all times. Ear defenders at the welfare tent, free, while they last.', + min_per_order: 1, + ))->getId(); + + $crew = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $ticketsCategory, + title: 'Crew & artist guest', + type: ProductPriceType::FREE, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: 0.0, initial_quantity_available: 300)]), + sale_end_date: $gates->addDay()->toDateTimeString(), + max_per_order: 4, + description: 'Production, stewards, traders and artist guest list. Your code came from whoever booked you.', + min_per_order: 1, + hide_when_sold_out: true, + is_hidden_without_promo_code: true, + ))->getId(); + + $tent = $this->camping($owner, $eventId, $campingCategory, 'Tent pitch', 'A marked 5m × 5m pitch in the main field for up to two people and one tent. Toilets and drinking water within 100m, hot showers by the harbour wall.', 45.00, 700, $gates, showRemaining: true); + $quiet = $this->camping($owner, $eventId, $campingCategory, 'Quiet campsite pitch', 'Same pitch, far side of the hill, and a no-sound-systems rule enforced from midnight. For families, light sleepers and anyone who has learned.', 55.00, 250, $gates, showRemaining: true); + $campervan = $this->camping($owner, $eventId, $campingCategory, 'Campervan or motorhome', 'Hardstanding pitch, 7m max, one vehicle. No electrical hook-up — bring what you need. Vehicle registration required at checkout.', 120.00, 180, $gates, showRemaining: true); + $glamping = $this->camping($owner, $eventId, $campingCategory, 'Pre-pitched bell tent (sleeps 4)', 'Up before you arrive, taken down after you leave, with real beds, rugs and a lantern. Sleeps four and the price is per tent, not per person.', 480.00, 70, $gates, showRemaining: true, highlight: 'Sells out first'); + + $siteTickets = [$weekend, $friday, $saturday, $sunday, $underTwelves, $crew]; + $campingProducts = [$tent, $quiet, $campervan, $glamping]; + + $this->ctx->createCapacityAssignment->handle(new UpsertCapacityAssignmentDTO( + name: 'Festival site capacity', + event_id: $eventId, + status: CapacityAssignmentStatus::ACTIVE, + capacity: self::SITE_CAPACITY, + product_ids: $siteTickets, + )); + + $this->ctx->createCapacityAssignment->handle(new UpsertCapacityAssignmentDTO( + name: 'Campsite pitches', + event_id: $eventId, + status: CapacityAssignmentStatus::ACTIVE, + capacity: self::CAMPSITE_CAPACITY, + product_ids: $campingProducts, + )); + + $this->ctx->createCheckInList->handle(new UpsertCheckInListDTO( + name: 'Main gate — wristband exchange', + description: 'Every ticket type. Wristband goes on at the gate and does not come off until Monday.', + eventId: $eventId, + productIds: $siteTickets, + expiresAt: $close->addDay()->toDateTimeString(), + activatesAt: $gates->subHours(2)->toDateTimeString(), + )); + + $this->ctx->createCheckInList->handle(new UpsertCheckInListDTO( + name: 'Campsite gate', + description: 'Pitch allocation and vehicle checks. Campervans are checked against the registration given at checkout.', + eventId: $eventId, + productIds: $campingProducts, + expiresAt: $close->addDay()->toDateTimeString(), + activatesAt: $gates->subHours(2)->toDateTimeString(), + )); + + $this->ctx->createAffiliate->handle($eventId, $owner->account_id, new UpsertAffiliateDTO( + name: 'Cork street team', + code: 'CORK', + email: 'streetteam@tideline.ie', + status: AffiliateStatus::ACTIVE, + )); + + $this->ctx->createAffiliate->handle($eventId, $owner->account_id, new UpsertAffiliateDTO( + name: 'Beara Bus partnership', + code: 'BEARABUS', + email: 'partners@tideline.ie', + status: AffiliateStatus::ACTIVE, + )); + + $allTickets = [$weekend, $friday, $saturday, $sunday, $underTwelves, $crew]; + + $this->question($eventId, 'Where should we post your wristband?', 'Weekend wristbands go out by post three weeks before, which means no queue at the gate. We only post within Ireland and the UK — leave this blank and we will hold it at the box office instead.', QuestionTypeEnum::ADDRESS, QuestionBelongsTo::PRODUCT, [$weekend], required: true); + $this->question($eventId, 'Date of birth', 'Under 18s must be with a responsible adult, and the bar is wristbanded separately at the gate. We check ID on arrival.', QuestionTypeEnum::DATE, QuestionBelongsTo::PRODUCT, $allTickets, required: true); + $this->question($eventId, 'Which adult will they be with?', 'Full name of the adult responsible for this child on site. They must be on the same order and present at the gate when the wristband goes on.', QuestionTypeEnum::SINGLE_LINE_TEXT, QuestionBelongsTo::PRODUCT, [$underTwelves], required: true); + $this->question($eventId, 'Vehicle registration', 'Checked against the pass at the gate. If you swap cars before the weekend, email us — it takes ten seconds to change and saves an argument in a field.', QuestionTypeEnum::SINGLE_LINE_TEXT, QuestionBelongsTo::PRODUCT, [$campervan, $parking], required: true); + $this->question($eventId, 'Emergency contact', 'Someone not coming with you. Held by the welfare team for the weekend and deleted afterwards.', QuestionTypeEnum::PHONE, QuestionBelongsTo::PRODUCT, $allTickets); + $this->question($eventId, 'Before you buy', 'All three apply to every ticket. The middle one catches people out every year.', QuestionTypeEnum::CHECKBOX, QuestionBelongsTo::ORDER, [], required: true, options: [ + 'I have read the terms and the searches-on-entry policy', + 'I understand day tickets do not include camping, and camping is a separate purchase', + 'I know the site is a working farm — uneven ground, no lighting off the main paths, and livestock in the next field', + ]); + $this->question($eventId, 'Access requirements', 'There is a viewing platform at the Harbour stage, accessible toilets and showers at both campsites, a charging point for powered chairs at welfare, and a flat gravel route from the accessible campsite to every stage. Personal assistants come free — tell us here and we will send a separate PA ticket.', QuestionTypeEnum::MULTI_LINE_TEXT, QuestionBelongsTo::ORDER, []); + $this->question($eventId, 'Anything we should know about the group?', 'Camping near friends, a birthday, a first festival, someone nervous in crowds. We read these and we do try.', QuestionTypeEnum::MULTI_LINE_TEXT, QuestionBelongsTo::ORDER, []); + $this->question($eventId, 'What are you most here for?', 'Genuinely just for us — it decides what we book next year.', QuestionTypeEnum::DROPDOWN, QuestionBelongsTo::ORDER, [], options: [ + 'The Saturday headliners', + 'The Sea Church sessions', + 'The Boathouse late nights', + 'The Long Table', + 'The swimming', + 'I come every year and do not need a reason', + ]); + $this->question($eventId, 'Box office notes (staff only)', 'Internal. Wristband reprints, upgrades, comped tickets and anything the gate needs to know.', QuestionTypeEnum::SINGLE_LINE_TEXT, QuestionBelongsTo::ORDER, [], isHidden: true); + + $this->ctx->createPromoCode->handle($eventId, new UpsertPromoCodeDTO( + code: 'tidecrew', + event_id: $eventId, + applicable_product_ids: [$crew], + discount_type: PromoCodeDiscountTypeEnum::NONE, + discount: 0.0, + expiry_date: $gates->addDay()->toDateTimeString(), + max_allowed_usages: 300, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT, + )); + + $this->ctx->createPromoCode->handle($eventId, new UpsertPromoCodeDTO( + code: 'localsrate', + event_id: $eventId, + applicable_product_ids: [$weekend, $friday, $saturday, $sunday], + discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE, + discount: 25.0, + expiry_date: $gates->subDay()->toDateTimeString(), + max_allowed_usages: 400, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT, + )); + + $this->ctx->applySettings($owner->account_id, $eventId, $this->settings()); + $this->ctx->uploadCover($eventId, $owner->account_id, 'festival.jpg'); + + return new SeededDemoEvent( + event_id: $eventId, + title: $event->getTitle(), + slug: $event->getSlug(), + occurrence_count: $this->ctx->occurrenceCount($eventId), + promo_codes: ['tidecrew', 'localsrate'], + ); + } + + private function gatesOpen(CarbonImmutable $now): CarbonImmutable + { + $year = $now->month >= 7 ? $now->year + 1 : $now->year; + + $gates = CarbonImmutable::create($year, 7, 1, 0, 0, 0, $now->timezone) + ->lastOfMonth(CarbonImmutable::FRIDAY) + ->setTime(12, 0); + + if ($gates->lt($now->addDays(150))) { + $gates = $gates->addYear()->lastOfMonth(CarbonImmutable::FRIDAY)->setTime(12, 0); + } + + return $gates; + } + + private function addon( + DemoOwner $owner, + int $eventId, + int $categoryId, + string $title, + string $description, + float $price, + int $quantity, + bool $showRemaining = false, + ): int { + return $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $categoryId, + title: $title, + type: ProductPriceType::PAID, + product_type: ProductType::GENERAL, + prices: collect([new ProductPriceDTO(price: $price, initial_quantity_available: $quantity)]), + max_per_order: 4, + description: $description, + min_per_order: 1, + show_quantity_remaining: $showRemaining, + is_addon_only: true, + ))->getId(); + } + + private function dayTicket( + DemoOwner $owner, + int $eventId, + int $categoryId, + string $title, + string $description, + float $price, + int $quantity, + CarbonImmutable $gates, + array $addonIds, + ?string $highlight = null, + ): int { + return $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $categoryId, + title: $title, + type: ProductPriceType::PAID, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: $price, initial_quantity_available: $quantity)]), + sale_end_date: $gates->subDay()->toDateTimeString(), + max_per_order: 6, + description: $description, + min_per_order: 1, + show_quantity_remaining: true, + addon_product_ids: $addonIds, + is_highlighted: $highlight !== null, + highlight_message: $highlight, + waitlist_enabled: true, + ))->getId(); + } + + private function camping( + DemoOwner $owner, + int $eventId, + int $categoryId, + string $title, + string $description, + float $price, + int $quantity, + CarbonImmutable $gates, + bool $showRemaining = false, + ?string $highlight = null, + ): int { + return $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $categoryId, + title: $title, + type: ProductPriceType::PAID, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: $price, initial_quantity_available: $quantity)]), + sale_end_date: $gates->subDays(3)->toDateTimeString(), + max_per_order: 2, + description: $description, + min_per_order: 1, + show_quantity_remaining: $showRemaining, + is_highlighted: $highlight !== null, + highlight_message: $highlight, + waitlist_enabled: true, + ))->getId(); + } + + private function question( + int $eventId, + string $title, + string $description, + QuestionTypeEnum $type, + QuestionBelongsTo $belongsTo, + array $productIds, + bool $required = false, + ?array $options = null, + bool $isHidden = false, + ): void { + $this->ctx->createQuestion->handle(new UpsertQuestionDTO( + title: $title, + type: $type, + required: $required, + options: $options, + event_id: $eventId, + product_ids: $productIds, + is_hidden: $isHidden, + belongs_to: $belongsTo, + description: $description, + )); + } + + private function settings(): array + { + return [ + 'homepage_theme_settings' => [ + 'mode' => 'dark', + 'accent' => '#FF6B4A', + 'background' => '#071E26', + 'background_type' => 'MIRROR_COVER_IMAGE', + 'font_family' => 'Oswald', + ], + 'homepage_background_type' => 'MIRROR_COVER_IMAGE', + 'homepage_background_color' => '#071E26', + 'homepage_body_background_color' => '#071E26', + 'homepage_primary_color' => '#FF6B4A', + 'homepage_primary_text_color' => '#071E26', + 'homepage_secondary_color' => '#0E323B', + 'homepage_secondary_text_color' => '#F3E3C8', + 'seo_title' => 'TIDELINE — three days on the Beara Peninsula, West Cork', + 'seo_description' => 'A 4,000-capacity coastal festival on a working farm above Ardgroom harbour. Three stages, camping and campervans on site, under 12s free, return shuttle from Cork city.', + 'seo_keywords' => 'irish festival, west cork festival, beara peninsula, camping festival ireland, boutique festival, cork music festival', + 'allow_search_engine_indexing' => true, + 'price_display_mode' => 'INCLUSIVE', + 'pass_platform_fee_to_buyer' => true, + 'require_attendee_details' => true, + 'attendee_details_collection_method' => 'PER_TICKET', + 'allow_copy_details_to_all_attendees' => true, + 'allow_attendee_self_edit' => true, + 'order_timeout_in_minutes' => 20, + 'continue_button_text' => 'Get tickets', + 'support_email' => 'box.office@tideline.ie', + 'show_marketing_opt_in' => true, + 'notify_organizer_of_new_orders' => true, + 'waitlist_auto_process' => true, + 'waitlist_offer_timeout_minutes' => 1440, + 'payment_providers' => ['STRIPE', 'OFFLINE'], + 'offline_payment_instructions' => '

Bank transfer is available on orders of six or more tickets.

Choose it at checkout and we will hold your tickets for 5 working days while the transfer clears. Transfer to Tideline Festival Ltd, IBAN IE29 AIBK 9311 5212 3456 78, and put your order reference in the payment reference field — without it we cannot match your payment and the hold will lapse.

Anything unusual, email box.office@tideline.ie before you transfer rather than after.

', + 'allow_orders_awaiting_offline_payment_to_check_in' => false, + 'pre_checkout_message' => '

Two things people get wrong every year.

Buying for six or more? Bank transfer is available on the next screen.

', + 'post_checkout_message' => '

You\'re coming. See you above the harbour.

Weekend wristbands post out three weeks before to the address on your order — change it any time before then from the link in your email. Day tickets and camping are collected at the gate with the QR in your confirmation.

Worth knowing now

The stage times and site map go out a fortnight before. Anything at all: box.office@tideline.ie.

', + 'email_footer_message' => 'TIDELINE · Ardgroom Harbour Farm, Beara Peninsula, Co. Cork. A 4,000-capacity festival on a working farm. Bring boots.', + 'ticket_design_settings' => [ + 'enabled' => true, + 'accent_color' => '#FF6B4A', + 'layout_type' => 'modern', + 'footer_text' => 'Wristband exchange at the main gate · Day tickets do not include camping · Under 12s must be with a named adult', + ], + ]; + } + + private function description(): string + { + return '

Four thousand people, three stages and a working farm above Ardgroom harbour, at the far end of the Beara Peninsula.

' + .'

TIDELINE is a coastal festival that is deliberately hard to get to. Three days of music that runs from sean-nós in a stone church to a sound system in a boathouse at 3am, on a headland where the Atlantic is on three sides of you and the phone signal gave up years ago.

' + .'

It is 4,000 people. It has been 4,000 people every year and it will stay 4,000 people.

' + .'

The stages

Getting here, and it matters

' + .'

The site is two and a quarter hours from Cork city on a road that is single lane for the last eleven kilometres. There is no parking without a pass, no taxis after 22:00, and the nearest town is a forty minute walk.

' + .'

Take the shuttle. It leaves Parnell Place at 09:00 on the Friday, comes back Monday at 11:00, and costs less than the diesel. Everyone who drove last year said they would take it this year.

' + .'

Staying

' + .'

1,200 pitches on site, split between the main field, a quiet campsite over the hill with a midnight sound curfew, hardstanding for campervans, and seventy pre-pitched bell tents for people who have decided they are too old for this. Camping is booked separately from your ticket and it always sells out first.

' + .'

Day tickets do not include camping. Day ticket holders leave the site by 01:00.

' + .'

Bringing children

' + .'

Under 12s come free but still need a ticket so we know how many are on site, and they need to be with a named adult the whole time. There is a supervised craft tent by the Long Table from 11:00 to 16:00, free ear defenders at welfare while they last, and the quiet campsite exists largely for families.

' + .'

Access

' + .'

A viewing platform at the Harbour stage, accessible toilets and showers at both campsites, a flat gravel route from the accessible camping area to every stage, and a powered-chair charging point at welfare. Personal assistants come free — put it in the access box at checkout and we will send a separate ticket. Parts of the site are a steep field, and we would rather tell you that now than have you find out on the Friday.

' + .'

The unglamorous part

' + .'

It is a farm. The ground is uneven, there is no lighting off the main paths, there is livestock in the next field, and there is a working slipway with deep water at the bottom of the campsite. Swimming is lifeguarded between 08:00 and 11:00 and at no other time — please do not test this.

' + .'

Ardgroom Harbour Farm, Beara Peninsula, Co. Cork. Bring boots. Bring a warm layer for the evenings, even in July. Leave the site the way you found it.

'; + } +} diff --git a/backend/app/Console/Commands/Demo/NightclubDemoEvent.php b/backend/app/Console/Commands/Demo/NightclubDemoEvent.php new file mode 100644 index 0000000000..0eb2cdf8af --- /dev/null +++ b/backend/app/Console/Commands/Demo/NightclubDemoEvent.php @@ -0,0 +1,389 @@ +timezone) + ->addDays(42) + ->next(CarbonImmutable::SATURDAY) + ->setTime(23, 0); + + $location = $this->ctx->createLocation->handle(new UpsertLocationDTO( + organizer_id: $owner->organizer_id, + account_id: $owner->account_id, + name: 'The Coal Yard', + structured_address: new AddressDTO( + venue_name: 'The Coal Yard', + address_line_1: '14 Newmarket Square', + address_line_2: 'Unit 3B', + city: 'Dublin', + state_or_region: 'Co. Dublin', + zip_or_postal_code: 'D08 XY42', + country: 'IE', + ), + latitude: 53.3369, + longitude: -6.2793, + )); + + $event = $this->ctx->createEvent->handle(new CreateEventDTO( + title: 'SUBTERRA 004 — Nite Kernel, Ánima, Basil Wren', + organizer_id: $owner->organizer_id, + account_id: $owner->account_id, + user_id: $owner->user_id, + start_date: $doors->toDateTimeString(), + end_date: $doors->addHours(6)->toDateTimeString(), + description: $this->description(), + attributes: collect([ + new AttributesDTO(name: 'Line-up', value: 'Nite Kernel, Ánima, Basil Wren', is_public: true), + new AttributesDTO(name: 'Sound', value: 'Funktion-One Res 5', is_public: true), + new AttributesDTO(name: 'Capacity', value: '320', is_public: true), + new AttributesDTO(name: 'Age policy', value: '18+ with photo ID', is_public: true), + new AttributesDTO(name: 'Last entry', value: '00:00', is_public: true), + new AttributesDTO(name: 'Internal note', value: 'Door split 60/40 with venue, settle on the night', is_public: false), + ]), + timezone: $this->timezone, + currency: $this->currency, + category: EventCategory::NIGHTLIFE, + event_location: new EventLocationData(type: LocationType::IN_PERSON, location_id: $location->getId()), + status: EventStatus::LIVE->name, + type: EventType::SINGLE, + )); + + $eventId = $event->getId(); + + $ticketsCategory = $this->ctx->renameDefaultCategory( + $eventId, + 'Tickets', + 'Entry for the night. First release is gone — second release is live now.', + 'Tickets are not on sale yet. Join the mailing list and we will tell you first.', + ); + $extrasCategory = $this->ctx->addCategory($eventId, 'Extras', 'Add these to your order now so you are not queuing for them at 1am.'); + $merchCategory = $this->ctx->addCategory($eventId, 'Merch', 'Screen-printed in Dublin in runs of 100. Collect from the merch table by the cloakroom on the night.'); + + $cloakroom = $this->addon($owner, $eventId, $extrasCategory, 'Cloakroom', 'Skip the 3am queue. One hook, one coat, one bag — pre-paid and pre-tagged, collect any time before 05:15. Cameras and anything larger than a rucksack must go in here.', 4.00, 320, 4); + $earplugs = $this->addon($owner, $eventId, $extrasCategory, 'Reusable earplugs', 'Filtered 19dB plugs in a little metal tin. The Res 5 does not negotiate and neither does tinnitus — genuinely, take a pair.', 3.50, 200, 4); + $tokens = $this->addon($owner, $eventId, $extrasCategory, 'Bar tokens — pack of 5', 'Five drink tokens for the price of four. Valid on anything behind the bar including the non-alcoholic list, and the water taps are free all night regardless.', 22.00, 400, 4); + $tee = $this->addon($owner, $eventId, $merchCategory, 'SUBTERRA 004 tee — run of 100', 'Heavyweight 240gsm organic cotton, boxy fit, single-colour acid green discharge print on black. Screen-printed by hand in Dublin 8. Collect from the merch table on the night — we do not post these.', 28.00, 100, 2, showRemaining: true, highlight: '100 only'); + $cassette = $this->addon($owner, $eventId, $merchCategory, 'Basil Wren — Opening Set 003 (cassette)', 'Ninety minutes of the SUBTERRA 003 warm-up, dubbed to chrome tape in an edition of 60, with a hand-numbered J-card. There is no digital version and there will not be one.', 12.00, 60, 2, showRemaining: true); + + $allAddons = [$cloakroom, $earplugs, $tokens, $tee, $cassette]; + + $generalAdmission = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $ticketsCategory, + title: 'General Admission', + type: ProductPriceType::TIERED, + product_type: ProductType::TICKET, + prices: collect([ + new ProductPriceDTO( + price: 12.50, + label: 'First release', + sale_start_date: $doors->subDays(120)->toDateTimeString(), + sale_end_date: $doors->subDays(73)->toDateTimeString(), + initial_quantity_available: 60, + ), + new ProductPriceDTO( + price: 16.50, + label: 'Second release', + sale_start_date: $doors->subDays(72)->toDateTimeString(), + sale_end_date: $doors->subDays(21)->toDateTimeString(), + initial_quantity_available: 120, + ), + new ProductPriceDTO( + price: 20.00, + label: 'Third release', + sale_start_date: $doors->subDays(20)->toDateTimeString(), + sale_end_date: $doors->subDays(6)->toDateTimeString(), + initial_quantity_available: 100, + ), + new ProductPriceDTO( + price: 24.00, + label: 'Final release', + sale_start_date: $doors->subDays(5)->toDateTimeString(), + sale_end_date: $doors->subHour()->toDateTimeString(), + initial_quantity_available: 40, + ), + ]), + sale_start_date: $doors->subDays(150)->toDateTimeString(), + sale_end_date: $doors->subHour()->toDateTimeString(), + max_per_order: 4, + description: 'Entry from 23:00, last entry 00:00 sharp. Priced in releases — when one sells out the next one opens, and the price only goes up. Bring photo ID.', + min_per_order: 1, + hide_before_sale_start_date: false, + hide_after_sale_end_date: false, + hide_when_sold_out: false, + show_quantity_remaining: true, + addon_product_ids: $allAddons, + is_highlighted: true, + highlight_message: 'Second release — going fast', + waitlist_enabled: true, + ))->getId(); + + $concession = $this->ticket($owner, $eventId, $ticketsCategory, 'Concession / unwaged', 'Same ticket, lower price, no questions asked and nothing to prove at the door. If you are unwaged, a student, or money is tight this month, take one of these — that is exactly what they are for.', 9.00, 40, 2, $doors, $allAddons, showRemaining: true); + $solidarity = $this->ticket($owner, $eventId, $ticketsCategory, 'Solidarity ticket', 'Your entry plus one concession ticket for someone who could not otherwise come. You get in on exactly the same terms as everyone else — the extra tenner just quietly refills the concession pot.', 32.00, 60, 2, $doors, $allAddons); + + $guestlist = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $ticketsCategory, + title: 'Friends of the Coal Yard', + type: ProductPriceType::FREE, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: 0.0, initial_quantity_available: 30)]), + sale_end_date: $doors->subHours(3)->toDateTimeString(), + max_per_order: 2, + description: 'Residents, artists, hosts and the people who carry the boxes. You know if this is you, and you know the code.', + min_per_order: 1, + hide_when_sold_out: true, + is_hidden_without_promo_code: true, + addon_product_ids: [$cloakroom, $earplugs, $tokens], + ))->getId(); + + $ticketIds = [$generalAdmission, $concession, $solidarity, $guestlist]; + + $this->question($eventId, 'Date of birth', 'Strictly over 18s. We check ID against this at the door and the name on the order has to match it, so please get it right — mismatches get turned away and we cannot refund them.', QuestionTypeEnum::DATE, QuestionBelongsTo::PRODUCT, $ticketIds, required: true); + $this->question($eventId, 'Emergency contact number', 'Only ever used if something happens to you on the night. Held by the welfare lead, never marketed to, deleted 30 days after the event.', QuestionTypeEnum::PHONE, QuestionBelongsTo::PRODUCT, $ticketIds); + $this->question($eventId, 'T-shirt size', 'Boxy unisex fit — it runs about one size large, so size down if you want it fitted.', QuestionTypeEnum::RADIO, QuestionBelongsTo::PRODUCT, [$tee], required: true, options: ['S', 'M', 'L', 'XL', '2XL', '3XL']); + $this->question($eventId, 'The floor policy', 'Tick to confirm you have read these. Door staff and floor hosts enforce all of them and there are no refunds if you are removed.', QuestionTypeEnum::CHECKBOX, QuestionBelongsTo::ORDER, [], required: true, options: [ + 'No filming, no flash, no screens on the dancefloor', + 'Zero tolerance for harassment, racism, homophobia or transphobia', + 'Over 18s only — photo ID at the door, no exceptions', + 'Last entry is 00:00 and my ticket is non-transferable', + ]); + $this->question($eventId, 'Access requirements', 'Step-free entry, an accessible ground-floor toilet and a quiet room under 70dB are all available on the night. Personal assistants come in free — tell us here and we will have it arranged before you arrive rather than at the door.', QuestionTypeEnum::MULTI_LINE_TEXT, QuestionBelongsTo::ORDER, []); + $this->question($eventId, 'How did you hear about SUBTERRA?', 'We spend nothing on ads, so this genuinely decides where the next one gets posted.', QuestionTypeEnum::DROPDOWN, QuestionBelongsTo::ORDER, [], options: [ + 'A friend dragged me', + 'Was at SUBTERRA 001–003', + 'Instagram', + 'Resident Advisor', + 'Poster in a record shop', + 'Following one of the artists', + 'Somewhere else', + ]); + $this->question($eventId, 'Door notes (staff only)', 'Internal field for guestlist annotations. Not shown to ticket buyers.', QuestionTypeEnum::SINGLE_LINE_TEXT, QuestionBelongsTo::ORDER, [], isHidden: true); + + $this->ctx->createPromoCode->handle($eventId, new UpsertPromoCodeDTO( + code: 'boxcarrier', + event_id: $eventId, + applicable_product_ids: [$guestlist], + discount_type: PromoCodeDiscountTypeEnum::NONE, + discount: 0.0, + expiry_date: $doors->subHours(3)->toDateTimeString(), + max_allowed_usages: 30, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT, + )); + + $this->ctx->createPromoCode->handle($eventId, new UpsertPromoCodeDTO( + code: 'earlydoors', + event_id: $eventId, + applicable_product_ids: [$generalAdmission], + discount_type: PromoCodeDiscountTypeEnum::FIXED, + discount: 3.0, + expiry_date: $doors->subDays(7)->toDateTimeString(), + max_allowed_usages: 50, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT, + )); + + $this->ctx->applySettings($owner->account_id, $eventId, $this->settings()); + $this->ctx->uploadCover($eventId, $owner->account_id, 'nightclub.jpg'); + + return new SeededDemoEvent( + event_id: $eventId, + title: $event->getTitle(), + slug: $event->getSlug(), + occurrence_count: $this->ctx->occurrenceCount($eventId), + promo_codes: ['boxcarrier', 'earlydoors'], + ); + } + + private function addon( + DemoOwner $owner, + int $eventId, + int $categoryId, + string $title, + string $description, + float $price, + int $quantity, + int $maxPerOrder, + bool $showRemaining = false, + ?string $highlight = null, + ): int { + return $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $categoryId, + title: $title, + type: ProductPriceType::PAID, + product_type: ProductType::GENERAL, + prices: collect([new ProductPriceDTO(price: $price, initial_quantity_available: $quantity)]), + max_per_order: $maxPerOrder, + description: $description, + min_per_order: 1, + hide_when_sold_out: false, + show_quantity_remaining: $showRemaining, + is_addon_only: true, + is_highlighted: $highlight !== null, + highlight_message: $highlight, + ))->getId(); + } + + private function ticket( + DemoOwner $owner, + int $eventId, + int $categoryId, + string $title, + string $description, + float $price, + int $quantity, + int $maxPerOrder, + CarbonImmutable $doors, + array $addonIds, + bool $showRemaining = false, + ): int { + return $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $categoryId, + title: $title, + type: ProductPriceType::PAID, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: $price, initial_quantity_available: $quantity)]), + sale_start_date: $doors->subDays(150)->toDateTimeString(), + sale_end_date: $doors->subHour()->toDateTimeString(), + max_per_order: $maxPerOrder, + description: $description, + min_per_order: 1, + hide_when_sold_out: false, + show_quantity_remaining: $showRemaining, + addon_product_ids: $addonIds, + ))->getId(); + } + + private function question( + int $eventId, + string $title, + string $description, + QuestionTypeEnum $type, + QuestionBelongsTo $belongsTo, + array $productIds, + bool $required = false, + ?array $options = null, + bool $isHidden = false, + ): void { + $this->ctx->createQuestion->handle(new UpsertQuestionDTO( + title: $title, + type: $type, + required: $required, + options: $options, + event_id: $eventId, + product_ids: $productIds, + is_hidden: $isHidden, + belongs_to: $belongsTo, + description: $description, + )); + } + + private function settings(): array + { + return [ + 'homepage_theme_settings' => [ + 'mode' => 'dark', + 'accent' => '#D0FF14', + 'background' => '#0A0A0B', + 'background_type' => 'COLOR', + 'font_family' => 'Space Grotesk', + ], + 'homepage_background_type' => 'COLOR', + 'homepage_background_color' => '#0A0A0B', + 'homepage_body_background_color' => '#0A0A0B', + 'homepage_primary_color' => '#D0FF14', + 'homepage_primary_text_color' => '#0A0A0B', + 'homepage_secondary_color' => '#1A1A1D', + 'homepage_secondary_text_color' => '#F2F2EE', + 'seo_title' => 'SUBTERRA 004 — Nite Kernel, Ánima, Basil Wren | The Coal Yard, Dublin 8', + 'seo_description' => 'Six hours, one room, no phones on the floor. Nite Kernel all night long with Ánima and Basil Wren at the Coal Yard, Dublin 8. 320 capacity.', + 'seo_keywords' => 'underground house, dublin nightlife, warehouse party, dublin 8, funktion one, techno, club night, subterra', + 'allow_search_engine_indexing' => true, + 'pre_checkout_message' => '

Before you pay, three things.

', + 'post_checkout_message' => '

You\'re in. See you in the dark.

Your ticket QR is attached and also lives in the confirmation email — screenshot it now, because there is no signal in the coal store.

The short version

', + 'email_footer_message' => 'SUBTERRA is a not-for-profit party at the Coal Yard, Dublin 8. Everything above the door fee goes back into the sound, the artists and the hosts.', + 'continue_button_text' => 'Get tickets', + 'support_email' => 'door@subterra.ie', + 'require_attendee_details' => true, + 'allow_copy_details_to_all_attendees' => true, + 'order_timeout_in_minutes' => 15, + 'price_display_mode' => 'INCLUSIVE', + 'show_marketing_opt_in' => true, + 'notify_organizer_of_new_orders' => true, + 'allow_attendee_self_edit' => false, + 'waitlist_auto_process' => true, + 'waitlist_offer_timeout_minutes' => 720, + 'ticket_design_settings' => [ + 'enabled' => true, + 'accent_color' => '#D0FF14', + 'layout_type' => 'modern', + 'footer_text' => '18+ · Photo ID required · Last entry 00:00 · Non-transferable · No phones on the floor', + ], + ]; + } + + private function description(): string + { + return '

Six hours. One room. No phones on the floor.

' + .'

SUBTERRA returns to the Coal Yard for the fourth time — a stripped-back Victorian coal store off Newmarket Square with a concrete floor, a low ceiling and a Funktion-One Res 5 pointed straight at it. Deep, dubbed-out, slightly broken house music from 11pm until the sun is a problem. Capacity is 320 and we are not adding more.

' + .'

Line-up

Running order

The rules

' + .'

These are not decoration. Door staff and floor hosts enforce all of them.

Access

' + .'

Step-free entry via the Newmarket Street side gate, accessible toilet on the ground floor, and a quiet room off the bar that stays under 70dB all night. Personal assistants come in free — tell us in the accessibility box at checkout and we will sort it before the night.

' + .'

Getting there

' + .'

Eight minutes’ walk from Dublin 8 / The Coombe, fifteen from Christchurch. Nightlink stops on Cork Street. There is no parking and the neighbours are asleep — please keep it quiet on the way in and on the way out.

' + .'

SUBTERRA is a not-for-profit party. Everything above the door fee goes back into the sound, the artists and the hosts.

'; + } +} diff --git a/backend/app/Console/Commands/Demo/SeededDemoEvent.php b/backend/app/Console/Commands/Demo/SeededDemoEvent.php new file mode 100644 index 0000000000..1fb4d83a22 --- /dev/null +++ b/backend/app/Console/Commands/Demo/SeededDemoEvent.php @@ -0,0 +1,21 @@ +event_id.'/'.$this->slug; + } +} diff --git a/backend/app/Console/Commands/Demo/YogaDemoEvent.php b/backend/app/Console/Commands/Demo/YogaDemoEvent.php new file mode 100644 index 0000000000..9734c2ffbb --- /dev/null +++ b/backend/app/Console/Commands/Demo/YogaDemoEvent.php @@ -0,0 +1,452 @@ +timezone)->next(CarbonImmutable::MONDAY); + $termEnd = $termStart->addWeeks(18)->next(CarbonImmutable::FRIDAY); + + $location = $this->ctx->createLocation->handle(new UpsertLocationDTO( + organizer_id: $owner->organizer_id, + account_id: $owner->account_id, + name: 'Stillroom', + structured_address: new AddressDTO( + venue_name: 'Stillroom', + address_line_1: '22 Blackpitts', + address_line_2: 'First floor, above the bakery', + city: 'Dublin', + state_or_region: 'Co. Dublin', + zip_or_postal_code: 'D08 R6X3', + country: 'IE', + ), + latitude: 53.3341, + longitude: -6.2735, + )); + + $event = $this->ctx->createEvent->handle(new CreateEventDTO( + title: 'Stillroom — Morning Practice & Weekend Specials', + organizer_id: $owner->organizer_id, + account_id: $owner->account_id, + user_id: $owner->user_id, + description: $this->description(), + attributes: collect([ + new AttributesDTO(name: 'Room size', value: '18 mats', is_public: true), + new AttributesDTO(name: 'Weekday classes', value: '06:45 Sunrise Vinyasa · 09:30 Slow Flow & Mobility', is_public: true), + new AttributesDTO(name: 'Booking', value: 'Per class — no membership or contract', is_public: true), + new AttributesDTO(name: 'Equipment', value: 'Mats, blocks, straps, bolsters and blankets provided', is_public: true), + new AttributesDTO(name: 'Access', value: 'First floor, seventeen stairs, no lift', is_public: true), + new AttributesDTO(name: 'Cancellation', value: 'Free move up to 6 hours before', is_public: true), + new AttributesDTO(name: 'Internal note', value: 'Second PA needed for the sound bath weekends', is_public: false), + ]), + timezone: $this->timezone, + currency: $this->currency, + category: EventCategory::WELLNESS, + event_location: new EventLocationData(type: LocationType::IN_PERSON, location_id: $location->getId()), + status: EventStatus::LIVE->name, + type: EventType::RECURRING, + )); + + $eventId = $event->getId(); + + $excludedDates = $this->excludedDates($termStart); + + $this->ctx->generateOccurrences->handle(new GenerateOccurrencesDTO( + event_id: $eventId, + recurrence_rule: [ + 'frequency' => 'weekly', + 'interval' => 1, + 'days_of_week' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + 'times_of_day' => [ + ['time' => '06:45', 'label' => 'Sunrise Vinyasa', 'duration_minutes' => 60], + ['time' => '09:30', 'label' => 'Slow Flow & Mobility', 'duration_minutes' => 75], + ], + 'range' => [ + 'type' => 'until', + 'start' => $termStart->toDateString(), + 'until' => $termEnd->toDateString(), + ], + 'default_capacity' => 18, + 'excluded_dates' => $excludedDates, + ], + )); + + $weekendOccurrenceIds = []; + $weekendPrices = []; + + foreach ($this->weekendSpecials() as $special) { + $start = $termStart + ->addWeeks($special['week']) + ->addDays($special['day_offset']) + ->setTimeFromTimeString($special['time']); + + $occurrenceId = $this->ctx->createOccurrence->handle(new UpsertEventOccurrenceDTO( + event_id: $eventId, + start_date: $this->ctx->toUtc($start->toDateTimeString(), $this->timezone), + end_date: $this->ctx->toUtc($start->addMinutes($special['minutes'])->toDateTimeString(), $this->timezone), + capacity: $special['capacity'], + label: $special['label'], + show_available_capacity: true, + is_overridden: true, + ))->getId(); + + $weekendOccurrenceIds[] = $occurrenceId; + + if ($special['price'] !== self::WEEKEND_BASE_PRICE) { + $weekendPrices[$occurrenceId] = $special['price']; + } + } + + $weekdayCategory = $this->ctx->renameDefaultCategory( + $eventId, + 'Weekday classes', + 'Mon–Fri, 06:45 and 09:30. Pick a date from the calendar — every class is booked individually and there is nothing to cancel afterwards.', + ); + $weekendCategory = $this->ctx->addCategory($eventId, 'Weekend specials', 'Longer one-off sessions, smaller rooms, usually two teachers. Twelve to eighteen places each and they do go.'); + $extrasCategory = $this->ctx->addCategory($eventId, 'Studio extras', 'Added to any class.'); + + $matHire = $this->addon($owner, $eventId, $extrasCategory, 'Mat hire', 'A clean studio mat waiting on your spot. Sanitised between every class. Bring your own if you would rather — most regulars do eventually.', 2.00); + $coffee = $this->addon($owner, $eventId, $extrasCategory, 'Coffee & pastry from downstairs', 'Ordered ahead and waiting at the bottom of the stairs when you finish. The bakery opens at 07:00, so this is only worth it after the 09:30.', 5.50); + + $dropIn = $this->classProduct($owner, $eventId, $weekdayCategory, 'Drop-in class', 'One class, any weekday morning. Choose the date and time above. No membership, no minimum, nothing renews.', 18.00, 4, [$matHire, $coffee], showRemaining: true); + $community = $this->classProduct($owner, $eventId, $weekdayCategory, 'Community class', 'The same class for less, no questions asked and nothing to prove. For students, carers, anyone out of work, and anyone for whom the full price twice a week is the reason they stop coming. We keep a few spots in every class for this.', 11.00, 2, [$matHire, $coffee]); + + $firstFree = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $weekdayCategory, + title: 'First class free', + type: ProductPriceType::FREE, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: 0.0)]), + max_per_order: 1, + description: 'New here? Take one on us. One per person, no card details held, and nobody will follow up to ask why you did not come back.', + min_per_order: 1, + is_hidden_without_promo_code: true, + addon_product_ids: [$matHire], + ))->getId(); + + $weekendSpecial = $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $weekendCategory, + title: 'Weekend special', + type: ProductPriceType::PAID, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: self::WEEKEND_BASE_PRICE)]), + max_per_order: 3, + description: 'A place at whichever weekend session you picked above. Price varies by session — the date you choose sets it. Longer, slower and smaller than a weekday class, and worth arriving early for.', + min_per_order: 1, + show_quantity_remaining: true, + addon_product_ids: [$matHire, $coffee], + is_highlighted: true, + highlight_message: 'Small rooms — 12 to 18 places', + waitlist_enabled: true, + ))->getId(); + + $weekendPriceId = $this->ctx->firstPriceId($weekendSpecial); + + foreach ($weekendPrices as $occurrenceId => $price) { + $this->ctx->upsertPriceOverride->handle(new UpsertPriceOverrideDTO( + event_id: $eventId, + event_occurrence_id: $occurrenceId, + product_price_id: $weekendPriceId, + price: $price, + )); + } + + $weekdayProducts = [$dropIn, $community, $firstFree, $matHire, $coffee]; + $weekendProducts = [$weekendSpecial, $matHire, $coffee]; + + foreach ($weekendOccurrenceIds as $occurrenceId) { + $this->ctx->updateVisibility->handle(new UpdateProductVisibilityDTO( + event_id: $eventId, + event_occurrence_id: $occurrenceId, + product_ids: $weekendProducts, + )); + } + + foreach ($this->ctx->occurrenceIdsExcluding($eventId, $weekendOccurrenceIds) as $occurrenceId) { + $this->ctx->updateVisibility->handle(new UpdateProductVisibilityDTO( + event_id: $eventId, + event_occurrence_id: $occurrenceId, + product_ids: $weekdayProducts, + )); + } + + $classProducts = [$dropIn, $community, $firstFree, $weekendSpecial]; + + $this->question($eventId, 'Injuries, conditions, or anything else we should know', 'A teacher reads this before every class. Knees, backs, wrists, shoulders, recent surgery, high or low blood pressure, anything you are managing. We will have props ready and alternatives planned rather than asking you about it in front of the room. Blank is fine too.', QuestionTypeEnum::MULTI_LINE_TEXT, QuestionBelongsTo::PRODUCT, $classProducts); + $this->question($eventId, 'Is this your first class at Stillroom?', 'Only so we know to look out for you at the door and show you where things are. Nobody gets announced to the room.', QuestionTypeEnum::RADIO, QuestionBelongsTo::PRODUCT, $classProducts, required: true, options: [ + 'Yes, first time here', + "First time here, but I've practised elsewhere", + "I've been before", + "I'm a regular", + ]); + $this->question($eventId, 'Are you pregnant or postnatal?', 'Optional, and only asked because some of what we teach changes — twists, deep backbends, lying flat, and breath retention in the breathwork sessions. If you tell us, the teacher will have alternatives ready without drawing attention to it. If you would rather not say, that is completely fine and you can talk to the teacher on the day instead.', QuestionTypeEnum::DROPDOWN, QuestionBelongsTo::PRODUCT, $classProducts, options: [ + 'No', + 'Pregnant — first trimester', + 'Pregnant — second trimester', + 'Pregnant — third trimester', + 'Postnatal, under six months', + 'Postnatal, over six months', + 'Rather not say', + ]); + $this->question($eventId, 'Emergency contact', 'Asked for weekend sessions only, because the breathwork and cold water mornings go a bit further than a normal class. Held by the teacher on the day and deleted afterwards.', QuestionTypeEnum::PHONE, QuestionBelongsTo::PRODUCT, [$weekendSpecial]); + $this->question($eventId, 'Before you book', 'Please tick both. Neither of these is us trying to get out of anything — the first is genuinely the most important thing you can do for your own practice.', QuestionTypeEnum::CHECKBOX, QuestionBelongsTo::ORDER, [], required: true, options: [ + "I'll tell the teacher about anything that changes between now and the class, and come out of a pose if it hurts", + 'I understand a class can be moved free up to 6 hours before, and is used if I cancel inside that', + ]); + $this->question($eventId, 'Access needs', 'The studio is on the first floor with seventeen stairs and no lift, which we know rules us out for some people and we are sorry about it. For everything else — a spot near the door, extra props, a quieter corner, coming in ten minutes early to settle before the room fills — just say so here.', QuestionTypeEnum::MULTI_LINE_TEXT, QuestionBelongsTo::ORDER, []); + $this->question($eventId, 'How did you hear about us?', 'We have never run an ad and would like to keep it that way.', QuestionTypeEnum::DROPDOWN, QuestionBelongsTo::ORDER, [], options: [ + 'A friend sent me', + 'The bakery downstairs', + 'Walked past and looked up', + 'Instagram', + 'Google', + 'Came to a weekend special first', + 'Somewhere else', + ]); + $this->question($eventId, 'Teacher notes (staff only)', 'Internal. Prop setup, regulars\' preferences, anything carried over from a previous class.', QuestionTypeEnum::SINGLE_LINE_TEXT, QuestionBelongsTo::ORDER, [], isHidden: true); + + $this->ctx->createPromoCode->handle($eventId, new UpsertPromoCodeDTO( + code: 'firstmat', + event_id: $eventId, + applicable_product_ids: [$firstFree], + discount_type: PromoCodeDiscountTypeEnum::NONE, + discount: 0.0, + expiry_date: $termEnd->setTime(23, 59)->toDateTimeString(), + max_allowed_usages: 300, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT, + )); + + $this->ctx->createPromoCode->handle($eventId, new UpsertPromoCodeDTO( + code: 'bringafriend', + event_id: $eventId, + applicable_product_ids: [$dropIn], + discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE, + discount: 50.0, + expiry_date: $termEnd->setTime(23, 59)->toDateTimeString(), + max_allowed_usages: 200, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT, + )); + + $this->ctx->applySettings($owner->account_id, $eventId, $this->settings()); + $this->ctx->uploadCover($eventId, $owner->account_id, 'yoga.jpg'); + + return new SeededDemoEvent( + event_id: $eventId, + title: $event->getTitle(), + slug: $event->getSlug(), + occurrence_count: $this->ctx->occurrenceCount($eventId), + promo_codes: ['firstmat', 'bringafriend'], + ); + } + + private function excludedDates(CarbonImmutable $termStart): array + { + $bankHoliday = $termStart->addWeeks(11); + $closureWeekStart = $termStart->addWeeks(15); + + $dates = [$bankHoliday->toDateString()]; + + for ($day = 0; $day < 5; $day++) { + $dates[] = $closureWeekStart->addDays($day)->toDateString(); + } + + return $dates; + } + + private function weekendSpecials(): array + { + return [ + ['week' => 0, 'day_offset' => 5, 'time' => '10:00', 'minutes' => 90, 'capacity' => 18, 'price' => 32.00, 'label' => 'Candlelit Yin & Sound Bath'], + ['week' => 1, 'day_offset' => 6, 'time' => '10:00', 'minutes' => 120, 'capacity' => 16, 'price' => 38.00, 'label' => 'Slow Sunday: Restorative & Yoga Nidra'], + ['week' => 3, 'day_offset' => 5, 'time' => '09:30', 'minutes' => 150, 'capacity' => 14, 'price' => 45.00, 'label' => 'Breathwork & Cold Water'], + ['week' => 4, 'day_offset' => 6, 'time' => '10:00', 'minutes' => 90, 'capacity' => 18, 'price' => 32.00, 'label' => 'Full Moon Yin & Sound Bath'], + ['week' => 6, 'day_offset' => 5, 'time' => '10:00', 'minutes' => 180, 'capacity' => 12, 'price' => 55.00, 'label' => 'Handstand Fundamentals'], + ['week' => 8, 'day_offset' => 6, 'time' => '10:00', 'minutes' => 120, 'capacity' => 16, 'price' => 38.00, 'label' => 'Restorative & Yoga Nidra'], + ['week' => 11, 'day_offset' => 5, 'time' => '10:00', 'minutes' => 150, 'capacity' => 16, 'price' => 42.00, 'label' => 'Yin for Winter & Tea Ceremony'], + ['week' => 15, 'day_offset' => 6, 'time' => '10:00', 'minutes' => 150, 'capacity' => 18, 'price' => 42.00, 'label' => 'Solstice Practice & Sound Bath'], + ]; + } + + private function addon(DemoOwner $owner, int $eventId, int $categoryId, string $title, string $description, float $price): int + { + return $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $categoryId, + title: $title, + type: ProductPriceType::PAID, + product_type: ProductType::GENERAL, + prices: collect([new ProductPriceDTO(price: $price)]), + max_per_order: 2, + description: $description, + min_per_order: 1, + is_addon_only: true, + ))->getId(); + } + + private function classProduct( + DemoOwner $owner, + int $eventId, + int $categoryId, + string $title, + string $description, + float $price, + int $maxPerOrder, + array $addonIds, + bool $showRemaining = false, + ): int { + return $this->ctx->createProduct->handle(new UpsertProductDTO( + account_id: $owner->account_id, + event_id: $eventId, + product_category_id: $categoryId, + title: $title, + type: ProductPriceType::PAID, + product_type: ProductType::TICKET, + prices: collect([new ProductPriceDTO(price: $price)]), + max_per_order: $maxPerOrder, + description: $description, + min_per_order: 1, + show_quantity_remaining: $showRemaining, + addon_product_ids: $addonIds, + waitlist_enabled: true, + ))->getId(); + } + + private function question( + int $eventId, + string $title, + string $description, + QuestionTypeEnum $type, + QuestionBelongsTo $belongsTo, + array $productIds, + bool $required = false, + ?array $options = null, + bool $isHidden = false, + ): void { + $this->ctx->createQuestion->handle(new UpsertQuestionDTO( + title: $title, + type: $type, + required: $required, + options: $options, + event_id: $eventId, + product_ids: $productIds, + is_hidden: $isHidden, + belongs_to: $belongsTo, + description: $description, + )); + } + + private function settings(): array + { + return [ + 'homepage_theme_settings' => [ + 'mode' => 'light', + 'accent' => '#B5654A', + 'background' => '#EDE4D8', + 'background_type' => 'COLOR', + 'font_family' => 'Lora', + ], + 'homepage_background_type' => 'COLOR', + 'homepage_background_color' => '#EDE4D8', + 'homepage_body_background_color' => '#EDE4D8', + 'homepage_primary_color' => '#B5654A', + 'homepage_primary_text_color' => '#FFFFFF', + 'homepage_secondary_color' => '#2E2A24', + 'homepage_secondary_text_color' => '#EDE4D8', + 'seo_title' => 'Stillroom — weekday morning yoga & weekend specials, Dublin 8', + 'seo_description' => 'Two classes every weekday morning at 06:45 and 09:30, plus longer weekend sessions — yin, sound baths, breathwork and nidra. Eighteen mats above a bakery in Dublin 8. Book by the class, no membership.', + 'seo_keywords' => 'yoga dublin 8, morning yoga dublin, vinyasa dublin, yin yoga, sound bath dublin, breathwork, drop in yoga class, blackpitts', + 'allow_search_engine_indexing' => true, + 'price_display_mode' => 'INCLUSIVE', + 'require_attendee_details' => true, + 'attendee_details_collection_method' => 'PER_TICKET', + 'allow_copy_details_to_all_attendees' => false, + 'allow_attendee_self_edit' => true, + 'order_timeout_in_minutes' => 15, + 'continue_button_text' => 'Book a class', + 'support_email' => 'hello@stillroom.ie', + 'show_marketing_opt_in' => true, + 'notify_organizer_of_new_orders' => true, + 'show_available_occurrence_capacity' => true, + 'hide_sold_out_occurrences' => false, + 'waitlist_auto_process' => true, + 'waitlist_offer_timeout_minutes' => 360, + 'pre_checkout_message' => '

Two quick things.

', + 'post_checkout_message' => '

You\'re booked. See you upstairs.

Your date, time and class are in the confirmation email — that is all you need, there is nothing to print and nobody checks a QR code at a yoga class.

Before you come

Need to move it? Reply to the confirmation email any time up to 6 hours before and we will shift you to another date, no charge and no explanation needed.

', + 'email_footer_message' => 'Stillroom · 22 Blackpitts, Dublin 8 · first floor, above the bakery. Move a class free up to 6 hours before.', + 'ticket_design_settings' => [ + 'enabled' => true, + 'accent_color' => '#B5654A', + 'layout_type' => 'modern', + 'footer_text' => '22 Blackpitts, Dublin 8 · first floor above the bakery · arrive 10 minutes early for your first class', + ], + ]; + } + + private function description(): string + { + return '

A small room above a bakery in Dublin 8, with good light and eighteen mats.

' + .'

Stillroom runs two classes every weekday morning and a slower, longer workshop most weekends. Everything is booked by the class — no membership, no minimum, no contract to cancel. If you come once a month that is genuinely fine.

' + .'

Weekday mornings

Monday to Friday, all term. Pick any date from the calendar below.

' + .'

Weekend specials

' + .'

One-off longer sessions, capped smaller, usually with two teachers in the room. Candlelit yin with live sound, restorative and nidra afternoons, breathwork, and the occasional handstand workshop for people who want to be upside down. These sell out — they are twelve to eighteen places each.

' + .'

What to expect

If you are new

' + .'

Start with the 09:30 Slow Flow. Use the code FIRSTMAT at checkout and your first class is free — one per person, no card details held, nothing to cancel afterwards. If you hate it you never have to see us again.

' + .'

Injuries, pregnancy and everything else

' + .'

There is a notes box at checkout and a teacher reads every one before class. Tell us about injuries, recent surgery, pregnancy or anything you are managing, and we will have props ready and quiet alternatives planned rather than singling you out in the room.

' + .'

Cancelling

' + .'

Cancel up to 6 hours before and we will move you to another date, no charge and no explanation needed. Inside 6 hours we cannot fill the space, so the class is used — though if something genuinely went wrong, email us and we will almost always sort it.

' + .'

22 Blackpitts, Dublin 8, first floor above the bakery. There are seventeen stairs and no lift, which we know rules us out for some people — we are sorry, and we are working on it.

'; + } +} diff --git a/backend/app/Console/Commands/SeedDemoEventsCommand.php b/backend/app/Console/Commands/SeedDemoEventsCommand.php new file mode 100644 index 0000000000..d3d1a134a4 --- /dev/null +++ b/backend/app/Console/Commands/SeedDemoEventsCommand.php @@ -0,0 +1,237 @@ +@example.com)} + {--password=DemoPass123! : Password for the created demo account} + {--only=* : Seed only these events: nightclub, conference, yoga, festival} + {--timezone=Europe/Dublin : Timezone for the seeded events} + {--currency=EUR : Currency code for the seeded events}'; + + protected $description = 'Seed three fully-built demo events — an underground club night, a two-day tech conference and a recurring yoga studio schedule — with products, add-ons, checkout questions, promo codes, themes and cover images.'; + + public function handle( + DemoSeedContext $context, + CreateAccountHandler $createAccountHandler, + CreateOrganizerHandler $createOrganizerHandler, + DatabaseManager $db, + ): int { + if (! $this->option('confirm')) { + $this->error('demo:seed writes demo events, products and orders-facing data to '.$db->connection()->getDatabaseName().'.'); + $this->line('Re-run with --confirm once you are sure this is the right database.'); + + return self::FAILURE; + } + + $selected = $this->selectedEvents(); + + if ($selected === null) { + return self::FAILURE; + } + + $timezone = (string) $this->option('timezone'); + $currency = strtoupper((string) $this->option('currency')); + + try { + $owner = $this->resolveOwner($createAccountHandler, $createOrganizerHandler, $db, $timezone, $currency); + } catch (Throwable $e) { + $this->error('Could not resolve an account to seed into: '.$e->getMessage()); + + return self::FAILURE; + } + + $this->warnIfAccountUnverified($db, $owner); + + $builders = [ + NightclubDemoEvent::KEY => fn () => (new NightclubDemoEvent($context, $timezone, $currency))->seed($owner), + ConferenceDemoEvent::KEY => fn () => (new ConferenceDemoEvent($context, $timezone, $currency))->seed($owner), + YogaDemoEvent::KEY => fn () => (new YogaDemoEvent($context, $timezone, $currency))->seed($owner), + FestivalDemoEvent::KEY => fn () => (new FestivalDemoEvent($context, $timezone, $currency))->seed($owner), + ]; + + $seeded = []; + + foreach ($selected as $key) { + $this->line('Seeding '.$key.' …'); + + try { + $seeded[] = $db->transaction($builders[$key]); + } catch (Throwable $e) { + $this->error('Failed while seeding '.$key.': '.$e->getMessage()); + $this->line($e->getFile().':'.$e->getLine()); + + return self::FAILURE; + } + } + + $this->report($owner, $seeded); + + return self::SUCCESS; + } + + private function selectedEvents(): ?array + { + $available = [NightclubDemoEvent::KEY, ConferenceDemoEvent::KEY, YogaDemoEvent::KEY, FestivalDemoEvent::KEY]; + $requested = (array) $this->option('only'); + + if ($requested === []) { + return $available; + } + + $unknown = array_diff($requested, $available); + + if ($unknown !== []) { + $this->error('Unknown --only value(s): '.implode(', ', $unknown)); + $this->line('Available: '.implode(', ', $available)); + + return null; + } + + return array_values(array_intersect($available, $requested)); + } + + private function resolveOwner( + CreateAccountHandler $createAccountHandler, + CreateOrganizerHandler $createOrganizerHandler, + DatabaseManager $db, + string $timezone, + string $currency, + ): DemoOwner { + $organizerId = $this->option('organizer-id'); + + if ($organizerId !== null) { + $organizer = $db->table('organizers')->where('id', (int) $organizerId)->first(); + + if ($organizer === null) { + throw new RuntimeException('Organizer '.$organizerId.' does not exist.'); + } + + $userId = $db->table('account_users') + ->where('account_id', $organizer->account_id) + ->orderBy('id') + ->value('user_id'); + + if ($userId === null) { + throw new RuntimeException('Account '.$organizer->account_id.' has no users.'); + } + + $this->authenticate((int) $userId); + + return new DemoOwner( + account_id: (int) $organizer->account_id, + organizer_id: (int) $organizer->id, + user_id: (int) $userId, + ); + } + + $email = $this->option('email') ?: 'demo+'.now()->format('YmdHis').'@example.com'; + $password = (string) $this->option('password'); + + $account = $createAccountHandler->handle(new CreateAccountDTO( + email: $email, + password: $password, + first_name: 'Demo', + locale: 'en', + last_name: 'Organiser', + timezone: $timezone, + currency_code: $currency, + )); + + $user = User::where('email', $email)->firstOrFail(); + + $db->table('accounts') + ->where('id', $account->getId()) + ->whereNull('account_verified_at') + ->update(['account_verified_at' => now()]); + + $this->authenticate($user->id); + + $organizer = $createOrganizerHandler->handle(new CreateOrganizerDTO( + name: 'Stillroom, Subterra & Runtime', + email: $email, + account_id: $account->getId(), + timezone: $timezone, + currency: $currency, + )); + + $this->newLine(); + $this->info('Created a demo account:'); + $this->line(' email '.$email); + $this->line(' password '.$password); + $this->newLine(); + + return new DemoOwner( + account_id: $account->getId(), + organizer_id: $organizer->getId(), + user_id: $user->id, + ); + } + + private function authenticate(int $userId): void + { + $user = User::find($userId); + + if ($user !== null) { + auth()->login($user); + } + } + + private function warnIfAccountUnverified(DatabaseManager $db, DemoOwner $owner): void + { + $verifiedAt = $db->table('accounts')->where('id', $owner->account_id)->value('account_verified_at'); + + if ($verifiedAt === null) { + $this->warn('Account '.$owner->account_id.' has no account_verified_at — the seeded events will not be publicly visible until it is verified.'); + } + } + + /** + * @param SeededDemoEvent[] $seeded + */ + private function report(DemoOwner $owner, array $seeded): void + { + $baseUrl = rtrim((string) config('app.frontend_url'), '/'); + + $this->newLine(); + $this->info('Seeded '.count($seeded).' demo event(s) for organizer '.$owner->organizer_id.':'); + $this->newLine(); + + $this->table( + ['Event', 'ID', 'Occurrences', 'Promo codes'], + array_map(static fn (SeededDemoEvent $event) => [ + mb_strimwidth($event->title, 0, 52, '…'), + $event->event_id, + $event->occurrence_count, + implode(', ', $event->promo_codes), + ], $seeded), + ); + + foreach ($seeded as $event) { + $this->line($baseUrl.$event->publicPath()); + } + } +} diff --git a/backend/app/Http/Actions/EmailTemplates/DeleteOrganizerEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/DeleteOrganizerEmailTemplateAction.php index d995893176..61bec7af1c 100644 --- a/backend/app/Http/Actions/EmailTemplates/DeleteOrganizerEmailTemplateAction.php +++ b/backend/app/Http/Actions/EmailTemplates/DeleteOrganizerEmailTemplateAction.php @@ -9,6 +9,7 @@ use HiEvents\Services\Application\Handlers\EmailTemplate\DeleteEmailTemplateHandler; use HiEvents\Services\Application\Handlers\EmailTemplate\DTO\DeleteEmailTemplateDTO; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Response as LaravelResponse; use Symfony\Component\HttpFoundation\Response; class DeleteOrganizerEmailTemplateAction extends BaseEmailTemplateAction @@ -17,7 +18,7 @@ public function __construct( private readonly DeleteEmailTemplateHandler $handler ) {} - public function __invoke(int $organizerId, int $templateId): JsonResponse + public function __invoke(int $organizerId, int $templateId): JsonResponse|LaravelResponse { $this->isActionAuthorized($organizerId, OrganizerDomainObject::class); @@ -41,6 +42,6 @@ public function __invoke(int $organizerId, int $templateId): JsonResponse ); } - return response()->json(['message' => 'Template deleted successfully'], ResponseCodes::HTTP_OK); + return $this->deletedResponse(); } } diff --git a/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php index 1598706766..de942f4455 100644 --- a/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php +++ b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php @@ -2,6 +2,7 @@ namespace HiEvents\Http\Actions\EventOccurrences; +use Dedoc\Scramble\Attributes\QueryParameter; use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\Exceptions\InvalidOccurrenceDatesException; use HiEvents\Http\Actions\Events\BasePublicEventAction; @@ -22,6 +23,8 @@ public function __construct( /** * @throws ValidationException */ + #[QueryParameter('start_date_from', description: 'Only return occurrences starting on or after this date (ISO 8601).', type: 'string')] + #[QueryParameter('start_date_to', description: 'Only return occurrences starting on or before this date (ISO 8601).', type: 'string')] public function __invoke(int $eventId, Request $request): Response|JsonResponse { $startDateFrom = $request->query('start_date_from'); diff --git a/backend/app/Http/Actions/Locations/GeoAutocompleteAction.php b/backend/app/Http/Actions/Locations/GeoAutocompleteAction.php index 04df5180ee..c5d1fff502 100644 --- a/backend/app/Http/Actions/Locations/GeoAutocompleteAction.php +++ b/backend/app/Http/Actions/Locations/GeoAutocompleteAction.php @@ -4,6 +4,7 @@ namespace HiEvents\Http\Actions\Locations; +use Dedoc\Scramble\Attributes\QueryParameter; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\ResponseCodes; @@ -22,6 +23,9 @@ public function __construct( private readonly GeoAutocompleteHandler $handler, ) {} + #[QueryParameter('query', description: 'Address search term.', type: 'string', required: true)] + #[QueryParameter('locale', description: 'Locale for the returned suggestions.', type: 'string')] + #[QueryParameter('country', description: 'Two-letter country code used to bias the results.', type: 'string')] public function __invoke(int $organizerId, Request $request): JsonResponse { $this->isActionAuthorized($organizerId, OrganizerDomainObject::class); diff --git a/backend/app/Http/Actions/Locations/GeoPlaceDetailsAction.php b/backend/app/Http/Actions/Locations/GeoPlaceDetailsAction.php index 70adf628fb..a3939a1d63 100644 --- a/backend/app/Http/Actions/Locations/GeoPlaceDetailsAction.php +++ b/backend/app/Http/Actions/Locations/GeoPlaceDetailsAction.php @@ -4,6 +4,7 @@ namespace HiEvents\Http\Actions\Locations; +use Dedoc\Scramble\Attributes\QueryParameter; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\ResponseCodes; @@ -20,6 +21,7 @@ public function __construct( private readonly GeoPlaceDetailsHandler $handler, ) {} + #[QueryParameter('locale', description: 'Locale for the returned place details.', type: 'string')] public function __invoke(int $organizerId, string $placeId, Request $request): JsonResponse { $this->isActionAuthorized($organizerId, OrganizerDomainObject::class); diff --git a/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php b/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php index 0f3dc1bb89..83b4754b44 100644 --- a/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php +++ b/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php @@ -2,6 +2,7 @@ namespace HiEvents\Http\Actions\Orders; +use Dedoc\Scramble\Attributes\Response as ResponseAttribute; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Http\Actions\BaseAction; use HiEvents\Services\Application\Handlers\Order\DTO\GetOrderInvoiceDTO; @@ -15,6 +16,7 @@ public function __construct( private readonly GetOrderInvoiceHandler $orderInvoiceHandler, ) {} + #[ResponseAttribute(status: 200, description: 'Invoice PDF', mediaType: 'application/pdf', type: 'string', format: 'binary')] public function __invoke(Request $request, int $eventId, int $orderId): Response { $this->isActionAuthorized($eventId, EventDomainObject::class); diff --git a/backend/app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php b/backend/app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php index f897249925..a8ac9901cd 100644 --- a/backend/app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php +++ b/backend/app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php @@ -12,6 +12,9 @@ public function __construct( private readonly GetPaymentIntentHandler $getPaymentIntentHandler, ) {} + /** + * @response array{status: string, paymentIntentId: string, amount: string} + */ public function __invoke(int $eventId, string $orderShortId): JsonResponse { $createIntent = $this->getPaymentIntentHandler->handle( diff --git a/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php index 96704dcd5f..b09d320907 100644 --- a/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php @@ -18,6 +18,13 @@ class CompleteOrderActionPublic extends BaseAction { public function __construct(private readonly CompleteOrderHandler $orderService) {} + /** + * Complete Order + * + * `order.questions` and `products.*.questions` are validated against the questions configured + * for the event. The `order.address` fields become required when the event's settings require + * a billing address. + */ public function __invoke(CompleteOrderRequest $request, int $eventId, string $orderShortId): JsonResponse { try { diff --git a/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php index 23822973e2..eaa5faa335 100644 --- a/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php @@ -28,6 +28,12 @@ public function __construct( ) {} /** + * Create Order + * + * `products.*.event_occurrence_id` is required for recurring events; for single-occurrence + * events it is inferred when omitted. Product availability, capacity, and pricing are + * validated against the event's live configuration. + * * @throws Throwable */ public function __invoke(CreateOrderRequest $request, int $eventId): JsonResponse diff --git a/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php b/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php index 2c57de8d8d..9b8332e5ee 100644 --- a/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php +++ b/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php @@ -2,6 +2,7 @@ namespace HiEvents\Http\Actions\Orders\Public; +use Dedoc\Scramble\Attributes\Response as ResponseAttribute; use HiEvents\Http\Actions\BaseAction; use HiEvents\Services\Application\Handlers\Order\Public\DownloadOrderInvoicePublicHandler; use Illuminate\Http\Response; @@ -12,6 +13,7 @@ public function __construct( private readonly DownloadOrderInvoicePublicHandler $downloadOrderInvoicePublicHandler, ) {} + #[ResponseAttribute(status: 200, description: 'Invoice PDF', mediaType: 'application/pdf', type: 'string', format: 'binary')] public function __invoke(int $eventId, string $orderShortId): Response { $invoice = $this->downloadOrderInvoicePublicHandler->handle( diff --git a/backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php index e28893a104..6e387e979f 100644 --- a/backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php @@ -2,6 +2,7 @@ namespace HiEvents\Http\Actions\Orders\Public; +use Dedoc\Scramble\Attributes\QueryParameter; use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\Order\OrderResourcePublic; use HiEvents\Services\Application\Handlers\Order\DTO\GetOrderPublicDTO; @@ -17,6 +18,7 @@ public function __construct( private readonly CheckoutSessionManagementService $sessionService, ) {} + #[QueryParameter('session_identifier', description: 'Checkout session identifier issued when the order was created. Sets the checkout session cookie on the response.', type: 'string')] public function __invoke(int $eventId, string $orderShortId, Request $request): JsonResponse { $order = $this->getOrderPublicHandler->handle(new GetOrderPublicDTO( diff --git a/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php b/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php index 522cb05731..996c76b020 100644 --- a/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php +++ b/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php @@ -19,6 +19,11 @@ class GetOrganizerReportAction extends BaseAction public function __construct(private readonly GetOrganizerReportHandler $reportHandler) {} /** + * Get Organizer Report + * + * The response rows depend on the requested `reportType`. Paginated report types return + * `{data, meta, links}`; non-paginated report types return `{data}`. + * * @throws ValidationException */ public function __invoke(GetOrganizerReportRequest $request, int $organizerId, string $reportType): JsonResponse diff --git a/backend/app/Http/Kernel.php b/backend/app/Http/Kernel.php index 69a1d37812..7bb138585e 100644 --- a/backend/app/Http/Kernel.php +++ b/backend/app/Http/Kernel.php @@ -14,7 +14,6 @@ use HiEvents\Http\Middleware\SetUserLocaleMiddleware; use HiEvents\Http\Middleware\TrimStrings; use HiEvents\Http\Middleware\TrustProxies; -use HiEvents\Http\Middleware\ValidateSignature; use HiEvents\Http\Middleware\VaporBinaryResponseMiddleware; use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth; use Illuminate\Auth\Middleware\Authorize; @@ -93,7 +92,6 @@ class Kernel extends HttpKernel 'can' => Authorize::class, 'guest' => RedirectIfAuthenticated::class, 'password.confirm' => RequirePassword::class, - 'signed' => ValidateSignature::class, 'throttle' => ThrottleRequests::class, 'verified' => EnsureEmailIsVerified::class, ]; diff --git a/backend/app/Http/Middleware/ValidateSignature.php b/backend/app/Http/Middleware/ValidateSignature.php deleted file mode 100644 index 7a4c60d4d5..0000000000 --- a/backend/app/Http/Middleware/ValidateSignature.php +++ /dev/null @@ -1,22 +0,0 @@ - - */ - protected $except = [ - // 'fbclid', - // 'utm_campaign', - // 'utm_content', - // 'utm_medium', - // 'utm_source', - // 'utm_term', - ]; -} diff --git a/backend/app/Http/Request/Message/SendMessageRequest.php b/backend/app/Http/Request/Message/SendMessageRequest.php index a6d57a20b0..15c95ce833 100644 --- a/backend/app/Http/Request/Message/SendMessageRequest.php +++ b/backend/app/Http/Request/Message/SendMessageRequest.php @@ -20,7 +20,7 @@ public function rules(): array 'message_type' => [new In(MessageTypeEnum::valuesArray()), 'required'], 'is_test' => 'boolean', 'send_copy_to_current_user' => 'boolean', - 'attendee_ids' => 'max:50,array|required_if:message_type,'.MessageTypeEnum::INDIVIDUAL_ATTENDEES->name, + 'attendee_ids' => 'array|max:50|required_if:message_type,'.MessageTypeEnum::INDIVIDUAL_ATTENDEES->name, 'attendee_ids.*' => 'integer', 'product_ids' => ['array', 'required_if:message_type,'.MessageTypeEnum::TICKET_HOLDERS->name], 'order_id' => 'integer|required_if:message_type,'.MessageTypeEnum::ORDER_OWNER->name, diff --git a/backend/app/Http/Request/Order/CompleteOrderRequest.php b/backend/app/Http/Request/Order/CompleteOrderRequest.php index e9c2818a74..e9f4c00b57 100644 --- a/backend/app/Http/Request/Order/CompleteOrderRequest.php +++ b/backend/app/Http/Request/Order/CompleteOrderRequest.php @@ -9,9 +9,27 @@ class CompleteOrderRequest extends BaseRequest { - public function rules(CompleteOrderValidator $orderValidator): array + public function rules(): array { - return $orderValidator->rules(); + if ($this->route() === null) { + return [ + 'order.first_name' => ['required', 'string', 'max:40'], + 'order.last_name' => ['required', 'string', 'max:40'], + 'order.email' => ['required', 'email'], + 'order.email_confirmation' => ['required', 'email', 'same:order.email'], + 'order.questions' => ['array'], + 'order.address' => ['array'], + 'order.address.address_line_1' => ['nullable', 'string', 'max:255'], + 'order.address.address_line_2' => ['nullable', 'string', 'max:255'], + 'order.address.city' => ['nullable', 'string', 'max:85'], + 'order.address.state_or_region' => ['nullable', 'string', 'max:85'], + 'order.address.zip_or_postal_code' => ['nullable', 'string', 'max:85'], + 'order.address.country' => ['nullable', 'string', 'max:2'], + 'products' => ['array'], + ]; + } + + return app(CompleteOrderValidator::class)->rules(); } public function messages(): array diff --git a/backend/app/Http/Request/Order/CreateOrderRequest.php b/backend/app/Http/Request/Order/CreateOrderRequest.php index d82ccfe3ca..4c7680964e 100644 --- a/backend/app/Http/Request/Order/CreateOrderRequest.php +++ b/backend/app/Http/Request/Order/CreateOrderRequest.php @@ -14,6 +14,20 @@ class CreateOrderRequest extends BaseRequest */ public function rules(): array { - return []; + if ($this->route() !== null) { + return []; + } + + return [ + 'products' => ['required', 'array'], + 'products.*.product_id' => ['required', 'integer'], + 'products.*.event_occurrence_id' => ['integer', 'nullable'], + 'products.*.quantities' => ['required', 'array'], + 'products.*.quantities.*.quantity' => ['required', 'integer', 'min:0'], + 'products.*.quantities.*.price_id' => ['required', 'integer'], + 'products.*.quantities.*.price' => ['numeric', 'min:0', 'nullable'], + 'promo_code' => ['nullable', 'string'], + 'affiliate_code' => ['nullable', 'string'], + ]; } } diff --git a/backend/app/OpenApi/BaseActionResponseTypeExtension.php b/backend/app/OpenApi/BaseActionResponseTypeExtension.php new file mode 100644 index 0000000000..51561b8fa8 --- /dev/null +++ b/backend/app/OpenApi/BaseActionResponseTypeExtension.php @@ -0,0 +1,144 @@ +isInstanceOf(BaseAction::class); + } + + public function getMethodReturnType(MethodCallEvent $event): ?Type + { + return match ($event->name) { + 'resourceResponse', 'filterableResourceResponse' => $this->getResourceResponseType($event), + 'jsonResponse' => $this->getJsonResponseType($event), + 'errorResponse' => $this->getErrorResponseType($event), + 'addTokenToResponse' => $event->getArg('response', 0), + default => null, + }; + } + + private function getResourceResponseType(MethodCallEvent $event): ?Type + { + $resourceType = $event->getArg('resource', 0); + + if (! $resourceType instanceof LiteralString || $resourceType->getValue() === '') { + return null; + } + + $dataType = $this->unwrapDataType($event->getArg('data', 1)); + + if ($dataType === null) { + return null; + } + + $statusPosition = $event->name === 'filterableResourceResponse' ? 3 : 2; + + $resolvedResource = $this->resolveResourceType($event, $resourceType->getValue(), $dataType); + + if (! $resolvedResource instanceof ObjectType) { + return null; + } + + $responseWrapper = $resolvedResource->isInstanceOf(ResourceCollection::class) + ? ResourceCollectionTypeManager::make($resolvedResource)->getResponseType() + : new Generic(ResourceResponse::class, [$resolvedResource]); + + return new Generic(JsonResponse::class, [ + $responseWrapper, + $this->getStatusType($event, $statusPosition, ResponseCodes::HTTP_OK), + new KeyedArrayType, + ]); + } + + private function getJsonResponseType(MethodCallEvent $event): Type + { + $dataType = $event->getArg('data', 0); + $wrapType = $event->getArg('wrapInData', 2, new LiteralBooleanType(false)); + + $bodyType = $wrapType instanceof LiteralBooleanType && $wrapType->value + ? new KeyedArrayType([new ArrayItemType_('data', $dataType)]) + : $dataType; + + return new Generic(JsonResponse::class, [ + $bodyType, + $this->getStatusType($event, 1, ResponseCodes::HTTP_OK), + new KeyedArrayType, + ]); + } + + private function getErrorResponseType(MethodCallEvent $event): Type + { + return new Generic(JsonResponse::class, [ + new KeyedArrayType([ + new ArrayItemType_('message', new StringType), + new ArrayItemType_('errors', $event->getArg('errors', 2, new ArrayType)), + ]), + $this->getStatusType($event, 1, ResponseCodes::HTTP_BAD_REQUEST), + new KeyedArrayType, + ]); + } + + private function getStatusType(MethodCallEvent $event, int $position, int $default): LiteralIntegerType + { + $statusType = $event->getArg('statusCode', $position, new LiteralIntegerType($default)); + + return $statusType instanceof LiteralIntegerType ? $statusType : new LiteralIntegerType($default); + } + + private function unwrapDataType(Type $dataType): ?ObjectType + { + if ($dataType instanceof Union) { + foreach ($dataType->types as $memberType) { + if ($memberType instanceof ObjectType) { + return $memberType; + } + } + + return null; + } + + return $dataType instanceof ObjectType ? $dataType : null; + } + + private function resolveResourceType(MethodCallEvent $event, string $resourceClass, ObjectType $dataType): Type + { + $isCollectionLike = $dataType->isInstanceOf(Collection::class) + || $dataType->isInstanceOf(Paginator::class); + + $reference = $isCollectionLike + ? new StaticMethodCallReferenceType($resourceClass, 'collection', [$dataType]) + : new NewCallReferenceType($resourceClass, [$dataType]); + + return ReferenceTypeResolver::getInstance()->resolve($event->scope, $reference); + } +} diff --git a/backend/app/OpenApi/BinaryResponseTypeToSchema.php b/backend/app/OpenApi/BinaryResponseTypeToSchema.php new file mode 100644 index 0000000000..a0d3f611bc --- /dev/null +++ b/backend/app/OpenApi/BinaryResponseTypeToSchema.php @@ -0,0 +1,33 @@ +isInstanceOf(BinaryFileResponse::class) || $type->isInstanceOf(StreamedResponse::class)); + } + + public function toResponse(Type $type): Response + { + return Response::make(200) + ->description('File download') + ->setContent( + 'application/octet-stream', + Schema::fromType((new StringType)->format('binary')), + ); + } +} diff --git a/backend/app/OpenApi/ErrorResponsesExtension.php b/backend/app/OpenApi/ErrorResponsesExtension.php new file mode 100644 index 0000000000..96f27f84a3 --- /dev/null +++ b/backend/app/OpenApi/ErrorResponsesExtension.php @@ -0,0 +1,58 @@ +route->gatherMiddleware())->filter(fn ($m) => is_string($m)); + + $existingCodes = collect($operation->responses) + ->filter(static fn ($response) => $response instanceof Response) + ->map(static fn (Response $response) => $response->code); + + if ($middleware->contains(fn (string $m) => Str::is(['auth', 'auth:*'], $m)) && ! $existingCodes->contains(403)) { + $operation->addResponse($this->messageResponse( + 403, + 'The authenticated user is not allowed to perform this action.', + )); + } + + if ($routeInfo->route->parameterNames() !== [] && ! $existingCodes->contains(404)) { + $operation->addResponse($this->messageResponse( + 404, + 'The requested resource was not found.', + )); + } + + if ($middleware->contains(fn (string $m) => str_starts_with($m, 'throttle:')) && ! $existingCodes->contains(429)) { + $operation->addResponse($this->messageResponse( + 429, + 'Too many requests. Retry once the rate limit window resets.', + )); + } + } + + private function messageResponse(int $statusCode, string $description): Response + { + $schema = (new ObjectType) + ->addProperty('message', new StringType) + ->setRequired(['message']); + + return Response::make($statusCode) + ->description($description) + ->setContent('application/json', Schema::fromType($schema)); + } +} diff --git a/backend/app/OpenApi/PaginationQueryParametersExtension.php b/backend/app/OpenApi/PaginationQueryParametersExtension.php new file mode 100644 index 0000000000..acdb3490ae --- /dev/null +++ b/backend/app/OpenApi/PaginationQueryParametersExtension.php @@ -0,0 +1,81 @@ +methodNode(); + + if ($methodNode === null) { + return; + } + + if ($this->callsMethod($methodNode, 'getPaginationQueryParams')) { + $operation->addParameters($this->paginationParameters()); + } + + if ($this->callsMethod($methodNode, 'isIncludeRequested')) { + $operation->addParameters([ + Parameter::make('include', 'query') + ->setSchema(Schema::fromType(new StringType)) + ->description('Comma-separated list of relations to include in the response.'), + ]); + } + } + + private function callsMethod(ClassMethod $methodNode, string $methodName): bool + { + return (new NodeFinder)->findFirst( + (array) $methodNode->stmts, + static fn (Node $node): bool => $node instanceof MethodCall + && $node->name instanceof Identifier + && $node->name->name === $methodName, + ) !== null; + } + + /** + * @return Parameter[] + */ + private function paginationParameters(): array + { + return [ + Parameter::make('page', 'query') + ->setSchema(Schema::fromType((new IntegerType)->default(1))), + Parameter::make('per_page', 'query') + ->setSchema(Schema::fromType((new IntegerType)->default(25))), + Parameter::make('sort_by', 'query') + ->setSchema(Schema::fromType(new StringType)) + ->description('Sortable fields are listed in the response `meta.allowed_sorts`.'), + Parameter::make('sort_direction', 'query') + ->setSchema(Schema::fromType((new StringType)->enum(['asc', 'desc']))), + Parameter::make('query', 'query') + ->setSchema(Schema::fromType(new StringType)) + ->description('Search term used to filter the results.'), + Parameter::make('filter_fields', 'query') + ->setSchema(Schema::fromType( + (new ObjectType)->additionalProperties((new ObjectType)->additionalProperties(new StringType)), + )) + ->setStyle('deepObject') + ->setExplode(true) + ->description('Filters in the form `filter_fields[field][operator]=value`. Filterable fields are listed in the response `meta.allowed_filter_fields`.'), + ]; + } +} diff --git a/backend/app/OpenApi/UnwrappedResourceResponseTypeToSchema.php b/backend/app/OpenApi/UnwrappedResourceResponseTypeToSchema.php new file mode 100644 index 0000000000..bc246afb31 --- /dev/null +++ b/backend/app/OpenApi/UnwrappedResourceResponseTypeToSchema.php @@ -0,0 +1,40 @@ +isInstanceOf(JsonResponse::class) + && count($type->templateTypes) >= 2 + && $type->templateTypes[1] instanceof LiteralIntegerType + && $type->templateTypes[0] instanceof ObjectType + && $type->templateTypes[0]->isInstanceOf(JsonResource::class); + } + + /** + * @param Generic $type + */ + public function toResponse(Type $type): Response + { + return Response::make($type->templateTypes[1]->value) + ->setContent( + 'application/json', + Schema::fromType($this->openApiTransformer->transform($type->templateTypes[0])), + ); + } +} diff --git a/backend/app/Providers/ScrambleServiceProvider.php b/backend/app/Providers/ScrambleServiceProvider.php new file mode 100644 index 0000000000..11b74e40e2 --- /dev/null +++ b/backend/app/Providers/ScrambleServiceProvider.php @@ -0,0 +1,64 @@ +app->bind( + MiddlewareAuthSecurityStrategy::class, + static fn () => new MiddlewareAuthSecurityStrategy( + scheme: SecurityScheme::http('bearer', 'JWT'), + ), + ); + } + + public function boot(): void + { + Scramble::configure() + ->routes(static function (Route $route): bool { + if (Str::is(['mail-test', '*sitemap*', 'admin', 'admin/*'], $route->uri())) { + return false; + } + + return str_starts_with($route->getActionName(), 'HiEvents\\Http\\Actions\\'); + }) + ->withOperationTransformers(static function (Operation $operation, RouteInfo $routeInfo): void { + $tag = Str::of((string) $routeInfo->className()) + ->after('Http\\Actions\\') + ->beforeLast('\\') + ->replace('\\', ' / '); + + if ($tag->isNotEmpty()) { + $operation->tags = [$tag->toString()]; + } + + if ($operation->summary === '') { + $operation->summary(self::summaryFromActionName(class_basename((string) $routeInfo->className()))); + } + + if (str_starts_with($routeInfo->route->uri(), 'public/') && ! str_contains($operation->summary, '(public)')) { + $operation->summary($operation->summary.' (public)'); + } + }); + } + + private static function summaryFromActionName(string $actionName): string + { + return Str::of($actionName)->headline()->explode(' ') + ->reject(static fn (string $word): bool => in_array($word, ['Action', 'Public', ''], true)) + ->implode(' '); + } +} diff --git a/backend/app/Resources/Attendee/AttendeeResource.php b/backend/app/Resources/Attendee/AttendeeResource.php index fd87c58e24..edc7c71f42 100644 --- a/backend/app/Resources/Attendee/AttendeeResource.php +++ b/backend/app/Resources/Attendee/AttendeeResource.php @@ -26,6 +26,7 @@ public function toArray(Request $request): array 'product_price_id' => $this->getProductPriceId(), 'event_id' => $this->getEventId(), 'email' => $this->getEmail(), + /** @var 'ACTIVE'|'AWAITING_PAYMENT'|'CANCELLED' */ 'status' => $this->getStatus(), 'first_name' => $this->getFirstName(), 'last_name' => $this->getLastName(), diff --git a/backend/app/Resources/Attendee/AttendeeResourcePublic.php b/backend/app/Resources/Attendee/AttendeeResourcePublic.php index 00dfc5687b..54e5eaf842 100644 --- a/backend/app/Resources/Attendee/AttendeeResourcePublic.php +++ b/backend/app/Resources/Attendee/AttendeeResourcePublic.php @@ -27,6 +27,7 @@ public function toArray(Request $request): array return [ 'id' => $this->getId(), 'email' => $this->getEmail(), + /** @var 'ACTIVE'|'AWAITING_PAYMENT'|'CANCELLED' */ 'status' => $this->getStatus(), 'first_name' => $this->getFirstName(), 'last_name' => $this->getLastName(), diff --git a/backend/app/Resources/Event/EventResource.php b/backend/app/Resources/Event/EventResource.php index c7d3ec5e7e..2618ca085f 100644 --- a/backend/app/Resources/Event/EventResource.php +++ b/backend/app/Resources/Event/EventResource.php @@ -27,9 +27,12 @@ public function toArray(Request $request): array 'start_date' => $this->getStartDate(), 'end_date' => $this->getEndDate(), 'next_occurrence_start_date' => $this->getNextOccurrenceStartDate(), + /** @var 'DRAFT'|'LIVE'|'ARCHIVED'|null */ 'status' => $this->getStatus(), + /** @var 'SINGLE'|'RECURRING' */ 'type' => $this->getType(), 'recurrence_rule' => $this->getRecurrenceRule(), + /** @var 'UPCOMING'|'ONGOING'|'ENDED' */ 'lifecycle_status' => $this->getLifeCycleStatus(), 'currency' => $this->getCurrency(), 'timezone' => $this->getTimezone(), diff --git a/backend/app/Resources/Event/EventResourcePublic.php b/backend/app/Resources/Event/EventResourcePublic.php index af10e71787..7bbf362939 100644 --- a/backend/app/Resources/Event/EventResourcePublic.php +++ b/backend/app/Resources/Event/EventResourcePublic.php @@ -48,10 +48,13 @@ public function toArray(Request $request): array 'upcoming_occurrences_sold_out' => $this->getUpcomingOccurrencesSoldOut(), 'last_occurrence_date' => $this->when($isRecurring, fn () => $this->getLastOccurrenceStartDate()), 'occurrences_month' => $this->when($isRecurring, fn () => $this->getOccurrencesMonth()), + /** @var 'SINGLE'|'RECURRING' */ 'type' => $this->getType(), 'currency' => $this->getCurrency(), 'slug' => $this->getSlug(), + /** @var 'DRAFT'|'LIVE'|'ARCHIVED'|null */ 'status' => $this->getStatus(), + /** @var 'UPCOMING'|'ONGOING'|'ENDED' */ 'lifecycle_status' => $this->getLifecycleStatus(), 'timezone' => $this->getTimezone(), 'event_location' => $this->when( diff --git a/backend/app/Resources/Order/OrderResource.php b/backend/app/Resources/Order/OrderResource.php index a0af233ffb..0654ddef9c 100644 --- a/backend/app/Resources/Order/OrderResource.php +++ b/backend/app/Resources/Order/OrderResource.php @@ -24,8 +24,11 @@ public function toArray(Request $request): array 'total_tax' => $this->getTotalTax(), 'total_fee' => $this->getTotalFee(), 'total_refunded' => $this->getTotalRefunded(), + /** @var 'RESERVED'|'CANCELLED'|'COMPLETED'|'AWAITING_OFFLINE_PAYMENT'|'ABANDONED' */ 'status' => $this->getStatus(), + /** @var 'REFUND_PENDING'|'REFUND_FAILED'|'REFUNDED'|'PARTIALLY_REFUNDED'|null */ 'refund_status' => $this->getRefundStatus(), + /** @var 'NO_PAYMENT_REQUIRED'|'AWAITING_PAYMENT'|'AWAITING_OFFLINE_PAYMENT'|'PAYMENT_FAILED'|'PAYMENT_RECEIVED'|null */ 'payment_status' => $this->getPaymentStatus(), 'currency' => $this->getCurrency(), 'first_name' => $this->getFirstName(), diff --git a/backend/app/Resources/Order/OrderResourcePublic.php b/backend/app/Resources/Order/OrderResourcePublic.php index bb49863f6b..ff385dac30 100644 --- a/backend/app/Resources/Order/OrderResourcePublic.php +++ b/backend/app/Resources/Order/OrderResourcePublic.php @@ -26,8 +26,11 @@ public function toArray(Request $request): array 'total_tax' => $this->getTotalTax(), 'total_gross' => $this->getTotalGross(), 'total_fee' => $this->getTotalFee(), + /** @var 'RESERVED'|'CANCELLED'|'COMPLETED'|'AWAITING_OFFLINE_PAYMENT'|'ABANDONED' */ 'status' => $this->getStatus(), + /** @var 'REFUND_PENDING'|'REFUND_FAILED'|'REFUNDED'|'PARTIALLY_REFUNDED'|null */ 'refund_status' => $this->getRefundStatus(), + /** @var 'NO_PAYMENT_REQUIRED'|'AWAITING_PAYMENT'|'AWAITING_OFFLINE_PAYMENT'|'PAYMENT_FAILED'|'PAYMENT_RECEIVED'|null */ 'payment_status' => $this->getPaymentStatus(), 'currency' => $this->getCurrency(), 'reserved_until' => $this->getReservedUntil(), diff --git a/backend/app/Resources/Product/ProductResource.php b/backend/app/Resources/Product/ProductResource.php index 88b944fe33..a545b2bd5a 100644 --- a/backend/app/Resources/Product/ProductResource.php +++ b/backend/app/Resources/Product/ProductResource.php @@ -22,7 +22,9 @@ public function toArray(Request $request): array return [ 'id' => $this->getId(), 'title' => $this->getTitle(), + /** @var 'PAID'|'FREE'|'DONATION'|'TIERED'|'REGISTRATION' */ 'type' => $this->getType(), + /** @var 'TICKET'|'GENERAL' */ 'product_type' => $this->getProductType(), 'order' => $this->getOrder(), 'description' => $this->getDescription(), diff --git a/backend/app/Resources/Product/ProductResourcePublic.php b/backend/app/Resources/Product/ProductResourcePublic.php index 00790447ce..1383da48ac 100644 --- a/backend/app/Resources/Product/ProductResourcePublic.php +++ b/backend/app/Resources/Product/ProductResourcePublic.php @@ -17,7 +17,9 @@ public function toArray(Request $request): array return [ 'id' => $this->getId(), 'title' => $this->getTitle(), + /** @var 'PAID'|'FREE'|'DONATION'|'TIERED'|'REGISTRATION' */ 'type' => $this->getType(), + /** @var 'TICKET'|'GENERAL' */ 'product_type' => $this->getProductType(), 'description' => $this->getDescription(), 'max_per_order' => $this->getMaxPerOrder(), diff --git a/backend/composer.json b/backend/composer.json index 79cbf74ea0..ee592c3053 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -11,6 +11,7 @@ "ext-xmlwriter": "*", "barryvdh/laravel-dompdf": "^3.0", "brick/money": "^0.10.1", + "dedoc/scramble": "^0.13", "doctrine/dbal": "^3.6", "ezyang/htmlpurifier": "^4.17", "guzzlehttp/guzzle": "^7.2", diff --git a/backend/composer.lock b/backend/composer.lock index fc3882e364..c8ef47cac8 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d629c0ab94ca3eb682267d76a76cd4a0", + "content-hash": "9b1666c301c02d12a833ad54c450b69c", "packages": [ { "name": "aws/aws-crt-php", @@ -576,6 +576,87 @@ ], "time": "2025-08-20T19:15:30+00:00" }, + { + "name": "dedoc/scramble", + "version": "v0.13.39", + "source": { + "type": "git", + "url": "https://github.com/dedoc/scramble.git", + "reference": "7fcc4758d62f22d326637a578168bd5eb67a8625" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dedoc/scramble/zipball/7fcc4758d62f22d326637a578168bd5eb67a8625", + "reference": "7fcc4758d62f22d326637a578168bd5eb67a8625", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "myclabs/deep-copy": "^1.12", + "nikic/php-parser": "^5.0", + "php": "^8.1", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "spatie/laravel-package-tools": "^1.9.2" + }, + "require-dev": { + "larastan/larastan": "^3.3", + "laravel/pint": "^v1.1.0", + "laravel/scout": "^10.0|^11.0", + "nunomaduro/collision": "^7.0|^8.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^2.34|^3.7|^4.4", + "pestphp/pest-plugin-laravel": "^2.3|^3.1|^4.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5|^11.5.3|^12.5.12", + "spatie/laravel-permission": "^6.10|^7.2", + "spatie/pest-plugin-snapshots": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Dedoc\\Scramble\\ScrambleServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Dedoc\\Scramble\\": "src", + "Dedoc\\Scramble\\Database\\Factories\\": "database/factories" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Roman Lytvynenko", + "email": "litvinenko95@gmail.com", + "role": "Developer" + } + ], + "description": "Automatic generation of API documentation for Laravel applications.", + "homepage": "https://github.com/dedoc/scramble", + "keywords": [ + "documentation", + "laravel", + "openapi" + ], + "support": { + "issues": "https://github.com/dedoc/scramble/issues", + "source": "https://github.com/dedoc/scramble/tree/v0.13.39" + }, + "funding": [ + { + "url": "https://github.com/romalytvynenko", + "type": "github" + } + ], + "time": "2026-08-06T06:42:48+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -3965,6 +4046,66 @@ }, "time": "2024-09-04T18:46:31+00:00" }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, { "name": "namshi/jose", "version": "7.2.3", @@ -10762,66 +10903,6 @@ }, "time": "2024-05-16T03:13:13+00:00" }, - { - "name": "myclabs/deep-copy", - "version": "1.13.4", - "source": { - "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" - }, - "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" - }, - "type": "library", - "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], - "psr-4": { - "DeepCopy\\": "src/DeepCopy/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Create deep copies (clones) of your objects", - "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" - ], - "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" - }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" - } - ], - "time": "2025-08-01T08:46:24+00:00" - }, { "name": "nunomaduro/collision", "version": "v8.9.4", diff --git a/backend/config/app.php b/backend/config/app.php index ef678ac945..3f6a7de149 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -6,6 +6,7 @@ use HiEvents\Providers\EventServiceProvider; use HiEvents\Providers\RepositoryServiceProvider; use HiEvents\Providers\RouteServiceProvider; +use HiEvents\Providers\ScrambleServiceProvider; use Illuminate\Support\Facades\Facade; use Illuminate\Support\ServiceProvider; @@ -240,6 +241,7 @@ EventServiceProvider::class, RouteServiceProvider::class, RepositoryServiceProvider::class, + ScrambleServiceProvider::class, ])->toArray(), diff --git a/backend/config/scramble.php b/backend/config/scramble.php new file mode 100644 index 0000000000..98a19403ce --- /dev/null +++ b/backend/config/scramble.php @@ -0,0 +1,222 @@ + [ + * 'include' => 'api', + * 'exclude' => ['api/internal'], + * ], + * + * Without *, patterns match path segments (api matches api and api/users, not apiary). + * With *, Str::is is used (e.g. api/v*). + * + * One static include → default server is /{include} and paths are stripped (/users). + * Multiple includes or wildcards → server defaults to / and paths stay full (/api/users). + * Override with `servers`, or use Scramble::registerApi() for separate bases. + */ + 'api_path' => 'api', + + /* + * Your API domain. By default, app domain is used. This is also a part of the default API routes + * matcher, so when implementing your own, make sure you use this config if needed. + */ + 'api_domain' => null, + + /* + * The path where your OpenAPI specification will be exported. + */ + 'export_path' => 'openapi.json', + + /* + * Cache configuration for the generated OpenAPI document. + * + * Use `scramble:cache` to warm the cache and `scramble:clear` to invalidate it. + */ + 'cache' => [ + 'key' => 'scramble.openapi', + 'store' => 'file', + ], + + 'info' => [ + /* + * API version. + */ + 'version' => env('API_VERSION') ?? trim(@file_get_contents(base_path('VERSION')) ?: '0.0.1'), + + /* + * Description rendered on the home page of the API documentation (`/docs/api`). + */ + 'description' => <<<'MARKDOWN' +The Hi.Events API powers event management, ticketing, and checkout. + +## Authentication + +Management endpoints require a bearer token (JWT) obtained from `POST /auth/login`. Endpoints under `/public/*` — event pages, checkout, ticket lookup, check-in — require no authentication and are marked *(public)*. + +## Stability + +This API is currently **unversioned and likely to change**. Endpoints, request fields, and response schemas may change between releases without notice, so pin your integration to a specific Hi.Events release and review the changelog when upgrading. + +## Outgoing webhooks + +Webhooks are configured per event or organizer (see the *Webhooks* endpoints) and deliver a `POST` request to your URL for each subscribed event: + +```json +{ + "event_type": "order.created", + "event_sent_at": "2026-08-07T10:00:00+00:00", + "payload": { } +} +``` + +`payload` matches the schema of the corresponding resource (see the *Schemas* section): + +| Events | Payload schema | +| --- | --- | +| `order.created`, `order.updated`, `order.marked_as_paid`, `order.refunded`, `order.cancelled` | `OrderResource` | +| `attendee.created`, `attendee.updated`, `attendee.cancelled` | `AttendeeResource` | +| `product.created`, `product.updated`, `product.deleted` | `ProductResource` | +| `event.created`, `event.updated`, `event.archived` | `EventResource` | +| `checkin.created`, `checkin.deleted` | `AttendeeCheckInResource` | +| `occurrence.cancelled` | `EventOccurrenceResource` | + +`order.created` and `order.cancelled` additionally emit an `attendee.created` / `attendee.cancelled` webhook for each attendee on the order. Deliveries are signed: the `Signature` header contains an HMAC-SHA256 hash of the JSON body using your webhook's secret. +MARKDOWN, + ], + + 'ui' => [ + 'title' => null, + ], + + 'renderer' => 'elements', + + 'renderers' => [ + /* + * Stoplight Elements config options: https://docs.stoplight.io/docs/elements/b074dc47b2826-elements-configuration-options + */ + 'elements' => [ + 'view' => 'scramble::docs', + 'theme' => 'light', + 'hideTryIt' => false, + 'hideSchemas' => false, + 'logo' => '', + 'tryItCredentialsPolicy' => 'include', + 'layout' => 'responsive', + 'router' => 'hash', + ], + /* + * Scalar API reference config options: https://scalar.com/products/api-references/configuration + */ + 'scalar' => [ + 'view' => 'scramble::scalar', + 'cdn' => 'https://cdn.jsdelivr.net/npm/@scalar/api-reference', + 'theme' => 'laravel', + 'proxyUrl' => 'https://proxy.scalar.com', + 'darkMode' => false, + 'showDeveloperTools' => 'never', + 'agent' => ['disabled' => true], + 'credentials' => 'include', + ], + ], + + /* + * The list of servers of the API. By default, when `null`, server URL will be created from + * `scramble.api_path` and `scramble.api_domain` config variables. When providing an array, you + * will need to specify the local server URL manually (if needed). + * + * Example of non-default config (final URLs are generated using Laravel `url` helper): + * + * ```php + * 'servers' => [ + * 'Live' => 'api', + * 'Prod' => 'https://scramble.dedoc.co/api', + * ], + * ``` + */ + 'servers' => [ + 'Default' => '/', + ], + + /** + * Determines how Scramble stores the descriptions of enum cases. + * Available options: + * - 'description' – Case descriptions are stored as the enum schema's description using table formatting. + * - 'extension' – Case descriptions are stored in the `x-enumDescriptions` enum schema extension. + * + * @see https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-enum-descriptions + * - false - Case descriptions are ignored. + */ + 'enum_cases_description_strategy' => 'description', + + /** + * Determines how Scramble stores the names of enum cases. + * Available options: + * - 'names' – Case names are stored in the `x-enumNames` enum schema extension. + * - 'varnames' - Case names are stored in the `x-enum-varnames` enum schema extension. + * - false - Case names are not stored. + */ + 'enum_cases_names_strategy' => false, + + /** + * When Scramble encounters deep objects in query parameters, it flattens the parameters so the generated + * OpenAPI document correctly describes the API. Flattening deep query parameters is relevant until + * OpenAPI 3.2 is released and query string structure can be described properly. + * + * For example, this nested validation rule describes the object with `bar` property: + * `['foo.bar' => ['required', 'int']]`. + * + * When `flatten_deep_query_parameters` is `true`, Scramble will document the parameter like so: + * `{"name":"foo[bar]", "schema":{"type":"int"}, "required":true}`. + * + * When `flatten_deep_query_parameters` is `false`, Scramble will document the parameter like so: + * `{"name":"foo", "schema": {"type":"object", "properties":{"bar":{"type": "int"}}, "required": ["bar"]}, "required":true}`. + */ + 'flatten_deep_query_parameters' => true, + + 'middleware' => [ + 'web', + RestrictedDocsAccess::class, + ], + + 'extensions' => [ + BaseActionResponseTypeExtension::class, + BinaryResponseTypeToSchema::class, + ErrorResponsesExtension::class, + PaginationQueryParametersExtension::class, + UnwrappedResourceResponseTypeToSchema::class, + ], + + /* + * Automatically document API security (OpenAPI `security` / `securitySchemes`) based on route + * middleware. + * + * Disabled by default. Uncomment the line below to enable `MiddlewareAuthSecurityStrategy`. + * When at least one documented route uses middleware matching the configured patterns (by default + * `auth` and `auth:*`), bearer auth is applied globally. Routes without matching middleware are + * marked as public (`security: []`). + * + * Set to `null` explicitly to disable. If you already configure security manually via + * `afterOpenApiGenerated` / `extendOpenApi`, keep this disabled to avoid duplicate schemes. + * + * Customize with a class-string or [class, options]: + * + * 'security_strategy' => [ + * \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class, + * [ + * 'middleware' => ['auth', 'auth:*'], + * 'scheme' => \Dedoc\Scramble\Support\Generator\SecurityScheme::http('bearer'), + * ], + * ], + */ + 'security_strategy' => MiddlewareAuthSecurityStrategy::class, +]; diff --git a/backend/resources/demo/covers/conference.jpg b/backend/resources/demo/covers/conference.jpg new file mode 100644 index 0000000000..6edc223c90 Binary files /dev/null and b/backend/resources/demo/covers/conference.jpg differ diff --git a/backend/resources/demo/covers/festival.jpg b/backend/resources/demo/covers/festival.jpg new file mode 100644 index 0000000000..d7ebadd7d9 Binary files /dev/null and b/backend/resources/demo/covers/festival.jpg differ diff --git a/backend/resources/demo/covers/nightclub.jpg b/backend/resources/demo/covers/nightclub.jpg new file mode 100644 index 0000000000..4b63671a7d Binary files /dev/null and b/backend/resources/demo/covers/nightclub.jpg differ diff --git a/backend/resources/demo/covers/yoga.jpg b/backend/resources/demo/covers/yoga.jpg new file mode 100644 index 0000000000..79a5216492 Binary files /dev/null and b/backend/resources/demo/covers/yoga.jpg differ diff --git a/backend/routes/api.php b/backend/routes/api.php index bbff2c58bb..f9eb088b86 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -442,7 +442,6 @@ function (Router $router): void { $router->get('/events/{event_id}/questions/{question_id}', GetQuestionAction::class); $router->delete('/events/{event_id}/questions/{question_id}', DeleteQuestionAction::class); $router->get('/events/{event_id}/questions', GetQuestionsAction::class); - $router->post('/events/{event_id}/questions/export', ExportOrdersAction::class); $router->post('/events/{event_id}/questions/sort', SortQuestionsAction::class); $router->put('/events/{event_id}/questions/{question_id}/answers/{answer_id}', EditQuestionAnswerAction::class); $router->match(['get', 'post'], '/events/{event_id}/questions/answers/export', ExportQuestionAnswersAction::class); @@ -668,4 +667,6 @@ function (Router $router): void { } ); -include_once __DIR__.'/mail.php'; +if (app()->environment('local', 'development')) { + include_once __DIR__.'/mail.php'; +} diff --git a/backend/tests/Feature/OpenApi/OpenApiGenerationTest.php b/backend/tests/Feature/OpenApi/OpenApiGenerationTest.php new file mode 100644 index 0000000000..bbc656773d --- /dev/null +++ b/backend/tests/Feature/OpenApi/OpenApiGenerationTest.php @@ -0,0 +1,79 @@ +assertSame('3.1.0', $document['openapi']); + $this->assertSame( + trim(file_get_contents(base_path('VERSION'))), + $document['info']['version'], + ); + $this->assertStringContainsString('unversioned', $document['info']['description']); + + $this->assertArrayHasKey('/auth/login', $document['paths']); + $this->assertArrayHasKey('/events', $document['paths']); + $this->assertArrayHasKey('/events/{eventId}', $document['paths']); + $this->assertArrayHasKey('/public/events/{eventId}', $document['paths']); + $this->assertArrayHasKey('/public/events/{eventId}/order', $document['paths']); + + $this->assertArrayNotHasKey('/mail-test', $document['paths']); + $this->assertEmpty(array_filter( + array_keys($document['paths']), + static fn (string $path) => str_starts_with($path, '/admin') || str_contains($path, 'sitemap'), + )); + + foreach ($document['paths'] as $path => $operations) { + if (! str_starts_with($path, '/public/')) { + continue; + } + + foreach (array_intersect_key($operations, array_flip(['get', 'post', 'put', 'patch', 'delete'])) as $operation) { + $this->assertStringContainsString('(public)', $operation['summary'], $path); + } + } + + $this->assertSame([], $document['paths']['/auth/login']['post']['security']); + $this->assertSame( + ['type' => 'http', 'scheme' => 'bearer', 'bearerFormat' => 'JWT'], + $document['components']['securitySchemes']['http'], + ); + + $eventsListSchema = $document['paths']['/events']['get']['responses']['200']['content']['application/json']['schema']; + $this->assertSame( + ['data', 'links', 'meta'], + array_keys($eventsListSchema['properties']), + ); + + $createAffiliate = $document['paths']['/events/{eventId}/affiliates']['post']; + $this->assertArrayHasKey('201', $createAffiliate['responses']); + $this->assertArrayHasKey('AffiliateResource', $document['components']['schemas']); + $this->assertArrayHasKey('CompleteOrderRequest', $document['components']['schemas']); + + $getEvent = $document['paths']['/events/{eventId}']['get']['responses']; + $this->assertArrayHasKey('403', $getEvent); + $this->assertArrayHasKey('404', $getEvent); + + $this->assertContains( + 'COMPLETED', + $document['components']['schemas']['OrderResource']['properties']['status']['enum'], + ); + $this->assertStringContainsString('Outgoing webhooks', $document['info']['description']); + foreach (['OrderResource', 'AttendeeResource', 'ProductResource', 'EventResource', 'AttendeeCheckInResource', 'EventOccurrenceResource'] as $webhookPayloadSchema) { + $this->assertArrayHasKey($webhookPayloadSchema, $document['components']['schemas']); + } + } +} diff --git a/backend/tests/Unit/Http/Request/Order/CompleteOrderRequestTest.php b/backend/tests/Unit/Http/Request/Order/CompleteOrderRequestTest.php new file mode 100644 index 0000000000..306c04043b --- /dev/null +++ b/backend/tests/Unit/Http/Request/Order/CompleteOrderRequestTest.php @@ -0,0 +1,52 @@ +rules(); + + $this->assertSame(['required', 'string', 'max:40'], $rules['order.first_name']); + $this->assertSame(['required', 'email', 'same:order.email'], $rules['order.email_confirmation']); + $this->assertArrayHasKey('order.questions', $rules); + $this->assertArrayHasKey('order.address', $rules); + $this->assertArrayHasKey('products', $rules); + } + + public function test_rules_with_bound_route_delegates_to_complete_order_validator(): void + { + $validatorRules = ['order.first_name' => ['required']]; + + $validator = Mockery::mock(CompleteOrderValidator::class); + $validator->shouldReceive('rules')->once()->andReturn($validatorRules); + $this->app->instance(CompleteOrderValidator::class, $validator); + + $request = new CompleteOrderRequest; + $request->setRouteResolver(static fn () => new Route(['PUT'], '/events/{event_id}/order/{order_short_id}', [])); + + $this->assertSame($validatorRules, $request->rules()); + } + + public function test_messages_delegates_to_complete_order_validator(): void + { + $messages = ['order.email' => 'A valid email is required']; + + $validator = Mockery::mock(CompleteOrderValidator::class); + $validator->shouldReceive('messages')->once()->andReturn($messages); + $this->app->instance(CompleteOrderValidator::class, $validator); + + $this->assertSame($messages, (new CompleteOrderRequest)->messages()); + } +} diff --git a/backend/tests/Unit/Http/Request/Order/CreateOrderRequestTest.php b/backend/tests/Unit/Http/Request/Order/CreateOrderRequestTest.php new file mode 100644 index 0000000000..a364f1b5f9 --- /dev/null +++ b/backend/tests/Unit/Http/Request/Order/CreateOrderRequestTest.php @@ -0,0 +1,32 @@ +setRouteResolver(static fn () => new Route(['POST'], '/events/{event_id}/order', [])); + + $this->assertSame([], $request->rules()); + } + + public function test_rules_without_bound_route_returns_static_documentation_shape(): void + { + $rules = (new CreateOrderRequest)->rules(); + + $this->assertSame(['required', 'array'], $rules['products']); + $this->assertSame(['required', 'integer'], $rules['products.*.product_id']); + $this->assertSame(['integer', 'nullable'], $rules['products.*.event_occurrence_id']); + $this->assertSame(['required', 'integer', 'min:0'], $rules['products.*.quantities.*.quantity']); + $this->assertArrayHasKey('promo_code', $rules); + $this->assertArrayHasKey('affiliate_code', $rules); + } +} diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php index 874151e17d..cca59457ca 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php @@ -220,6 +220,21 @@ public function test_normalizes_missing_occurrence_id_for_single_event_checkout( $this->assertSame(10, $normalized['products'][0]['event_occurrence_id']); } + public function test_requires_occurrence_id_for_recurring_event_checkout(): void + { + $this->setupEventLookup(1, isRecurring: true); + + $data = $this->createRequestData(10); + unset($data['products'][0]['event_occurrence_id']); + + try { + $this->service->validateRequestData(1, $data); + $this->fail('Expected ValidationException for missing event_occurrence_id'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('products.0.event_occurrence_id', $exception->errors()); + } + } + public function test_accepts_occurrence_with_unlimited_capacity(): void { $occurrence = $this->createOccurrence( diff --git a/e2e/pages/occurrence.page.ts b/e2e/pages/occurrence.page.ts index 8f01556183..b28db66bd3 100644 --- a/e2e/pages/occurrence.page.ts +++ b/e2e/pages/occurrence.page.ts @@ -1,4 +1,4 @@ -import type { Locator, Page } from '@playwright/test'; +import { expect, type Locator, type Page } from '@playwright/test'; export class OccurrencePage { constructor(private readonly page: Page) {} @@ -17,11 +17,13 @@ export class OccurrencePage { } async pickWeekday(label: string): Promise { - await this.dialog().getByRole('checkbox', { name: label, exact: true }).check({ force: true }); + const dot = this.dialog().getByRole('checkbox', { name: new RegExp(`^${label}`) }); + await dot.click(); + await expect(dot).toBeChecked(); } async chooseFixedNumberOfDates(count: number): Promise { - await this.dialog().getByText('Set number of dates').click(); + await this.dialog().getByText('For a number of dates').click(); await this.dialog().getByLabel(/^Number of dates to create/).fill(String(count)); } diff --git a/frontend/src/api/email-template.client.ts b/frontend/src/api/email-template.client.ts index d5c2fbab35..e1b13e2fd3 100644 --- a/frontend/src/api/email-template.client.ts +++ b/frontend/src/api/email-template.client.ts @@ -37,7 +37,7 @@ export const emailTemplateClient = { }, deleteForOrganizer: async (organizerId: IdParam, templateId: IdParam) => { - const response = await api.delete<{ message: string }>(`organizers/${organizerId}/email-templates/${templateId}`); + const response = await api.delete(`organizers/${organizerId}/email-templates/${templateId}`); return response.data; }, @@ -70,7 +70,7 @@ export const emailTemplateClient = { }, deleteForEvent: async (eventId: IdParam, templateId: IdParam) => { - const response = await api.delete<{ message: string }>(`events/${eventId}/email-templates/${templateId}`); + const response = await api.delete(`events/${eventId}/email-templates/${templateId}`); return response.data; }, diff --git a/frontend/src/components/layouts/OrganizerLayout/index.tsx b/frontend/src/components/layouts/OrganizerLayout/index.tsx index 5ecea5d021..81b03e8577 100644 --- a/frontend/src/components/layouts/OrganizerLayout/index.tsx +++ b/frontend/src/components/layouts/OrganizerLayout/index.tsx @@ -88,7 +88,12 @@ const OrganizerLayout = () => { }, ] as NavItem[] : []), { label: 'Overview' }, - { link: 'dashboard', label: t`Organizer Dashboard`, icon: IconDashboard }, + { + link: 'dashboard', + label: t`Organizer Dashboard`, + icon: IconDashboard, + isActive: (isActive) => isActive || /\/manage\/organizer(\/[^/]+)?\/?$/.test(location.pathname), + }, { link: 'reports', label: t`Reports`, diff --git a/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/RecurrenceScheduleDrawer.module.scss b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/RecurrenceScheduleDrawer.module.scss new file mode 100644 index 0000000000..5ed6617c3d --- /dev/null +++ b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/RecurrenceScheduleDrawer.module.scss @@ -0,0 +1,609 @@ +@use "../../../../../styles/mixins"; + +.content { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.body { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: 0; +} + +.header { + display: flex; + align-items: center; + gap: var(--mantine-spacing-md); + padding: 18px var(--mantine-spacing-lg); + border-bottom: 1px solid var(--hi-color-gray-2); + background-color: var(--mantine-color-body); + flex-shrink: 0; +} + +.headerText { + display: flex; + align-items: baseline; + gap: var(--mantine-spacing-sm); + min-width: 0; +} + +.headerTitle { + margin: 0; + font-size: 1.0625rem; + font-weight: 700; + letter-spacing: -0.01em; + white-space: nowrap; +} + +.headerActions { + display: flex; + align-items: center; + gap: var(--mantine-spacing-sm); + margin-inline-start: auto; + flex-shrink: 0; +} + +.escHint { + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-text-tertiary); + + @include mixins.respond-below(md) { + display: none; + } +} + +.main { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + flex: 1; + min-height: 0; +} + +.formColumn { + overflow-y: auto; + padding: var(--mantine-spacing-lg); + outline: none; +} + +.previewColumn { + overflow-y: auto; + padding: var(--mantine-spacing-lg); + background-color: var(--hi-color-gray); + border-left: 1px solid var(--hi-color-gray-2); + display: flex; + flex-direction: column; + gap: var(--hi-spacing-md); +} + +.previewEyebrow { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--hi-color-gray-dark); +} + +.footer { + display: flex; + align-items: center; + gap: var(--mantine-spacing-md); + padding: var(--mantine-spacing-md) var(--mantine-spacing-lg); + border-top: 1px solid var(--hi-color-gray-2); + background-color: var(--mantine-color-body); + flex-shrink: 0; +} + +.footerHint { + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-gray-dark); + min-width: 0; +} + +.footerActions { + display: flex; + gap: var(--mantine-spacing-sm); + margin-inline-start: auto; + flex-shrink: 0; +} + +.block { + padding: var(--hi-spacing-lg) 0; + + &:first-child { + padding-top: 0; + } + + & + & { + border-top: 1px solid var(--hi-color-border); + } +} + +.blockHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--hi-spacing-md); + margin-bottom: var(--hi-spacing-md); + min-height: 30px; + flex-wrap: wrap; + + &:last-child { + margin-bottom: 0; + } +} + +.blockLabel { + font-weight: 600; + font-size: var(--mantine-font-size-sm); +} + +.capacityInput { + width: 170px; +} + +.fieldHint { + margin: var(--hi-spacing-sm) 0 0; + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-gray-dark); +} + +.sentenceRow { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.sentenceRow :global(.mantine-InputWrapper-root), +.timeSlot :global(.mantine-InputWrapper-root), +.blockHeader :global(.mantine-InputWrapper-root) { + margin-bottom: 0; +} + +.sentenceWord { + font-size: var(--mantine-font-size-sm); + color: var(--mantine-color-text); +} + +.stepper { + display: inline-flex; + align-items: stretch; + border: 1.5px solid var(--hi-color-gray-2); + border-radius: 7px; + overflow: hidden; + + button { + width: 30px; + border: none; + background: none; + font: inherit; + font-size: var(--mantine-font-size-md); + color: var(--hi-color-gray-dark); + cursor: pointer; + padding: 4px 0; + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); + + &:hover:not(:disabled) { + background-color: var(--hi-color-gray); + color: var(--hi-primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid var(--mantine-color-primary-4); + outline-offset: -2px; + } + } + + span { + display: flex; + align-items: center; + justify-content: center; + min-width: 34px; + padding: 0 4px; + font-size: var(--mantine-font-size-sm); + font-weight: 600; + border-left: 1px solid var(--hi-color-border); + border-right: 1px solid var(--hi-color-border); + } +} + +.dayDots { + display: inline-flex; + gap: 2px; + flex-wrap: wrap; +} + +.dayDot { + width: 44px; + height: 44px; + padding: 4px; + border: none; + background: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + + span { + width: 36px; + height: 36px; + border-radius: 50%; + border: 1.5px solid var(--hi-color-gray-2); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + color: var(--mantine-color-text); + transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); + } + + &:hover:not(.selected) span { + border-color: var(--hi-primary); + color: var(--hi-primary); + } + + &.selected span { + background-color: var(--hi-primary); + border-color: var(--hi-primary); + color: var(--mantine-color-white); + font-weight: 700; + } + + &:focus-visible { + outline: 2px solid var(--mantine-color-primary-4); + outline-offset: -2px; + border-radius: 50%; + } +} + +.patternContent { + margin-top: var(--hi-spacing-md); +} + +.monthGrid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 4px; + max-width: 320px; +} + +.monthCell { + aspect-ratio: 1; + border-radius: 50%; + border: 1.5px solid var(--hi-color-gray-2); + background: none; + font: inherit; + font-size: var(--mantine-font-size-xs); + font-weight: 500; + color: var(--mantine-color-text); + cursor: pointer; + transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); + + &:hover:not(.selected) { + border-color: var(--hi-primary); + color: var(--hi-primary); + } + + &.selected { + background-color: var(--hi-primary); + border-color: var(--hi-primary); + color: var(--mantine-color-white); + font-weight: 700; + } + + &:focus-visible { + outline: 2px solid var(--mantine-color-primary-4); + outline-offset: 1px; + } +} + +.inlineSelect { + width: 150px; +} + +.inlineDayInput { + width: 64px; + + input { + text-align: center; + } +} + +.inlineDateInput { + width: 150px; +} + +.inlineCountInput { + width: 90px; +} + +.timeSlot { + display: grid; + grid-template-columns: 100px 14px 100px minmax(0, 1fr) 28px; + grid-template-areas: "start sep end label remove"; + gap: var(--hi-spacing-sm); + align-items: center; +} + +.slotStart { + grid-area: start; +} + +.slotSep { + grid-area: sep; +} + +.slotEnd { + grid-area: end; +} + +.slotLabel { + grid-area: label; +} + +.slotRemove { + grid-area: remove; +} + +.timeInput { + text-align: center; +} + +.timeSeparator { + font-size: var(--mantine-font-size-sm); + color: var(--hi-color-gray-dark); + text-align: center; +} + +.endTimeWrap { + position: relative; +} + +.plusDayPill { + position: absolute; + top: -8px; + right: -6px; + z-index: 1; + padding: 1px 6px; + border-radius: 999px; + background-color: var(--hi-accent-brand-soft); + color: var(--hi-primary); + font-size: 10px; + font-weight: 600; + white-space: nowrap; + pointer-events: none; +} + +.countCard { + display: flex; + align-items: baseline; + gap: var(--hi-spacing-sm); + border-radius: var(--hi-radius-lg); + background-color: var(--mantine-color-body); + border: 1px solid var(--hi-color-gray-2); + padding: 14px 16px; +} + +.countNumber { + font-size: 24px; + font-weight: 800; + color: var(--hi-primary); + line-height: 1; +} + +.countNumberWarning { + color: var(--hi-color-warning); +} + +.countSubtitle { + font-size: 13px; + color: var(--hi-color-gray-dark); +} + +.calendarCard { + border-radius: var(--hi-radius-lg); + background-color: var(--mantine-color-body); + border: 1px solid var(--hi-color-gray-2); + padding: var(--hi-spacing-md); +} + +.calendarHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--hi-spacing-sm); + margin-bottom: var(--hi-spacing-sm); + + span { + font-size: 13px; + font-weight: 700; + text-align: center; + } + + button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: none; + border-radius: var(--hi-radius-sm); + color: var(--hi-color-gray-dark); + cursor: pointer; + + &:hover:not(:disabled) { + background-color: var(--hi-color-gray); + color: var(--hi-primary); + } + + &:disabled { + opacity: 0.35; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid var(--mantine-color-primary-4); + } + } +} + +.calendarWeekdays { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + margin-bottom: 4px; + + span { + text-align: center; + font-size: 9.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--hi-color-text-tertiary); + } +} + +.calendarGrid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 2px; +} + +.calendarCell { + display: inline-flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; + border-radius: 50%; + font-size: 11.5px; + color: var(--mantine-color-text); + + &.outside { + color: var(--hi-color-text-tertiary); + } + + &.occurrence { + background-color: var(--hi-primary); + color: var(--mantine-color-white); + font-weight: 700; + } +} + +.ruleBox { + display: flex; + align-items: flex-start; + gap: var(--hi-spacing-sm); + border-radius: var(--hi-radius-md); + background-color: var(--hi-color-success-soft); + color: var(--hi-color-success); + padding: var(--hi-spacing-md); + font-size: var(--mantine-font-size-xs); + line-height: 1.5; + + strong { + font-weight: 700; + } + + svg { + flex-shrink: 0; + margin-top: 1px; + } +} + +.ruleDot { + flex-shrink: 0; + width: 7px; + height: 7px; + margin-top: 5px; + border-radius: 50%; + background-color: var(--hi-color-success); +} + +.ruleBoxWarning { + background-color: color-mix(in srgb, var(--hi-color-warning) 14%, transparent); + color: var(--hi-color-warning); +} + +.previewFootnote { + margin: 0; + font-size: 11px; + color: var(--hi-color-text-tertiary); +} + +.previewEmpty { + display: flex; + align-items: center; + gap: var(--hi-spacing-sm); + border: 1px dashed var(--hi-color-gray-2); + border-radius: var(--hi-radius-lg); + padding: var(--hi-spacing-md); + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-gray-dark); + + svg { + flex-shrink: 0; + color: var(--hi-color-text-tertiary); + } +} + +.previewBelowForm { + display: none; +} + +@include mixins.respond-below(xl) { + .main { + grid-template-columns: minmax(0, 1fr); + } + + .previewColumn { + display: none; + } + + .previewBelowForm { + display: flex; + flex-direction: column; + gap: var(--hi-spacing-md); + margin-top: var(--mantine-spacing-md); + } +} + +@include mixins.respond-below(sm) { + .header, + .footer { + padding-left: var(--mantine-spacing-md); + padding-right: var(--mantine-spacing-md); + } + + .formColumn { + padding: var(--mantine-spacing-md); + } + + .footerHint { + display: none; + } + + .footerActions { + flex: 1; + + button { + flex: 1; + } + } + + .timeSlot { + grid-template-columns: minmax(0, 1fr) 14px minmax(0, 1fr) 28px; + grid-template-areas: + "start sep end remove" + "label label label label"; + } +} diff --git a/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/index.tsx new file mode 100644 index 0000000000..d21d10d895 --- /dev/null +++ b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/index.tsx @@ -0,0 +1,1169 @@ +import React, {useEffect, useId, useMemo, useState} from "react"; +import {t} from "@lingui/macro"; +import { + ActionIcon, + Button, + CloseButton, + Drawer, + NumberInput, + SegmentedControl, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import {useForm} from "@mantine/form"; +import {modals} from "@mantine/modals"; +import {useParams} from "react-router"; +import { + IconAlertTriangle, + IconChevronLeft, + IconChevronRight, + IconPlus, + IconSparkles, + IconX, +} from "@tabler/icons-react"; +import classNames from "classnames"; + +import {GenericModalProps, RecurrenceRule, RecurrenceTimeSlot} from "../../../../../types.ts"; +import {useGenerateOccurrences} from "../../../../../mutations/useGenerateOccurrences.ts"; +import {GET_EVENT_QUERY_KEY, useGetEvent} from "../../../../../queries/useGetEvent.ts"; +import {GET_EVENT_OCCURRENCES_QUERY_KEY} from "../../../../../queries/useGetEventOccurrences.ts"; +import {useQueryClient} from "@tanstack/react-query"; +import {showError, showSuccess} from "../../../../../utilites/notifications.tsx"; +import {useFormErrorResponseHandler} from "../../../../../hooks/useFormErrorResponseHandler.tsx"; +import classes from './RecurrenceScheduleDrawer.module.scss'; + +const MAX_PREVIEW = 1200; + +const DAYS_OF_WEEK = [ + {value: 'monday', label: t`Mon`, full: t`Monday`}, + {value: 'tuesday', label: t`Tue`, full: t`Tuesday`}, + {value: 'wednesday', label: t`Wed`, full: t`Wednesday`}, + {value: 'thursday', label: t`Thu`, full: t`Thursday`}, + {value: 'friday', label: t`Fri`, full: t`Friday`}, + {value: 'saturday', label: t`Sat`, full: t`Saturday`}, + {value: 'sunday', label: t`Sun`, full: t`Sunday`}, +]; + +const FREQUENCIES = [ + {value: 'daily', label: t`Daily`}, + {value: 'weekly', label: t`Weekly`}, + {value: 'monthly', label: t`Monthly`}, + {value: 'yearly', label: t`Yearly`}, +]; + +const WEEK_POSITIONS = [ + {value: '1', label: t`First`}, + {value: '2', label: t`Second`}, + {value: '3', label: t`Third`}, + {value: '4', label: t`Fourth`}, + {value: '-1', label: t`Last`}, +]; + +const MONTHS = [ + {value: '1', label: t`January`}, + {value: '2', label: t`February`}, + {value: '3', label: t`March`}, + {value: '4', label: t`April`}, + {value: '5', label: t`May`}, + {value: '6', label: t`June`}, + {value: '7', label: t`July`}, + {value: '8', label: t`August`}, + {value: '9', label: t`September`}, + {value: '10', label: t`October`}, + {value: '11', label: t`November`}, + {value: '12', label: t`December`}, +]; + +const DAY_NUMBER_MAP: Record = { + 'sunday': 0, 'monday': 1, 'tuesday': 2, 'wednesday': 3, + 'thursday': 4, 'friday': 5, 'saturday': 6, +}; + +const frequencyUnitLabel = (frequency: string, interval: number): string => { + if (interval === 1) { + switch (frequency) { + case 'daily': return t`day`; + case 'weekly': return t`week`; + case 'monthly': return t`month`; + case 'yearly': return t`year`; + default: return ''; + } + } + switch (frequency) { + case 'daily': return t`days`; + case 'weekly': return t`weeks`; + case 'monthly': return t`months`; + case 'yearly': return t`years`; + default: return ''; + } +}; + +const getNthWeekdayOfMonth = (year: number, month: number, dayOfWeek: number, position: number): Date | null => { + if (position === -1) { + const lastDay = new Date(year, month + 1, 0); + for (let d = lastDay.getDate(); d >= 1; d--) { + const candidate = new Date(year, month, d); + if (candidate.getDay() === dayOfWeek) return candidate; + } + return null; + } + let count = 0; + for (let d = 1; d <= 31; d++) { + const candidate = new Date(year, month, d); + if (candidate.getMonth() !== month) break; + if (candidate.getDay() === dayOfWeek) { + count++; + if (count === position) return candidate; + } + } + return null; +}; + +const parseLocalDate = (value: string): Date | null => { + if (!value) return null; + const [y, m, d] = value.split('-').map(Number); + if (!y || !m || !d) return null; + return new Date(y, m - 1, d); +}; + +const computePreviewDates = (values: RecurrenceFormValues): Date[] => { + const dates: Date[] = []; + const today = parseLocalDate(values.range_start) ?? new Date(); + today.setHours(0, 0, 0, 0); + + const endDate = values.range_type === 'until' && values.range_until + ? new Date(values.range_until + 'T23:59:59') + : null; + const maxCount = values.range_type === 'count' + ? Math.min(values.range_count || 1, MAX_PREVIEW) + : MAX_PREVIEW; + + if (values.range_type === 'until' && !endDate) return dates; + + const addCandidate = (date: Date): boolean => { + if (endDate && date > endDate) return false; + if (dates.length >= maxCount) return false; + dates.push(new Date(date)); + return true; + }; + + switch (values.frequency) { + case 'daily': { + const current = new Date(today); + let safety = 0; + while (dates.length < maxCount && safety < MAX_PREVIEW + 100) { + if (!addCandidate(current)) break; + current.setDate(current.getDate() + (values.interval || 1)); + safety++; + } + break; + } + case 'weekly': { + const selectedDays = values.days_of_week + .map(d => DAY_NUMBER_MAP[d]) + .filter(d => d !== undefined) + .sort((a, b) => a - b); + if (selectedDays.length === 0) break; + + const weekStart = new Date(today); + const todayDay = weekStart.getDay(); + const diff = todayDay === 0 ? -6 : 1 - todayDay; + weekStart.setDate(weekStart.getDate() + diff); + + let safety = 0; + outer: + while (dates.length < maxCount && safety < MAX_PREVIEW + 100) { + for (const dayNum of selectedDays) { + const candidate = new Date(weekStart); + const offset = dayNum === 0 ? 6 : dayNum - 1; + candidate.setDate(weekStart.getDate() + offset); + if (candidate >= today) { + if (!addCandidate(candidate)) break outer; + } + } + weekStart.setDate(weekStart.getDate() + 7 * (values.interval || 1)); + safety++; + } + break; + } + case 'monthly': { + if (values.monthly_pattern === 'by_day_of_month') { + const days = values.days_of_month + .map(d => parseInt(d)) + .filter(n => !isNaN(n)) + .sort((a, b) => a - b); + if (days.length === 0) break; + + const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1); + let safety = 0; + outer2: + while (dates.length < maxCount && safety < MAX_PREVIEW + 100) { + for (const day of days) { + const candidate = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day); + if (candidate.getMonth() !== currentMonth.getMonth()) continue; + if (candidate >= today) { + if (!addCandidate(candidate)) break outer2; + } + } + currentMonth.setMonth(currentMonth.getMonth() + (values.interval || 1)); + safety++; + } + } else { + const targetDay = DAY_NUMBER_MAP[values.day_of_week] ?? 1; + const position = parseInt(values.week_position) || 1; + + const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1); + let safety = 0; + while (dates.length < maxCount && safety < MAX_PREVIEW + 100) { + const candidate = getNthWeekdayOfMonth( + currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, position + ); + if (candidate && candidate >= today) { + if (!addCandidate(candidate)) break; + } + currentMonth.setMonth(currentMonth.getMonth() + (values.interval || 1)); + safety++; + } + } + break; + } + case 'yearly': { + const month = parseInt(values.yearly_month) - 1; + const day = values.yearly_day; + let year = today.getFullYear(); + let safety = 0; + while (dates.length < maxCount && safety < MAX_PREVIEW + 100) { + const candidate = new Date(year, month, day); + if (candidate.getMonth() === month && candidate >= today) { + if (!addCandidate(candidate)) break; + } + if (endDate && candidate > endDate) break; + year += (values.interval || 1); + safety++; + } + break; + } + } + + return dates; +}; + +const computeEndTime = (startTime: string, durationMinutes: number): string => { + if (!startTime || !durationMinutes) return ''; + const [h, m] = startTime.split(':').map(Number); + if (isNaN(h) || isNaN(m)) return ''; + const totalMinutes = h * 60 + m + durationMinutes; + const endH = Math.floor(totalMinutes / 60) % 24; + const endM = totalMinutes % 60; + return `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`; +}; + +const computeDurationFromTimes = (startTime: string, endTime: string): number | null => { + if (!startTime || !endTime) return null; + const [sh, sm] = startTime.split(':').map(Number); + const [eh, em] = endTime.split(':').map(Number); + if (isNaN(sh) || isNaN(sm) || isNaN(eh) || isNaN(em)) return null; + let diff = (eh * 60 + em) - (sh * 60 + sm); + if (diff <= 0) diff += 24 * 60; + return diff; +}; + +const slotWrapsMidnight = (slot: TimeSlotFormValue): boolean => { + if (!slot.time || !slot.end_time) return false; + const [sh, sm] = slot.time.split(':').map(Number); + const [eh, em] = slot.end_time.split(':').map(Number); + if (isNaN(sh) || isNaN(sm) || isNaN(eh) || isNaN(em)) return false; + return (eh * 60 + em) - (sh * 60 + sm) <= 0; +}; + +const formatShortDate = (date: Date): string => { + return date.toLocaleDateString(undefined, {day: 'numeric', month: 'short'}); +}; + +interface TimeSlotFormValue { + time: string; + end_time: string; + label: string; +} + +interface RecurrenceFormValues { + frequency: string; + interval: number; + days_of_week: string[]; + time_slots: TimeSlotFormValue[]; + range_start: string; + range_type: string; + range_until: string; + range_count: number; + default_capacity: number | undefined; + monthly_pattern: string; + days_of_month: string[]; + day_of_week: string; + week_position: string; + yearly_month: string; + yearly_day: number; +} + +const formatLocalDate = (date: Date): string => { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +}; + +const todayLocalDate = (): string => formatLocalDate(new Date()); + +const IntervalStepper = ({value, onChange}: { value: number; onChange: (value: number) => void }) => ( +
+ + {value || 1} + +
+); + +const DayDots = ({selected, onToggle}: { selected: string[]; onToggle: (value: string) => void }) => ( +
+ {DAYS_OF_WEEK.map(day => { + const isSelected = selected.includes(day.value); + return ( + + ); + })} +
+); + +const MonthDayGrid = ({selected, onToggle}: { selected: string[]; onToggle: (value: string) => void }) => ( +
+ {Array.from({length: 31}, (_, i) => String(i + 1)).map(day => { + const isSelected = selected.includes(day); + return ( + + ); + })} +
+); + +const monthIndexOf = (date: Date): number => date.getFullYear() * 12 + date.getMonth(); + +const MiniCalendar = ({previewDates, rangeStart}: { previewDates: Date[]; rangeStart: string }) => { + const [viewMonth, setViewMonth] = useState(null); + + const firstMonth = monthIndexOf(previewDates[0]); + const lastMonth = monthIndexOf(previewDates[previewDates.length - 1]); + + useEffect(() => { + setViewMonth(current => current === null || current < firstMonth || current > lastMonth + ? firstMonth + : current); + }, [firstMonth, lastMonth]); + + const month = viewMonth === null || viewMonth < firstMonth || viewMonth > lastMonth ? firstMonth : viewMonth; + const year = Math.floor(month / 12); + const monthOfYear = month % 12; + + const occurrenceKeys = useMemo( + () => new Set(previewDates.map(formatLocalDate)), + [previewDates] + ); + + const startDate = parseLocalDate(rangeStart); + const firstOfMonth = new Date(year, monthOfYear, 1); + const gridStart = new Date(firstOfMonth); + gridStart.setDate(gridStart.getDate() - ((gridStart.getDay() + 6) % 7)); + + const cells = Array.from({length: 42}, (_, i) => { + const date = new Date(gridStart); + date.setDate(gridStart.getDate() + i); + return date; + }); + + return ( +
+
+ + {firstOfMonth.toLocaleDateString(undefined, {month: 'long', year: 'numeric'})} + +
+
+ {DAYS_OF_WEEK.map(day => {day.label})} +
+
+ {cells.map((date, i) => { + const outside = date.getMonth() !== monthOfYear + || (startDate !== null && date < startDate); + const isOccurrence = occurrenceKeys.has(formatLocalDate(date)); + return ( + + {date.getDate()} + + ); + })} +
+
+ ); +}; + +interface RuleSentence { + bold: string; + rest: string; +} + +interface RecurrenceScheduleDrawerProps extends GenericModalProps { + onGenerationStarted: (jobUuid: string, totalCount: number) => void; +} + +export const RecurrenceScheduleDrawer = ({onClose, onGenerationStarted}: RecurrenceScheduleDrawerProps) => { + const {eventId} = useParams(); + const {data: event} = useGetEvent(eventId); + const generateMutation = useGenerateOccurrences(); + const queryClient = useQueryClient(); + const errorHandler = useFormErrorResponseHandler(); + const headingId = useId(); + const formId = useId(); + + const hasExistingRule = !!event?.recurrence_rule; + + const parseTimeSlotsFromRule = (rule: RecurrenceRule): TimeSlotFormValue[] => { + const times = rule.times_of_day; + const fallbackDuration = rule.duration_minutes || 120; + + if (!times?.length) { + return [{time: '09:00', end_time: computeEndTime('09:00', fallbackDuration), label: ''}]; + } + + return times.map((entry) => { + if (typeof entry === 'string') { + return { + time: entry, + end_time: computeEndTime(entry, fallbackDuration), + label: '', + }; + } + const duration = entry.duration_minutes || fallbackDuration; + return { + time: entry.time, + end_time: computeEndTime(entry.time, duration), + label: entry.label || '', + }; + }); + }; + + const form = useForm({ + initialValues: { + frequency: 'weekly', + interval: 1, + days_of_week: [], + time_slots: [{time: '09:00', end_time: '11:00', label: ''}], + range_start: todayLocalDate(), + range_type: 'until', + range_until: '', + range_count: 10, + default_capacity: undefined, + monthly_pattern: 'by_day_of_month', + days_of_month: ['1'], + day_of_week: 'monday', + week_position: '1', + yearly_month: String(new Date().getMonth() + 1), + yearly_day: 1, + }, + validate: { + days_of_week: (value, values) => values.frequency === 'weekly' && value.length === 0 + ? t`Pick at least one day of the week` + : null, + range_until: (value, values) => values.range_type === 'until' && !value + ? t`Pick an end date` + : null, + time_slots: (value) => value.every(s => !s.time.trim()) + ? t`Add at least one time` + : null, + days_of_month: (value, values) => values.frequency === 'monthly' + && values.monthly_pattern === 'by_day_of_month' + && value.length === 0 + ? t`Pick at least one day of the month` + : null, + }, + }); + + useEffect(() => { + if (event?.recurrence_rule) { + const rule = event.recurrence_rule; + const earliestOccurrence = event.occurrences?.length + ? event.occurrences + .map(o => o.start_date) + .filter((d): d is string => !!d) + .sort()[0] + : undefined; + const startFromRule = rule.range?.start; + const fallbackStart = earliestOccurrence + ? earliestOccurrence.slice(0, 10) + : todayLocalDate(); + + form.setValues({ + frequency: rule.frequency || 'weekly', + interval: rule.interval || 1, + days_of_week: rule.days_of_week || [], + time_slots: parseTimeSlotsFromRule(rule), + range_start: startFromRule ? startFromRule.slice(0, 10) : fallbackStart, + range_type: rule.range?.type || 'until', + range_until: rule.range?.until || '', + range_count: rule.range?.count || 10, + default_capacity: rule.default_capacity ?? undefined, + monthly_pattern: rule.monthly_pattern || 'by_day_of_month', + days_of_month: rule.days_of_month?.map(String) || ['1'], + day_of_week: rule.day_of_week || 'monday', + week_position: String(rule.week_position || 1), + yearly_month: String(rule.month || new Date().getMonth() + 1), + yearly_day: rule.days_of_month?.[0] || 1, + }); + form.resetDirty(); + } + }, [event]); + + const toggleDayOfWeek = (value: string) => { + const current = form.values.days_of_week; + form.setFieldValue('days_of_week', current.includes(value) + ? current.filter(d => d !== value) + : [...current, value]); + }; + + const toggleDayOfMonth = (value: string) => { + const current = form.values.days_of_month; + form.setFieldValue('days_of_month', current.includes(value) + ? current.filter(d => d !== value) + : [...current, value]); + }; + + const handleAddTime = () => { + const lastSlot = form.values.time_slots[form.values.time_slots.length - 1]; + const defaultStart = lastSlot?.end_time || '09:00'; + const defaultEnd = computeEndTime(defaultStart, 120); + form.setFieldValue('time_slots', [ + ...form.values.time_slots, + {time: defaultStart, end_time: defaultEnd, label: ''}, + ]); + }; + + const handleRemoveTime = (index: number) => { + const updated = form.values.time_slots.filter((_, i) => i !== index); + form.setFieldValue('time_slots', updated.length > 0 ? updated : [{time: '', end_time: '', label: ''}]); + }; + + const handleSlotChange = (index: number, field: keyof TimeSlotFormValue, value: string) => { + const updated = [...form.values.time_slots]; + updated[index] = {...updated[index], [field]: value}; + form.setFieldValue('time_slots', updated); + }; + + const previewDates = useMemo( + () => computePreviewDates(form.values), + [ + form.values.frequency, form.values.interval, form.values.days_of_week, + form.values.range_start, + form.values.range_type, form.values.range_until, form.values.range_count, + form.values.monthly_pattern, form.values.days_of_month, + form.values.day_of_week, form.values.week_position, + form.values.yearly_month, form.values.yearly_day, + ] + ); + + const validTimes = form.values.time_slots.filter(s => s.time.trim() !== ''); + const totalOccurrences = previewDates.length * Math.max(validTimes.length, 1); + const exceedsLimit = totalOccurrences > MAX_PREVIEW; + + const handleSubmit = (values: RecurrenceFormValues) => { + const filteredSlots = values.time_slots.filter(s => s.time.trim() !== ''); + + const timesOfDay: RecurrenceTimeSlot[] = filteredSlots.length > 0 + ? filteredSlots.map(s => { + const duration = computeDurationFromTimes(s.time, s.end_time); + return { + time: s.time, + ...(s.label ? {label: s.label} : {}), + ...(duration ? {duration_minutes: duration} : {}), + }; + }) + : [{time: '09:00'}]; + + const range: RecurrenceRule['range'] = values.range_type === 'until' + ? {type: 'until', until: values.range_until} + : {type: 'count', count: values.range_count}; + + if (values.range_start) { + range.start = values.range_start; + } + + const existingRule = event?.recurrence_rule; + const preservedMetadata: Partial = {}; + if (existingRule?.excluded_occurrences && existingRule.excluded_occurrences.length > 0) { + preservedMetadata.excluded_occurrences = existingRule.excluded_occurrences; + } + if (existingRule?.excluded_dates && existingRule.excluded_dates.length > 0) { + preservedMetadata.excluded_dates = existingRule.excluded_dates; + } + if (existingRule?.additional_dates && existingRule.additional_dates.length > 0) { + preservedMetadata.additional_dates = existingRule.additional_dates; + } + + const rule: RecurrenceRule = { + frequency: values.frequency as RecurrenceRule['frequency'], + interval: values.interval, + times_of_day: timesOfDay, + range, + default_capacity: values.default_capacity ?? null, + ...preservedMetadata, + }; + + if (values.frequency === 'weekly') { + rule.days_of_week = values.days_of_week; + } + + if (values.frequency === 'monthly') { + rule.monthly_pattern = values.monthly_pattern as RecurrenceRule['monthly_pattern']; + if (values.monthly_pattern === 'by_day_of_month') { + rule.days_of_month = values.days_of_month.map(d => parseInt(d)).filter(n => !isNaN(n)); + } else { + rule.day_of_week = values.day_of_week; + rule.week_position = parseInt(values.week_position); + } + } + + if (values.frequency === 'yearly') { + rule.month = parseInt(values.yearly_month); + rule.days_of_month = [values.yearly_day]; + } + + generateMutation.mutate({eventId, data: {recurrence_rule: rule}}, { + onSuccess: (response) => { + if (response.status === 'IN_PROGRESS' && response.job_uuid) { + onGenerationStarted(response.job_uuid, totalOccurrences); + onClose(); + } else if (response.status === 'FINISHED') { + showSuccess(t`Schedule created successfully`); + queryClient.invalidateQueries({queryKey: [GET_EVENT_OCCURRENCES_QUERY_KEY]}); + queryClient.invalidateQueries({queryKey: [GET_EVENT_QUERY_KEY, eventId]}); + onClose(); + } else { + showError(t`Failed to create schedule. Please try again.`); + } + }, + onError: (error: any) => { + const errors = error?.response?.data?.errors; + if (error?.response?.status === 422 && errors) { + const firstError = Object.values(errors).flat()[0] as string | undefined; + showError(firstError || t`Please check the provided information is correct`); + errorHandler(form, error); + } else { + showError(error?.response?.data?.message || t`Failed to create schedule`); + } + }, + }); + }; + + const handleClose = () => { + if (!form.isDirty()) { + onClose(); + return; + } + + modals.openConfirmModal({ + title: t`Discard changes?`, + children: ( + + {t`You have unsaved changes. Are you sure you want to discard them?`} + + ), + labels: {confirm: t`Discard`, cancel: t`Keep editing`}, + confirmProps: {color: 'red'}, + zIndex: 400, + onConfirm: onClose, + }); + }; + + const handleKeyDown = (keyboardEvent: React.KeyboardEvent) => { + if ((keyboardEvent.metaKey || keyboardEvent.ctrlKey) && keyboardEvent.key === 'Enter') { + keyboardEvent.preventDefault(); + (document.getElementById(formId) as HTMLFormElement | null)?.requestSubmit(); + } + }; + + const cadenceUnit = frequencyUnitLabel(form.values.frequency, form.values.interval); + const cadenceSummary = form.values.interval === 1 + ? t`Every ${cadenceUnit}` + : t`Every ${form.values.interval} ${cadenceUnit}`; + + const ruleSentence = useMemo((): RuleSentence | null => { + if (previewDates.length === 0) return null; + const values = form.values; + + let onPart = ''; + if (values.frequency === 'weekly') { + const dayList = DAYS_OF_WEEK + .filter(day => values.days_of_week.includes(day.value)) + .map(day => day.label) + .join(', '); + if (dayList) onPart = t`on ${dayList}`; + } else if (values.frequency === 'monthly') { + if (values.monthly_pattern === 'by_day_of_month') { + const dayList = values.days_of_month + .map(Number) + .filter(n => !isNaN(n)) + .sort((a, b) => a - b) + .join(', '); + if (dayList) onPart = t`on ${dayList}`; + } else { + const position = WEEK_POSITIONS.find(p => p.value === values.week_position)?.label ?? ''; + const day = DAYS_OF_WEEK.find(d => d.value === values.day_of_week)?.full ?? ''; + onPart = t`on the ${position} ${day}`; + } + } else if (values.frequency === 'yearly') { + const month = MONTHS.find(m => m.value === values.yearly_month)?.label ?? ''; + const day = values.yearly_day; + onPart = t`on ${month} ${day}`; + } + + const times = validTimes + .map(s => s.end_time ? `${s.time}–${s.end_time}` : s.time) + .join(` ${t`and`} `); + + const startDate = parseLocalDate(values.range_start) ?? new Date(); + const start = formatShortDate(startDate); + const range = values.range_type === 'until' && values.range_until + ? (() => { + const end = formatShortDate(parseLocalDate(values.range_until)!); + return t`from ${start} until ${end}`; + })() + : (() => { + const count = values.range_count; + return t`from ${start}, for ${count} dates`; + })(); + + return { + bold: onPart ? `${cadenceSummary} ${onPart}` : cadenceSummary, + rest: `, ${times}, ${range}.`, + }; + }, [form.values, previewDates.length, validTimes, cadenceSummary]); + + const dateCount = previewDates.length; + const timesPerDay = validTimes.length; + const max = MAX_PREVIEW; + + const countSubtitle = timesPerDay > 1 + ? t`sessions · ${dateCount} dates × ${timesPerDay} times` + : t`dates`; + + const ruleBox = ruleSentence && ( +
+ {exceedsLimit ? ( + <> + + {t`That's ${totalOccurrences} sessions — the maximum is ${max}. Shorten the range or frequency.`} + + ) : ( + <> + + {ruleSentence.bold}{ruleSentence.rest} + + )} +
+ ); + + const countLine = ( +
+ + {totalOccurrences} + + {countSubtitle} +
+ ); + + const emptyPreview = ( +
+ + {t`Pick days to see your dates.`} +
+ ); + + return ( + +
+
+

+ {hasExistingRule ? t`Edit schedule` : t`Set up your schedule`} +

+
+
+ {t`Esc to close`} + +
+
+ +
+
+
+
+
+ {t`Repeats`} + form.setFieldValue('frequency', value)} + data={FREQUENCIES} + /> +
+ +
+ {t`Every`} + form.setFieldValue('interval', value)} + /> + {cadenceUnit} + + {form.values.frequency === 'weekly' && ( + <> + {t`on`} + + + )} + + {form.values.frequency === 'monthly' && ( + <> + {t`on`} + form.setFieldValue('monthly_pattern', value)} + data={[ + {label: t`Days of the month`, value: 'by_day_of_month'}, + {label: t`A weekday pattern`, value: 'by_day_of_week'}, + ]} + /> + + )} + + {form.values.frequency === 'yearly' && ( + <> + {t`on`} + + - - {frequencyUnitLabel(form.values.frequency, form.values.interval)} - - } - rightSectionWidth={60} - {...form.getInputProps('interval')} - /> - - - {form.values.frequency === 'weekly' && ( - - - {DAYS_OF_WEEK.map(day => ( - - ))} - - - )} - - {form.values.frequency === 'monthly' && ( - - - - - - - - - {form.values.monthly_pattern === 'by_day_of_month' && ( -
- {t`Days of Month`} - - - {daysOfMonthOptions.map(day => ( - - {day} - - ))} - - -
- )} - - {form.values.monthly_pattern === 'by_day_of_week' && ( - - - - )} -
- )} - - {form.values.frequency === 'yearly' && ( - -