implementation #1
1
.tool-versions
Normal file
|
@ -0,0 +1 @@
|
|||
nodejs 22.16.0
|
1252
package-lock.json
generated
12
package.json
|
@ -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",
|
||||
|
||||
"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",
|
||||
gaiety
commented
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"
|
||||
gaiety
commented
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.
|
||||
}
|
||||
}
|
||||
|
|
10
src/App.js
|
@ -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">
|
||||
gaiety
commented
Ideally this would be a page where you pick a band, but for now we just hard code 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
|
@ -0,0 +1,13 @@
|
|||
import parse from 'html-react-parser';
|
||||
|
||||
function BandDetails({ band }) {
|
||||
return (
|
||||
<div className="details">
|
||||
<img src={band.imgUrl} alt="" />
|
||||
gaiety
commented
Backend should return alt text for the images. Backend should return alt text for the images.
|
||||
{parse(band.description_blurb)}
|
||||
gaiety
commented
Safely parse the HTML with Safely parse the HTML with `html-react-parser`, more research needed if this is the most ideal tool/method.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BandDetails;
|
||||
|
169
src/BandForm.js
|
@ -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>
|
||||
gaiety
commented
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
|
||||
gaiety
commented
All of these should have 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 */}
|
||||
gaiety
commented
Currently only checks that anything is entered, given more time I'd write regex for validation (likely against the HTML 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
|
@ -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 */}
|
||||
gaiety
commented
Needs pretty icon, to render not as an ugly number but as an elegent date. 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 */}
|
||||
gaiety
commented
Needs icon, line break. Needs icon, line break.
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default BandHeader;
|
|
@ -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>.",
|
||||
gaiety
commented
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": [
|
||||
{
|
||||
|
|
117
src/index.css
|
@ -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
|
@ -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
|
@ -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;
|
||||
}
|
Latest React has nicer/simpler form action handling