import type { Meta, StoryObj } from '@storybook/vue3-vite' import { action } from 'storybook/actions' import { computed, nextTick, ref, watch } from 'vue' import type { ComponentProps } from 'vue-component-type-helpers' import type { CpSeatMapSeatSelection } from '@/constants/seatMap/CpSeatMapSeatSelection' import type { Seat } from '@/constants/seatMap/Seat' import type { SeatMap } from '@/constants/seatMap/SeatMap' import type { SeatMapZone } from '@/constants/seatMap/SeatMapZone' import type { SeatRow } from '@/constants/seatMap/SeatRow' import type { SeatRowFacilityFlags } from '@/constants/seatMap/SeatRowFacilityFlags' import { useIsBreakpoint } from '@/composables/useIsBreakpoint' import CpSeatMap from '@/components/CpSeatMap.vue' import { IataTravelerTypes } from '@/constants/seatMap/IataTravelerTypes' import { SeatStatuses } from '@/constants/seatMap/SeatStatuses' import { displaySeat } from '@/helpers/seatMap' const AIRCRAFT_LABELS = { 'atr-72': 'ATR 72 — 2+2', a320: 'A320 — 3+3', b777: 'B777 — 3+4+3', } as const type AircraftId = keyof typeof AIRCRAFT_LABELS /** * `aircraft` is a story-only knob: the component has no such prop, the render * function turns it into the matching `seatMap`, which is therefore hidden. */ type CpSeatMapArgs = Omit, 'seatMap'> & { aircraft: AircraftId seatMap?: SeatMap } const meta = { title: 'Business/CpSeatMap', component: CpSeatMap, parameters: { layout: 'padded', }, args: { aircraft: 'a320', }, argTypes: { aircraft: { control: { type: 'select', labels: AIRCRAFT_LABELS }, options: Object.keys(AIRCRAFT_LABELS), description: 'Story-only: picks the seat map handed to the component.', }, seatMap: { control: false, table: { disable: true } }, }, // The plane wings sit 180px outside the cabin, so the story needs side room. The // grey backdrop is what makes the white fuselage and wings readable; mobile hides // the plane altogether, so it goes back to a plain background there. decorators: [ () => ({ setup() { const isMobile = useIsBreakpoint() const backdropStyle = computed(() => ({ display: 'flex', margin: '0 auto', justifyContent: 'center', padding: '40px', borderRadius: '8px', backgroundColor: isMobile.value ? 'transparent' : 'var(--cp-background-tertiary)', overflow: 'hidden', })) return { backdropStyle } }, template: '
', }), ], } satisfies Meta export default meta type Story = StoryObj const EXTRA_LEGROOM = { amount: 12, color: '#f5a623', data_id: 1, eligible_traveler_ids: [], formatted_amount: '€12', label: 'Extra legroom', } let seatId = 0 const makeSeat = (column: string, status: SeatStatuses, isExitRow: boolean): Seat => { seatId += 1 return { characteristics: [], column, eligible_ancillaries: [], has_specific_message: false, id: seatId, is_exit_row: isExitRow, occupied_traveler_id: status === SeatStatuses.OCCUPIED ? seatId : 0, status, type: 'seat', ...(status === SeatStatuses.PAYABLE ? { selected_ancillary: EXTRA_LEGROOM } : {}), ...(status === SeatStatuses.BLOCKED ? { blocking_code: 'CREW' } : {}), } } interface CabinFacilities { bottom?: SeatRowFacilityFlags top?: SeatRowFacilityFlags } interface CabinInput { columns: string[] exitRows?: number[] /** Row number -> galleys and lavatories sitting above or below that row. */ facilities?: Record id: string /** `null` marks an aisle. */ layout: (string | null)[] /** Share of seats already taken, scattered deterministically. */ occupancy?: number rowNumbers: number[] /** Row number -> column -> status, overriding everything else. */ statuses?: Record> title: string } /** * Deterministic scatter so a story always renders the same cabin — Math.random * would reshuffle the map on every reload and make screenshots useless. */ const seatHash = (row: number, column: string) => (row * 31 + column.charCodeAt(0) * 17) % 100 const resolveStatus = (row: number, column: string, isExit: boolean, occupancy: number) => { // Exit rows sell as extra legroom. if (isExit) return SeatStatuses.PAYABLE const hash = seatHash(row, column) if (hash < occupancy * 100) return SeatStatuses.OCCUPIED if (hash > 97) return SeatStatuses.BLOCKED return SeatStatuses.FREE } const makeCabinRows = ({ columns, id, rowNumbers, exitRows = [], facilities = {}, occupancy = 0, statuses = {}, }: CabinInput): SeatRow[] => { return rowNumbers.map((number) => { const isExit = exitRows.includes(number) const rowStatuses = statuses[number] ?? {} const rowFacilities = facilities[number] ?? {} const seats = columns.reduce>((accumulator, column) => { const status = rowStatuses[column] ?? resolveStatus(number, column, isExit, occupancy) accumulator[column] = makeSeat(column, status, isExit) return accumulator }, {}) return { has_exit_back: false, has_exit_front: false, has_exit_left: isExit, has_exit_right: isExit, is_exit: isExit, number, seats, zone: id, ...(rowFacilities.top ? { top: rowFacilities.top } : {}), ...(rowFacilities.bottom ? { bottom: rowFacilities.bottom } : {}), } }) } const makeSeatMap = (aircraft: string, cabins: CabinInput[]): SeatMap => { const zones = cabins.reduce>((accumulator, cabin) => { accumulator[cabin.id] = { layout: cabin.layout, prbds: [], title: cabin.title } return accumulator }, {}) const rows = cabins.flatMap(makeCabinRows) return { aircraft, ref_id_segment: `segment-${aircraft.toLowerCase()}`, exit_row_warning_child_max_age: 12, zones, rows, } } const rowRange = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => from + index) /** Seats the stories hand back as selected, kept free so they read as assignable. */ const FREE_FOR_SELECTION = { B: SeatStatuses.FREE, C: SeatStatuses.FREE } /** * ATR 72-600: 72 seats in a single 2+2 cabin, forward galley, rear lavatory, * overwing exits at row 12. */ const atr72 = makeSeatMap('ATR 72', [ { id: 'cabin', title: 'Cabin', layout: ['A', 'B', null, 'C', 'D'], columns: ['A', 'B', 'C', 'D'], rowNumbers: rowRange(1, 18), exitRows: [12], occupancy: 0.34, statuses: { 7: FREE_FOR_SELECTION, 8: FREE_FOR_SELECTION }, facilities: { 1: { top: { galley_left: true } }, 18: { bottom: { lavatory_right: true } }, }, }, ]) /** * A320: 2+2 business over three rows, then 3+3 economy to row 32. Forward galley * and lavatory between the cabins, an overwing exit row, two lavatories aft. */ const a320 = makeSeatMap('A320', [ { id: 'business', title: 'Business', layout: ['A', 'B', null, 'C', 'D'], columns: ['A', 'B', 'C', 'D'], rowNumbers: rowRange(1, 3), occupancy: 0.4, }, { id: 'economy', title: 'Economy', layout: ['A', 'B', 'C', null, 'D', 'E', 'F'], columns: ['A', 'B', 'C', 'D', 'E', 'F'], rowNumbers: rowRange(7, 32), exitRows: [11], occupancy: 0.38, statuses: { 7: FREE_FOR_SELECTION, 8: FREE_FOR_SELECTION }, facilities: { 7: { top: { galley_left: true, lavatory_right: true } }, 32: { bottom: { lavatory_left: true, lavatory_right: true } }, }, }, ]) /** * B777-300ER: 2+2+2 business to row 8, then 3+4+3 economy from row 20 to 48. * Ten seats across trips the component's large-plane treatment, which narrows * seats and aisles. Mid-cabin lavatories sit ahead of the second exit row. * No `I` column — aviation skips it to avoid reading it as a 1. */ const b777 = makeSeatMap('B777', [ { id: 'business', title: 'Business', layout: ['A', 'B', null, 'C', 'D', null, 'E', 'F'], columns: ['A', 'B', 'C', 'D', 'E', 'F'], rowNumbers: rowRange(1, 8), occupancy: 0.45, statuses: { 7: FREE_FOR_SELECTION, 8: FREE_FOR_SELECTION }, facilities: { 1: { top: { galley_left: true, galley_right: true } }, 8: { bottom: { lavatory_left: true, lavatory_right: true } }, }, }, { id: 'economy', title: 'Economy', layout: ['A', 'B', 'C', null, 'D', 'E', 'F', 'G', null, 'H', 'J', 'K'], columns: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K'], rowNumbers: rowRange(20, 48), exitRows: [20, 35], occupancy: 0.36, facilities: { 34: { bottom: { lavatory_left: true, lavatory_right: true } }, 48: { bottom: { galley_left: true, lavatory_right: true } }, }, }, ]) const AIRCRAFT: Record = { 'atr-72': atr72, a320, b777, } const selection = ( row: number, column: string, value: CpSeatMapSeatSelection, ): Record => ({ [displaySeat({ row, column })]: value, }) /** * The `aircraft` control picks the map, so one aircraft can be compared against * another from the controls panel. * * `isInteractive` wires the click back into `selections`, which is what a consumer * has to do: the map only reports the seat, it never remembers it. */ const withAircraftControl = (isInteractive = false) => (args: CpSeatMapArgs) => ({ components: { CpSeatMap }, setup() { const seatMap = computed(() => AIRCRAFT[args.aircraft]) const ownSelections = ref>({}) const selections = computed(() => (isInteractive ? ownSelections.value : args.selections)) // `aircraft` isn't a component prop, so it's kept off `v-bind`. const componentArgs = computed(() => { const { aircraft, seatMap: _, ...rest } = args return rest }) // A seat key from one aircraft rarely exists on the next one. watch( () => args.aircraft, () => { ownSelections.value = {} }, ) const handleSelect = (seat: Seat, rowNumber: number) => { action('select')(seat, rowNumber) if (!isInteractive) return const key = displaySeat({ row: rowNumber, column: seat.column }) // Clicking the seat again frees it; clicking another moves the traveler over. ownSelections.value = key in ownSelections.value ? {} : { [key]: { initials: 'RN' } } } return { componentArgs, seatMap, selections, handleSelect } }, template: ` `, }) /** * Nothing assigned yet, and the story does the seating itself: click a free seat * to take it, click it again to give it up, click another to move over. Occupied, * blocked and paid-for seats read from the map. */ export const Default: Story = { render: withAircraftControl(true), } interface StoryTraveler { id: number initials: string name: string /** Seat label such as `7B`, `null` while the traveler has no seat. */ seat: null | string } /** * Rebuilt on demand: switching aircraft has to start the seating over, and `7B` * and `8C` are the two seats every map in this file keeps free. */ const makeTravelers = (): StoryTraveler[] => [ { id: 1, initials: 'JD', name: 'Jane Doe', seat: '7B' }, { id: 2, initials: 'AM', name: 'Alex Moore', seat: '8C' }, { id: 3, initials: 'SR', name: 'Sam Reed', seat: null }, ] const travelerSelections = (travelers: StoryTraveler[]) => { return travelers.reduce>((accumulator, traveler) => { if (traveler.seat) accumulator[traveler.seat] = { initials: traveler.initials } return accumulator }, {}) } const getLayoutStyle = (isMobile: boolean) => ({ display: 'flex', width: '100%', justifyContent: 'center', // The panel floats over the bottom of the screen, the last rows would sit under it. paddingBottom: isMobile ? '180px' : '0', }) /** * Fixed on both sizes: it follows the scroll and stays out of the flow, so the plane * keeps the middle of the story. Mobile pins it to the bottom of the screen, desktop * to the right-hand side. */ const getPanelStyle = (isMobile: boolean) => ({ position: 'fixed', zIndex: 2, ...(isMobile ? { right: 'var(--cp-spacing-lg)', bottom: 'var(--cp-spacing-lg)', left: 'var(--cp-spacing-lg)' } : { top: 'var(--cp-spacing-2xl)', right: 'var(--cp-spacing-2xl)', width: '232px' }), display: 'flex', flexDirection: 'column', gap: 'var(--cp-spacing-xs)', padding: isMobile ? 'var(--cp-spacing-md)' : 'var(--cp-spacing-lg)', border: '1px solid var(--cp-border-soft)', borderRadius: 'var(--cp-radius-lg)', backgroundColor: 'var(--cp-background-primary)', boxShadow: 'var(--cp-shadows-overlay)', }) /** * The seating, the active traveler and the seat to highlight all live in the * story: the map reports a click and renders `selections` and `highlightedSeatId`, * it remembers nothing. `isSelectionEnabled` follows the active traveler, so the * seats stay disabled — and tooltipped "Select a traveler first" — until one is * picked. */ const withTravelerPanel = (args: CpSeatMapArgs) => ({ components: { CpSeatMap }, setup() { const seatMap = computed(() => AIRCRAFT[args.aircraft]) const isMobile = useIsBreakpoint() const travelers = ref(makeTravelers()) const activeTravelerId = ref(null) const highlightedSeatId = ref(null) const componentArgs = computed(() => { const { aircraft, seatMap: _, ...rest } = args return rest }) const selections = computed(() => travelerSelections(travelers.value)) const activeTraveler = computed(() => { return travelers.value.find((traveler) => traveler.id === activeTravelerId.value) ?? null }) // The panel works in seat labels, `highlightedSeatId` expects the seat id. const seatIds = computed(() => { const entries = seatMap.value.rows.flatMap((row) => Object.values(row.seats).map( (seat) => [displaySeat({ row: row.number, column: seat.column }), seat.id] as const, ), ) return new Map(entries) }) watch( () => args.aircraft, () => { travelers.value = makeTravelers() activeTravelerId.value = null highlightedSeatId.value = null }, ) /** The map only plays the highlight when the seat flips to highlighted, hence the reset. */ const highlight = async (seatLabel: null | string) => { highlightedSeatId.value = null await nextTick() highlightedSeatId.value = seatLabel ? (seatIds.value.get(seatLabel) ?? null) : null } const handleSelectTraveler = (traveler: StoryTraveler) => { activeTravelerId.value = traveler.id highlight(traveler.seat) } const handleSelect = (seat: Seat, rowNumber: number) => { action('select')(seat, rowNumber) const traveler = activeTraveler.value if (!traveler) return const seatLabel = displaySeat({ row: rowNumber, column: seat.column }) const occupant = travelers.value.find((other) => other.seat === seatLabel) // Their own seat frees it, a seat held by another traveler swaps the two. if (occupant === traveler) { traveler.seat = null } else { if (occupant) occupant.seat = traveler.seat traveler.seat = seatLabel } highlight(traveler.seat) } const hint = computed(() => { if (!activeTraveler.value) return 'Select a traveler to assign a seat.' return `Pick a seat for ${activeTraveler.value.name}.` }) const travelerStyle = (traveler: StoryTraveler) => { const isActive = traveler.id === activeTravelerId.value return { display: 'flex', width: '100%', alignItems: 'center', gap: 'var(--cp-spacing-sm)', padding: 'var(--cp-spacing-sm)', border: `1px solid ${isActive ? 'var(--cp-border-accent-primary)' : 'transparent'}`, borderRadius: 'var(--cp-radius-md)', backgroundColor: isActive ? 'var(--cp-background-accent-primary)' : 'transparent', cursor: 'pointer', textAlign: 'left', } } const initialsStyle = (traveler: StoryTraveler) => ({ display: 'flex', width: '28px', height: '28px', flex: '0 0 auto', alignItems: 'center', justifyContent: 'center', borderRadius: 'var(--cp-radius-full)', backgroundColor: traveler.seat ? 'var(--cp-background-accent-solid)' : 'var(--cp-background-secondary)', color: traveler.seat ? 'var(--cp-text-white)' : 'var(--cp-text-tertiary)', fontSize: '11px', fontWeight: '700', }) const seatStyle = (traveler: StoryTraveler) => ({ marginLeft: 'auto', color: traveler.seat ? 'var(--cp-text-primary)' : 'var(--cp-text-tertiary)', fontWeight: '600', }) const layoutStyle = computed(() => getLayoutStyle(isMobile.value)) const panelStyle = computed(() => getPanelStyle(isMobile.value)) return { activeTravelerId, componentArgs, handleSelect, handleSelectTraveler, highlightedSeatId, hint, initialsStyle, isMobile, layoutStyle, panelStyle, seatMap, seatStyle, selections, travelerStyle, travelers, } }, template: `
`, }) /** * Three travelers, two of them already seated, listed in a panel that leaves the * plane centered and follows the scroll — pinned to the right of the screen on * desktop, to the bottom of it on mobile. Clicking one makes it the * active traveler, scrolls its seat into view and highlights it; clicking a seat then * moves that traveler over, clicking its own seat frees it, and clicking a seat * another traveler holds swaps the two. */ export const WithSelection: Story = { args: { onSelect: action('select'), }, render: withTravelerPanel, } /** * One of the seated travelers carries a lap infant: `hasLinkedTraveler` shows the * badge and `linkedTravelerType` picks its icon. */ export const WithLinkedInfant: Story = { args: { selections: { ...selection(7, 'B', { initials: 'JD', hasLinkedTraveler: true, linkedTravelerType: IataTravelerTypes.INFANT, }), ...selection(8, 'C', { initials: 'AM' }), }, onSelect: action('select'), }, render: withAircraftControl(), }