implementation #1

Open
gaiety wants to merge 11 commits from implementation into main
11 changed files with 910 additions and 740 deletions

1
.tool-versions Normal file
View file

@ -0,0 +1 @@
nodejs 22.16.0

1252
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,11 +3,11 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^19.1.0",
Review

Latest React has nicer/simpler form action handling

Latest React has nicer/simpler form action handling
"react-dom": "^19.1.0",
"react-scripts": "5.0.1"
},
"scripts": {
@ -33,5 +33,9 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"html-react-parser": "^5.2.5",
Review

Description comes from JSON as raw HTML, this is a quick solution so we don't trust the HTML too far. But I'd do more research into the cleanest solution.

Extra points for the backend simply storing as markdown instead.

Description comes from JSON as raw HTML, this is a quick solution so we don't trust the HTML too far. But I'd do more research into the cleanest solution. Extra points for the backend simply storing as markdown instead.
"react-imask": "^7.6.1"
Review

Many react mask plugins don't work, and very few address accessibility.

If this was a real project I'd pair with designers to find alternative ways to implement these forms without masking, more in line with the GOV.UK design system does.

Many react mask plugins don't work, and very few address accessibility. If this was a real project I'd pair with designers to find alternative ways to implement these forms _without_ masking, more in line with the GOV.UK design system does.
}
}

View file

@ -2,13 +2,19 @@ import skaBand from "./band-json/ska-band.json";
import kpopBand from "./band-json/kpop-band.json";
import punkBand from "./band-json/punk-band.json";
import BandHeader from "./BandHeader";
import BandDetails from "./BandDetails";
import BandForm from "./BandForm";
function App() {
const bands = [skaBand, kpopBand, punkBand];
return (
<div className="App">
Review

Ideally this would be a page where you pick a band, but for now we just hard code [0].

Ideally this would be a page where you pick a band, but for now we just hard code `[0]`.
<BandForm band={bands[0]} />
<div className="App wrapper">
<BandHeader band={bands[0]} />
<main className="content">
<BandDetails band={bands[0]} />
<BandForm band={bands[0]} />
</main>
</div>
);
}

13
src/BandDetails.js Normal file
View file

@ -0,0 +1,13 @@
import parse from 'html-react-parser';
function BandDetails({ band }) {
return (
<div className="details">
<img src={band.imgUrl} alt="" />
Review

Backend should return alt text for the images.

Backend should return alt text for the images.
{parse(band.description_blurb)}
Review

Safely parse the HTML with html-react-parser, more research needed if this is the most ideal tool/method.

Safely parse the HTML with `html-react-parser`, more research needed if this is the most ideal tool/method.
</div>
);
}
export default BandDetails;

View file

@ -1,13 +1,168 @@
import { useState, useRef } from 'react';
import { IMaskInput } from 'react-imask';
const CentsInADollar = 100;
const DefaultTotal = 0;
function deepClone(data) {
// TODO: replace with structuredClone?
return JSON.parse(
JSON.stringify(
data
)
);
}
function costToReadable(cost) {
return cost / CentsInADollar;
}
function submitPurchase(data) {
// TODO: Send to backend
console.log(data);
}
function BandForm({ band }) {
const formRef = useRef(null);
const [total, setTotal] = useState(DefaultTotal);
const [formIsPending, setFormIsPending] = useState(false);
function normalizePurchaseData(formData) {
const data = deepClone(Object.fromEntries(formData));
delete data.ticketQuantity;
data.tickets = formData.getAll('ticketQuantity').map((quantity, index) => {
const {cost, name} = band.ticketTypes[index];
return {
name,
quantity,
totalCost: cost * quantity,
}
});
return data;
}
function purchase(formData) {
if (formRef.current.checkValidity() === false) {
console.warn('Form is invalid');
return;
}
setFormIsPending(true);
submitPurchase(normalizePurchaseData(formData));
}
function ticketQuantityChanged() {
const formData = new FormData(formRef.current);
const ticketQuantities = formData.getAll('ticketQuantity');
const total = ticketQuantities.reduce((total, quantity, index) => {
return total += (band.ticketTypes[index].cost * quantity);
}, 0)
setTotal(total > 0 ? total : 0)
}
return (
<div>
<h1>{band.name}</h1>
{band.ticketTypes.map((ticket) => (
<p>
{ticket.name} - {ticket.description}
</p>
<form className="form" action={purchase} ref={formRef}>
<h2>Select Tickets</h2>
{band.ticketTypes.map(({type, name, description, cost}, index) => (
<div className="ticketWrapper" key={name}>
<div className="ticket">
<div className="ticketDetails">
<h3 id={name}>{name}</h3>
<p>{description}</p>
<h4>${costToReadable(cost)}</h4>
</div>
<div>
{/* TODO: describedby cost and description */}
<input
labelledby={name}
defaultValue="0"
name="ticketQuantity"
id={name}
type="number"
step="1"
min="0"
onChange={ticketQuantityChanged}
/>
</div>
</div>
<hr />
</div>
))}
</div>
<div className="total">
<h3>Total</h3>
<p aria-live="polite">${costToReadable(total)}</p>
Review

Need to test this with an actual screenreader. Should announce when prices change so the user is aware how much their "cart" costs.

Need to test this with an actual screenreader. Should announce when prices change so the user is aware how much their "cart" costs.
</div>
<div className="information">
<div className="form-row">
<input
Review

All of these should have autocomplete properties for easier autofill.

All of these should have `autocomplete` properties for easier autofill.
required
name="first_name"
label="First Name"
placeholder="First Name"
type="text"
/>
<input
required
name="last_name"
label="Last Name"
placeholder="Last Name"
type="text"
/>
</div>
{/* TODO: third party library address verification */}
<input
required
name="address"
label="Address"
placeholder="Address"
type="text"
/>
</div>
<fieldset className="payment">
<legend>Payment Details</legend>
{/* TODO: regex pattern */}
Review

Currently only checks that anything is entered, given more time I'd write regex for validation (likely against the HTML pattern attribute).

Currently only checks that anything is entered, given more time I'd write regex for validation (likely against the HTML `pattern` attribute).
<IMaskInput
required
mask="0000 0000 0000 0000"
name="cardNumber"
label="Card Number"
placeholder="0000 0000 0000 0000"
type="text"
></IMaskInput>
<div className="form-row">
{/* TODO: regex pattern */}
<IMaskInput
required
mask="00 / 00"
pattern="\d*"
name="cardExpiration"
label="Card Expiration (MM/YY)"
placeholder="MM / YY"
type="text"
></IMaskInput>
{/* TODO: regex pattern */}
<IMaskInput
required
mask={Number}
name="cardCVV"
label="Card CVV"
placeholder="CVV"
type="text"
></IMaskInput>
</div>
</fieldset>
<button type="submit" disabled={formIsPending}>Get Tickets</button>
</form>
);
}

13
src/BandHeader.js Normal file
View file

@ -0,0 +1,13 @@
function BandHeader({ band }) {
return (
<header className="header">
<h1>{band.name}</h1>
<div className="header-details">
<time>{band.date}</time> {/* TODO: Format properly */}
Review

Needs pretty icon, to render not as an ugly number but as an elegent date.
Yet, should properly fill out the <time> attributes with what the computer friendly timestamp is.

Needs pretty icon, to render not as an ugly number but as an elegent date. Yet, should properly fill out the `<time>` attributes with what the computer friendly timestamp is.
<address>{band.location}</address> {/* TODO: Format properly */}
Review

Needs icon, line break.

Needs icon, line break.
</div>
</header>
);
}
export default BandHeader;

View file

@ -3,7 +3,7 @@
"id": "flaming-potatoes",
"date": 1683644012000,
"location": "Groove, 125 MacDougal St, New York, NY 10012",
"description_blurb": "<p>We're the Flaming Potatoes, and once you come to this awesome small club performance, you'll be our Best Spuds!</p>.",
Review

Rendered funny with a period after the paragraph? Likely a typo.

Rendered funny with a period _after_ the paragraph? Likely a typo.
"description_blurb": "<p>We're the Flaming Potatoes, and once you come to this awesome small club performance, you'll be our Best Spuds!</p>",
"imgUrl": "https://placehold.co/600x400/51aa97/000000",
"ticketTypes": [
{

View file

@ -1,13 +1,120 @@
body {
margin: 0;
@import "./reset.css";
@import "./variables.css";
* {
box-sizing: border-box;
}
html {
color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: var(--spacing-lg);
font-size: var(--font-base);
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
input {
width: 100%;
padding: 1rem;
font-size: var(--font-base);
}
hr {
border-color: var(--color-text);
border-width: 1px;
width: 100%;
}
h1, h2 {
color: var(--color-text-darker);
font-weight: bold;
}
h1 {
font-size: var(--font-xl);
}
h2 {
font-size: var(--font-lg);
}
h3 {
font-size: var(--font-lg);
color: var(--color-text-dark);
}
h4 {
font-size: var(--font-lg);
}
.wrapper {
max-width: 1024px;
margin: 0 auto;
display: grid;
flex-direction: column;
gap: var(--spacing-lg);
}
.content {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-lg);
}
.details {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.form {
background-color: var(--color-section);
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
.ticketWrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.ticket {
display: flex;
flex-direction: row;
gap: var(--spacing-lg);
}
.ticketDetails {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.total {
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: var(--font-lg);
}
.form-row {
display: flex;
flex-direction: row;
gap: var(--spacing-md);
}
.information, .payment {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
legend {
margin-bottom: var(--spacing-md);
}
}
}

48
src/reset.css Normal file
View file

@ -0,0 +1,48 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

13
src/variables.css Normal file
View file

@ -0,0 +1,13 @@
:root {
--spacing-md: 1rem;
--spacing-lg: 2rem;
--color-text: #7d8ca1;
--color-text-dark: #626262;
--color-text-darker: #3d4753;
--color-section: #f7f8fa;
--font-base: 16px;
--font-lg: 1.75rem;
--font-xl: 2.5rem;
}