diff --git a/41e20f-wedding-table-seating-planner/CardListing/6241aa1f-0a8a-45f3-9b8b-57ac600070c4.json b/41e20f-wedding-table-seating-planner/CardListing/6241aa1f-0a8a-45f3-9b8b-57ac600070c4.json index 07f85957..b59d5012 100644 --- a/41e20f-wedding-table-seating-planner/CardListing/6241aa1f-0a8a-45f3-9b8b-57ac600070c4.json +++ b/41e20f-wedding-table-seating-planner/CardListing/6241aa1f-0a8a-45f3-9b8b-57ac600070c4.json @@ -282,7 +282,22 @@ "links": { "self": "../ListingThumbnails/table-seating-b62d23ba.png" } + }, + "specs.23": { + "links": { + "self": "../Spec/039f23ea-f61a-4a68-a322-6ff5acb17bf7" + } + }, + "specs.24": { + "links": { + "self": "../Spec/10c3938c-b579-45cc-a849-4811b0fbc16b" + } + }, + "specs.25": { + "links": { + "self": "../Spec/39355975-9e51-49d1-9b77-88bcdb7c5feb" + } } } } -} \ No newline at end of file +} diff --git a/41e20f-wedding-table-seating-planner/Spec/039f23ea-f61a-4a68-a322-6ff5acb17bf7.json b/41e20f-wedding-table-seating-planner/Spec/039f23ea-f61a-4a68-a322-6ff5acb17bf7.json new file mode 100644 index 00000000..0ddd75b4 --- /dev/null +++ b/41e20f-wedding-table-seating-planner/Spec/039f23ea-f61a-4a68-a322-6ff5acb17bf7.json @@ -0,0 +1,45 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../components/setup-wizard", + "name": "default" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "SetupWizard", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/41e20f-wedding-table-seating-planner/Spec/10c3938c-b579-45cc-a849-4811b0fbc16b.json b/41e20f-wedding-table-seating-planner/Spec/10c3938c-b579-45cc-a849-4811b0fbc16b.json new file mode 100644 index 00000000..edd0a911 --- /dev/null +++ b/41e20f-wedding-table-seating-planner/Spec/10c3938c-b579-45cc-a849-4811b0fbc16b.json @@ -0,0 +1,45 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../components/card-views", + "name": "PlaceCardView" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "PlaceCardView", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/41e20f-wedding-table-seating-planner/Spec/39355975-9e51-49d1-9b77-88bcdb7c5feb.json b/41e20f-wedding-table-seating-planner/Spec/39355975-9e51-49d1-9b77-88bcdb7c5feb.json new file mode 100644 index 00000000..397a1737 --- /dev/null +++ b/41e20f-wedding-table-seating-planner/Spec/39355975-9e51-49d1-9b77-88bcdb7c5feb.json @@ -0,0 +1,45 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../components/card-views", + "name": "TableCardView" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "TableCardView", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "@cardstack/base/spec", + "name": "Spec" + } + } + } +} diff --git a/41e20f-wedding-table-seating-planner/components/card-views.gts b/41e20f-wedding-table-seating-planner/components/card-views.gts new file mode 100644 index 00000000..4979f00f --- /dev/null +++ b/41e20f-wedding-table-seating-planner/components/card-views.gts @@ -0,0 +1,156 @@ +import Component from '@glimmer/component'; + +// Shared visuals for the wedding stationery. Both the PlaceCard / TableCard +// card definitions AND the planner's print sheet render these, so the card and +// what gets printed stay identical (single source of truth). Sizing is driven +// by the container (container queries), so the same component looks right as a +// large isolated card, a small fitted tile, or a print-sheet cell. + +interface PlaceCardSignature { + Element: HTMLDivElement; + Args: { + eventTitle?: string | null; + guestName?: string | null; + tableName?: string | null; + message?: string | null; + }; +} + +export class PlaceCardView extends Component { + +} + +interface TableCardSignature { + Element: HTMLDivElement; + Args: { + eventTitle?: string | null; + tableName?: string | null; + accent?: string | null; + }; +} + +export class TableCardView extends Component { + +} diff --git a/41e20f-wedding-table-seating-planner/components/setup-wizard.gts b/41e20f-wedding-table-seating-planner/components/setup-wizard.gts new file mode 100644 index 00000000..c4c384e5 --- /dev/null +++ b/41e20f-wedding-table-seating-planner/components/setup-wizard.gts @@ -0,0 +1,653 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import CalendarHeartIcon from '@cardstack/boxel-icons/calendar-heart'; +import CrownIcon from '@cardstack/boxel-icons/crown'; +import UsersIcon from '@cardstack/boxel-icons/users'; +import LayoutPreview from './layout-preview'; +import type { Table } from '../table'; +import type { Fixture } from '../fixture'; + +// First-run setup wizard for the Table Seating Planner. Self-contained and +// composable: owns its own step state + styling, talks to the host planner only +// through args. Two-pane layout — a vertical stepper on the left, the active +// step's content on the right. Steps: event details → hosts (the couple) → +// guests → start from a template (previewed with the LayoutPreview SVG) or blank. +interface TemplateLike { + name?: string | null; + tableCount?: number | null; + seatCount?: number | null; + tables?: Table[]; + fixtures?: Fixture[]; +} + +interface Signature { + Element: HTMLDivElement; + Args: { + eventTitle?: string | null; + venue?: string | null; + eventDate?: string | null; + hostCount?: number; + guestCount?: number; + templates?: TemplateLike[]; + templatesLoading?: boolean; + onEventTitle: (e: Event) => void; + onVenue: (e: Event) => void; + onEventDate: (e: Event) => void; + onAddHosts: () => void; + onAddGuests: () => void; + onLoadTemplates: () => void; + onApplyTemplate: (index: number) => void; + onSkip: () => void; + }; +} + +const LAST_STEP = 4; + +export default class SetupWizard extends Component { + @tracked step = 1; + + get step1Done(): boolean { + return !!this.args.eventTitle && this.args.eventTitle.trim().length > 0; + } + get step2Done(): boolean { + return (this.args.hostCount ?? 0) > 0; + } + get step3Done(): boolean { + return (this.args.guestCount ?? 0) > 0; + } + get canProceed(): boolean { + if (this.step === 1) return this.step1Done; + if (this.step === 2) return this.step2Done; + if (this.step === 3) return this.step3Done; + return true; + } + get nextDisabled(): boolean { + return !this.canProceed; + } + get isLastStep(): boolean { + return this.step === LAST_STEP; + } + + private enter = (step: number) => { + this.step = step; + if (step === LAST_STEP) this.args.onLoadTemplates(); + }; + next = () => { + if (this.step < LAST_STEP) this.enter(this.step + 1); + }; + back = () => { + if (this.step > 1) this.step -= 1; + }; + skipStep = () => { + if (this.step < LAST_STEP) this.enter(this.step + 1); + else this.args.onSkip(); + }; + applyTpl = (index: number) => { + this.args.onApplyTemplate(index); + }; + + +} diff --git a/41e20f-wedding-table-seating-planner/components/tsp.gts b/41e20f-wedding-table-seating-planner/components/tsp.gts index 6eda372b..9bee327d 100644 --- a/41e20f-wedding-table-seating-planner/components/tsp.gts +++ b/41e20f-wedding-table-seating-planner/components/tsp.gts @@ -32,6 +32,8 @@ import FixtureGlyph from './fixture-glyph'; import LayoutPreview from './layout-preview'; import ImageSourceField from '@cardstack/catalog/fields/image-source/image-source'; import SeatingPlanPopover from './seating-plan-popover'; +import { PlaceCardView, TableCardView } from './card-views'; +import SetupWizard from './setup-wizard'; import PencilIcon from '@cardstack/boxel-icons/pencil'; import XIcon from '@cardstack/boxel-icons/x'; import LockIcon from '@cardstack/boxel-icons/lock'; @@ -42,10 +44,14 @@ import StarIcon from '@cardstack/boxel-icons/star'; import DownloadIcon from '@cardstack/boxel-icons/download'; import SearchIcon from '@cardstack/boxel-icons/search'; import CameraIcon from '@cardstack/boxel-icons/camera'; +import PrinterIcon from '@cardstack/boxel-icons/printer'; +import LayoutIcon from '@cardstack/boxel-icons/layout-dashboard'; import ArrowsMoveIcon from '@cardstack/boxel-icons/arrows-move'; import TemplateIcon from '@cardstack/boxel-icons/template'; import RefreshIcon from '@cardstack/boxel-icons/refresh'; import FloorPlanIcon from '@cardstack/boxel-icons/floor-plan'; +import ArrowBackUpIcon from '@cardstack/boxel-icons/arrow-back-up'; +import ArrowForwardUpIcon from '@cardstack/boxel-icons/arrow-forward-up'; import { FIXTURE_KINDS, FIXTURE_KIND_LABELS, @@ -551,12 +557,6 @@ export class TableSeatingPlannerIsolated extends Component< } return s; } - get allRosterSeated(): boolean { - return ( - this.guests.length > 0 && - this.guests.every((g) => this.seatedGuestSet.has(g as Guest)) - ); - } get totalGuests() { try { return this.guests.length; @@ -574,6 +574,36 @@ export class TableSeatingPlannerIsolated extends Component< get tableCount() { return this.tables.length; } + get hasCanvasContent(): boolean { + return ( + this.tables.length > 0 || this.fixtures.length > 0 || this.hasFloorPlan + ); + } + get hasEventInfo(): boolean { + return !!this.args.model?.eventTitle?.trim(); + } + get hostsCount(): number { + return (this.args.model?.hosts ?? []).length; + } + + // --- First-run setup wizard (steps live in the SetupWizard component) --- + // Decided once at construction: the wizard opens only for a brand-new planner + // (empty canvas, no event info, no guests). Because it is NOT re-derived from + // those fields, typing the event name or adding a guest inside the wizard does + // not make it close itself. It closes only on Skip / ✕ / Create tables. + @tracked wizardDismissed = + this.hasCanvasContent || this.hasEventInfo || this.guests.length > 0; + get showWizard(): boolean { + return !this.wizardDismissed; + } + skipWizard = () => { + this.wizardDismissed = true; + }; + applyTemplateFromWizard = (index: number) => { + let tpl = this.templates[index]; + if (tpl) this.applyTemplate(tpl); + this.wizardDismissed = true; + }; get tableVMs(): TableVM[] { let rankMap = this.tableRank; return this.tables.map((t) => { @@ -922,6 +952,12 @@ export class TableSeatingPlannerIsolated extends Component< closeTablePopover = () => { this.popoverTableKey = null; }; + // Right-click a fixture to select it (opens its inspector editor). + openFixtureEdit = (id: string, e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.selectOnly(id); + }; popoverDuplicate = () => { this.duplicateTable(); this.closeTablePopover(); @@ -929,7 +965,7 @@ export class TableSeatingPlannerIsolated extends Component< popoverDelete = () => { this.requestDelete( 'Delete this table?', - `“${this.selectedTable?.name || 'Table'}” will be removed and its guests unseated. You can undo afterwards.`, + `“${this.selectedTable?.name || 'Table'}” will be removed and its guests unassigned. You can undo afterwards.`, () => { this.removeTable(); this.closeTablePopover(); @@ -955,7 +991,7 @@ export class TableSeatingPlannerIsolated extends Component< confirmDeleteTable = () => { this.requestDelete( 'Delete this table?', - `“${this.selectedTable?.name || 'Table'}” will be removed and its guests unseated. You can undo afterwards.`, + `“${this.selectedTable?.name || 'Table'}” will be removed and its guests unassigned. You can undo afterwards.`, () => this.removeTable(), ); }; @@ -969,7 +1005,7 @@ export class TableSeatingPlannerIsolated extends Component< confirmDeleteSelected = () => { this.requestDelete( `Delete ${this.selCount} selected?`, - 'The selected tables and elements will be removed and their guests unseated. You can undo afterwards.', + 'The selected tables and elements will be removed and their guests unassigned. You can undo afterwards.', () => this.deleteSelected(), ); }; @@ -1070,7 +1106,7 @@ export class TableSeatingPlannerIsolated extends Component< addTableShape = (shape: string) => { this.addMenuOpen = false; let { x, y } = this.worldCenter(); - let n = this.tables.filter((t) => t.shape === shape).length + 1; + let n = this.tables.length + 1; let w = 150; let h = 150; let seatCount = 8; @@ -1094,7 +1130,7 @@ export class TableSeatingPlannerIsolated extends Component< } let label = TABLE_SHAPE_LABELS[shape] ?? 'Table'; let t = new Table({ - name: `${label} ${n}`, + name: label === 'Table' ? `Table ${n}` : `${label} Table ${n}`, shape, seatCount, seatingStyle: style, @@ -3285,6 +3321,47 @@ export class TableSeatingPlannerIsolated extends Component< this.savingSnapshot = false; } }; + @tracked printOnlyTable: Table | null = null; + @tracked printMode: 'cards' | 'chart' = 'cards'; + get tablesToPrint(): Table[] { + return this.printOnlyTable ? [this.printOnlyTable] : this.tables; + } + private nextFrame(): Promise { + return new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + } + private printWith = async ( + mode: 'cards' | 'chart', + onlyTable: Table | null, + ) => { + this.printMode = mode; + this.printOnlyTable = onlyTable; + await this.nextFrame(); // let the print sheet re-render before printing + window.print(); + this.printOnlyTable = null; + }; + // Print all tables' cards (table card + each guest's place card). + printCards = () => this.printWith('cards', null); + // Print a single table's cards. + printTable = (t: Table) => this.printWith('cards', t); + // Print the whole seating chart as one scaled overview page. + printChart = () => this.printWith('chart', null); + @tracked printMenuOpen = false; + togglePrintMenu = () => { + this.printMenuOpen = !this.printMenuOpen; + }; + closePrintMenu = () => { + this.printMenuOpen = false; + }; + printCardsFromMenu = () => { + this.printMenuOpen = false; + this.printCards(); + }; + printChartFromMenu = () => { + this.printMenuOpen = false; + this.printChart(); + }; private partyChildren(roster: Guest[]): Map { let inRoster = new Set(roster); let rootOf = (g: Guest): Guest => { @@ -3503,7 +3580,10 @@ export class TableSeatingPlannerIsolated extends Component< }; removeHost = (h: Host) => { let hosts = ((this.args.model?.hosts ?? []) as Host[]).filter(Boolean); - this.args.model.hosts = hosts.filter((x) => x !== h); + let hid = (h as any).id; + this.args.model.hosts = hosts.filter((x) => + hid ? (x as any).id !== hid : x !== h, + ); this.showToast(`${h.fullName || 'Host'} removed from hosts`); }; get eventDateInput(): string { @@ -3529,24 +3609,29 @@ export class TableSeatingPlannerIsolated extends Component< e.stopPropagation(); }; removeGuest = (g: Guest) => { - let party = new Set([ - g, - ...(this.partyChildren(this.guests).get(g) ?? []), - ]); + let members = [g, ...(this.partyChildren(this.guests).get(g) ?? [])]; + let party = new Set(members); + let partyIds = new Set( + members.map((m) => (m as any).id).filter(Boolean) as string[], + ); + // Match by id when available (store proxies aren't reference-stable), and + // fall back to identity for not-yet-saved cards. + let inParty = (x: Guest) => + party.has(x) || (!!(x as any).id && partyIds.has((x as any).id)); let roster = [...this.guests]; let seats = this.snapshotSeats(); let apply = () => { for (let t of this.tables) { let arr = (t.seatedGuests ?? []) as Guest[]; - if (!arr.some((x) => party.has(x as Guest))) continue; + if (!arr.some((x) => inParty(x as Guest))) continue; let slots = this.slotsOf(t); let keep = arr .map((x, i) => ({ g: x as Guest, slot: slots[i] })) - .filter((e) => !party.has(e.g)); + .filter((e) => !inParty(e.g)); t.seatedGuests = keep.map((e) => e.g); t.seatSlots = keep.map((e) => e.slot); } - this.args.model.guests = roster.filter((x) => !party.has(x)); + this.args.model.guests = roster.filter((x) => !inParty(x)); }; apply(); this.pushUndo(() => { @@ -3935,11 +4020,10 @@ export class TableSeatingPlannerIsolated extends Component<