@saywright/studio 0.0.0-stage → 0.1.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.
Files changed (107) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +63 -2
  4. package/bin/saywright.mjs +10 -0
  5. package/dist/THIRD_PARTY_NOTICES.md +2699 -0
  6. package/dist/axe.min.js +12 -0
  7. package/dist/bridge.js +6582 -0
  8. package/dist/cli.mjs +56050 -0
  9. package/dist/client/assets/Calibration-xY4YDLzU.js +1 -0
  10. package/dist/client/assets/Calibration-xY4YDLzU.js.br +0 -0
  11. package/dist/client/assets/Calibration-xY4YDLzU.js.gz +0 -0
  12. package/dist/client/assets/CodeViews-ytEy2pIT.js +3 -0
  13. package/dist/client/assets/CodeViews-ytEy2pIT.js.br +0 -0
  14. package/dist/client/assets/CodeViews-ytEy2pIT.js.gz +0 -0
  15. package/dist/client/assets/CommandPalette-BA3akF-p.js +1 -0
  16. package/dist/client/assets/CommandPalette-BA3akF-p.js.br +0 -0
  17. package/dist/client/assets/CommandPalette-BA3akF-p.js.gz +0 -0
  18. package/dist/client/assets/HelpDialog-C6qmdEwi.js +1 -0
  19. package/dist/client/assets/HelpDialog-C6qmdEwi.js.br +0 -0
  20. package/dist/client/assets/HelpDialog-C6qmdEwi.js.gz +0 -0
  21. package/dist/client/assets/ReportView-Bj9xxLCi.js +4 -0
  22. package/dist/client/assets/ReportView-Bj9xxLCi.js.br +0 -0
  23. package/dist/client/assets/ReportView-Bj9xxLCi.js.gz +0 -0
  24. package/dist/client/assets/SettingsDialog-VrACcdUX.js +1 -0
  25. package/dist/client/assets/SettingsDialog-VrACcdUX.js.br +0 -0
  26. package/dist/client/assets/SettingsDialog-VrACcdUX.js.gz +0 -0
  27. package/dist/client/assets/asr.worker-FcRNDSsJ.js +17 -0
  28. package/dist/client/assets/asr.worker-FcRNDSsJ.js.br +0 -0
  29. package/dist/client/assets/asr.worker-FcRNDSsJ.js.gz +0 -0
  30. package/dist/client/assets/button-B2NFEpCs.js +18 -0
  31. package/dist/client/assets/button-B2NFEpCs.js.br +0 -0
  32. package/dist/client/assets/button-B2NFEpCs.js.gz +0 -0
  33. package/dist/client/assets/camera-BD9K1f5I.js +1 -0
  34. package/dist/client/assets/cn-Wq33h1G4.js +1 -0
  35. package/dist/client/assets/cn-Wq33h1G4.js.br +0 -0
  36. package/dist/client/assets/cn-Wq33h1G4.js.gz +0 -0
  37. package/dist/client/assets/confirm-D1U-bFIL.js +41 -0
  38. package/dist/client/assets/confirm-D1U-bFIL.js.br +0 -0
  39. package/dist/client/assets/confirm-D1U-bFIL.js.gz +0 -0
  40. package/dist/client/assets/footprints-DiYtIryA.js +1 -0
  41. package/dist/client/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  42. package/dist/client/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  43. package/dist/client/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  44. package/dist/client/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  45. package/dist/client/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  46. package/dist/client/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  47. package/dist/client/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  48. package/dist/client/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  49. package/dist/client/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  50. package/dist/client/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  51. package/dist/client/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  52. package/dist/client/assets/index-BakNwOFF.css +2 -0
  53. package/dist/client/assets/index-BakNwOFF.css.br +0 -0
  54. package/dist/client/assets/index-BakNwOFF.css.gz +0 -0
  55. package/dist/client/assets/index-DUAtV_Av.js +18 -0
  56. package/dist/client/assets/index-DUAtV_Av.js.br +0 -0
  57. package/dist/client/assets/index-DUAtV_Av.js.gz +0 -0
  58. package/dist/client/assets/ort-wasm-simd-threaded-DcHrbrbl.wasm +0 -0
  59. package/dist/client/assets/preload-helper-BLdHbfdN.js +1 -0
  60. package/dist/client/assets/preload-helper-BLdHbfdN.js.br +0 -0
  61. package/dist/client/assets/preload-helper-BLdHbfdN.js.gz +0 -0
  62. package/dist/client/assets/refresh-cw-Dobx2YmO.js +1 -0
  63. package/dist/client/assets/tracker.worker-DWwj76VA.js +41 -0
  64. package/dist/client/assets/tracker.worker-DWwj76VA.js.br +0 -0
  65. package/dist/client/assets/tracker.worker-DWwj76VA.js.gz +0 -0
  66. package/dist/client/assets/vision_bundle-ByYCzljp.js +41 -0
  67. package/dist/client/assets/vision_bundle-ByYCzljp.js.br +0 -0
  68. package/dist/client/assets/vision_bundle-ByYCzljp.js.gz +0 -0
  69. package/dist/client/favicon.svg +1 -0
  70. package/dist/client/index.html +27 -0
  71. package/dist/client/index.html.br +0 -0
  72. package/dist/client/index.html.gz +0 -0
  73. package/dist/client/voice-capture-worklet.js +23 -0
  74. package/dist/sample/index.html +13 -0
  75. package/dist/sample/package.json +23 -0
  76. package/dist/sample/playwright.config.ts +20 -0
  77. package/dist/sample/saywright.config.json +68 -0
  78. package/dist/sample/src/App.tsx +32 -0
  79. package/dist/sample/src/components/AddressCard.tsx +16 -0
  80. package/dist/sample/src/components/CouponField.tsx +50 -0
  81. package/dist/sample/src/components/Header.tsx +26 -0
  82. package/dist/sample/src/components/LineItems.tsx +22 -0
  83. package/dist/sample/src/components/OrderSummary.tsx +32 -0
  84. package/dist/sample/src/components/ProductCard.tsx +26 -0
  85. package/dist/sample/src/ds/Badge.tsx +14 -0
  86. package/dist/sample/src/ds/Button.tsx +16 -0
  87. package/dist/sample/src/ds/Card.tsx +17 -0
  88. package/dist/sample/src/ds/Heading.tsx +14 -0
  89. package/dist/sample/src/ds/ProductGrid.tsx +15 -0
  90. package/dist/sample/src/ds/SummaryRow.tsx +18 -0
  91. package/dist/sample/src/ds/TextField.tsx +31 -0
  92. package/dist/sample/src/ds/index.ts +7 -0
  93. package/dist/sample/src/lib/cart.ts +20 -0
  94. package/dist/sample/src/lib/money.ts +7 -0
  95. package/dist/sample/src/lib/pricing.ts +34 -0
  96. package/dist/sample/src/lib/route.ts +17 -0
  97. package/dist/sample/src/main.tsx +10 -0
  98. package/dist/sample/src/pages/Checkout.tsx +37 -0
  99. package/dist/sample/src/pages/Shop.tsx +27 -0
  100. package/dist/sample/src/styles.css +155 -0
  101. package/dist/sample/tests/smoke.spec.ts +7 -0
  102. package/dist/sample/tsconfig.json +37 -0
  103. package/dist/sample/vite.config.ts +8 -0
  104. package/dist/server/node-BgmEDdmn.mjs +23 -0
  105. package/dist/server/node-DuXxFg5I.mjs +2501 -0
  106. package/dist/server/speech-worker.mjs +266 -0
  107. package/package.json +97 -4
@@ -0,0 +1,32 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Header } from './components/Header';
3
+ import { useRoute } from './lib/route';
4
+ import { Checkout } from './pages/Checkout';
5
+ import { Shop } from './pages/Shop';
6
+
7
+ export function App() {
8
+ const route = useRoute();
9
+ const [toast, setToast] = useState<string | null>(null);
10
+
11
+ useEffect(() => {
12
+ if (!toast) return;
13
+ const t = setTimeout(() => setToast(null), 2200);
14
+ return () => clearTimeout(t);
15
+ }, [toast]);
16
+
17
+ useEffect(() => {
18
+ document.title = route === 'shop' ? 'Tidewell · Ceramics' : 'Tidewell · Checkout';
19
+ }, [route]);
20
+
21
+ return (
22
+ <div className="shell">
23
+ <Header route={route} />
24
+ {route === 'shop' ? <Shop onToast={setToast} /> : <Checkout onToast={setToast} />}
25
+ {toast && (
26
+ <div className="toast" role="status">
27
+ {toast}
28
+ </div>
29
+ )}
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,16 @@
1
+ import { Card } from '../ds';
2
+ import { ADDRESS } from '../lib/cart';
3
+
4
+ export function AddressCard() {
5
+ return (
6
+ <Card title="Shipping to">
7
+ <div className="address">
8
+ {ADDRESS.name}
9
+ <br />
10
+ {ADDRESS.lines[0]}
11
+ <br />
12
+ {ADDRESS.lines[1]}
13
+ </div>
14
+ </Card>
15
+ );
16
+ }
@@ -0,0 +1,50 @@
1
+ import { useState } from 'react';
2
+ import { Badge, Button, TextField } from '../ds';
3
+ import { findCoupon, type Coupon } from '../lib/pricing';
4
+
5
+ interface CouponFieldProps {
6
+ coupon: Coupon | null;
7
+ onApply: (coupon: Coupon) => void;
8
+ onRemove: () => void;
9
+ }
10
+
11
+ export function CouponField({ coupon, onApply, onRemove }: CouponFieldProps) {
12
+ const [code, setCode] = useState('');
13
+ const [error, setError] = useState<string | undefined>();
14
+
15
+ if (coupon) {
16
+ return (
17
+ <div className="coupon-applied">
18
+ <Badge tone="success">✓ {coupon.code} applied</Badge>
19
+ <Button variant="link" size="sm" onClick={onRemove}>
20
+ Remove
21
+ </Button>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ const apply = () => {
27
+ const found = findCoupon(code);
28
+ if (!found) {
29
+ setError('That code isn’t valid.');
30
+ return;
31
+ }
32
+ setError(undefined);
33
+ onApply(found);
34
+ };
35
+
36
+ return (
37
+ <form
38
+ className="coupon"
39
+ onSubmit={(e) => {
40
+ e.preventDefault();
41
+ apply();
42
+ }}
43
+ >
44
+ <TextField label="Coupon" placeholder="Coupon code" value={code} onChange={setCode} error={error} data-testid="coupon-input" />
45
+ <Button variant="secondary" size="sm" type="submit" data-testid="coupon-apply">
46
+ Apply
47
+ </Button>
48
+ </form>
49
+ );
50
+ }
@@ -0,0 +1,26 @@
1
+ import type { Route } from '../lib/route';
2
+
3
+ export function Header({ route }: { route: Route }) {
4
+ return (
5
+ <header className="header">
6
+ <a className="logo" href="#/shop">
7
+ tidewell
8
+ </a>
9
+ <nav className="nav" aria-label="Main">
10
+ <a href="#/shop" aria-current={route === 'shop' ? 'page' : undefined}>
11
+ Shop
12
+ </a>
13
+ <a href="#/checkout" aria-current={route === 'checkout' ? 'page' : undefined}>
14
+ Checkout
15
+ </a>
16
+ </nav>
17
+ <div className="header-end">
18
+ <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
19
+ <rect x="3" y="7" width="10" height="7" rx="2" fill="none" stroke="currentColor" strokeWidth="1.5" />
20
+ <path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2" fill="none" stroke="currentColor" strokeWidth="1.5" />
21
+ </svg>
22
+ Secure checkout
23
+ </div>
24
+ </header>
25
+ );
26
+ }
@@ -0,0 +1,22 @@
1
+ import { Card } from '../ds';
2
+ import type { CartItem } from '../lib/cart';
3
+ import { money } from '../lib/money';
4
+
5
+ export function LineItems({ items }: { items: CartItem[] }) {
6
+ return (
7
+ <Card title={`Items (${items.length})`}>
8
+ <div className="line-items">
9
+ {items.map((item) => (
10
+ <div className="line-item" key={item.id}>
11
+ <span className="thumb" style={{ background: item.swatch }} />
12
+ <span>
13
+ <b>{item.name}</b>
14
+ <small>{item.detail}</small>
15
+ </span>
16
+ <span className="price">{money(item.price * item.qty)}</span>
17
+ </div>
18
+ ))}
19
+ </div>
20
+ </Card>
21
+ );
22
+ }
@@ -0,0 +1,32 @@
1
+ import { Button, Card, SummaryRow } from '../ds';
2
+ import { money, minus } from '../lib/money';
3
+ import type { Coupon, Totals } from '../lib/pricing';
4
+ import { CouponField } from './CouponField';
5
+
6
+ interface OrderSummaryProps {
7
+ totals: Totals;
8
+ coupon: Coupon | null;
9
+ onApply: (coupon: Coupon) => void;
10
+ onRemove: () => void;
11
+ onPay: () => void;
12
+ }
13
+
14
+ export function OrderSummary({ totals, coupon, onApply, onRemove, onPay }: OrderSummaryProps) {
15
+ return (
16
+ <Card title="Order summary">
17
+ <div className="summary">
18
+ <CouponField coupon={coupon} onApply={onApply} onRemove={onRemove} />
19
+ <hr />
20
+ <SummaryRow label="Subtotal" value={money(totals.subtotal)} data-testid="summary-subtotal" />
21
+ <SummaryRow label="Shipping" value={money(totals.shipping)} data-testid="summary-shipping" />
22
+ {coupon && <SummaryRow label={`Discount (${coupon.code})`} value={minus(totals.discount)} tone="success" data-testid="summary-discount" />}
23
+ <hr />
24
+ <SummaryRow label="Total" value={money(totals.total)} emphasis="strong" size="lg" data-testid="summary-total" />
25
+ <Button size="lg" fullWidth onClick={onPay} data-testid="pay-button">
26
+ Pay {money(totals.total)}
27
+ </Button>
28
+ <p className="pay-note">Free returns within 30 days</p>
29
+ </div>
30
+ </Card>
31
+ );
32
+ }
@@ -0,0 +1,26 @@
1
+ import { Button } from '../ds';
2
+ import { money } from '../lib/money';
3
+
4
+ export interface Product {
5
+ id: string;
6
+ name: string;
7
+ note: string;
8
+ price: number;
9
+ swatch: string;
10
+ }
11
+
12
+ export function ProductCard({ product, onAdd }: { product: Product; onAdd: (p: Product) => void }) {
13
+ return (
14
+ <article className="product">
15
+ <div className="product-img" style={{ background: product.swatch }} />
16
+ <div className="product-meta">
17
+ <b>{product.name}</b>
18
+ <span>{money(product.price)}</span>
19
+ </div>
20
+ <small>{product.note}</small>
21
+ <Button variant="secondary" size="sm" onClick={() => onAdd(product)}>
22
+ Add to cart
23
+ </Button>
24
+ </article>
25
+ );
26
+ }
@@ -0,0 +1,14 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+
3
+ export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
4
+ tone?: 'neutral' | 'success' | 'danger' | 'brand';
5
+ children: ReactNode;
6
+ }
7
+
8
+ export function Badge({ tone = 'neutral', children, ...rest }: BadgeProps) {
9
+ return (
10
+ <span className="ds-badge" data-tone={tone} {...rest}>
11
+ {children}
12
+ </span>
13
+ );
14
+ }
@@ -0,0 +1,16 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from 'react';
2
+
3
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
4
+ variant?: 'primary' | 'secondary' | 'ghost' | 'link' | 'danger';
5
+ size?: 'sm' | 'md' | 'lg';
6
+ fullWidth?: boolean;
7
+ children: ReactNode;
8
+ }
9
+
10
+ export function Button({ variant = 'primary', size = 'md', fullWidth = false, type = 'button', children, ...rest }: ButtonProps) {
11
+ return (
12
+ <button className="ds-button" data-variant={variant} data-size={size} data-full={fullWidth} type={type} {...rest}>
13
+ {children}
14
+ </button>
15
+ );
16
+ }
@@ -0,0 +1,17 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+
3
+ export interface CardProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
4
+ title?: string;
5
+ padding?: 'sm' | 'md' | 'lg';
6
+ tone?: 'default' | 'muted' | 'brand';
7
+ children: ReactNode;
8
+ }
9
+
10
+ export function Card({ title, padding = 'md', tone = 'default', children, ...rest }: CardProps) {
11
+ return (
12
+ <section className="ds-card" data-padding={padding} data-tone={tone} {...rest}>
13
+ {title && <h2 className="ds-card-title">{title}</h2>}
14
+ {children}
15
+ </section>
16
+ );
17
+ }
@@ -0,0 +1,14 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+
3
+ export interface HeadingProps extends HTMLAttributes<HTMLHeadingElement> {
4
+ size?: 'sm' | 'md' | 'lg' | 'xl';
5
+ children: ReactNode;
6
+ }
7
+
8
+ export function Heading({ size = 'lg', children, ...rest }: HeadingProps) {
9
+ return (
10
+ <h1 className="ds-heading" data-size={size} {...rest}>
11
+ {children}
12
+ </h1>
13
+ );
14
+ }
@@ -0,0 +1,15 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+
3
+ export interface ProductGridProps extends HTMLAttributes<HTMLDivElement> {
4
+ columns?: '2' | '3' | '4';
5
+ gap?: 'sm' | 'md' | 'lg' | 'xl';
6
+ children: ReactNode;
7
+ }
8
+
9
+ export function ProductGrid({ columns = '3', gap = 'md', children, ...rest }: ProductGridProps) {
10
+ return (
11
+ <div className="ds-grid" data-columns={columns} data-gap={gap} {...rest}>
12
+ {children}
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,18 @@
1
+ import type { HTMLAttributes } from 'react';
2
+
3
+ export interface SummaryRowProps extends HTMLAttributes<HTMLDivElement> {
4
+ label: string;
5
+ value: string;
6
+ size?: 'sm' | 'md' | 'lg';
7
+ emphasis?: 'regular' | 'strong';
8
+ tone?: 'default' | 'success' | 'danger';
9
+ }
10
+
11
+ export function SummaryRow({ label, value, size = 'md', emphasis = 'regular', tone = 'default', ...rest }: SummaryRowProps) {
12
+ return (
13
+ <div className="ds-row" data-size={size} data-emphasis={emphasis} data-tone={tone} {...rest}>
14
+ <span>{label}</span>
15
+ <span className="ds-row-value">{value}</span>
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,31 @@
1
+ import { useId, type InputHTMLAttributes } from 'react';
2
+
3
+ export interface TextFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
4
+ label: string;
5
+ value: string;
6
+ onChange: (value: string) => void;
7
+ hint?: string;
8
+ error?: string;
9
+ }
10
+
11
+ export function TextField({ label, value, onChange, hint, error, ...rest }: TextFieldProps) {
12
+ const id = useId();
13
+ return (
14
+ <div className="ds-field">
15
+ <label htmlFor={id}>{label}</label>
16
+ <input
17
+ id={id}
18
+ className="ds-input"
19
+ value={value}
20
+ aria-invalid={error ? true : undefined}
21
+ onChange={(e) => onChange(e.target.value)}
22
+ {...rest}
23
+ />
24
+ {(error || hint) && (
25
+ <span className="ds-field-hint" data-tone={error ? 'danger' : undefined}>
26
+ {error ?? hint}
27
+ </span>
28
+ )}
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,7 @@
1
+ export { Badge } from './Badge';
2
+ export { Button } from './Button';
3
+ export { Card } from './Card';
4
+ export { Heading } from './Heading';
5
+ export { ProductGrid } from './ProductGrid';
6
+ export { SummaryRow } from './SummaryRow';
7
+ export { TextField } from './TextField';
@@ -0,0 +1,20 @@
1
+ export interface CartItem {
2
+ id: string;
3
+ name: string;
4
+ detail: string;
5
+ price: number;
6
+ qty: number;
7
+ swatch: string;
8
+ }
9
+
10
+ export const CART: CartItem[] = [
11
+ { id: 'dripper', name: 'Stoneware pour-over set', detail: 'Ash glaze · Qty 1', price: 54, qty: 1, swatch: 'radial-gradient(circle at 35% 30%, #ece6db, #b3aa9c)' },
12
+ { id: 'beans', name: 'Single-origin beans, 2 × 250 g', detail: 'Huila, Colombia · Qty 1', price: 32, qty: 1, swatch: 'linear-gradient(160deg, #6e4b35, #3a261b)' },
13
+ ];
14
+
15
+ export const SHIPPING_FLAT = 12;
16
+
17
+ export const ADDRESS = {
18
+ name: 'Sam Rivera',
19
+ lines: ['214 Harbor Street, Apt 3', 'Portland, OR 97209'],
20
+ };
@@ -0,0 +1,7 @@
1
+ export function money(n: number): string {
2
+ return `$${n.toFixed(2)}`;
3
+ }
4
+
5
+ export function minus(n: number): string {
6
+ return `−$${n.toFixed(2)}`;
7
+ }
@@ -0,0 +1,34 @@
1
+ import type { CartItem } from './cart';
2
+
3
+ export interface Coupon {
4
+ code: string;
5
+ /** Fraction off, e.g. 0.2 for 20%. */
6
+ rate: number;
7
+ /** What the discount covers. */
8
+ appliesTo: 'items' | 'order';
9
+ }
10
+
11
+ const COUPONS: Record<string, Coupon> = {
12
+ SAVE20: { code: 'SAVE20', rate: 0.2, appliesTo: 'items' },
13
+ WELCOME10: { code: 'WELCOME10', rate: 0.1, appliesTo: 'items' },
14
+ };
15
+
16
+ export function findCoupon(code: string): Coupon | null {
17
+ return COUPONS[code.trim().toUpperCase()] ?? null;
18
+ }
19
+
20
+ export interface Totals {
21
+ subtotal: number;
22
+ shipping: number;
23
+ discount: number;
24
+ total: number;
25
+ }
26
+
27
+ const round2 = (n: number) => Math.round(n * 100) / 100;
28
+
29
+ export function calcTotals(items: CartItem[], shipping: number, coupon: Coupon | null): Totals {
30
+ const subtotal = round2(items.reduce((sum, item) => sum + item.price * item.qty, 0));
31
+ const rate = coupon?.rate ?? 0;
32
+ const discount = round2((subtotal + shipping) * rate);
33
+ return { subtotal, shipping, discount, total: round2(subtotal + shipping - discount) };
34
+ }
@@ -0,0 +1,17 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ export type Route = 'checkout' | 'shop';
4
+
5
+ function read(): Route {
6
+ return window.location.hash.startsWith('#/shop') ? 'shop' : 'checkout';
7
+ }
8
+
9
+ export function useRoute(): Route {
10
+ const [route, setRoute] = useState<Route>(read);
11
+ useEffect(() => {
12
+ const onHash = () => setRoute(read());
13
+ window.addEventListener('hashchange', onHash);
14
+ return () => window.removeEventListener('hashchange', onHash);
15
+ }, []);
16
+ return route;
17
+ }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { App } from './App';
4
+ import './styles.css';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
@@ -0,0 +1,37 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { AddressCard } from '../components/AddressCard';
3
+ import { LineItems } from '../components/LineItems';
4
+ import { OrderSummary } from '../components/OrderSummary';
5
+ import { Heading } from '../ds';
6
+ import { CART, SHIPPING_FLAT } from '../lib/cart';
7
+ import { money } from '../lib/money';
8
+ import { calcTotals, type Coupon } from '../lib/pricing';
9
+
10
+ export function Checkout({ onToast }: { onToast: (text: string) => void }) {
11
+ const [coupon, setCoupon] = useState<Coupon | null>(null);
12
+ const totals = useMemo(() => calcTotals(CART, SHIPPING_FLAT, coupon), [coupon]);
13
+
14
+ return (
15
+ <main className="page">
16
+ <div className="page-head">
17
+ <Heading size="lg">Checkout</Heading>
18
+ <div className="steps" aria-label="Progress">
19
+ Cart <i /> Details <i /> <b>Payment</b>
20
+ </div>
21
+ </div>
22
+ <div className="checkout">
23
+ <div className="stack">
24
+ <AddressCard />
25
+ <LineItems items={CART} />
26
+ </div>
27
+ <OrderSummary
28
+ totals={totals}
29
+ coupon={coupon}
30
+ onApply={setCoupon}
31
+ onRemove={() => setCoupon(null)}
32
+ onPay={() => onToast(`Paid ${money(totals.total)}. Thank you!`)}
33
+ />
34
+ </div>
35
+ </main>
36
+ );
37
+ }
@@ -0,0 +1,27 @@
1
+ import { ProductCard, type Product } from '../components/ProductCard';
2
+ import { Badge, Heading, ProductGrid } from '../ds';
3
+
4
+ const PRODUCTS: Product[] = [
5
+ { id: 'dripper', name: 'Stoneware pour-over set', note: 'Ash glaze, thrown in Portland', price: 54, swatch: 'radial-gradient(circle at 40% 35%, #f1ece3, #b9af9f)' },
6
+ { id: 'celadon', name: 'Celadon mug, 12 oz', note: 'Dishwasher safe', price: 28, swatch: 'radial-gradient(circle at 55% 40%, #dce3e4, #8fa2a6)' },
7
+ { id: 'plate', name: 'Speckled dinner plate', note: 'Set of two', price: 34, swatch: 'radial-gradient(circle at 45% 30%, #efe3d3, #c59f78)' },
8
+ { id: 'bowl', name: 'Ash-glaze serving bowl', note: 'Holds 2 quarts', price: 31, swatch: 'radial-gradient(circle at 50% 40%, #e9e4da, #a99f90)' },
9
+ { id: 'night', name: 'Night mug, 10 oz', note: 'Matte black glaze', price: 26, swatch: 'radial-gradient(circle at 40% 45%, #3a484c, #151b1d)' },
10
+ { id: 'vase', name: 'Ivory bud vase', note: 'Small batch', price: 46, swatch: 'radial-gradient(circle at 50% 35%, #f3eee6, #cdbfae)' },
11
+ ];
12
+
13
+ export function Shop({ onToast }: { onToast: (text: string) => void }) {
14
+ return (
15
+ <main className="page">
16
+ <div className="page-head">
17
+ <Heading size="lg">Ceramics</Heading>
18
+ <Badge tone="brand">6 pieces · new this week</Badge>
19
+ </div>
20
+ <ProductGrid columns="3" gap="md">
21
+ {PRODUCTS.map((p) => (
22
+ <ProductCard key={p.id} product={p} onAdd={(product) => onToast(`Added ${product.name}`)} />
23
+ ))}
24
+ </ProductGrid>
25
+ </main>
26
+ );
27
+ }