Skip to content

Commit 24b8628

Browse files
committed
refactor(web): extract ticket-card
1 parent d47c40a commit 24b8628

5 files changed

Lines changed: 310 additions & 228 deletions

File tree

‎packages/translations/source/en.xlf‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -245,12 +245,6 @@
245245
<trans-unit id="home.subscribe-block.error">
246246
<source>Could not subscribe. Please try again.</source>
247247
</trans-unit>
248-
<trans-unit id="home.tickets-block.title">
249-
<source>Tickets</source>
250-
</trans-unit>
251-
<trans-unit id="home.tickets-block.error">
252-
<source>Error loading tickets</source>
253-
</trans-unit>
254248
<trans-unit id="home.tickets-block.sold-out-sticker">
255249
<source>Sold out</source>
256250
</trans-unit>
@@ -263,6 +257,12 @@
263257
<trans-unit id="home.tickets-block.save">
264258
<source>Save <x id="0" equiv-text="${discount}"/> today</source>
265259
</trans-unit>
260+
<trans-unit id="home.tickets-block.title">
261+
<source>Tickets</source>
262+
</trans-unit>
263+
<trans-unit id="home.tickets-block.error">
264+
<source>Error loading tickets</source>
265+
</trans-unit>
266266
<trans-unit id="markdown.remote-markdown.error">
267267
<source>Error loading content</source>
268268
</trans-unit>
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
import { html, nothing, render as litRender } from 'lit';
3+
import { fixture } from '../../../__tests__/helpers/fixtures';
4+
import type { Ticket } from '../../models/ticket';
5+
import type { TicketCard } from './ticket-card';
6+
import './ticket-card';
7+
8+
const ticket: Ticket = {
9+
available: true,
10+
currency: '$',
11+
info: 'General admission',
12+
name: 'Regular Ticket',
13+
price: 100,
14+
regular: true,
15+
soldOut: false,
16+
url: 'https://example.com/buy',
17+
};
18+
19+
const render = async (props: Partial<TicketCard> = {}) => {
20+
const result = await fixture<TicketCard>(html`<ticket-card></ticket-card>`);
21+
Object.assign(result.element, { ticket, ...props });
22+
await result.element.updateComplete;
23+
return result;
24+
};
25+
26+
describe('ticket-card', () => {
27+
afterEach(() => {
28+
litRender(nothing, document.body);
29+
});
30+
31+
it('shows the name, price, dates and info, with a link to buy', async () => {
32+
const { shadowRoot } = await render({
33+
ticket: { ...ticket, starts: 'Jan 1', ends: 'Feb 1' },
34+
});
35+
36+
expect(shadowRoot.querySelector('.name')).toHaveTextContent('Regular Ticket');
37+
expect(shadowRoot.querySelector('.price')).toHaveTextContent('$100');
38+
expect(shadowRoot.querySelector('.dates')).toHaveTextContent('Jan 1 – Feb 1');
39+
expect(shadowRoot.querySelector('.info')).toHaveTextContent('General admission');
40+
expect(shadowRoot.querySelector('.stub hb-button')).toHaveAttribute('href', ticket.url);
41+
expect(shadowRoot.querySelector('.stub hb-button')).toHaveTextContent('Buy ticket');
42+
expect(shadowRoot.querySelector('hb-sticker')).toBeNull();
43+
});
44+
45+
it('shows the saving against the full price', async () => {
46+
const { shadowRoot } = await render({ ticket: { ...ticket, price: 80 }, fullPrice: 100 });
47+
48+
expect(shadowRoot.querySelector('hb-sticker')).toHaveTextContent('Save 20% today');
49+
});
50+
51+
it('shows no saving on the primary ticket', async () => {
52+
const { shadowRoot } = await render({
53+
ticket: { ...ticket, primary: true, regular: false },
54+
fullPrice: 100,
55+
});
56+
57+
expect(shadowRoot.querySelector('hb-sticker')).toBeNull();
58+
});
59+
60+
it('marks a popular ticket with a sticker', async () => {
61+
const { shadowRoot } = await render({ ticket: { ...ticket, inDemand: true } });
62+
63+
expect(shadowRoot.querySelector('.ticket')).toHaveClass('in-demand');
64+
expect(shadowRoot.querySelector('hb-sticker')).toHaveTextContent('Popular');
65+
});
66+
67+
it('turns off buying a sold out ticket, and says so in text', async () => {
68+
const { shadowRoot } = await render({
69+
ticket: { ...ticket, soldOut: true, inDemand: true },
70+
fullPrice: 200,
71+
});
72+
73+
expect(shadowRoot.querySelector('.ticket')).toHaveClass('sold-out');
74+
expect(shadowRoot.querySelectorAll('hb-sticker')).toHaveLength(1);
75+
expect(shadowRoot.querySelector('hb-sticker')).toHaveTextContent('Sold out');
76+
expect(shadowRoot.querySelector('.stub hb-button')).toHaveAttribute('disabled');
77+
expect(shadowRoot.querySelector('.stub hb-button')).toHaveTextContent('You missed it!');
78+
});
79+
80+
it('turns off buying a ticket that is not available yet', async () => {
81+
const { shadowRoot } = await render({ ticket: { ...ticket, available: false } });
82+
83+
expect(shadowRoot.querySelector('.stub hb-button')).toHaveAttribute('disabled');
84+
expect(shadowRoot.querySelector('.stub hb-button')).toHaveTextContent('Not available yet');
85+
});
86+
});
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { msg, str } from '@lit/localize';
2+
import { css, html, nothing } from 'lit';
3+
import { customElement, property } from 'lit/decorators.js';
4+
import type { Ticket } from '../../models/ticket';
5+
import { getLocale } from '../../utils/localization';
6+
import { ThemedComponent } from '../themed-component';
7+
import '../ui/hb-button';
8+
import '../ui/hb-sticker';
9+
10+
/**
11+
* A ticket-shaped card: the name, price, dates and info, stickers when it is sold out, popular or
12+
* cheaper than full price, and a tear-off stub with the link to buy.
13+
*/
14+
@customElement('ticket-card')
15+
export class TicketCard extends ThemedComponent {
16+
static override styles = css`
17+
:host {
18+
display: block;
19+
block-size: 100%;
20+
}
21+
22+
.ticket {
23+
position: relative;
24+
display: flex;
25+
flex-direction: column;
26+
block-size: 100%;
27+
border: var(--hb-border-width) solid var(--hb-border-color);
28+
border-radius: var(--hb-radius-l);
29+
background-color: var(--hb-panel-background);
30+
backdrop-filter: var(--hb-backdrop-filter);
31+
color: var(--hb-color-on-surface);
32+
box-shadow: var(--hb-shadow-card);
33+
}
34+
35+
.ticket.in-demand {
36+
background-color: var(--hb-color-primary-container);
37+
color: var(--hb-color-on-primary-container);
38+
}
39+
40+
.stickers {
41+
position: absolute;
42+
inset-block-start: calc(var(--hb-space-3) * -1);
43+
inset-inline-end: var(--hb-space-4);
44+
display: flex;
45+
gap: var(--hb-space-1);
46+
}
47+
48+
.main {
49+
flex: 1;
50+
padding: var(--hb-space-6) var(--hb-space-5) var(--hb-space-5);
51+
}
52+
53+
.name {
54+
margin: 0;
55+
padding: 0;
56+
font: 700 var(--hb-text-lg) / 1.2 var(--hb-font-body);
57+
}
58+
59+
.price {
60+
margin: var(--hb-space-3) 0 0;
61+
font: 800 var(--hb-text-4xl) / 1 var(--hb-font-display);
62+
overflow-wrap: anywhere;
63+
}
64+
65+
.sold-out .price {
66+
text-decoration: line-through;
67+
}
68+
69+
.dates {
70+
margin: var(--hb-space-3) 0 0;
71+
font: 500 var(--hb-text-sm) / 1.4 var(--hb-font-mono);
72+
}
73+
74+
.info {
75+
margin: var(--hb-space-2) 0 0;
76+
font-size: var(--hb-text-sm);
77+
}
78+
79+
/* The tear-off line, with a notch at each end in the band's color. */
80+
.stub {
81+
position: relative;
82+
padding: var(--hb-space-4) var(--hb-space-5) var(--hb-space-5);
83+
border-block-start: var(--hb-border-width) dashed currentColor;
84+
}
85+
86+
.stub::before,
87+
.stub::after {
88+
content: '';
89+
position: absolute;
90+
inset-block-start: 0;
91+
inline-size: 24px;
92+
block-size: 24px;
93+
border: var(--hb-border-width) solid var(--hb-border-color);
94+
border-radius: 50%;
95+
background-color: var(--hb-band-background, var(--hb-color-surface));
96+
translate: 0 -50%;
97+
}
98+
99+
/* Half circles that cover the card's edge. */
100+
.stub::before {
101+
inset-inline-start: -14px;
102+
clip-path: inset(0 0 0 50%);
103+
}
104+
105+
.stub::after {
106+
inset-inline-end: -14px;
107+
clip-path: inset(0 50% 0 0);
108+
}
109+
110+
.stub hb-button {
111+
inline-size: 100%;
112+
}
113+
114+
@media (forced-colors: active) {
115+
.ticket {
116+
border-color: CanvasText;
117+
}
118+
}
119+
`;
120+
121+
@property({ attribute: false })
122+
accessor ticket: Ticket | undefined;
123+
/** The primary ticket's price, which a regular ticket shows its saving against. */
124+
@property({ type: Number })
125+
accessor fullPrice: number | undefined;
126+
127+
override render() {
128+
const ticket = this.ticket;
129+
if (!ticket) return nothing;
130+
const discount = this.discount(ticket);
131+
const classes = [ticket.soldOut && 'sold-out', ticket.inDemand && 'in-demand'].filter(Boolean);
132+
return html`
133+
<article class="ticket ${classes.join(' ')}">
134+
<div class="stickers">
135+
${
136+
ticket.soldOut
137+
? html`<hb-sticker accent="2" tilt="4"
138+
>${msg('Sold out', { id: 'home.tickets-block.sold-out-sticker' })}</hb-sticker
139+
>`
140+
: nothing
141+
}
142+
${
143+
!ticket.soldOut && ticket.inDemand
144+
? html`<hb-sticker tilt="4"
145+
>${msg('Popular', { id: 'home.tickets-block.in-demand' })}</hb-sticker
146+
>`
147+
: nothing
148+
}
149+
${discount ? html`<hb-sticker accent="4" tilt="-3">${discount}</hb-sticker>` : nothing}
150+
</div>
151+
<div class="main">
152+
<h3 class="name">${ticket.name}</h3>
153+
<p class="price">${ticket.currency}${ticket.price}</p>
154+
${ticket.starts ? html`<p class="dates">${ticket.starts} – ${ticket.ends}</p>` : nothing}
155+
<p class="info">${ticket.info}</p>
156+
</div>
157+
<div class="stub">
158+
<hb-button
159+
variant="cta"
160+
href="${ticket.url}"
161+
target="_blank"
162+
?disabled="${ticket.soldOut || !ticket.available}"
163+
>
164+
${this.buttonLabel(ticket)}
165+
</hb-button>
166+
</div>
167+
</article>
168+
`;
169+
}
170+
171+
private buttonLabel(ticket: Ticket) {
172+
if (ticket.soldOut) {
173+
return msg('You missed it!', {
174+
id: 'home.tickets-block.sold-out',
175+
desc: 'Shown on a sold-out ticket.',
176+
});
177+
}
178+
return ticket.available
179+
? msg('Buy ticket', { id: 'common.buy-ticket' })
180+
: msg('Not available yet', { id: 'home.tickets-block.not-available' });
181+
}
182+
183+
private discount(ticket: Ticket) {
184+
const fullPrice = this.fullPrice;
185+
if (!ticket.regular || ticket.primary || ticket.soldOut || !fullPrice) {
186+
return '';
187+
}
188+
const discount = new Intl.NumberFormat(getLocale(), { style: 'percent' }).format(
189+
Math.round(100 - (ticket.price * 100) / fullPrice) / 100,
190+
);
191+
return msg(str`Save ${discount} today`, { id: 'home.tickets-block.save' });
192+
}
193+
}
194+
195+
declare global {
196+
interface HTMLElementTagNameMap {
197+
'ticket-card': TicketCard;
198+
}
199+
}

‎packages/web/src/components/home/tickets-block.test.ts‎

Lines changed: 10 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
33
import { html } from 'lit';
44
import { fixture } from '../../../__tests__/helpers/fixtures';
55
import type { Ticket } from '../../models/ticket';
6+
import type { TicketCard } from './ticket-card';
67
import type { TicketsBlock } from './tickets-block';
78
import './tickets-block';
89

@@ -42,66 +43,26 @@ describe('tickets-block', () => {
4243
expect(shadowRoot).toHaveTextContent('Error loading tickets');
4344
});
4445

45-
it('renders ticket details on success', async () => {
46+
it('shows each ticket as a card, with the full price to show savings against', async () => {
4647
const { element, shadowRoot } = await fixture<TicketsBlock>(
4748
html`<tickets-block></tickets-block>`,
4849
);
49-
element.tickets = new Success([ticket]);
50-
await element.updateComplete;
51-
52-
expect(shadowRoot).toHaveTextContent(ticket.name);
53-
expect(shadowRoot).toHaveTextContent(ticket.info);
54-
expect(shadowRoot.querySelector('.price')).toHaveTextContent('$100');
55-
expect(shadowRoot.querySelector('.stub hb-button')).toHaveAttribute('href', ticket.url);
56-
expect(shadowRoot.querySelector('.stub hb-button')).toHaveTextContent('Buy ticket');
57-
});
58-
59-
it('shows the discount against the primary ticket', async () => {
60-
const { element, shadowRoot } = await fixture<TicketsBlock>(
61-
html`<tickets-block></tickets-block>`,
62-
);
63-
element.tickets = new Success([
64-
{ ...ticket, name: 'Full price', primary: true, regular: false },
65-
{ ...ticket, price: 80 },
66-
]);
50+
const primary = { ...ticket, name: 'Full price', price: 120, primary: true, regular: false };
51+
element.tickets = new Success([primary, ticket]);
6752
await element.updateComplete;
6853

69-
expect(shadowRoot.querySelector('hb-sticker')).toHaveTextContent('Save 20% today');
54+
const cards = [...shadowRoot.querySelectorAll<TicketCard>('.tickets ticket-card')];
55+
expect(cards.map((card) => card.ticket)).toEqual([primary, ticket]);
56+
expect(cards.map((card) => card.fullPrice)).toEqual([120, 120]);
7057
});
7158

72-
it('marks a popular ticket with a sticker', async () => {
59+
it('has no full price without a primary ticket', async () => {
7360
const { element, shadowRoot } = await fixture<TicketsBlock>(
7461
html`<tickets-block></tickets-block>`,
7562
);
76-
element.tickets = new Success([{ ...ticket, inDemand: true }]);
77-
await element.updateComplete;
78-
79-
expect(shadowRoot.querySelector('.ticket')).toHaveClass('in-demand');
80-
expect(shadowRoot.querySelector('hb-sticker')).toHaveTextContent('Popular');
81-
});
82-
83-
it('turns off buying a sold out ticket, and says so in text', async () => {
84-
const { element, shadowRoot } = await fixture<TicketsBlock>(
85-
html`<tickets-block></tickets-block>`,
86-
);
87-
element.tickets = new Success([{ ...ticket, soldOut: true, inDemand: true }]);
88-
await element.updateComplete;
89-
90-
expect(shadowRoot.querySelector('.ticket')).toHaveClass('sold-out');
91-
expect(shadowRoot.querySelectorAll('hb-sticker')).toHaveLength(1);
92-
expect(shadowRoot.querySelector('hb-sticker')).toHaveTextContent('Sold out');
93-
expect(shadowRoot.querySelector('.stub hb-button')).toHaveAttribute('disabled');
94-
expect(shadowRoot.querySelector('.stub hb-button')).toHaveTextContent('You missed it!');
95-
});
96-
97-
it('turns off buying a ticket that is not available yet', async () => {
98-
const { element, shadowRoot } = await fixture<TicketsBlock>(
99-
html`<tickets-block></tickets-block>`,
100-
);
101-
element.tickets = new Success([{ ...ticket, available: false }]);
63+
element.tickets = new Success([ticket]);
10264
await element.updateComplete;
10365

104-
expect(shadowRoot.querySelector('.stub hb-button')).toHaveAttribute('disabled');
105-
expect(shadowRoot.querySelector('.stub hb-button')).toHaveTextContent('Not available yet');
66+
expect(shadowRoot.querySelector<TicketCard>('ticket-card')!.fullPrice).toBeUndefined();
10667
});
10768
});

0 commit comments

Comments
 (0)