From aade3e72c3ac2ca1ff4061143fb5344cf5d29a44 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Wed, 9 Sep 2026 19:02:06 +0800 Subject: [PATCH 1/2] Add the Events cluster Specs from the matrix layer Moves Event, Booking, Location, Capacity, RSVP Status, Confirm Booking and Check In Booking into cards/events as Specs with their example instances. Bookings link the two Event instances and the CRM contacts already in the catalog; Location reads the catalog's geo-point field and Capacity the State Pill hue helpers. Booking's isolated component is a module-level class so its tracked state type-checks, and its action handlers return void. Co-Authored-By: Claude Fable 5.1 --- .../76353cf8-7a5d-406c-9ec6-756b058f9031.json | 30 + .../ad4c7635-3cf8-4a5d-b06c-dec6756b058f.json | 37 ++ cards/events/Booking/bk-20260802-00101.json | 43 ++ cards/events/Booking/bk-20260802-00144.json | 43 ++ cards/events/Booking/bk-20260912-00214.json | 43 ++ cards/events/Booking/bk-20260912-00399.json | 43 ++ cards/events/Booking/bk-20260912-00417.json | 43 ++ cards/events/Booking/bk-20261017-00551.json | 43 ++ cards/events/Booking/bk-20261101-00602.json | 43 ++ cards/events/Event/latin-cardio-launch.json | 23 + .../events/Event/northbridge-v-riverton.json | 50 ++ .../Location/meridian-park-stadium.json | 40 ++ .../northgate-fulfillment-centre.json | 40 ++ cards/events/Spec/booking.json | 33 ++ cards/events/Spec/capacity-field.json | 60 ++ cards/events/Spec/check-in-booking.json | 33 ++ cards/events/Spec/confirm-booking.json | 33 ++ cards/events/Spec/event.json | 33 ++ cards/events/Spec/location.json | 38 ++ cards/events/Spec/rsvp-status-field.json | 33 ++ cards/events/booking.gts | 537 ++++++++++++++++++ cards/events/capacity-field.gts | 347 +++++++++++ cards/events/check-in-booking.gts | 85 +++ cards/events/confirm-booking.gts | 84 +++ cards/events/event.gts | 465 +++++++++++++++ cards/events/location.gts | 441 ++++++++++++++ cards/events/rsvp-status-field.gts | 60 ++ 27 files changed, 2803 insertions(+) create mode 100644 cards/events/Booking/76353cf8-7a5d-406c-9ec6-756b058f9031.json create mode 100644 cards/events/Booking/ad4c7635-3cf8-4a5d-b06c-dec6756b058f.json create mode 100644 cards/events/Booking/bk-20260802-00101.json create mode 100644 cards/events/Booking/bk-20260802-00144.json create mode 100644 cards/events/Booking/bk-20260912-00214.json create mode 100644 cards/events/Booking/bk-20260912-00399.json create mode 100644 cards/events/Booking/bk-20260912-00417.json create mode 100644 cards/events/Booking/bk-20261017-00551.json create mode 100644 cards/events/Booking/bk-20261101-00602.json create mode 100644 cards/events/Event/latin-cardio-launch.json create mode 100644 cards/events/Event/northbridge-v-riverton.json create mode 100644 cards/events/Location/meridian-park-stadium.json create mode 100644 cards/events/Location/northgate-fulfillment-centre.json create mode 100644 cards/events/Spec/booking.json create mode 100644 cards/events/Spec/capacity-field.json create mode 100644 cards/events/Spec/check-in-booking.json create mode 100644 cards/events/Spec/confirm-booking.json create mode 100644 cards/events/Spec/event.json create mode 100644 cards/events/Spec/location.json create mode 100644 cards/events/Spec/rsvp-status-field.json create mode 100644 cards/events/booking.gts create mode 100644 cards/events/capacity-field.gts create mode 100644 cards/events/check-in-booking.gts create mode 100644 cards/events/confirm-booking.gts create mode 100644 cards/events/event.gts create mode 100644 cards/events/location.gts create mode 100644 cards/events/rsvp-status-field.gts diff --git a/cards/events/Booking/76353cf8-7a5d-406c-9ec6-756b058f9031.json b/cards/events/Booking/76353cf8-7a5d-406c-9ec6-756b058f9031.json new file mode 100644 index 00000000..9d116422 --- /dev/null +++ b/cards/events/Booking/76353cf8-7a5d-406c-9ec6-756b058f9031.json @@ -0,0 +1,30 @@ +{ + "data": { + "type": "card", + "attributes": { + "reference": null, + "quantity": null, + "rsvp": null, + "paymentStatus": null, + "totalPrice": { + "amount": null, + "currency": { + "code": null + } + }, + "checkedInAt": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../booking", + "name": "Booking" + } + } + } +} diff --git a/cards/events/Booking/ad4c7635-3cf8-4a5d-b06c-dec6756b058f.json b/cards/events/Booking/ad4c7635-3cf8-4a5d-b06c-dec6756b058f.json new file mode 100644 index 00000000..32168753 --- /dev/null +++ b/cards/events/Booking/ad4c7635-3cf8-4a5d-b06c-dec6756b058f.json @@ -0,0 +1,37 @@ +{ + "data": { + "type": "card", + "attributes": { + "reference": null, + "quantity": null, + "rsvp": null, + "paymentStatus": null, + "totalPrice": { + "amount": null, + "currency": { + "code": null + } + }, + "checkedInAt": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "event": { + "links": { + "self": "../Event/latin-cardio-launch" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../booking", + "name": "Booking" + } + } + } +} diff --git a/cards/events/Booking/bk-20260802-00101.json b/cards/events/Booking/bk-20260802-00101.json new file mode 100644 index 00000000..6206fe8d --- /dev/null +++ b/cards/events/Booking/bk-20260802-00101.json @@ -0,0 +1,43 @@ +{ + "data": { + "type": "card", + "attributes": { + "reference": "BK-20260802-00101", + "quantity": 1, + "rsvp": "Going", + "paymentStatus": "Paid", + "totalPrice": { + "amount": 45, + "currency": { + "code": "GBP" + } + }, + "checkedInAt": "2026-08-14T06:40:24.114Z", + "cardInfo": { + "name": null, + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "event": { + "links": { + "self": "../Event/northbridge-v-riverton" + } + }, + "holder": { + "links": { + "self": "../../crm/Contact/priya-shah" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../booking", + "name": "Booking" + }, + "_touched": 1787195029841 + } + } +} diff --git a/cards/events/Booking/bk-20260802-00144.json b/cards/events/Booking/bk-20260802-00144.json new file mode 100644 index 00000000..da7afa61 --- /dev/null +++ b/cards/events/Booking/bk-20260802-00144.json @@ -0,0 +1,43 @@ +{ + "data": { + "type": "card", + "attributes": { + "reference": "BK-20260802-00144", + "quantity": 1, + "rsvp": "Going", + "paymentStatus": "Paid", + "totalPrice": { + "amount": 35, + "currency": { + "code": "GBP" + } + }, + "checkedInAt": "2026-08-02T13:05:00.000Z", + "cardInfo": { + "name": null, + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "event": { + "links": { + "self": "../Event/northbridge-v-riverton" + } + }, + "holder": { + "links": { + "self": "../../crm/Contact/dev-okafor" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../booking", + "name": "Booking" + }, + "_touched": 1787195032634 + } + } +} diff --git a/cards/events/Booking/bk-20260912-00214.json b/cards/events/Booking/bk-20260912-00214.json new file mode 100644 index 00000000..6682e723 --- /dev/null +++ b/cards/events/Booking/bk-20260912-00214.json @@ -0,0 +1,43 @@ +{ + "data": { + "type": "card", + "attributes": { + "reference": "BK-20260912-00214", + "quantity": 2, + "rsvp": "Going", + "paymentStatus": "Paid", + "totalPrice": { + "amount": 90, + "currency": { + "code": "GBP" + } + }, + "checkedInAt": null, + "cardInfo": { + "name": null, + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "event": { + "links": { + "self": "../Event/northbridge-v-riverton" + } + }, + "holder": { + "links": { + "self": "../../crm/Contact/priya-shah" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../booking", + "name": "Booking" + }, + "_touched": 1787195035457 + } + } +} diff --git a/cards/events/Booking/bk-20260912-00399.json b/cards/events/Booking/bk-20260912-00399.json new file mode 100644 index 00000000..3f23e275 --- /dev/null +++ b/cards/events/Booking/bk-20260912-00399.json @@ -0,0 +1,43 @@ +{ + "data": { + "type": "card", + "attributes": { + "reference": "BK-20260912-00399", + "quantity": 1, + "rsvp": "Going", + "paymentStatus": "Unpaid", + "totalPrice": { + "amount": 38, + "currency": { + "code": "GBP" + } + }, + "checkedInAt": null, + "cardInfo": { + "name": null, + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "event": { + "links": { + "self": "../Event/northbridge-v-riverton" + } + }, + "holder": { + "links": { + "self": "../../crm/Contact/marcus-obi" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../booking", + "name": "Booking" + }, + "_touched": 1787195038250 + } + } +} diff --git a/cards/events/Booking/bk-20260912-00417.json b/cards/events/Booking/bk-20260912-00417.json new file mode 100644 index 00000000..2eebde89 --- /dev/null +++ b/cards/events/Booking/bk-20260912-00417.json @@ -0,0 +1,43 @@ +{ + "data": { + "type": "card", + "attributes": { + "reference": "BK-20260912-00417", + "quantity": 2, + "rsvp": "Going", + "paymentStatus": "Paid", + "totalPrice": { + "amount": 90, + "currency": { + "code": "GBP" + } + }, + "checkedInAt": null, + "cardInfo": { + "name": null, + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "event": { + "links": { + "self": "../Event/northbridge-v-riverton" + } + }, + "holder": { + "links": { + "self": "../../crm/Contact/mei-fong" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../booking", + "name": "Booking" + }, + "_touched": 1787195040924 + } + } +} diff --git a/cards/events/Booking/bk-20261017-00551.json b/cards/events/Booking/bk-20261017-00551.json new file mode 100644 index 00000000..db0c5cd2 --- /dev/null +++ b/cards/events/Booking/bk-20261017-00551.json @@ -0,0 +1,43 @@ +{ + "data": { + "type": "card", + "attributes": { + "reference": "BK-20261017-00551", + "quantity": 2, + "rsvp": "Invited", + "paymentStatus": "Unpaid", + "totalPrice": { + "amount": 110, + "currency": { + "code": "GBP" + } + }, + "checkedInAt": null, + "cardInfo": { + "name": null, + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "event": { + "links": { + "self": "../Event/northbridge-v-riverton" + } + }, + "holder": { + "links": { + "self": "../../crm/Contact/jason-lim" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../booking", + "name": "Booking" + }, + "_touched": 1787195043971 + } + } +} diff --git a/cards/events/Booking/bk-20261101-00602.json b/cards/events/Booking/bk-20261101-00602.json new file mode 100644 index 00000000..62aa25d2 --- /dev/null +++ b/cards/events/Booking/bk-20261101-00602.json @@ -0,0 +1,43 @@ +{ + "data": { + "type": "card", + "attributes": { + "reference": "BK-20261101-00602", + "quantity": 1, + "rsvp": "Going", + "paymentStatus": "Paid", + "totalPrice": { + "amount": 42, + "currency": { + "code": "GBP" + } + }, + "checkedInAt": null, + "cardInfo": { + "name": null, + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "event": { + "links": { + "self": "../Event/latin-cardio-launch" + } + }, + "holder": { + "links": { + "self": "../../crm/Contact/priya-shah" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../booking", + "name": "Booking" + }, + "_touched": 1787195046855 + } + } +} diff --git a/cards/events/Event/latin-cardio-launch.json b/cards/events/Event/latin-cardio-launch.json new file mode 100644 index 00000000..5dae18e3 --- /dev/null +++ b/cards/events/Event/latin-cardio-launch.json @@ -0,0 +1,23 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Latin Cardio launch class", + "kind": "Class", + "startsAt": "2026-08-27T10:30:00Z", + "endsAt": "2026-08-27T11:30:00Z", + "cardInfo": { + "name": null, + "summary": "First run of the new Latin Cardio block.", + "notes": null, + "cardThumbnailURL": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../event", + "name": "Event" + } + } + } +} diff --git a/cards/events/Event/northbridge-v-riverton.json b/cards/events/Event/northbridge-v-riverton.json new file mode 100644 index 00000000..54959415 --- /dev/null +++ b/cards/events/Event/northbridge-v-riverton.json @@ -0,0 +1,50 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "End of Season Awards Night", + "kind": "Club Event", + "startsAt": "2027-05-22T18:00:00.000Z", + "endsAt": "2027-05-22T22:30:00.000Z", + "status": "Scheduled", + "capacity": { + "total": 450, + "unit": "seats", + "allocations": [ + { + "name": "Members", + "quantity": 300 + }, + { + "name": "Guests", + "quantity": 100 + }, + { + "name": "Staff & players", + "quantity": 50 + } + ] + }, + "description": "Season awards dinner in the Meridian Park hospitality suite — player of the season, golden boot, and the supporters' awards. Dress code: smart. Doors 6pm, dinner 7:30pm.", + "cardInfo": { + "name": null, + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "venue": { + "links": { + "self": "../Location/meridian-park-stadium" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../event", + "name": "Event" + } + } + } +} diff --git a/cards/events/Location/meridian-park-stadium.json b/cards/events/Location/meridian-park-stadium.json new file mode 100644 index 00000000..ed4f8269 --- /dev/null +++ b/cards/events/Location/meridian-park-stadium.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Meridian Park Stadium", + "kind": "Stadium", + "address": { + "addressLine1": "1 Meridian Way", + "addressLine2": null, + "city": "Northbridge", + "state": "Greater Manchester", + "postalCode": "M40 7XB", + "country": { + "name": "United Kingdom", + "code": "GB" + }, + "poBoxNumber": null + }, + "geo": { + "lat": 53.4831, + "lon": -2.2004 + }, + "phone": "+441612345678", + "website": "https://meridianparkstadium.example", + "description": "Home ground of Northbridge FC since 1996. Four covered stands, step-free access on all levels, and rail links from Northbridge Central every ten minutes on match days.", + "cardInfo": { + "name": null, + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../location", + "name": "Location" + } + } + } +} diff --git a/cards/events/Location/northgate-fulfillment-centre.json b/cards/events/Location/northgate-fulfillment-centre.json new file mode 100644 index 00000000..2baf051c --- /dev/null +++ b/cards/events/Location/northgate-fulfillment-centre.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Northbridge Training Ground", + "kind": "Training Ground", + "address": { + "addressLine1": "Millbrook Lane", + "addressLine2": null, + "city": "Northbridge", + "state": "West Yorkshire", + "postalCode": "LS9 4QT", + "country": { + "name": "United Kingdom", + "code": "GB" + }, + "poBoxNumber": null + }, + "geo": { + "lat": 53.8121, + "lon": -1.4933 + }, + "phone": "+441132456789", + "website": null, + "description": "First-team and academy training complex — three grass pitches, one all-weather dome, and the club gym. Open days on the first Sunday of each month.", + "cardInfo": { + "name": null, + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../location", + "name": "Location" + } + } + } +} diff --git a/cards/events/Spec/booking.json b/cards/events/Spec/booking.json new file mode 100644 index 00000000..b2c83dc4 --- /dev/null +++ b/cards/events/Spec/booking.json @@ -0,0 +1,33 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Booking card", + "specType": "card", + "ref": { + "module": "../booking", + "name": "Booking" + }, + "readMe": "# Booking\n\n## Summary\nA claim on places at an Event — who is coming, how many places, where the\nmoney stands, and whether they actually showed up. Shared-realm block at\n`booking.gts`. Four facts kept as four fields, deliberately not collapsed\ninto one \"status\": `rsvp` is intent, `paymentStatus` is money,\n`checkedInAt` is what happened, `quantity` is how much capacity the claim\nconsumes.\n\n## API\n- `reference` — the human-quoted booking code (mono-rendered)\n- `event` — linksTo Event; `holder` — linksTo Contact\n- `quantity` — NumberField (places)\n- `rsvp` — RsvpStatusField\n- `paymentStatus` — Unpaid / Paid / Waived / Refunded (`BookingPaymentStatusField`, exported; Waived covers comps so \"everything not Paid owes money\" stays a safe query)\n- `totalPrice` — base AmountWithCurrency\n- `checkedInAt` — event fact, written once by the Check In Booking command\n\n## Usage\n```ts\nimport { Booking } from '@cardstack/catalog/cards/events/booking';\n\nclass TicketBooking extends Booking { /* seats, sections */ }\n```\nLifecycle moves go through the commands (Confirm Booking, Check In\nBooking), not hand-edits — that is what keeps `checkedInAt` one-time-use.\n\n## Non-goals\nSeat-level assignment (section, row, seat) is a seating-plan concern for\nthe consumer's extending card. Payment processing is an integration; this\ncard only records where the money stands. Attendance rates and no-show\nlists are computed from `checkedInAt` by queries, never stored.", + "cardInfo": { + "name": "Booking", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Booking/bk-20260912-00417" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/events/Spec/capacity-field.json b/cards/events/Spec/capacity-field.json new file mode 100644 index 00000000..b89289c4 --- /dev/null +++ b/cards/events/Spec/capacity-field.json @@ -0,0 +1,60 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Capacity field", + "specType": "field", + "ref": { + "module": "../capacity-field", + "name": "default" + }, + "readMe": "# Capacity\n\n## Summary\nHow much of something a place or offer can hold, and how that total is\ncarved up before anything is sold or assigned. Shared-realm block at\n`capacity-field.gts` (default export `CapacityField`).\n\n## API\n- `total` — NumberField, the ceiling\n- `unit` — StringField, what one unit is in the consumer's words (seats, rooms, places)\n- `allocations` — containsMany `CapacityAllocationField` (`{ name, quantity }`); slice names are the consumer's vocabulary\n- `allocated`, `unallocated` — computed, never stored\n\n## Usage\n```ts\nimport CapacityField from '@cardstack/catalog/cards/events/capacity-field';\n\n@field capacity = contains(CapacityField);\n```\nEmbedded renders the total, a proportional split bar and a legend, with an\nover-allocation warning when the slices exceed the total; atom renders\n`52,000 seats`.\n\n## Non-goals\nCapacity is configuration, not live state. What has been used against the\nceiling is booking/assignment data that lives on other cards and changes by\nthe minute — a consumer joins the two at render time (an availability meter\nbelongs to the consumer that holds the bookings, e.g. an Event's booking\nquery). Storing `remaining` here would store a derived value that goes stale\non the first booking. Per-section seat maps are a seating-plan concern, not a\ncapacity concern.", + "containedExamples": [ + { + "total": 52000, + "unit": "seats", + "allocations": [ + { + "name": "Season tickets", + "quantity": 28000 + }, + { + "name": "Members", + "quantity": 15000 + }, + { + "name": "Away supporters", + "quantity": 3000 + }, + { + "name": "Hospitality", + "quantity": 2500 + } + ] + } + ], + "cardInfo": { + "name": "Capacity", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "meta": { + "fields": { + "containedExamples": [ + { + "adoptsFrom": { + "module": "../capacity-field", + "name": "default" + } + } + ] + }, + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/events/Spec/check-in-booking.json b/cards/events/Spec/check-in-booking.json new file mode 100644 index 00000000..efa7a9f3 --- /dev/null +++ b/cards/events/Spec/check-in-booking.json @@ -0,0 +1,33 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Check In Booking command", + "specType": "command", + "ref": { + "module": "../check-in-booking", + "name": "default" + }, + "readMe": "# Check In Booking\n\n## Summary\nRecords that the holder actually arrived. Shared-realm command at\n`check-in-booking.gts`. Writes the booking's `checkedInAt` exactly once —\na second scan is refused with the original time, which is what makes a\nticket one-time-use — and flips `rsvp` to Going, because turning up\noutranks whatever the RSVP said.\n\n## API\nInput: `booking` (linksTo Booking), `realm`. Result: the booking, the\n`checkedInAt` instant, a message. A Declined booking is refused: the\nrecord says they cancelled, so the front desk gets an error to resolve,\nnot a silent re-admission.\n\n## Usage\nThe scan handler's single call. Whatever should FOLLOW arrival —\nattendance points (Credit Points), a survey trigger — is the consumer's\nmove, chained after this command's result.\n\n## Example\nThe linked Booking is the natural target — Going, paid, not yet checked\nin: exactly what a turnstile sees.\n\n## Non-goals\nNo QR validation or hardware protocol — decoding a pass and finding the\nbooking it names is the integration's job; this command is the state\nchange behind it. No occupancy math: live headcounts are queries over\n`checkedInAt`.", + "cardInfo": { + "name": "Check In Booking", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Booking/bk-20260912-00417" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/events/Spec/confirm-booking.json b/cards/events/Spec/confirm-booking.json new file mode 100644 index 00000000..ce4292c0 --- /dev/null +++ b/cards/events/Spec/confirm-booking.json @@ -0,0 +1,33 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Confirm Booking command", + "specType": "command", + "ref": { + "module": "../confirm-booking", + "name": "default" + }, + "readMe": "# Confirm Booking\n\n## Summary\nMoves a booking's attendance intent to Going — the purchase-complete /\n\"see you there\" transition. Shared-realm command at `confirm-booking.gts`.\n\n## API\nInput: `booking` (linksTo Booking), `realm`. Result: the booking and a\nmessage. An already-Going booking is reported, not re-written; transitions\nthe RSVP graph refuses are errors, not forced writes. Re-fetches the\nbooking before reading it.\n\n## Usage\nCall after whatever your domain counts as commitment: payment cleared, a\nseat picked, a guest list approved. Chain your own moves (points, emails)\nafter the result.\n\n## Example\nThe linked Booking is the natural target — a paid, Going booking shaped by\nthis command.\n\n## Non-goals\nDeliberately does not touch `paymentStatus`: money is a different fact\nwith its own writer, and coupling them would make every free event wrong.\nNot the check-in — arrival is the Check In Booking command's fact.", + "cardInfo": { + "name": "Confirm Booking", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Booking/bk-20260912-00417" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/events/Spec/event.json b/cards/events/Spec/event.json new file mode 100644 index 00000000..fb4ebafc --- /dev/null +++ b/cards/events/Spec/event.json @@ -0,0 +1,33 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Event card", + "specType": "card", + "ref": { + "module": "../event", + "name": "Event" + }, + "readMe": "# Event\n\n## Summary\nSomething that happens at a time, usually at a place, usually with an\naudience. Shared-realm block at `event.gts`. What KIND of happening (a\nmatch, a gig, an open day) is the consumer's `kind` label; the domain's\nextras (opponent, headliner, home/away) live on the consumer's extending\ncard.\n\n## API\n- `title`, `kind`, `description`\n- `startsAt` / `endsAt` — DateTimeFields (instants; an all-day event is one whose consumer chose not to render the times)\n- `venue` — linksTo Location\n- `capacity` — the Capacity field: the configured ceiling and named slices\n- `status` — Draft / Scheduled / Live / Completed / Cancelled (`EventStatusField`, also exported; Cancelled can be reinstated — postponements do exactly this)\n\nEmbedded and fitted lead with a date block — events are found by date\nfirst, name second.\n\n## Usage\n```ts\nimport { Event, EventStatusField } from '@cardstack/catalog/cards/events/event';\n\nclass Match extends Event { /* opponent, homeAway, competition */ }\n```\n\n## Non-goals\nHow much is booked against the capacity is live Booking data the consumer\nqueries — never stored here. Recurrence (weekly classes) is a schedule\nconcern, one level up: a recurring thing GENERATES events, it is not an\nevent with a repeat flag. Ticket pricing tiers and sales windows are\nselling concerns for the consumer.", + "cardInfo": { + "name": "Event", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Event/northbridge-v-riverton" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/events/Spec/location.json b/cards/events/Spec/location.json new file mode 100644 index 00000000..af931505 --- /dev/null +++ b/cards/events/Spec/location.json @@ -0,0 +1,38 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Location card", + "specType": "card", + "ref": { + "module": "../location", + "name": "Location" + }, + "readMe": "# Location\n\n## Summary\nA named physical place — somewhere things happen, ship to, or are held at.\nShared-realm block at `location.gts`.\n\n## API\n- `name` — StringField (drives `cardTitle`)\n- `kind` — StringField label in the consumer's vocabulary (Stadium, Office, Warehouse); deliberately free text and never a lifecycle, so it is not colour-coded\n- `address` — base `AddressField`\n- `geo` — catalog `GeoPointField` (`{ lat, lon }`); isolated renders it as a map when set\n- `phone` — base `PhoneNumberField`; `website` — base `WebsiteField`\n- `description` — MarkdownField\n\n## Usage\n```ts\nimport { Location } from '@cardstack/catalog/cards/events/location';\n\n@field venue = linksTo(Location);\n```\nEmbedded is a pin row (name, city/country, kind pill); fitted adds place,\nkind and street progressively across the size tiers; isolated is the place's\npage with map, address, reach and about panels.\n\n## Non-goals\nEverything a place means to one domain stays on the consumer: a stadium's\ncapacity (see the Capacity field), a warehouse's racking, an office's desks.\nThat split is what lets one Location serve an event's venue and an order's\npickup point without either seeing the other's baggage. Rooms/areas within a\nplace are a consumer concern until a real second consumer demands a shared\nsub-location shape.", + "cardInfo": { + "name": "Location", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Location/meridian-park-stadium" + } + }, + "linkedExamples.1": { + "links": { + "self": "../Location/northgate-fulfillment-centre" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/events/Spec/rsvp-status-field.json b/cards/events/Spec/rsvp-status-field.json new file mode 100644 index 00000000..481a2e94 --- /dev/null +++ b/cards/events/Spec/rsvp-status-field.json @@ -0,0 +1,33 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "RSVP Status field", + "specType": "field", + "ref": { + "module": "../rsvp-status-field", + "name": "default" + }, + "readMe": "# RSVP Status\n\n## Summary\nWhere someone stands on attending something — the answer to an invitation,\nnot the payment for it and not the turnstile scan. Shared-realm block at\n`rsvp-status-field.gts`, built on the `statusField` factory, so consumers get\nconstrained dropdowns, state pills and the `nextStatuses`/`canTransition`\nhelpers for free.\n\n## Vocabulary\nInvited → Going / Maybe / Waitlisted / Declined, with every answer allowed to\nchange (people change their minds up to the doors opening) except returning\nto Invited — an answer, once given, can change but not un-happen. Nothing is\nterminal.\n\n## Usage\n```ts\nimport RsvpStatusField from '@cardstack/catalog/cards/events/rsvp-status-field';\n\n@field rsvp = contains(RsvpStatusField);\n```\nConsumers with a different vocabulary (a wedding's \"Attending with guest\")\nbuild their own via `statusField`; this export is the common five-answer\nform.\n\n## Example\nSerializes as a plain string, so `containedExamples` cannot hold one — the\nlinked Booking carries `Going` in use.\n\n## Non-goals\nPayment state and attendance are different facts: a booking can be Going and\nunpaid, or Going and never checked in. Those live as their own fields\n(payment status, `checkedInAt`) on the consumer.", + "cardInfo": { + "name": "RSVP Status", + "summary": null, + "notes": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Booking/bk-20260912-00417" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/cards/events/booking.gts b/cards/events/booking.gts new file mode 100644 index 00000000..51d82b72 --- /dev/null +++ b/cards/events/booking.gts @@ -0,0 +1,537 @@ +import { + CardDef, + Component, + contains, + field, + linksTo, + realmURL, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import DateTimeField from 'https://cardstack.com/base/datetime'; +import AmountWithCurrency from 'https://cardstack.com/base/amount-with-currency'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import { Button } from '@cardstack/boxel-ui/components'; +import TicketIcon from '@cardstack/boxel-icons/ticket'; + +import { Contact } from '@cardstack/catalog/cards/crm/contact'; +import { Event } from './event'; +import RsvpStatusField from './rsvp-status-field'; +import { statusField } from '@cardstack/catalog/fields/status/status'; + +/** + * Money state, separate from attendance intent: a booking can be Going and + * unpaid, or Refunded and still attended (a goodwill refund). Waived covers + * comps and zero-price bookings so "everything not Paid owes money" stays a + * safe query. + */ +export const BookingPaymentStatusField = statusField({ + displayName: 'Payment Status', + options: [ + { value: 'Unpaid', hue: 'amber', meaning: 'Owed — not yet settled' }, + { value: 'Paid', hue: 'green', meaning: 'Settled in full', holds: true }, + { + value: 'Waived', + hue: 'slate', + meaning: 'Nothing owed — comp or zero price', + holds: true, + }, + { + value: 'Refunded', + hue: 'red', + meaning: 'Returned to the payer', + terminal: true, + holds: true, + }, + ], + transitions: { + Unpaid: ['Paid', 'Waived'], + Paid: ['Refunded'], + Waived: ['Unpaid'], + Refunded: [], + }, +}); + +/** + * A claim on places at an Event — who is coming, how many places, where the + * money stands, and whether they actually showed up. Four facts, four + * fields, deliberately not collapsed: `rsvp` is intent, `paymentStatus` is + * money, `checkedInAt` is what happened, `quantity` is how much of the + * capacity this claim consumes. + * + * `checkedInAt` is an event fact written once by the Check In Booking + * command (which is also what makes a ticket one-time-use); anything + * derived — attendance rates, no-show lists — is computed from it, never + * stored. Seat-level assignment (section, row, seat) is a seating-plan + * concern for the consumer's extending card. + */ +class BookingIsolated extends Component { + @tracked runningAction: 'confirm' | 'check-in' | undefined; + @tracked actionProblem: string | undefined; + + get places() { + let q = this.args.model.quantity ?? 1; + return q === 1 ? '1 place' : `${q} places`; + } + + get realm(): string | undefined { + return this.args.model?.[realmURL]?.href; + } + + /** Commands need a live command context and a saved card. */ + get canAct(): boolean { + return Boolean( + this.args.context?.commandContext && this.args.model?.id && this.realm, + ); + } + + get canConfirm(): boolean { + let m = this.args.model; + return ( + this.canAct && + !m.checkedInAt && + m.rsvp !== 'Going' && + m.rsvp !== 'Declined' + ); + } + + get canCheckIn(): boolean { + let m = this.args.model; + return this.canAct && !m.checkedInAt && m.rsvp !== 'Declined'; + } + + private runCommand = async (kind: 'confirm' | 'check-in') => { + let context = this.args.context?.commandContext; + if (!context || !this.realm) { + return; + } + this.runningAction = kind; + this.actionProblem = undefined; + try { + // Literal lazy imports: both commands import Booking back, so a + // static import here would be a module cycle. + if (kind === 'confirm') { + let { default: ConfirmBookingCommand } = + // @ts-expect-error TS2834: the realm resolves extensionless module ids + await import('./confirm-booking'); + await new ConfirmBookingCommand(context).execute({ + booking: this.args.model, + realm: this.realm, + } as any); + } else { + let { default: CheckInBookingCommand } = + // @ts-expect-error TS2834: the realm resolves extensionless module ids + await import('./check-in-booking'); + await new CheckInBookingCommand(context).execute({ + booking: this.args.model, + realm: this.realm, + } as any); + } + } catch (error: any) { + this.actionProblem = error?.message ?? String(error); + } finally { + this.runningAction = undefined; + } + }; + + confirm = () => { + void this.runCommand('confirm'); + }; + checkIn = () => { + void this.runCommand('check-in'); + }; + + +} + +export class Booking extends CardDef { + static displayName = 'Booking'; + static icon = TicketIcon; + + @field reference = contains(StringField); + @field event = linksTo(Event); + @field holder = linksTo(Contact); + @field quantity = contains(NumberField); + @field rsvp = contains(RsvpStatusField); + @field paymentStatus = contains(BookingPaymentStatusField); + @field totalPrice = contains(AmountWithCurrency); + @field checkedInAt = contains(DateTimeField); + + @field cardTitle = contains(StringField, { + computeVia: function (this: Booking) { + return ( + this.reference ?? + (this.holder?.name + ? `Booking for ${this.holder.name}` + : `Untitled ${this.constructor.displayName}`) + ); + }, + }); + + // Queryable read surface: lists and searches see who and what without + // resolving the links themselves. + @field eventTitle = contains(StringField, { + computeVia: function (this: Booking) { + return this.event?.cardTitle; + }, + }); + + @field holderName = contains(StringField, { + computeVia: function (this: Booking) { + return this.holder?.name; + }, + }); + + static atom = class Atom extends Component { + + }; + + static embedded = class Embedded extends Component { + get places() { + let q = this.args.model.quantity ?? 1; + return q === 1 ? '1 place' : `${q} places`; + } + + }; + + static fitted = class Fitted extends Component { + get places() { + let q = this.args.model.quantity ?? 1; + return q === 1 ? '1 place' : `${q} places`; + } + + }; + + static isolated = BookingIsolated; +} diff --git a/cards/events/capacity-field.gts b/cards/events/capacity-field.gts new file mode 100644 index 00000000..6a6f02b7 --- /dev/null +++ b/cards/events/capacity-field.gts @@ -0,0 +1,347 @@ +import { htmlSafe } from '@ember/template'; +import { + Component, + FieldDef, + field, + contains, + containsMany, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import GaugeIcon from '@cardstack/boxel-icons/gauge'; + +import { stateColor, type Hue } from '@cardstack/catalog/components/state-pill'; + +/** + * How much of something a place or offer can hold, and how that total is + * carved up before anything is sold or assigned. + * + * Capacity is configuration, not live state: the field stores the ceiling and + * the named slices (a stadium's season-ticket / member / away split, a venue + * room's theatre vs banquet layouts, a course's staff-reserved seats). What + * has actually been used against that ceiling is booking data that lives on + * other cards and changes by the minute — a consumer joins the two at render + * time. Storing "remaining" here would be storing a derived value that goes + * stale the moment anyone books. + * + * Allocation names are the consumer's vocabulary; the block has no opinion on + * what a slice is called or how many there are. + */ +export class CapacityAllocationField extends FieldDef { + static displayName = 'Capacity Allocation'; + + @field name = contains(StringField); + @field quantity = contains(NumberField); + + @field title = contains(StringField, { + computeVia: function (this: CapacityAllocationField) { + return this.name ?? 'Allocation'; + }, + }); + + static embedded = class Embedded extends Component { + + }; +} + +// The segment palette cycles in a fixed order so the same allocation index +// gets the same hue on every card — a legend the eye can carry between rows. +const SEGMENT_HUES: Hue[] = [ + 'teal', + 'blue', + 'purple', + 'amber', + 'pink', + 'green', +]; + +// Inline styles carry only computed widths and token-derived colors, never +// user strings. +function htmlSafeStyle(style: string) { + return htmlSafe(style); +} + +function formatCount(n?: number | null): string { + if (n == null || Number.isNaN(n)) { + return '—'; + } + return new Intl.NumberFormat().format(n); +} + +interface Segment { + name: string; + quantity: number; + percent: number; + bg: string; + ring: string; +} + +function segmentsOf(model: { + total?: number | null; + allocations?: { name?: string | null; quantity?: number | null }[]; +}): Segment[] { + let total = model.total ?? 0; + if (total <= 0) { + return []; + } + return (model.allocations ?? []) + .filter((a) => (a.quantity ?? 0) > 0) + .map((a, i) => { + let color = stateColor(SEGMENT_HUES[i % SEGMENT_HUES.length]); + return { + name: a.name ?? '—', + quantity: a.quantity ?? 0, + percent: Math.min(100, ((a.quantity ?? 0) / total) * 100), + bg: color.ring, + ring: color.ring, + }; + }); +} + +function allocatedOf(model: { + allocations?: { quantity?: number | null }[]; +}): number { + return (model.allocations ?? []).reduce( + (sum, a) => sum + (a.quantity ?? 0), + 0, + ); +} + +export default class CapacityField extends FieldDef { + static displayName = 'Capacity'; + static icon = GaugeIcon; + + @field total = contains(NumberField); + /** What one unit is, in the consumer's words: seats, rooms, places. */ + @field unit = contains(StringField); + @field allocations = containsMany(CapacityAllocationField); + + @field allocated = contains(NumberField, { + computeVia: function (this: CapacityField) { + return allocatedOf(this); + }, + }); + + @field unallocated = contains(NumberField, { + computeVia: function (this: CapacityField) { + return Math.max(0, (this.total ?? 0) - allocatedOf(this)); + }, + }); + + @field title = contains(StringField, { + computeVia: function (this: CapacityField) { + let total = this.total; + if (total == null) { + return 'Capacity'; + } + let unit = this.unit ? ` ${this.unit}` : ''; + return `${formatCount(total)}${unit}`; + }, + }); + + static atom = class Atom extends Component { + + }; + + static embedded = class Embedded extends Component { + get segments(): Segment[] { + return segmentsOf(this.args.model); + } + + get overAllocated(): boolean { + let total = this.args.model.total ?? 0; + return total > 0 && allocatedOf(this.args.model) > total; + } + + get hasBreakdown(): boolean { + return this.segments.length > 0; + } + + widthOf = (segment: Segment) => `width: ${segment.percent}%`; + swatchOf = (segment: Segment) => `background: ${segment.bg}`; + + + }; +} diff --git a/cards/events/check-in-booking.gts b/cards/events/check-in-booking.gts new file mode 100644 index 00000000..84aecd49 --- /dev/null +++ b/cards/events/check-in-booking.gts @@ -0,0 +1,85 @@ +import { + CardDef, + contains, + field, + linksTo, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import DateTimeField from 'https://cardstack.com/base/datetime'; +import { Command } from '@cardstack/runtime-common'; +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; + +import { Booking } from './booking'; + +export class CheckInBookingInput extends CardDef { + @field booking = linksTo(Booking, { searchable: true }); + @field realm = contains(StringField); +} + +export class CheckInBookingResult extends CardDef { + @field booking = linksTo(Booking); + @field checkedInAt = contains(DateTimeField); + @field message = contains(StringField); +} + +/** + * Records that the holder actually arrived. Writes `checkedInAt` exactly + * once — a second scan is refused with the original time, which is what + * makes a ticket one-time-use — and flips intent to Going, because turning + * up outranks whatever the RSVP said. + * + * A Declined booking is refused: the record says they cancelled, so the + * front desk gets an error to resolve, not a silent re-admission. + * Whatever should FOLLOW arrival — attendance points, a survey trigger — + * is the consumer's move, chained after this command. + */ +export default class CheckInBookingCommand extends Command< + typeof CheckInBookingInput, + typeof CheckInBookingResult +> { + static actionVerb = 'Check In'; + + async getInputType() { + return CheckInBookingInput; + } + + protected async run( + input: CheckInBookingInput, + ): Promise { + let { booking, realm } = input; + if (!booking) throw new Error('A booking is required'); + if (!realm) throw new Error('A realm is required'); + if (booking.id) { + booking = (await new GetCardCommand(this.commandContext).execute({ + cardId: booking.id, + })) as Booking; + } + + let ref = booking.reference ?? 'booking'; + if (booking.checkedInAt) { + throw new Error( + `${ref} was already checked in at ${new Date( + booking.checkedInAt, + ).toISOString()}`, + ); + } + if (booking.rsvp === 'Declined') { + throw new Error(`${ref} was declined — resolve before admitting`); + } + + let now = new Date(); + booking.checkedInAt = now; + booking.rsvp = 'Going'; + await new SaveCardCommand(this.commandContext).execute({ + card: booking, + realm, + } as any); + + return new CheckInBookingResult({ + booking, + checkedInAt: now, + message: `Checked in ${ref}`, + }); + } +} diff --git a/cards/events/confirm-booking.gts b/cards/events/confirm-booking.gts new file mode 100644 index 00000000..e65c4037 --- /dev/null +++ b/cards/events/confirm-booking.gts @@ -0,0 +1,84 @@ +import { + CardDef, + contains, + field, + linksTo, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import { Command } from '@cardstack/runtime-common'; +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import GetCardCommand from '@cardstack/boxel-host/commands/get-card'; + +import { Booking } from './booking'; +import RsvpStatusField from './rsvp-status-field'; +import { canTransition } from '@cardstack/catalog/fields/status/status'; + +export class ConfirmBookingInput extends CardDef { + @field booking = linksTo(Booking, { searchable: true }); + @field realm = contains(StringField); +} + +export class ConfirmBookingResult extends CardDef { + @field booking = linksTo(Booking); + @field message = contains(StringField); +} + +/** + * Moves a booking's attendance intent to Going — the purchase-complete / + * "see you there" transition. Respects the RSVP graph: a booking already + * Going is reported, not re-written, and a state the graph will not release + * (there is none today, but the check is what keeps that true if the + * vocabulary grows) is refused rather than forced. + * + * Deliberately does NOT touch paymentStatus: money is a different fact + * with its own writer, and coupling them here would make every free event + * wrong. + */ +export default class ConfirmBookingCommand extends Command< + typeof ConfirmBookingInput, + typeof ConfirmBookingResult +> { + static actionVerb = 'Confirm Booking'; + + async getInputType() { + return ConfirmBookingInput; + } + + protected async run( + input: ConfirmBookingInput, + ): Promise { + let { booking, realm } = input; + if (!booking) throw new Error('A booking is required'); + if (!realm) throw new Error('A realm is required'); + if (booking.id) { + booking = (await new GetCardCommand(this.commandContext).execute({ + cardId: booking.id, + })) as Booking; + } + + let ref = booking.reference ?? 'booking'; + if (booking.rsvp === 'Going') { + return new ConfirmBookingResult({ + booking, + message: `${ref} was already confirmed`, + }); + } + if ( + booking.rsvp && + !canTransition(RsvpStatusField, booking.rsvp, 'Going') + ) { + throw new Error(`Cannot confirm a booking that is ${booking.rsvp}`); + } + + booking.rsvp = 'Going'; + await new SaveCardCommand(this.commandContext).execute({ + card: booking, + realm, + } as any); + + return new ConfirmBookingResult({ + booking, + message: `Confirmed ${ref} — holder is Going`, + }); + } +} diff --git a/cards/events/event.gts b/cards/events/event.gts new file mode 100644 index 00000000..0732296b --- /dev/null +++ b/cards/events/event.gts @@ -0,0 +1,465 @@ +import { + CardDef, + Component, + contains, + field, + linksTo, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import DateTimeField from 'https://cardstack.com/base/datetime'; +import MarkdownField from 'https://cardstack.com/base/markdown'; +import CalendarEventIcon from '@cardstack/boxel-icons/calendar-event'; + +import { Location } from './location'; +import CapacityField from './capacity-field'; +import { statusField } from '@cardstack/catalog/fields/status/status'; + +/** + * Event lifecycle. Draft is unannounced planning; Scheduled is public; + * Live only matters to consumers that render "happening now"; Cancelled can + * be reinstated (postponements do exactly this), Completed cannot un-happen. + */ +export const EventStatusField = statusField({ + displayName: 'Event Status', + options: [ + { value: 'Draft', hue: 'slate', meaning: 'Being planned — not public' }, + { value: 'Scheduled', hue: 'teal', meaning: 'Announced and upcoming' }, + { value: 'Live', hue: 'green', meaning: 'Happening now' }, + { + value: 'Completed', + hue: 'slate', + meaning: 'Took place', + terminal: true, + holds: true, + }, + { + value: 'Cancelled', + hue: 'red', + meaning: 'Called off — can be reinstated', + holds: true, + }, + ], + transitions: { + Draft: ['Scheduled', 'Cancelled'], + Scheduled: ['Live', 'Completed', 'Cancelled'], + Live: ['Completed'], + Cancelled: ['Scheduled'], + }, +}); + +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +export function datePart( + value?: Date | null, +): { day: string; month: string } | null { + if (!value) { + return null; + } + let d = new Date(value); + if (Number.isNaN(d.getTime())) { + return null; + } + return { day: String(d.getDate()), month: MONTHS[d.getMonth()] }; +} + +/** + * Something that happens at a time, usually at a place, usually with an + * audience — the generic block. What KIND of happening (a match, a gig, an + * open day) is the consumer's `kind` label; the domain's extras (opponent, + * headliner, home/away) live on the consumer's extending card. + * + * `capacity` is the configured ceiling and its named slices (see the + * Capacity spec); how much is booked against it is live Booking data the + * consumer queries. `startsAt`/`endsAt` are instants (DateTimeFields) — an + * all-day event is one whose consumer chose not to render the times. + */ +export class Event extends CardDef { + static displayName = 'Event'; + static icon = CalendarEventIcon; + + @field title = contains(StringField); + @field kind = contains(StringField); + @field startsAt = contains(DateTimeField); + @field endsAt = contains(DateTimeField); + @field venue = linksTo(Location); + @field capacity = contains(CapacityField); + @field status = contains(EventStatusField); + @field description = contains(MarkdownField); + + @field cardTitle = contains(StringField, { + computeVia: function (this: Event) { + return this.title?.trim()?.length + ? this.title + : `Untitled ${this.constructor.displayName}`; + }, + }); + + static atom = class Atom extends Component { + + }; + + static embedded = class Embedded extends Component { + get date() { + return datePart(this.args.model.startsAt); + } + + }; + + static fitted = class Fitted extends Component { + get date() { + return datePart(this.args.model.startsAt); + } + + }; + + static isolated = class Isolated extends Component { + + }; +} diff --git a/cards/events/location.gts b/cards/events/location.gts new file mode 100644 index 00000000..3be732da --- /dev/null +++ b/cards/events/location.gts @@ -0,0 +1,441 @@ +import { + CardDef, + Component, + contains, + field, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import AddressField from 'https://cardstack.com/base/address'; +import PhoneNumberField from 'https://cardstack.com/base/phone-number'; +import WebsiteField from 'https://cardstack.com/base/website'; +import MarkdownField from 'https://cardstack.com/base/markdown'; +import GeoPointField from '@cardstack/catalog/fields/geo-point/geo-point'; +import MapPinIcon from '@cardstack/boxel-icons/map-pin'; + +/** + * A named physical place — somewhere things happen, ship to, or are held at. + * + * The block is the place itself: name, what kind of place it is, where it + * stands and how to reach it. Everything a place means to one domain — a + * stadium's capacity, a warehouse's racking, an office's desks — belongs on + * the consumer's card, which links here or extends this. That split is what + * lets one Location serve an event's venue and an order's pickup point + * without either seeing the other's baggage. + * + * `kind` is a label in the consumer's vocabulary (Stadium, Office, Warehouse, + * Clinic) — deliberately free text and never a lifecycle, so it is not + * colour-coded. + */ +export class Location extends CardDef { + static displayName = 'Location'; + static icon = MapPinIcon; + + @field name = contains(StringField); + @field kind = contains(StringField); + @field address = contains(AddressField); + @field geo = contains(GeoPointField); + @field phone = contains(PhoneNumberField); + @field website = contains(WebsiteField); + @field description = contains(MarkdownField); + + @field cardTitle = contains(StringField, { + computeVia: function (this: Location) { + return this.name?.trim()?.length + ? this.name + : `Untitled ${this.constructor.displayName}`; + }, + }); + + static atom = class Atom extends Component { + + }; + + static embedded = class Embedded extends Component { + get place() { + let a = this.args.model?.address; + return [a?.city, a?.country?.name ?? a?.state].filter(Boolean).join(', '); + } + + }; + + static fitted = class Fitted extends Component { + get name() { + return this.args.model?.name?.trim() || 'Unnamed Location'; + } + get place() { + let a = this.args.model?.address; + return [a?.city, a?.country?.name ?? a?.state].filter(Boolean).join(', '); + } + get street() { + return this.args.model?.address?.addressLine1; + } + + }; + + static isolated = class Isolated extends Component { + get name() { + return this.args.model?.name?.trim() || 'Unnamed Location'; + } + get hasGeo() { + return ( + this.args.model?.geo?.lat != null && this.args.model?.geo?.lon != null + ); + } + get hasReach() { + return Boolean(this.args.model?.phone || this.args.model?.website); + } + + }; +} diff --git a/cards/events/rsvp-status-field.gts b/cards/events/rsvp-status-field.gts new file mode 100644 index 00000000..b3a9c60e --- /dev/null +++ b/cards/events/rsvp-status-field.gts @@ -0,0 +1,60 @@ +import CalendarCheckIcon from '@cardstack/boxel-icons/calendar-check'; + +import { statusField } from '@cardstack/catalog/fields/status/status'; + +/** + * Where someone stands on attending something — the answer to an invitation, + * not the payment for it and not the turnstile scan. + * + * RSVP is intent, so nothing here is terminal: people change their minds up to + * the doors opening, and a Declined that could never become a Going would just + * push consumers into deleting and recreating records. The graph instead + * encodes the one move that IS illegal: nobody returns to Invited — an answer, + * once given, can change but not un-happen. + * + * Built on `statusField`, so the option set renders as constrained dropdowns + * and state pills for free, and consumers can call its `nextStatuses` / + * `canTransition` helpers against this class. Consumers with a different + * vocabulary (a wedding's "Attending with guest") build their own via + * `statusField`; this export is the common five-answer form. + */ +export const RsvpStatusField = statusField({ + displayName: 'RSVP Status', + icon: CalendarCheckIcon, + options: [ + { + value: 'Invited', + hue: 'slate', + meaning: 'Asked, no answer yet', + }, + { + value: 'Going', + hue: 'green', + meaning: 'Confirmed they will attend', + }, + { + value: 'Maybe', + hue: 'amber', + meaning: 'Interested but not committed', + }, + { + value: 'Waitlisted', + hue: 'purple', + meaning: 'Wants to attend; no place for them yet', + }, + { + value: 'Declined', + hue: 'red', + meaning: 'Said no — can still change their mind', + }, + ], + transitions: { + Invited: ['Going', 'Maybe', 'Waitlisted', 'Declined'], + Going: ['Maybe', 'Declined'], + Maybe: ['Going', 'Waitlisted', 'Declined'], + Waitlisted: ['Going', 'Declined'], + Declined: ['Going', 'Maybe'], + }, +}); + +export default RsvpStatusField; From 5f46834db8d52938f5ee6c6c27caf4806d4a7664 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 11 Sep 2026 18:19:06 +0800 Subject: [PATCH 2/2] Events Specs: fill cardDescription so the Spec chooser tiles read Co-Authored-By: Claude Fable 5.1 --- cards/events/Spec/booking.json | 3 ++- cards/events/Spec/capacity-field.json | 3 ++- cards/events/Spec/check-in-booking.json | 3 ++- cards/events/Spec/confirm-booking.json | 3 ++- cards/events/Spec/event.json | 3 ++- cards/events/Spec/location.json | 3 ++- cards/events/Spec/rsvp-status-field.json | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/cards/events/Spec/booking.json b/cards/events/Spec/booking.json index b2c83dc4..73d7d226 100644 --- a/cards/events/Spec/booking.json +++ b/cards/events/Spec/booking.json @@ -14,7 +14,8 @@ "summary": null, "notes": null, "cardThumbnailURL": null - } + }, + "cardDescription": "A claim on places at an Event — who is coming, how many places, where the money stands, and whether they actually showed up." }, "relationships": { "linkedExamples.0": { diff --git a/cards/events/Spec/capacity-field.json b/cards/events/Spec/capacity-field.json index b89289c4..4b33a35c 100644 --- a/cards/events/Spec/capacity-field.json +++ b/cards/events/Spec/capacity-field.json @@ -38,7 +38,8 @@ "summary": null, "notes": null, "cardThumbnailURL": null - } + }, + "cardDescription": "How much of something a place or offer can hold, and how that total is carved up before anything is sold or assigned." }, "meta": { "fields": { diff --git a/cards/events/Spec/check-in-booking.json b/cards/events/Spec/check-in-booking.json index efa7a9f3..665d5abd 100644 --- a/cards/events/Spec/check-in-booking.json +++ b/cards/events/Spec/check-in-booking.json @@ -14,7 +14,8 @@ "summary": null, "notes": null, "cardThumbnailURL": null - } + }, + "cardDescription": "Records that the holder actually arrived." }, "relationships": { "linkedExamples.0": { diff --git a/cards/events/Spec/confirm-booking.json b/cards/events/Spec/confirm-booking.json index ce4292c0..ac0487ce 100644 --- a/cards/events/Spec/confirm-booking.json +++ b/cards/events/Spec/confirm-booking.json @@ -14,7 +14,8 @@ "summary": null, "notes": null, "cardThumbnailURL": null - } + }, + "cardDescription": "Moves a booking's attendance intent to Going — the purchase-complete / \"see you there\" transition." }, "relationships": { "linkedExamples.0": { diff --git a/cards/events/Spec/event.json b/cards/events/Spec/event.json index fb4ebafc..ba461b31 100644 --- a/cards/events/Spec/event.json +++ b/cards/events/Spec/event.json @@ -14,7 +14,8 @@ "summary": null, "notes": null, "cardThumbnailURL": null - } + }, + "cardDescription": "Something that happens at a time, usually at a place, usually with an audience." }, "relationships": { "linkedExamples.0": { diff --git a/cards/events/Spec/location.json b/cards/events/Spec/location.json index af931505..03dfa62b 100644 --- a/cards/events/Spec/location.json +++ b/cards/events/Spec/location.json @@ -14,7 +14,8 @@ "summary": null, "notes": null, "cardThumbnailURL": null - } + }, + "cardDescription": "A named physical place — somewhere things happen, ship to, or are held at." }, "relationships": { "linkedExamples.0": { diff --git a/cards/events/Spec/rsvp-status-field.json b/cards/events/Spec/rsvp-status-field.json index 481a2e94..4364da83 100644 --- a/cards/events/Spec/rsvp-status-field.json +++ b/cards/events/Spec/rsvp-status-field.json @@ -14,7 +14,8 @@ "summary": null, "notes": null, "cardThumbnailURL": null - } + }, + "cardDescription": "Where someone stands on attending something — the answer to an invitation, not the payment for it and not the turnstile scan." }, "relationships": { "linkedExamples.0": {