@citizenplane/pimp 18.21.2 → 18.22.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citizenplane/pimp",
3
- "version": "18.21.2",
3
+ "version": "18.22.0",
4
4
  "scripts": {
5
5
  "dev": "storybook dev -p 8081",
6
6
  "build-storybook": "storybook build --output-dir ./docs",
@@ -0,0 +1,271 @@
1
+ <template>
2
+ <div class="cpBasket">
3
+ <div v-if="hasLeading" class="cpBasket__trip">
4
+ <slot name="leading" />
5
+ </div>
6
+
7
+ <cp-transition-expand>
8
+ <div v-if="hasSections">
9
+ <div class="cpBasket__details">
10
+ <div v-for="category in visibleCategories" :key="category.id" class="cpBasket__category">
11
+ <div class="cpBasket__categoryTitle">
12
+ <p>{{ category.label }}</p>
13
+ <p>{{ category.subtotal }}</p>
14
+ </div>
15
+ <div v-if="category.hasGroups" class="cpBasket__groups">
16
+ <cp-transition-list-items>
17
+ <div v-for="group in category.groups" :key="group.id" class="cpBasket__group">
18
+ <p v-if="group.label" class="cpBasket__route">{{ group.label }}</p>
19
+ <cp-transition-list-items>
20
+ <div v-for="row in group.rows" :key="row.key" :class="group.rowClass">
21
+ <span>{{ row.label }}</span>
22
+ <span>{{ row.value }}</span>
23
+ </div>
24
+ </cp-transition-list-items>
25
+ </div>
26
+ </cp-transition-list-items>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ </div>
31
+ </cp-transition-expand>
32
+
33
+ <div class="cpBasket__total">
34
+ <p>{{ totalLabel }}</p>
35
+ <p class="cpBasket__totalValue">{{ total }}</p>
36
+ </div>
37
+
38
+ <div v-if="hasTrailing" class="cpBasket__trailing">
39
+ <slot name="trailing" />
40
+ </div>
41
+ </div>
42
+ </template>
43
+
44
+ <script setup lang="ts">
45
+ import { computed, useSlots } from 'vue'
46
+
47
+ import type { CpBasketCategory, CpBasketRow } from '@/constants/CpBasketTypes'
48
+
49
+ import CpTransitionExpand from '@/components/CpTransitionExpand.vue'
50
+ import CpTransitionListItems from '@/components/CpTransitionListItems.vue'
51
+
52
+ interface Props {
53
+ hideDetailsOnCategories?: string[]
54
+ rows: CpBasketRow[]
55
+ total: string
56
+ totalLabel: string
57
+ }
58
+
59
+ interface ResolvedRow extends CpBasketRow {
60
+ key: string
61
+ }
62
+
63
+ interface ResolvedGroup {
64
+ id: string
65
+ label: string | null
66
+ rowClass: string
67
+ rows: ResolvedRow[]
68
+ }
69
+
70
+ interface ResolvedCategory {
71
+ groups: ResolvedGroup[]
72
+ hasGroups: boolean
73
+ id: string
74
+ label: string
75
+ subtotal: string
76
+ }
77
+
78
+ const props = withDefaults(defineProps<Props>(), {
79
+ hideDetailsOnCategories: () => [],
80
+ })
81
+
82
+ const UNGROUPED_ID = '__cpBasketUngrouped__'
83
+
84
+ const slots = useSlots()
85
+
86
+ const hasLeading = computed(() => !!slots.leading)
87
+
88
+ const hasTrailing = computed(() => !!slots.trailing)
89
+
90
+ const hideDetailsIds = computed(() => new Set(props.hideDetailsOnCategories))
91
+
92
+ const dedupeById = <T extends { id: string }>(items: T[]): T[] =>
93
+ items.filter((item, index) => items.findIndex((other) => other.id === item.id) === index)
94
+
95
+ const orderedCategories = computed<CpBasketCategory[]>(() => dedupeById(props.rows.map((row) => row.category)))
96
+
97
+ const withKeys = (rows: CpBasketRow[], categoryId: string): ResolvedRow[] =>
98
+ rows.map((row) => ({ ...row, key: `${categoryId}-${row.group?.id ?? 'ungrouped'}-${row.label}` }))
99
+
100
+ const buildResolvedGroup = (id: string, label: string | null, rows: CpBasketRow[], categoryId: string): ResolvedGroup => ({
101
+ id,
102
+ label,
103
+ rowClass: label ? 'cpBasket__row cpBasket__row--isIndented' : 'cpBasket__row',
104
+ rows: withKeys(rows, categoryId),
105
+ })
106
+
107
+ const buildResolvedCategory = (category: CpBasketCategory, categoryRows: CpBasketRow[]): ResolvedCategory => {
108
+ if (hideDetailsIds.value.has(category.id)) {
109
+ return { groups: [], hasGroups: false, id: category.id, label: category.label, subtotal: category.subtotal }
110
+ }
111
+
112
+ const groupsInCategory = categoryRows.flatMap((row) => (row.group ? [row.group] : []))
113
+ const uniqueGroups = dedupeById(groupsInCategory)
114
+ const labelledGroups = uniqueGroups.map((group) =>
115
+ buildResolvedGroup(
116
+ group.id,
117
+ group.label,
118
+ categoryRows.filter((row) => row.group?.id === group.id),
119
+ category.id,
120
+ ),
121
+ )
122
+
123
+ const ungroupedRows = categoryRows.filter((row) => row.group == null)
124
+ const ungroupedGroup: ResolvedGroup[] =
125
+ ungroupedRows.length === 0 ? [] : [buildResolvedGroup(UNGROUPED_ID, null, ungroupedRows, category.id)]
126
+
127
+ const groups = [...labelledGroups, ...ungroupedGroup]
128
+
129
+ return { groups, hasGroups: groups.length > 0, id: category.id, label: category.label, subtotal: category.subtotal }
130
+ }
131
+
132
+ const visibleCategories = computed<ResolvedCategory[]>(() =>
133
+ orderedCategories.value.map((category) =>
134
+ buildResolvedCategory(
135
+ category,
136
+ props.rows.filter((row) => row.category.id === category.id),
137
+ ),
138
+ ),
139
+ )
140
+
141
+ const hasSections = computed(() => visibleCategories.value.length > 0)
142
+ </script>
143
+
144
+ <style lang="scss">
145
+ .cpBasket {
146
+ display: flex;
147
+ flex-direction: column;
148
+ padding: var(--cp-spacing-sm);
149
+ border-radius: var(--cp-radius-xl);
150
+ background: var(--cp-background-tertiary);
151
+ gap: var(--cp-spacing-sm);
152
+
153
+ &__trip {
154
+ display: flex;
155
+ flex-direction: column;
156
+ padding: var(--cp-spacing-lg);
157
+ gap: var(--cp-spacing-md);
158
+ }
159
+
160
+ &__leg {
161
+ display: flex;
162
+ flex-direction: column;
163
+ gap: var(--cp-spacing-sm);
164
+
165
+ & + & {
166
+ padding-top: var(--cp-spacing-md);
167
+ border-top: fn.px-to-rem(1) solid var(--cp-border-soft);
168
+ }
169
+ }
170
+
171
+ &__legDate {
172
+ color: var(--cp-text-secondary);
173
+ font-size: var(--cp-text-size-xs);
174
+ font-weight: 600;
175
+ line-height: var(--cp-text-xs-line-height);
176
+ text-transform: uppercase;
177
+ }
178
+
179
+ &__details {
180
+ display: flex;
181
+ overflow: hidden;
182
+ flex-direction: column;
183
+ padding: var(--cp-spacing-md) var(--cp-spacing-lg);
184
+ border-radius: var(--cp-radius-lg);
185
+ background: var(--cp-background-secondary);
186
+ gap: var(--cp-spacing-sm-md);
187
+ }
188
+
189
+ &__category {
190
+ display: flex;
191
+ flex-direction: column;
192
+
193
+ & + & {
194
+ padding-top: var(--cp-spacing-sm-md);
195
+ border-top: fn.px-to-rem(1) solid var(--cp-border-soft);
196
+ }
197
+ }
198
+
199
+ &__categoryTitle {
200
+ display: flex;
201
+ align-items: center;
202
+ justify-content: space-between;
203
+ color: var(--cp-text-primary);
204
+ font-size: var(--cp-text-size-md);
205
+ font-weight: 600;
206
+ line-height: var(--cp-text-md-line-height);
207
+ padding-block: var(--cp-spacing-sm);
208
+
209
+ & > :last-child {
210
+ color: var(--cp-text-secondary);
211
+ }
212
+ }
213
+
214
+ &__groups {
215
+ display: flex;
216
+ flex-direction: column;
217
+ gap: var(--cp-spacing-md);
218
+ }
219
+
220
+ &__group {
221
+ position: relative;
222
+ display: flex;
223
+ flex-direction: column;
224
+ gap: var(--cp-spacing-sm);
225
+ }
226
+
227
+ &__route {
228
+ color: var(--cp-text-secondary);
229
+ font-size: var(--cp-text-size-sm);
230
+ font-weight: 500;
231
+ line-height: var(--cp-text-sm-line-height);
232
+ }
233
+
234
+ &__row {
235
+ display: flex;
236
+ width: 100%;
237
+ justify-content: space-between;
238
+ color: var(--cp-text-secondary);
239
+ font-size: var(--cp-text-size-sm);
240
+ gap: var(--cp-spacing-md);
241
+ line-height: var(--cp-text-sm-line-height);
242
+
243
+ &--isIndented {
244
+ padding-left: var(--cp-spacing-md);
245
+ }
246
+ }
247
+
248
+ &__total {
249
+ display: flex;
250
+ align-items: flex-end;
251
+ justify-content: space-between;
252
+ padding: var(--cp-spacing-lg);
253
+ color: var(--cp-text-primary);
254
+ font-size: var(--cp-text-size-lg);
255
+ font-weight: 600;
256
+ line-height: var(--cp-text-lg-line-height);
257
+ }
258
+
259
+ &__totalValue {
260
+ color: var(--cp-text-accent-primary);
261
+ font-variant-numeric: tabular-nums;
262
+ }
263
+
264
+ &__trailing {
265
+ display: flex;
266
+ flex-direction: column;
267
+ align-items: center;
268
+ gap: var(--cp-spacing-sm);
269
+ }
270
+ }
271
+ </style>
@@ -24,6 +24,7 @@ import CpAncillaryCategory from './CpAncillaryCategory.vue'
24
24
  import CpAncillaryFlightSelection from './CpAncillaryFlightSelection.vue'
25
25
  import CpAncillaryItem from './CpAncillaryItem.vue'
26
26
  import CpBadge from './CpBadge.vue'
27
+ import CpBasket from './CpBasket.vue'
27
28
  import CpBottomSheet from './CpBottomSheet.vue'
28
29
  import CpButton from './CpButton.vue'
29
30
  import CpButtonGroup from './CpButtonGroup.vue'
@@ -123,6 +124,7 @@ const Components = {
123
124
  CpAncillaryCategory,
124
125
  CpAncillaryFlightSelection,
125
126
  CpAncillaryItem,
127
+ CpBasket,
126
128
  CpLoader,
127
129
  CpInput,
128
130
  CpText,
@@ -198,6 +200,7 @@ const Pimp: Plugin = {
198
200
  }
199
201
 
200
202
  export type { CpAncillaryFlightSelectionFlight } from '@/constants/CpAncillaryFlightSelectionTypes'
203
+ export type { CpBasketCategory, CpBasketGroup, CpBasketRow } from '@/constants/CpBasketTypes'
201
204
  export type { CpStepperStep } from '@/constants/CpStepperTypes'
202
205
  export type {
203
206
  CpSeatMapConfig,
@@ -243,6 +246,7 @@ export {
243
246
  CpAncillaryFlightSelection,
244
247
  CpAncillaryItem,
245
248
  CpBadge,
249
+ CpBasket,
246
250
  CpBottomSheet,
247
251
  CpButton,
248
252
  CpButtonGroup,
@@ -0,0 +1,17 @@
1
+ export interface CpBasketCategory {
2
+ id: string
3
+ label: string
4
+ subtotal: string
5
+ }
6
+
7
+ export interface CpBasketGroup {
8
+ id: string
9
+ label: string
10
+ }
11
+
12
+ export interface CpBasketRow {
13
+ category: CpBasketCategory
14
+ group?: CpBasketGroup | null
15
+ label: string
16
+ value: string
17
+ }
@@ -0,0 +1,323 @@
1
+ import type { Args, Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+
4
+ import type { CpBasketRow } from '@/constants/CpBasketTypes'
5
+
6
+ import CpBasket from '@/components/CpBasket.vue'
7
+ import CpButton from '@/components/CpButton.vue'
8
+ import CpIcon from '@/components/CpIcon.vue'
9
+ import CpTrip from '@/components/CpTrip.vue'
10
+
11
+ const outboundTrip = {
12
+ arrivalDate: {
13
+ formatted: 'Tue, 10 Jun 2026',
14
+ raw: '2026-06-10',
15
+ },
16
+ arrivalTime: '10:20',
17
+ labels: {
18
+ nonStop: 'Direct',
19
+ },
20
+ dayCountAfterDeparture: 0,
21
+ departureDate: {
22
+ formatted: 'Tue, 10 Jun 2026',
23
+ raw: '2026-06-10',
24
+ },
25
+ departureTime: '09:00',
26
+ destination: {
27
+ iataCode: 'ORY',
28
+ name: 'Paris Orly Airport',
29
+ },
30
+ flightDuration: {
31
+ formatted: '1h 20m',
32
+ raw: 'PT1H20M',
33
+ },
34
+ airlines: [
35
+ {
36
+ iataCode: 'AF',
37
+ name: 'Air France',
38
+ },
39
+ ],
40
+ origin: {
41
+ iataCode: 'CDG',
42
+ name: 'Paris Charles de Gaulle Airport',
43
+ },
44
+ }
45
+
46
+ const returnTrip = {
47
+ arrivalDate: {
48
+ formatted: 'Fri, 13 Jun 2026',
49
+ raw: '2026-06-13',
50
+ },
51
+ arrivalTime: '19:50',
52
+ labels: {
53
+ nonStop: 'Direct',
54
+ },
55
+ dayCountAfterDeparture: 0,
56
+ departureDate: {
57
+ formatted: 'Fri, 13 Jun 2026',
58
+ raw: '2026-06-13',
59
+ },
60
+ departureTime: '18:30',
61
+ destination: {
62
+ iataCode: 'CDG',
63
+ name: 'Paris Charles de Gaulle Airport',
64
+ },
65
+ flightDuration: {
66
+ formatted: '1h 20m',
67
+ raw: 'PT1H20M',
68
+ },
69
+ airlines: [
70
+ {
71
+ iataCode: 'AF',
72
+ name: 'Air France',
73
+ },
74
+ ],
75
+ origin: {
76
+ iataCode: 'ORY',
77
+ name: 'Paris Orly Airport',
78
+ },
79
+ }
80
+
81
+ const seatsCategory = { id: 'seats', label: 'Seats', subtotal: '24,00 €' }
82
+ const baggageCategory = { id: 'baggage', label: 'Baggage', subtotal: '20,00 €' }
83
+ const extrasCategory = { id: 'extras', label: 'Extras', subtotal: '3,00 €' }
84
+
85
+ const outboundGroup = { id: 'CDG-ORY', label: 'CDG → ORY' }
86
+ const returnGroup = { id: 'ORY-CDG', label: 'ORY → CDG' }
87
+
88
+ const rows: CpBasketRow[] = [
89
+ { label: 'Seat 12A', value: '12,00 €', category: seatsCategory, group: outboundGroup },
90
+ { label: 'Seat 14C', value: '12,00 €', category: seatsCategory, group: returnGroup },
91
+ { label: '2 × Cabin baggage 10kg', value: '20,00 €', category: baggageCategory, group: outboundGroup },
92
+ { label: 'Carbon offset', value: '3,00 €', category: extrasCategory },
93
+ ]
94
+
95
+ const fakeRowPool: CpBasketRow[] = [
96
+ { label: 'Extra bag 20kg', value: '15,00 €', category: baggageCategory, group: outboundGroup },
97
+ { label: 'Seat 15B', value: '10,00 €', category: seatsCategory, group: returnGroup },
98
+ { label: 'Meal', value: '9,00 €', category: extrasCategory },
99
+ { label: 'Priority boarding', value: '8,00 €', category: extrasCategory, group: null },
100
+ ]
101
+
102
+ const flightsCategory = { id: 'flights', label: 'Flights', subtotal: '159,80 €' }
103
+ const hiddenSeatsCategory = { id: 'seats', label: 'Seats', subtotal: '99,60 €' }
104
+ const tripExtrasCategory = { id: 'tripExtras', label: 'Trip extras', subtotal: '269,40 €' }
105
+ const taxesCategory = { id: 'taxes', label: 'Taxes & Fees', subtotal: '14,30 €' }
106
+
107
+ const hiddenDetailsRows: CpBasketRow[] = [
108
+ { label: 'CDG → MRS', value: '79,90 €', category: flightsCategory, group: outboundGroup },
109
+ { label: 'MRS → CDG', value: '79,90 €', category: flightsCategory, group: returnGroup },
110
+ { label: 'Extra legroom', value: '99,60 €', category: hiddenSeatsCategory },
111
+ { label: 'Priority boarding', value: '269,40 €', category: tripExtrasCategory },
112
+ { label: 'VAT', value: '14,30 €', category: taxesCategory },
113
+ ]
114
+
115
+ const meta = {
116
+ title: 'Business/CpBasket',
117
+ component: CpBasket,
118
+ argTypes: {
119
+ hideDetailsOnCategories: {
120
+ control: 'object',
121
+ description: 'Category ids rendered as a title-only line, with no groups or rows',
122
+ table: { defaultValue: { summary: '[]' } },
123
+ },
124
+ rows: {
125
+ control: 'object',
126
+ description: 'Flat list of basket rows, each carrying its own category and group',
127
+ },
128
+ total: {
129
+ control: 'text',
130
+ description: 'Preformatted total amount',
131
+ },
132
+ totalLabel: {
133
+ control: 'text',
134
+ description: 'Label shown next to the total, translated by the consumer',
135
+ },
136
+ },
137
+ } satisfies Meta<typeof CpBasket>
138
+
139
+ export default meta
140
+
141
+ type Story = StoryObj<typeof meta>
142
+
143
+ const defaultRender = (args: Args) => ({
144
+ components: { CpBasket, CpTrip },
145
+ setup() {
146
+ return { args, outboundTrip, returnTrip }
147
+ },
148
+ template: `
149
+ <CpBasket v-bind="args">
150
+ <template #leading>
151
+ <div class="cpBasket__leg">
152
+ <p class="cpBasket__legDate">{{ outboundTrip.departureDate.formatted }}</p>
153
+ <CpTrip hide-airlines :trip="outboundTrip" />
154
+ </div>
155
+ <div class="cpBasket__leg">
156
+ <p class="cpBasket__legDate">{{ returnTrip.departureDate.formatted }}</p>
157
+ <CpTrip hide-airlines :trip="returnTrip" />
158
+ </div>
159
+ </template>
160
+ </CpBasket>
161
+ `,
162
+ })
163
+
164
+ const defaultStoryRender = (args: Args) => ({
165
+ components: { CpBasket, CpButton, CpTrip },
166
+ setup() {
167
+ const storyRows = ref<CpBasketRow[]>([...rows])
168
+ const fakeRowCount = ref(0)
169
+
170
+ const addFakeRow = () => {
171
+ const pick = fakeRowPool[Math.floor(Math.random() * fakeRowPool.length)]
172
+ fakeRowCount.value += 1
173
+ storyRows.value = [...storyRows.value, { ...pick, label: `${pick.label} #${fakeRowCount.value}` }]
174
+ }
175
+
176
+ return { addFakeRow, args, outboundTrip, returnTrip, storyRows }
177
+ },
178
+ template: `
179
+ <div style="display: flex; flex-direction: column; gap: 16px; max-width: 415px;">
180
+ <CpButton @click="addFakeRow">Add fake row</CpButton>
181
+ <CpBasket v-bind="args" :rows="storyRows">
182
+ <template #leading>
183
+ <div class="cpBasket__leg">
184
+ <p class="cpBasket__legDate">{{ outboundTrip.departureDate.formatted }}</p>
185
+ <CpTrip hide-airlines :trip="outboundTrip" />
186
+ </div>
187
+ <div class="cpBasket__leg">
188
+ <p class="cpBasket__legDate">{{ returnTrip.departureDate.formatted }}</p>
189
+ <CpTrip hide-airlines :trip="returnTrip" />
190
+ </div>
191
+ </template>
192
+ </CpBasket>
193
+ </div>
194
+ `,
195
+ })
196
+
197
+ /**
198
+ * Three categories (seats, baggage, extras) with rows grouped per route in the
199
+ * `#leading` slot rendering the two `cp-trip` legs. The button above the
200
+ * basket appends one random fake row per click (sometimes ungrouped) to
201
+ * eyeball the `cp-transition-list-items` insert animation.
202
+ */
203
+ export const Default: Story = {
204
+ args: {
205
+ rows,
206
+ total: '47,00 €',
207
+ totalLabel: 'Total',
208
+ },
209
+ render: defaultStoryRender,
210
+ }
211
+
212
+ /**
213
+ * No rows: only the `#leading` slot and the total render, the details block
214
+ * collapses via `cp-transition-expand`.
215
+ */
216
+ export const EmptyRows: Story = {
217
+ args: {
218
+ rows: [],
219
+ total: '0,00 €',
220
+ totalLabel: 'Total',
221
+ },
222
+ render: defaultRender,
223
+ }
224
+
225
+ /**
226
+ * One category mixing a labelled group with ungrouped rows whose `group` is
227
+ * either omitted or explicitly `null` — both render the same way: no label,
228
+ * no indentation, after the labelled group.
229
+ */
230
+ export const GroupedAndUngroupedRows: Story = {
231
+ args: {
232
+ rows: [
233
+ {
234
+ label: 'Seat 12A',
235
+ value: '12,00 €',
236
+ category: { id: 'seats', label: 'Seats', subtotal: '32,00 €' },
237
+ group: outboundGroup,
238
+ },
239
+ { label: 'Seat 14C', value: '12,00 €', category: { id: 'seats', label: 'Seats', subtotal: '32,00 €' } },
240
+ {
241
+ label: 'Priority boarding',
242
+ value: '8,00 €',
243
+ category: { id: 'seats', label: 'Seats', subtotal: '32,00 €' },
244
+ group: null,
245
+ },
246
+ ],
247
+ total: '32,00 €',
248
+ totalLabel: 'Total',
249
+ },
250
+ render: defaultRender,
251
+ }
252
+
253
+ /**
254
+ * No `#leading` slot content: the leading container is not rendered at all.
255
+ */
256
+ export const NoLeadingSlot: Story = {
257
+ args: {
258
+ rows,
259
+ total: '47,00 €',
260
+ totalLabel: 'Total',
261
+ },
262
+ render: (args: Args) => ({
263
+ components: { CpBasket },
264
+ setup() {
265
+ return { args }
266
+ },
267
+ template: `<CpBasket v-bind="args" />`,
268
+ }),
269
+ }
270
+
271
+ /**
272
+ * `hideDetailsOnCategories` collapses "Seats", "Trip extras" and
273
+ * "Taxes & Fees" to their title line while "Flights" keeps its grouped rows,
274
+ * matching the Figma payment-summary reference.
275
+ */
276
+ export const HiddenDetails: Story = {
277
+ args: {
278
+ hideDetailsOnCategories: ['seats', 'tripExtras', 'taxes'],
279
+ rows: hiddenDetailsRows,
280
+ total: '543,10 €',
281
+ totalLabel: 'Total',
282
+ },
283
+ render: defaultRender,
284
+ }
285
+
286
+ /**
287
+ * `#trailing` slot rendered after the total, with a `var(--cp-spacing-sm)`
288
+ * gap only appearing because the slot is provided.
289
+ */
290
+ export const TrailingSlot: Story = {
291
+ args: {
292
+ rows,
293
+ total: '47,00 €',
294
+ totalLabel: 'Total',
295
+ },
296
+ render: (args: Args) => ({
297
+ components: { CpBasket, CpButton, CpIcon, CpTrip },
298
+ setup() {
299
+ return { args, outboundTrip, returnTrip }
300
+ },
301
+ template: `
302
+ <CpBasket v-bind="args">
303
+ <template #leading>
304
+ <div class="cpBasket__leg">
305
+ <p class="cpBasket__legDate">{{ outboundTrip.departureDate.formatted }}</p>
306
+ <CpTrip hide-airlines :trip="outboundTrip" />
307
+ </div>
308
+ <div class="cpBasket__leg">
309
+ <p class="cpBasket__legDate">{{ returnTrip.departureDate.formatted }}</p>
310
+ <CpTrip hide-airlines :trip="returnTrip" />
311
+ </div>
312
+ </template>
313
+ <template #trailing>
314
+ <CpButton appearance="secondary" color="accent" size="lg" style="width: 100%;">
315
+ <template #leading-icon><CpIcon type="download" /></template>
316
+ Download your invoice (PDF)
317
+ </CpButton>
318
+ <p style="color: var(--cp-text-secondary); font-size: 14px;">Paid on Jul 25, 2026 · card •• 4242</p>
319
+ </template>
320
+ </CpBasket>
321
+ `,
322
+ }),
323
+ }