@citizenplane/pimp 18.10.2 → 18.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/components/CpSeatMap.vue.d.ts +29 -0
  2. package/dist/components/CpSeatMap.vue.d.ts.map +1 -0
  3. package/dist/components/CpSeatMapEmergencyExitDialog.vue.d.ts +21 -0
  4. package/dist/components/CpSeatMapEmergencyExitDialog.vue.d.ts.map +1 -0
  5. package/dist/components/CpSeatMapGrid.vue.d.ts +17 -0
  6. package/dist/components/CpSeatMapGrid.vue.d.ts.map +1 -0
  7. package/dist/components/CpSeatMapPlane.vue.d.ts +24 -0
  8. package/dist/components/CpSeatMapPlane.vue.d.ts.map +1 -0
  9. package/dist/components/CpSeatMapRow.vue.d.ts +22 -0
  10. package/dist/components/CpSeatMapRow.vue.d.ts.map +1 -0
  11. package/dist/components/CpSeatMapRowFacility.vue.d.ts +16 -0
  12. package/dist/components/CpSeatMapRowFacility.vue.d.ts.map +1 -0
  13. package/dist/components/CpSeatMapSeat.vue.d.ts +21 -0
  14. package/dist/components/CpSeatMapSeat.vue.d.ts.map +1 -0
  15. package/dist/components/CpSeatMapSeatWrapper.vue.d.ts +23 -0
  16. package/dist/components/CpSeatMapSeatWrapper.vue.d.ts.map +1 -0
  17. package/dist/components/CpSeatMapZone.vue.d.ts +16 -0
  18. package/dist/components/CpSeatMapZone.vue.d.ts.map +1 -0
  19. package/dist/components/CpTabs.vue.d.ts.map +1 -1
  20. package/dist/components/icons/IconPlaneNose.vue.d.ts +3 -0
  21. package/dist/components/icons/IconPlaneNose.vue.d.ts.map +1 -0
  22. package/dist/components/icons/IconPlaneWing.vue.d.ts +3 -0
  23. package/dist/components/icons/IconPlaneWing.vue.d.ts.map +1 -0
  24. package/dist/components/icons/IconSparkle.vue.d.ts +3 -0
  25. package/dist/components/icons/IconSparkle.vue.d.ts.map +1 -0
  26. package/dist/components/index.d.ts +14 -1
  27. package/dist/components/index.d.ts.map +1 -1
  28. package/dist/constants/seatMap/CpSeatMapConfig.d.ts +22 -0
  29. package/dist/constants/seatMap/CpSeatMapConfig.d.ts.map +1 -0
  30. package/dist/constants/seatMap/CpSeatMapSeatSelection.d.ts +12 -0
  31. package/dist/constants/seatMap/CpSeatMapSeatSelection.d.ts.map +1 -0
  32. package/dist/constants/seatMap/CpSeatMapTransitionDirections.d.ts +5 -0
  33. package/dist/constants/seatMap/CpSeatMapTransitionDirections.d.ts.map +1 -0
  34. package/dist/constants/seatMap/IataTravelerTypes.d.ts +7 -0
  35. package/dist/constants/seatMap/IataTravelerTypes.d.ts.map +1 -0
  36. package/dist/constants/seatMap/Seat.d.ts +18 -0
  37. package/dist/constants/seatMap/Seat.d.ts.map +1 -0
  38. package/dist/constants/seatMap/SeatAncillary.d.ts +9 -0
  39. package/dist/constants/seatMap/SeatAncillary.d.ts.map +1 -0
  40. package/dist/constants/seatMap/SeatCharacteristic.d.ts +7 -0
  41. package/dist/constants/seatMap/SeatCharacteristic.d.ts.map +1 -0
  42. package/dist/constants/seatMap/SeatCharacteristicCodes.d.ts +10 -0
  43. package/dist/constants/seatMap/SeatCharacteristicCodes.d.ts.map +1 -0
  44. package/dist/constants/seatMap/SeatMap.d.ts +10 -0
  45. package/dist/constants/seatMap/SeatMap.d.ts.map +1 -0
  46. package/dist/constants/seatMap/SeatMapZone.d.ts +6 -0
  47. package/dist/constants/seatMap/SeatMapZone.d.ts.map +1 -0
  48. package/dist/constants/seatMap/SeatRow.d.ts +15 -0
  49. package/dist/constants/seatMap/SeatRow.d.ts.map +1 -0
  50. package/dist/constants/seatMap/SeatRowFacilities.d.ts +10 -0
  51. package/dist/constants/seatMap/SeatRowFacilities.d.ts.map +1 -0
  52. package/dist/constants/seatMap/SeatRowFacilityFlags.d.ts +8 -0
  53. package/dist/constants/seatMap/SeatRowFacilityFlags.d.ts.map +1 -0
  54. package/dist/constants/seatMap/SeatStatuses.d.ts +8 -0
  55. package/dist/constants/seatMap/SeatStatuses.d.ts.map +1 -0
  56. package/dist/constants/seatMap/index.d.ts +16 -0
  57. package/dist/constants/seatMap/index.d.ts.map +1 -0
  58. package/dist/helpers/seatMap.d.ts +28 -0
  59. package/dist/helpers/seatMap.d.ts.map +1 -0
  60. package/dist/pimp.es.js +4633 -4130
  61. package/dist/pimp.umd.js +40 -40
  62. package/dist/style.css +1 -1
  63. package/package.json +1 -1
  64. package/src/components/CpSeatMap.vue +226 -0
  65. package/src/components/CpSeatMapEmergencyExitDialog.vue +87 -0
  66. package/src/components/CpSeatMapGrid.vue +41 -0
  67. package/src/components/CpSeatMapPlane.vue +221 -0
  68. package/src/components/CpSeatMapRow.vue +183 -0
  69. package/src/components/CpSeatMapRowFacility.vue +88 -0
  70. package/src/components/CpSeatMapSeat.vue +138 -0
  71. package/src/components/CpSeatMapSeatWrapper.vue +189 -0
  72. package/src/components/CpSeatMapZone.vue +60 -0
  73. package/src/components/CpTabs.vue +25 -15
  74. package/src/components/icons/IconPlaneNose.vue +39 -0
  75. package/src/components/icons/IconPlaneWing.vue +11 -0
  76. package/src/components/icons/IconSparkle.vue +8 -0
  77. package/src/components/index.ts +51 -0
  78. package/src/constants/seatMap/CpSeatMapConfig.ts +47 -0
  79. package/src/constants/seatMap/CpSeatMapSeatSelection.ts +12 -0
  80. package/src/constants/seatMap/CpSeatMapTransitionDirections.ts +4 -0
  81. package/src/constants/seatMap/IataTravelerTypes.ts +6 -0
  82. package/src/constants/seatMap/Seat.ts +18 -0
  83. package/src/constants/seatMap/SeatAncillary.ts +8 -0
  84. package/src/constants/seatMap/SeatCharacteristic.ts +7 -0
  85. package/src/constants/seatMap/SeatCharacteristicCodes.ts +9 -0
  86. package/src/constants/seatMap/SeatMap.ts +10 -0
  87. package/src/constants/seatMap/SeatMapZone.ts +5 -0
  88. package/src/constants/seatMap/SeatRow.ts +15 -0
  89. package/src/constants/seatMap/SeatRowFacilities.ts +10 -0
  90. package/src/constants/seatMap/SeatRowFacilityFlags.ts +7 -0
  91. package/src/constants/seatMap/SeatStatuses.ts +7 -0
  92. package/src/constants/seatMap/index.ts +16 -0
  93. package/src/helpers/seatMap.ts +76 -0
  94. package/src/stories/CpSeatMap.stories.ts +357 -0
@@ -0,0 +1,357 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { action } from 'storybook/actions'
3
+ import { computed, ref, watch } from 'vue'
4
+
5
+ import type { CpSeatMapSeatSelection } from '@/constants/seatMap/CpSeatMapSeatSelection'
6
+ import type { Seat } from '@/constants/seatMap/Seat'
7
+ import type { SeatMap } from '@/constants/seatMap/SeatMap'
8
+ import type { SeatMapZone } from '@/constants/seatMap/SeatMapZone'
9
+ import type { SeatRow } from '@/constants/seatMap/SeatRow'
10
+ import type { SeatRowFacilityFlags } from '@/constants/seatMap/SeatRowFacilityFlags'
11
+
12
+ import CpSeatMap from '@/components/CpSeatMap.vue'
13
+ import CpSelect from '@/components/CpSelect.vue'
14
+
15
+ import { IataTravelerTypes } from '@/constants/seatMap/IataTravelerTypes'
16
+ import { SeatStatuses } from '@/constants/seatMap/SeatStatuses'
17
+ import { displaySeat } from '@/helpers/seatMap'
18
+
19
+ const meta = {
20
+ title: 'Business/CpSeatMap',
21
+ component: CpSeatMap,
22
+ parameters: {
23
+ layout: 'padded',
24
+ },
25
+ // The plane wings sit 180px outside the cabin, so the story needs side room.
26
+ decorators: [
27
+ () => ({
28
+ template:
29
+ '<div style="margin: 0 auto; padding: 40px; border-radius: 8px; background-color: #fff;"><story/></div>',
30
+ }),
31
+ ],
32
+ } satisfies Meta<typeof CpSeatMap>
33
+
34
+ export default meta
35
+
36
+ type Story = StoryObj<typeof meta>
37
+
38
+ const EXTRA_LEGROOM = {
39
+ amount: 12,
40
+ color: '#f5a623',
41
+ data_id: 1,
42
+ eligible_traveler_ids: [],
43
+ formatted_amount: '€12',
44
+ label: 'Extra legroom',
45
+ }
46
+
47
+ let seatId = 0
48
+
49
+ const makeSeat = (column: string, status: SeatStatuses, isExitRow: boolean): Seat => {
50
+ seatId += 1
51
+
52
+ return {
53
+ characteristics: [],
54
+ column,
55
+ eligible_ancillaries: [],
56
+ has_specific_message: false,
57
+ id: seatId,
58
+ is_exit_row: isExitRow,
59
+ occupied_traveler_id: status === SeatStatuses.OCCUPIED ? seatId : 0,
60
+ status,
61
+ type: 'seat',
62
+ ...(status === SeatStatuses.PAYABLE ? { selected_ancillary: EXTRA_LEGROOM } : {}),
63
+ ...(status === SeatStatuses.BLOCKED ? { blocking_code: 'CREW' } : {}),
64
+ }
65
+ }
66
+
67
+ interface CabinFacilities {
68
+ bottom?: SeatRowFacilityFlags
69
+ top?: SeatRowFacilityFlags
70
+ }
71
+
72
+ interface CabinInput {
73
+ columns: string[]
74
+ exitRows?: number[]
75
+ /** Row number -> galleys and lavatories sitting above or below that row. */
76
+ facilities?: Record<number, CabinFacilities>
77
+ id: string
78
+ /** `null` marks an aisle. */
79
+ layout: (string | null)[]
80
+ /** Share of seats already taken, scattered deterministically. */
81
+ occupancy?: number
82
+ rowNumbers: number[]
83
+ /** Row number -> column -> status, overriding everything else. */
84
+ statuses?: Record<number, Record<string, SeatStatuses>>
85
+ title: string
86
+ }
87
+
88
+ /**
89
+ * Deterministic scatter so a story always renders the same cabin — Math.random
90
+ * would reshuffle the map on every reload and make screenshots useless.
91
+ */
92
+ const seatHash = (row: number, column: string) => (row * 31 + column.charCodeAt(0) * 17) % 100
93
+
94
+ const resolveStatus = (row: number, column: string, isExit: boolean, occupancy: number) => {
95
+ // Exit rows sell as extra legroom.
96
+ if (isExit) return SeatStatuses.PAYABLE
97
+
98
+ const hash = seatHash(row, column)
99
+ if (hash < occupancy * 100) return SeatStatuses.OCCUPIED
100
+ if (hash > 97) return SeatStatuses.BLOCKED
101
+
102
+ return SeatStatuses.FREE
103
+ }
104
+
105
+ const makeCabinRows = ({
106
+ columns,
107
+ id,
108
+ rowNumbers,
109
+ exitRows = [],
110
+ facilities = {},
111
+ occupancy = 0,
112
+ statuses = {},
113
+ }: CabinInput): SeatRow[] => {
114
+ return rowNumbers.map((number) => {
115
+ const isExit = exitRows.includes(number)
116
+ const rowStatuses = statuses[number] ?? {}
117
+ const rowFacilities = facilities[number] ?? {}
118
+
119
+ const seats = columns.reduce<Record<string, Seat>>((accumulator, column) => {
120
+ const status = rowStatuses[column] ?? resolveStatus(number, column, isExit, occupancy)
121
+ accumulator[column] = makeSeat(column, status, isExit)
122
+ return accumulator
123
+ }, {})
124
+
125
+ return {
126
+ has_exit_back: false,
127
+ has_exit_front: false,
128
+ has_exit_left: isExit,
129
+ has_exit_right: isExit,
130
+ is_exit: isExit,
131
+ number,
132
+ seats,
133
+ zone: id,
134
+ ...(rowFacilities.top ? { top: rowFacilities.top } : {}),
135
+ ...(rowFacilities.bottom ? { bottom: rowFacilities.bottom } : {}),
136
+ }
137
+ })
138
+ }
139
+
140
+ const makeSeatMap = (aircraft: string, cabins: CabinInput[]): SeatMap => {
141
+ const zones = cabins.reduce<Record<string, SeatMapZone>>((accumulator, cabin) => {
142
+ accumulator[cabin.id] = { layout: cabin.layout, prbds: [], title: cabin.title }
143
+ return accumulator
144
+ }, {})
145
+
146
+ const rows = cabins.flatMap(makeCabinRows)
147
+
148
+ return {
149
+ aircraft,
150
+ ref_id_segment: `segment-${aircraft.toLowerCase()}`,
151
+ exit_row_warning_child_max_age: 12,
152
+ zones,
153
+ rows,
154
+ }
155
+ }
156
+
157
+ const rowRange = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => from + index)
158
+
159
+ /** Seats the stories hand back as selected, kept free so they read as assignable. */
160
+ const FREE_FOR_SELECTION = { B: SeatStatuses.FREE, C: SeatStatuses.FREE }
161
+
162
+ /**
163
+ * ATR 72-600: 72 seats in a single 2+2 cabin, forward galley, rear lavatory,
164
+ * overwing exits at row 12.
165
+ */
166
+ const atr72 = makeSeatMap('ATR 72', [
167
+ {
168
+ id: 'cabin',
169
+ title: 'Cabin',
170
+ layout: ['A', 'B', null, 'C', 'D'],
171
+ columns: ['A', 'B', 'C', 'D'],
172
+ rowNumbers: rowRange(1, 18),
173
+ exitRows: [12],
174
+ occupancy: 0.34,
175
+ statuses: { 7: FREE_FOR_SELECTION, 8: FREE_FOR_SELECTION },
176
+ facilities: {
177
+ 1: { top: { galley_left: true } },
178
+ 18: { bottom: { lavatory_right: true } },
179
+ },
180
+ },
181
+ ])
182
+
183
+ /**
184
+ * A320: 2+2 business over three rows, then 3+3 economy to row 32. Forward galley
185
+ * and lavatory between the cabins, an overwing exit row, two lavatories aft.
186
+ */
187
+ const a320 = makeSeatMap('A320', [
188
+ {
189
+ id: 'business',
190
+ title: 'Business',
191
+ layout: ['A', 'B', null, 'C', 'D'],
192
+ columns: ['A', 'B', 'C', 'D'],
193
+ rowNumbers: rowRange(1, 3),
194
+ occupancy: 0.4,
195
+ },
196
+ {
197
+ id: 'economy',
198
+ title: 'Economy',
199
+ layout: ['A', 'B', 'C', null, 'D', 'E', 'F'],
200
+ columns: ['A', 'B', 'C', 'D', 'E', 'F'],
201
+ rowNumbers: rowRange(7, 32),
202
+ exitRows: [11],
203
+ occupancy: 0.38,
204
+ statuses: { 7: FREE_FOR_SELECTION, 8: FREE_FOR_SELECTION },
205
+ facilities: {
206
+ 7: { top: { galley_left: true, lavatory_right: true } },
207
+ 32: { bottom: { lavatory_left: true, lavatory_right: true } },
208
+ },
209
+ },
210
+ ])
211
+
212
+ /**
213
+ * B777-300ER: 2+2+2 business to row 8, then 3+4+3 economy from row 20 to 48.
214
+ * Ten seats across trips the component's large-plane treatment, which narrows
215
+ * seats and aisles. Mid-cabin lavatories sit ahead of the second exit row.
216
+ * No `I` column — aviation skips it to avoid reading it as a 1.
217
+ */
218
+ const b777 = makeSeatMap('B777', [
219
+ {
220
+ id: 'business',
221
+ title: 'Business',
222
+ layout: ['A', 'B', null, 'C', 'D', null, 'E', 'F'],
223
+ columns: ['A', 'B', 'C', 'D', 'E', 'F'],
224
+ rowNumbers: rowRange(1, 8),
225
+ occupancy: 0.45,
226
+ statuses: { 7: FREE_FOR_SELECTION, 8: FREE_FOR_SELECTION },
227
+ facilities: {
228
+ 1: { top: { galley_left: true, galley_right: true } },
229
+ 8: { bottom: { lavatory_left: true, lavatory_right: true } },
230
+ },
231
+ },
232
+ {
233
+ id: 'economy',
234
+ title: 'Economy',
235
+ layout: ['A', 'B', 'C', null, 'D', 'E', 'F', 'G', null, 'H', 'J', 'K'],
236
+ columns: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K'],
237
+ rowNumbers: rowRange(20, 48),
238
+ exitRows: [20, 35],
239
+ occupancy: 0.36,
240
+ facilities: {
241
+ 34: { bottom: { lavatory_left: true, lavatory_right: true } },
242
+ 48: { bottom: { galley_left: true, lavatory_right: true } },
243
+ },
244
+ },
245
+ ])
246
+
247
+ const AIRCRAFT: Record<string, SeatMap> = {
248
+ 'atr-72': atr72,
249
+ a320,
250
+ b777,
251
+ }
252
+
253
+ const AIRCRAFT_OPTIONS = [
254
+ { label: 'ATR 72 — 2+2', value: 'atr-72' },
255
+ { label: 'A320 — 3+3', value: 'a320' },
256
+ { label: 'B777 — 3+4+3', value: 'b777' },
257
+ ]
258
+
259
+ const selection = (
260
+ row: number,
261
+ column: string,
262
+ value: CpSeatMapSeatSelection,
263
+ ): Record<string, CpSeatMapSeatSelection> => ({
264
+ [displaySeat({ row, column })]: value,
265
+ })
266
+
267
+ /**
268
+ * Every story picks its aircraft, so one map can be compared against another.
269
+ *
270
+ * `isInteractive` wires the click back into `selections`, which is what a consumer
271
+ * has to do: the map only reports the seat, it never remembers it.
272
+ */
273
+ const withAircraftPicker =
274
+ (isInteractive = false) =>
275
+ (args: NonNullable<Story['args']>) => ({
276
+ components: { CpSeatMap, CpSelect },
277
+ setup() {
278
+ const aircraft = ref('a320')
279
+ const seatMap = computed(() => AIRCRAFT[aircraft.value] as SeatMap)
280
+
281
+ const ownSelections = ref<Record<string, CpSeatMapSeatSelection>>({})
282
+ const selections = computed(() => (isInteractive ? ownSelections.value : args.selections))
283
+
284
+ // A seat key from one aircraft rarely exists on the next one.
285
+ watch(aircraft, () => {
286
+ ownSelections.value = {}
287
+ })
288
+
289
+ const handleSelect = (seat: Seat, rowNumber: number) => {
290
+ action('select')(seat, rowNumber)
291
+ if (!isInteractive) return
292
+
293
+ const key = displaySeat({ row: rowNumber, column: seat.column })
294
+ // Clicking the seat again frees it; clicking another moves the traveler over.
295
+ ownSelections.value = key in ownSelections.value ? {} : { [key]: { initials: 'RN' } }
296
+ }
297
+
298
+ return { args, aircraft, seatMap, selections, handleSelect, aircraftOptions: AIRCRAFT_OPTIONS }
299
+ },
300
+ // The plane nose bleeds upwards out of the cabin, so the picker is lifted above it.
301
+ template: `
302
+ <div style="display: flex; flex-direction: column; gap: 24px; width:fit-content; margin: 0 auto;">
303
+ <div style="position: absolute; top: 24px; left: 24px; z-index: 1; max-width: 280px; background-color: #fff;">
304
+ <cp-select v-model="aircraft" hide-default-value label="Aircraft" :options="aircraftOptions" />
305
+ </div>
306
+ <cp-seat-map v-bind="args" :seat-map="seatMap" :selections="selections" @select="handleSelect" />
307
+ </div>
308
+ `,
309
+ })
310
+
311
+ /**
312
+ * Nothing assigned yet, and the story does the seating itself: click a free seat
313
+ * to take it, click it again to give it up, click another to move over. Occupied,
314
+ * blocked and paid-for seats read from the map.
315
+ */
316
+ export const Default: Story = {
317
+ args: {
318
+ seatMap: a320,
319
+ },
320
+ render: withAircraftPicker(true),
321
+ }
322
+
323
+ /**
324
+ * The consumer has seated two travelers and hands their initials back through
325
+ * `selections`, keyed by seat (`7B`).
326
+ */
327
+ export const WithSelection: Story = {
328
+ args: {
329
+ seatMap: a320,
330
+ selections: {
331
+ ...selection(7, 'B', { initials: 'JD' }),
332
+ ...selection(8, 'C', { initials: 'AM' }),
333
+ },
334
+ onSelect: action('select'),
335
+ },
336
+ render: withAircraftPicker(),
337
+ }
338
+
339
+ /**
340
+ * One of the seated travelers carries a lap infant: `hasLinkedTraveler` shows the
341
+ * badge and `linkedTravelerType` picks its icon.
342
+ */
343
+ export const WithLinkedInfant: Story = {
344
+ args: {
345
+ seatMap: a320,
346
+ selections: {
347
+ ...selection(7, 'B', {
348
+ initials: 'JD',
349
+ hasLinkedTraveler: true,
350
+ linkedTravelerType: IataTravelerTypes.INFANT,
351
+ }),
352
+ ...selection(8, 'C', { initials: 'AM' }),
353
+ },
354
+ onSelect: action('select'),
355
+ },
356
+ render: withAircraftPicker(),
357
+ }