Compare commits
11 commits
main
...
implementa
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3f45514fd8 | ||
![]() |
71c24d1cec | ||
![]() |
4b9e0627be | ||
![]() |
80625a82ed | ||
![]() |
0592650985 | ||
![]() |
746534d4d3 | ||
![]() |
46a18112cc | ||
![]() |
8a684ba8a2 | ||
![]() |
d770642d7d | ||
![]() |
c02139b1c9 | ||
![]() |
f595d0ac8f |
11 changed files with 910 additions and 740 deletions
1
.tool-versions
Normal file
1
.tool-versions
Normal file
|
@ -0,0 +1 @@
|
||||||
|
nodejs 22.16.0
|
1252
package-lock.json
generated
1252
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
@ -3,11 +3,11 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^13.4.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"react": "^18.2.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-scripts": "5.0.1"
|
"react-scripts": "5.0.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -33,5 +33,9 @@
|
||||||
"last 1 firefox version",
|
"last 1 firefox version",
|
||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"html-react-parser": "^5.2.5",
|
||||||
|
"react-imask": "^7.6.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
10
src/App.js
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 kpopBand from "./band-json/kpop-band.json";
|
||||||
import punkBand from "./band-json/punk-band.json";
|
import punkBand from "./band-json/punk-band.json";
|
||||||
|
|
||||||
|
import BandHeader from "./BandHeader";
|
||||||
|
import BandDetails from "./BandDetails";
|
||||||
import BandForm from "./BandForm";
|
import BandForm from "./BandForm";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const bands = [skaBand, kpopBand, punkBand];
|
const bands = [skaBand, kpopBand, punkBand];
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<div className="App wrapper">
|
||||||
<BandForm band={bands[0]} />
|
<BandHeader band={bands[0]} />
|
||||||
|
<main className="content">
|
||||||
|
<BandDetails band={bands[0]} />
|
||||||
|
<BandForm band={bands[0]} />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
13
src/BandDetails.js
Normal file
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="" />
|
||||||
|
{parse(band.description_blurb)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BandDetails;
|
||||||
|
|
169
src/BandForm.js
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 }) {
|
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 (
|
return (
|
||||||
<div>
|
<form className="form" action={purchase} ref={formRef}>
|
||||||
<h1>{band.name}</h1>
|
<h2>Select Tickets</h2>
|
||||||
{band.ticketTypes.map((ticket) => (
|
|
||||||
<p>
|
{band.ticketTypes.map(({type, name, description, cost}, index) => (
|
||||||
{ticket.name} - {ticket.description}
|
<div className="ticketWrapper" key={name}>
|
||||||
</p>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="information">
|
||||||
|
<div className="form-row">
|
||||||
|
<input
|
||||||
|
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 */}
|
||||||
|
<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
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 */}
|
||||||
|
<address>{band.location}</address> {/* TODO: Format properly */}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BandHeader;
|
|
@ -3,7 +3,7 @@
|
||||||
"id": "flaming-potatoes",
|
"id": "flaming-potatoes",
|
||||||
"date": 1683644012000,
|
"date": 1683644012000,
|
||||||
"location": "Groove, 125 MacDougal St, New York, NY 10012",
|
"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>.",
|
"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",
|
"imgUrl": "https://placehold.co/600x400/51aa97/000000",
|
||||||
"ticketTypes": [
|
"ticketTypes": [
|
||||||
{
|
{
|
||||||
|
|
117
src/index.css
117
src/index.css
|
@ -1,13 +1,120 @@
|
||||||
body {
|
@import "./reset.css";
|
||||||
margin: 0;
|
@import "./variables.css";
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color: var(--color-text);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
margin: var(--spacing-lg);
|
||||||
|
font-size: var(--font-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
input {
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
width: 100%;
|
||||||
monospace;
|
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
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
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;
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue