@citizenplane/pimp 18.14.4 → 18.16.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 (56) hide show
  1. package/dist/components/CpTextMorph.vue.d.ts +11 -0
  2. package/dist/components/CpTextMorph.vue.d.ts.map +1 -0
  3. package/dist/components/CpTripDetails.vue.d.ts +14 -0
  4. package/dist/components/CpTripDetails.vue.d.ts.map +1 -0
  5. package/dist/components/CpTripLegRouteInformation.vue.d.ts +11 -0
  6. package/dist/components/CpTripLegRouteInformation.vue.d.ts.map +1 -0
  7. package/dist/components/CpTripLegRouteLayover.vue.d.ts +13 -0
  8. package/dist/components/CpTripLegRouteLayover.vue.d.ts.map +1 -0
  9. package/dist/components/CpTripLegRoutePlace.vue.d.ts +22 -0
  10. package/dist/components/CpTripLegRoutePlace.vue.d.ts.map +1 -0
  11. package/dist/components/CpTripLegRouteSegment.vue.d.ts +18 -0
  12. package/dist/components/CpTripLegRouteSegment.vue.d.ts.map +1 -0
  13. package/dist/components/index.d.ts +8 -1
  14. package/dist/components/index.d.ts.map +1 -1
  15. package/dist/constants/tripDetails/Airline.d.ts +5 -0
  16. package/dist/constants/tripDetails/Airline.d.ts.map +1 -0
  17. package/dist/constants/tripDetails/Airport.d.ts +7 -0
  18. package/dist/constants/tripDetails/Airport.d.ts.map +1 -0
  19. package/dist/constants/tripDetails/CpTripDetailsConfig.d.ts +16 -0
  20. package/dist/constants/tripDetails/CpTripDetailsConfig.d.ts.map +1 -0
  21. package/dist/constants/tripDetails/Flight.d.ts +5 -0
  22. package/dist/constants/tripDetails/Flight.d.ts.map +1 -0
  23. package/dist/constants/tripDetails/FlightSegment.d.ts +15 -0
  24. package/dist/constants/tripDetails/FlightSegment.d.ts.map +1 -0
  25. package/dist/constants/tripDetails/FlightStopTypes.d.ts +7 -0
  26. package/dist/constants/tripDetails/FlightStopTypes.d.ts.map +1 -0
  27. package/dist/constants/tripDetails/Terminal.d.ts +5 -0
  28. package/dist/constants/tripDetails/Terminal.d.ts.map +1 -0
  29. package/dist/constants/tripDetails/index.d.ts +9 -0
  30. package/dist/constants/tripDetails/index.d.ts.map +1 -0
  31. package/dist/helpers/tripDetails.d.ts +23 -0
  32. package/dist/helpers/tripDetails.d.ts.map +1 -0
  33. package/dist/pimp.es.js +5656 -4673
  34. package/dist/pimp.umd.js +66 -33
  35. package/dist/style.css +1 -1
  36. package/package.json +2 -1
  37. package/src/assets/images/dot.svg +3 -0
  38. package/src/components/CpTextMorph.vue +17 -0
  39. package/src/components/CpTripDetails.vue +64 -0
  40. package/src/components/CpTripLegRouteInformation.vue +224 -0
  41. package/src/components/CpTripLegRouteLayover.vue +63 -0
  42. package/src/components/CpTripLegRoutePlace.vue +196 -0
  43. package/src/components/CpTripLegRouteSegment.vue +99 -0
  44. package/src/components/index.ts +26 -0
  45. package/src/constants/tripDetails/Airline.ts +4 -0
  46. package/src/constants/tripDetails/Airport.ts +6 -0
  47. package/src/constants/tripDetails/CpTripDetailsConfig.ts +29 -0
  48. package/src/constants/tripDetails/Flight.ts +5 -0
  49. package/src/constants/tripDetails/FlightSegment.ts +15 -0
  50. package/src/constants/tripDetails/FlightStopTypes.ts +6 -0
  51. package/src/constants/tripDetails/Terminal.ts +4 -0
  52. package/src/constants/tripDetails/index.ts +9 -0
  53. package/src/helpers/tripDetails.ts +61 -0
  54. package/src/stories/CpTextMorph.stories.ts +236 -0
  55. package/src/stories/CpTrip.stories.ts +147 -0
  56. package/src/stories/CpTripDetails.stories.ts +134 -0
@@ -63,6 +63,7 @@ import CpTabs from './CpTabs.vue'
63
63
  import CpTelInput from './CpTelInput.vue'
64
64
  import CpText from './CpText.vue'
65
65
  import CpTextarea from './CpTextarea.vue'
66
+ import CpTextMorph from './CpTextMorph.vue'
66
67
  import CpToast from './CpToast.vue'
67
68
  import CpTooltip from './CpTooltip.vue'
68
69
  import CpTransitionCounter from './CpTransitionCounter.vue'
@@ -73,6 +74,11 @@ import CpTransitionSize from './CpTransitionSize.vue'
73
74
  import CpTransitionSlide from './CpTransitionSlide.vue'
74
75
  import CpTransitionTabContent from './CpTransitionTabContent.vue'
75
76
  import CpTrip from './CpTrip.vue'
77
+ import CpTripDetails from './CpTripDetails.vue'
78
+ import CpTripLegRouteInformation from './CpTripLegRouteInformation.vue'
79
+ import CpTripLegRouteLayover from './CpTripLegRouteLayover.vue'
80
+ import CpTripLegRoutePlace from './CpTripLegRoutePlace.vue'
81
+ import CpTripLegRouteSegment from './CpTripLegRouteSegment.vue'
76
82
  import IconAirline from './icons/IconAirline.vue'
77
83
  import IconCheckList from './icons/IconCheckList.vue'
78
84
  import IconCollapse from './icons/IconCollapse.vue'
@@ -125,6 +131,7 @@ const Components = {
125
131
  CpIcon,
126
132
  CpTelInput,
127
133
  CpTooltip,
134
+ CpTextMorph,
128
135
  CpAirlineLogo,
129
136
  IconAirline,
130
137
  IconOta,
@@ -143,6 +150,11 @@ const Components = {
143
150
  CpTransitionSlide,
144
151
  CpTransitionTabContent,
145
152
  CpTrip,
153
+ CpTripDetails,
154
+ CpTripLegRouteInformation,
155
+ CpTripLegRouteLayover,
156
+ CpTripLegRoutePlace,
157
+ CpTripLegRouteSegment,
146
158
  CpSeatMap,
147
159
  CpSeatMapEmergencyExitDialog,
148
160
  CpSeatMapGrid,
@@ -184,6 +196,14 @@ export type {
184
196
  SeatRowFacilityFlags,
185
197
  } from '@/constants/seatMap'
186
198
  export type { CpSeatMapSeatSelection as CpSeatMapSelection } from '@/constants/seatMap/CpSeatMapSeatSelection'
199
+ export type {
200
+ CpTripDetailsConfig,
201
+ Flight,
202
+ FlightAirline,
203
+ FlightAirport,
204
+ FlightSegment,
205
+ FlightTerminal,
206
+ } from '@/constants/tripDetails'
187
207
 
188
208
  export {
189
209
  CpSeatMapTransitionDirections,
@@ -194,6 +214,7 @@ export {
194
214
  SeatRowFacilityPosition,
195
215
  SeatStatuses,
196
216
  } from '@/constants/seatMap'
217
+ export { DEFAULT_CP_TRIP_DETAILS_CONFIG, FlightStopTypes } from '@/constants/tripDetails'
197
218
  // Consumers key `selections` with this, so it has to be the very same helper.
198
219
  export { displaySeat } from '@/helpers/seatMap'
199
220
  export {
@@ -256,6 +277,11 @@ export {
256
277
  CpTransitionSlide,
257
278
  CpTransitionTabContent,
258
279
  CpTrip,
280
+ CpTripDetails,
281
+ CpTripLegRouteInformation,
282
+ CpTripLegRouteLayover,
283
+ CpTripLegRoutePlace,
284
+ CpTripLegRouteSegment,
259
285
  IconAirline,
260
286
  IconCheckList,
261
287
  IconCollapse,
@@ -0,0 +1,4 @@
1
+ export interface FlightAirline {
2
+ iata_code: string
3
+ name: string
4
+ }
@@ -0,0 +1,6 @@
1
+ export interface FlightAirport {
2
+ city_name: string
3
+ iata_code: string
4
+ name: string
5
+ timezone: string
6
+ }
@@ -0,0 +1,29 @@
1
+ export interface CpTripDetailsConfig {
2
+ routeInformation: {
3
+ airline: string
4
+ connectionInfo: string
5
+ flightNo: string
6
+ technicalStop: string
7
+ }
8
+ routeLayover: {
9
+ layover: string
10
+ }
11
+ routePlace: {
12
+ terminalNotSpecified: string
13
+ }
14
+ }
15
+
16
+ export const DEFAULT_CP_TRIP_DETAILS_CONFIG: CpTripDetailsConfig = {
17
+ routeLayover: {
18
+ layover: 'layover',
19
+ },
20
+ routePlace: {
21
+ terminalNotSpecified: 'Terminal not specified',
22
+ },
23
+ routeInformation: {
24
+ connectionInfo: 'Connection info',
25
+ airline: 'airline',
26
+ flightNo: 'Flight No',
27
+ technicalStop: 'Technical stop',
28
+ },
29
+ }
@@ -0,0 +1,5 @@
1
+ import type { FlightSegment } from './FlightSegment'
2
+
3
+ export interface Flight {
4
+ segments: FlightSegment[]
5
+ }
@@ -0,0 +1,15 @@
1
+ import type { FlightAirline } from './Airline'
2
+ import type { FlightAirport } from './Airport'
3
+ import type { FlightTerminal } from './Terminal'
4
+
5
+ export interface FlightSegment {
6
+ airline: FlightAirline
7
+ arrival_date: string
8
+ departure_date: string
9
+ destination: FlightAirport
10
+ destination_terminal?: FlightTerminal
11
+ flight_number: string
12
+ origin: FlightAirport
13
+ origin_terminal?: FlightTerminal
14
+ via_airport?: Pick<FlightAirport, 'city_name' | 'iata_code'>
15
+ }
@@ -0,0 +1,6 @@
1
+ export enum FlightStopTypes {
2
+ DESTINATION = 'destination',
3
+ LAYOVER_DESTINATION = 'layover destination',
4
+ LAYOVER_ORIGIN = 'layover origin',
5
+ ORIGIN = 'origin',
6
+ }
@@ -0,0 +1,4 @@
1
+ export interface FlightTerminal {
2
+ label: string
3
+ terminal_code: string
4
+ }
@@ -0,0 +1,9 @@
1
+ export type { FlightAirline } from './Airline'
2
+ export type { FlightAirport } from './Airport'
3
+ export type { CpTripDetailsConfig } from './CpTripDetailsConfig'
4
+ export type { Flight } from './Flight'
5
+ export type { FlightSegment } from './FlightSegment'
6
+ export type { FlightTerminal } from './Terminal'
7
+
8
+ export { DEFAULT_CP_TRIP_DETAILS_CONFIG } from './CpTripDetailsConfig'
9
+ export { FlightStopTypes } from './FlightStopTypes'
@@ -0,0 +1,61 @@
1
+ import { DateTime, Duration } from 'luxon'
2
+
3
+ const DURATION_PLACEHOLDER = '-- h -- min'
4
+
5
+ // Luxon ships no types in Pimp (see src/types/luxon.d.ts) — this is the slice of
6
+ // DateTime/Duration's shape CpTripDetails actually relies on.
7
+ export interface TripDuration {
8
+ shiftTo(...units: string[]): TripDuration
9
+ toHuman(opts: { maximumFractionDigits?: number; unitDisplay?: string }): string
10
+ toISO(): string | null
11
+ toObject(): Record<string, number>
12
+ }
13
+
14
+ export interface TripDateTime {
15
+ diff(other: TripDateTime, units: string[]): TripDuration
16
+ toFormat(format: string): string
17
+ toISO(): string | null
18
+ }
19
+
20
+ export const fromGMTToLocal = (date: string, zone: string, locale = 'en'): TripDateTime => {
21
+ return DateTime.fromISO(date, { zone: 'utc' }).setZone(zone).setLocale(locale)
22
+ }
23
+
24
+ export const getDuration = (end: TripDateTime, start: TripDateTime): TripDuration => {
25
+ return end.diff(start, ['hours', 'minutes'])
26
+ }
27
+
28
+ export const getFormattedDuration = (duration?: TripDuration | null, locale = 'en'): string => {
29
+ if (!duration) return DURATION_PLACEHOLDER
30
+
31
+ // Concatenates values to make toHuman more readable (ex: 26h becomes 1d, 2hr)
32
+ const shiftedDuration = duration.shiftTo('hours', 'minutes').toObject()
33
+ const entries = Object.entries(shiftedDuration).filter(([, amount]) => amount)
34
+ const formattedDuration = Duration.fromObject(Object.fromEntries(entries), { locale })
35
+
36
+ return (
37
+ formattedDuration.toHuman({
38
+ unitDisplay: 'short',
39
+ maximumFractionDigits: 0,
40
+ }) || DURATION_PLACEHOLDER
41
+ )
42
+ }
43
+
44
+ export const getDayCountBetweenDates = ({
45
+ departureDate,
46
+ arrivalDate,
47
+ isArrival = false,
48
+ }: {
49
+ arrivalDate: TripDateTime
50
+ departureDate: TripDateTime
51
+ isArrival?: boolean
52
+ }): number => {
53
+ const departureDay = departureDate.toFormat('yyyy-LL-dd')
54
+ const arrivalDay = arrivalDate.toFormat('yyyy-LL-dd')
55
+
56
+ if (isArrival) {
57
+ return DateTime.fromISO(departureDay).diff(DateTime.fromISO(arrivalDay), 'days').days
58
+ }
59
+
60
+ return DateTime.fromISO(arrivalDay).diff(DateTime.fromISO(departureDay), 'days').days
61
+ }
@@ -0,0 +1,236 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { computed, ref } from 'vue'
3
+
4
+ import CpButton from '@/components/CpButton.vue'
5
+ import CpTextMorph from '@/components/CpTextMorph.vue'
6
+
7
+ const meta = {
8
+ title: 'Transitions/CpTextMorph',
9
+ component: CpTextMorph,
10
+ argTypes: {
11
+ text: {
12
+ control: 'text',
13
+ description: 'Text to display. Changing it morphs from the previous value.',
14
+ },
15
+ duration: {
16
+ control: 'number',
17
+ description: 'Duration of the morph in milliseconds.',
18
+ },
19
+ disabled: {
20
+ control: 'boolean',
21
+ description: 'Whether the morph is disabled.',
22
+ },
23
+ as: {
24
+ control: 'text',
25
+ description: 'HTML tag to render the component as.',
26
+ },
27
+ },
28
+ args: {
29
+ duration: 400,
30
+ disabled: false,
31
+ as: 'span',
32
+ },
33
+ } satisfies Meta<typeof CpTextMorph>
34
+
35
+ export default meta
36
+ type Story = StoryObj<typeof meta>
37
+
38
+ const wrapperStyle = 'display: flex; flex-direction: column; align-items: flex-start; gap: 24px;'
39
+
40
+ const singleLineStyle = 'font-size: 2rem; font-weight: 600; line-height: 1.2;'
41
+
42
+ const multiLineStyle = 'font-size: 1.25rem; line-height: 1.6;'
43
+
44
+ const singleLineTexts = ['Search for flights', 'Choose your seat']
45
+
46
+ /**
47
+ * Single line text. Use the button to swap the value and watch the
48
+ * characters morph from one label to the other.
49
+ */
50
+ export const Default: Story = {
51
+ args: {
52
+ text: singleLineTexts[0],
53
+ },
54
+ render: (args) => ({
55
+ components: { CpTextMorph, CpButton },
56
+ setup() {
57
+ const isMorphed = ref(false)
58
+ const text = computed(() => (isMorphed.value ? singleLineTexts[1] : args.text))
59
+
60
+ const toggle = () => {
61
+ isMorphed.value = !isMorphed.value
62
+ }
63
+
64
+ const buttonLabel = computed(() => (isMorphed.value ? 'Unmorph text' : 'Morph text'))
65
+
66
+ return { args, text, toggle, buttonLabel }
67
+ },
68
+ template: `
69
+ <div style="${wrapperStyle}">
70
+ <div style="${singleLineStyle}">
71
+ <CpTextMorph v-bind="args" :text="text" />
72
+ </div>
73
+ <CpButton appearance="primary" color="accent" @click="toggle">
74
+ <CpTextMorph v-bind="args" :text="buttonLabel" />
75
+ </CpButton>
76
+ </div>
77
+ `,
78
+ }),
79
+ }
80
+
81
+ /** Lines are explicit: torph keeps its content on a single line unless the value contains newlines. */
82
+ const multiLineTexts = [
83
+ [
84
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,',
85
+ 'sed do eiusmod tempor incididunt ut labore et dolore',
86
+ 'magna aliqua veniam quis nostrud.',
87
+ ].join('\n'),
88
+ [
89
+ 'Lorem ipsum dolor sit amet, quis nostrud exercitation',
90
+ 'ullamco laboris nisi ut aliquip ex ea commodo dolore',
91
+ 'magna aliqua consectetur adipiscing elit.',
92
+ ].join('\n'),
93
+ ]
94
+
95
+ /**
96
+ * Multi line paragraph. Both values share most of their words, so the
97
+ * morph mostly moves existing words to their new position across lines
98
+ * instead of fading everything in and out.
99
+ */
100
+ export const MultiLine: Story = {
101
+ args: {
102
+ text: multiLineTexts[0],
103
+ },
104
+ render: (args) => ({
105
+ components: { CpTextMorph, CpButton },
106
+ setup() {
107
+ const isMorphed = ref(false)
108
+ const text = computed(() => (isMorphed.value ? multiLineTexts[1] : args.text))
109
+
110
+ const toggle = () => {
111
+ isMorphed.value = !isMorphed.value
112
+ }
113
+
114
+ return { args, text, toggle }
115
+ },
116
+ template: `
117
+ <div style="${wrapperStyle}">
118
+ <div style="${multiLineStyle}">
119
+ <CpTextMorph v-bind="args" :text="text" />
120
+ </div>
121
+ <CpButton appearance="primary" color="accent" @click="toggle">
122
+ Morph text
123
+ </CpButton>
124
+ </div>
125
+ `,
126
+ }),
127
+ }
128
+
129
+ const bookingSteps = [
130
+ {
131
+ title: 'Select your flight',
132
+ fields: ['Paris (CDG) → London (LHR)', 'Wednesday 19 August, 09:45', 'Economy, 1 passenger'],
133
+ },
134
+ {
135
+ title: 'Select your seat',
136
+ fields: ['Seat 14A, window', 'Extra legroom', 'Front of the cabin'],
137
+ },
138
+ {
139
+ title: 'Confirm your booking',
140
+ fields: ['Paris (CDG) → London (LHR)', 'Seat 14A, window', 'Total: 189,00 €'],
141
+ },
142
+ ]
143
+
144
+ const dialogFooterStyle = 'display: flex; justify-content: space-between; gap: 8px; width: 100%;'
145
+
146
+ const dialogFieldsStyle = 'display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none;'
147
+ /**
148
+ * Use case — a multi step dialog whose title morphs on every step
149
+ * instead of being swapped instantly. The step titles share words on
150
+ * purpose ("Select your …"), so only the changing part animates while
151
+ * the rest stays in place.
152
+ *
153
+ * The title is wrapped in an `h2` inside the `title` slot to keep the
154
+ * dialog heading semantics. Around it, several transitions run together:
155
+ * `CpTransitionCounter` rolls the step number, `CpTransitionSize`
156
+ * animates the width of the whole "Step 1 of 3" subtitle and the body
157
+ * height, and the footer keeps a single action button whose label morphs
158
+ * from "Next" to "Confirm" while its width follows.
159
+ */
160
+ export const MultiStepDialog: Story = {
161
+ args: {
162
+ text: bookingSteps[0].title,
163
+ },
164
+ render: (args) => ({
165
+ components: { CpTextMorph },
166
+ setup() {
167
+ const isOpen = ref(false)
168
+ const stepIndex = ref(0)
169
+
170
+ const step = computed(() => bookingSteps[stepIndex.value])
171
+ const stepLabel = computed(() => `Step ${stepIndex.value + 1} of ${bookingSteps.length}`)
172
+ const isLastStep = computed(() => stepIndex.value === bookingSteps.length - 1)
173
+ const nextLabel = computed(() => (isLastStep.value ? 'Confirm' : 'Next'))
174
+
175
+ const open = () => {
176
+ stepIndex.value = 0
177
+ isOpen.value = true
178
+ }
179
+
180
+ const close = () => {
181
+ isOpen.value = false
182
+ }
183
+
184
+ const goToNextStep = () => {
185
+ if (isLastStep.value) {
186
+ close()
187
+ return
188
+ }
189
+ stepIndex.value++
190
+ }
191
+
192
+ return {
193
+ args,
194
+ isOpen,
195
+ stepIndex,
196
+ step,
197
+ stepLabel,
198
+ nextLabel,
199
+ open,
200
+ close,
201
+ goToNextStep,
202
+ }
203
+ },
204
+ template: `
205
+ <CpButton appearance="primary" color="accent" @click="open">Book a flight</CpButton>
206
+ <CpTransitionDialog>
207
+ <CpDialog v-if="isOpen" :max-width="480" @close="close">
208
+ <template #title>
209
+ <h2><CpTextMorph v-bind="args" :text="step.title" /></h2>
210
+ </template>
211
+ <template #subtitle>
212
+ <CpTextMorph v-bind="args" :text="stepLabel" />
213
+ </template>
214
+ <ul style="${dialogFieldsStyle}">
215
+ <li v-for="field in step.fields" :key="field">{{ field }}</li>
216
+ </ul>
217
+ <template #footer>
218
+ <div style="${dialogFooterStyle}">
219
+ <CpButton
220
+ appearance="secondary"
221
+ color="neutral"
222
+ :disabled="stepIndex === 0"
223
+ @click="stepIndex--"
224
+ >
225
+ Back
226
+ </CpButton>
227
+ <CpButton appearance="primary" color="accent" @click="goToNextStep">
228
+ <CpTextMorph v-bind="args" :text="nextLabel" />
229
+ </CpButton>
230
+ </div>
231
+ </template>
232
+ </CpDialog>
233
+ </CpTransitionDialog>
234
+ `,
235
+ }),
236
+ }
@@ -1,6 +1,10 @@
1
1
  import type { Args, Meta, StoryObj } from '@storybook/vue3-vite'
2
2
 
3
+ import type { Flight } from '@/constants/tripDetails/Flight'
4
+
5
+ import CpAccordion from '@/components/CpAccordion.vue'
3
6
  import CpTrip from '@/components/CpTrip.vue'
7
+ import CpTripDetails from '@/components/CpTripDetails.vue'
4
8
 
5
9
  const directTrip = {
6
10
  arrivalDate: {
@@ -221,6 +225,104 @@ const fourStopTrip = {
221
225
  ],
222
226
  }
223
227
 
228
+ /**
229
+ * Summary and segments below describe the same one-stop CDG → MUC → JFK
230
+ * itinerary: 09:00 Paris → 10:40 Munich, 2h 10m layover, 12:50 Munich →
231
+ * 16:15 New York, for 13h 15m of total travel time on 10 Jun 2026.
232
+ */
233
+ const fullTrip = {
234
+ arrivalDate: {
235
+ formatted: 'Wed, 10 Jun 2026',
236
+ raw: '2026-06-10',
237
+ },
238
+ arrivalTime: '16:15',
239
+ dayCountAfterDeparture: 0,
240
+ departureDate: {
241
+ formatted: 'Wed, 10 Jun 2026',
242
+ raw: '2026-06-10',
243
+ },
244
+ departureTime: '09:00',
245
+ destination: {
246
+ iataCode: 'JFK',
247
+ name: 'John F. Kennedy International Airport',
248
+ },
249
+ flightDuration: {
250
+ formatted: '13h 15m',
251
+ raw: 'PT13H15M',
252
+ },
253
+ labels: {
254
+ layover: 'layover',
255
+ stopCount: '1 stop',
256
+ },
257
+ airlines: [
258
+ {
259
+ iataCode: 'AF',
260
+ name: 'Air France',
261
+ },
262
+ {
263
+ iataCode: 'LH',
264
+ name: 'Lufthansa',
265
+ },
266
+ ],
267
+ origin: {
268
+ iataCode: 'CDG',
269
+ name: 'Paris Charles de Gaulle Airport',
270
+ },
271
+ stops: [
272
+ {
273
+ duration: '2h 10m',
274
+ iata: 'MUC',
275
+ name: 'Munich',
276
+ },
277
+ ],
278
+ }
279
+
280
+ /** Segment dates are GMT — `CpTripDetails` converts them to each airport's timezone. */
281
+ const fullTripFlight: Flight = {
282
+ segments: [
283
+ {
284
+ airline: { iata_code: 'AF', name: 'Air France' },
285
+ arrival_date: '2026-06-10T08:40:00',
286
+ departure_date: '2026-06-10T07:00:00',
287
+ destination: {
288
+ city_name: 'Munich',
289
+ iata_code: 'MUC',
290
+ name: 'Franz Josef Strauss Airport',
291
+ timezone: 'Europe/Berlin',
292
+ },
293
+ destination_terminal: { label: 'Terminal 2', terminal_code: '2' },
294
+ flight_number: 'AF1122',
295
+ origin: {
296
+ city_name: 'Paris',
297
+ iata_code: 'CDG',
298
+ name: 'Paris Charles de Gaulle Airport',
299
+ timezone: 'Europe/Paris',
300
+ },
301
+ origin_terminal: { label: 'Terminal 2E', terminal_code: '2E' },
302
+ },
303
+ {
304
+ airline: { iata_code: 'LH', name: 'Lufthansa' },
305
+ arrival_date: '2026-06-10T20:15:00',
306
+ departure_date: '2026-06-10T10:50:00',
307
+ destination: {
308
+ city_name: 'New York',
309
+ iata_code: 'JFK',
310
+ name: 'John F. Kennedy International Airport',
311
+ timezone: 'America/New_York',
312
+ },
313
+ destination_terminal: { label: 'Terminal 1', terminal_code: '1' },
314
+ flight_number: 'LH410',
315
+ origin: {
316
+ city_name: 'Munich',
317
+ iata_code: 'MUC',
318
+ name: 'Franz Josef Strauss Airport',
319
+ timezone: 'Europe/Berlin',
320
+ },
321
+ origin_terminal: { label: 'Terminal 2', terminal_code: '2' },
322
+ },
323
+ ],
324
+ }
325
+
224
326
  const meta = {
225
327
  title: 'Business/CpTrip',
226
328
  component: CpTrip,
@@ -321,3 +423,48 @@ export const HideAirlines: Story = {
321
423
  },
322
424
  render: defaultRender,
323
425
  }
426
+
427
+ /**
428
+ * A complete itinerary: `CpTrip` acts as the always-visible summary in the
429
+ * accordion header, and `CpTripDetails` renders the leg-by-leg breakdown in
430
+ * the expandable content. The rounded corners come from the wrapper, since
431
+ * `CpAccordion` is square by design so it can be stacked in a group.
432
+ */
433
+ export const FullTrip: Story = {
434
+ args: {
435
+ trip: fullTrip,
436
+ },
437
+ render: (args: Args) => ({
438
+ components: { CpAccordion, CpTrip, CpTripDetails },
439
+ setup() {
440
+ const wrapperStyle = [
441
+ 'box-sizing: border-box',
442
+ 'width: 640px',
443
+ 'max-width: 100%',
444
+ 'overflow: hidden',
445
+ 'border: 1px solid var(--cp-border-soft)',
446
+ 'border-radius: var(--cp-radius-xl)',
447
+ 'box-shadow: var(--cp-shadows-xs)',
448
+ ].join('; ')
449
+
450
+ const summaryStyle = 'padding-top: var(--cp-spacing-md);'
451
+ const detailsStyle = 'border-top: 1px solid var(--cp-border-soft);'
452
+
453
+ return { args, detailsStyle, fullTripFlight, summaryStyle, wrapperStyle }
454
+ },
455
+ template: `
456
+ <div :style="wrapperStyle">
457
+ <CpAccordion :default-open-state="true" icon-position="trailing" title="Outbound · Wed, 10 Jun 2026">
458
+ <template #leading-slot>
459
+ <div :style="summaryStyle">
460
+ <CpTrip v-bind="args" />
461
+ </div>
462
+ </template>
463
+ <div :style="detailsStyle">
464
+ <CpTripDetails :flight="fullTripFlight" />
465
+ </div>
466
+ </CpAccordion>
467
+ </div>
468
+ `,
469
+ }),
470
+ }