fix: address final review findings — Hero3D static class, InquiryModal a11y, date guards, shared email validation, README
Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
79
README.md
79
README.md
@@ -1,75 +1,10 @@
|
||||
# React + TypeScript + Vite
|
||||
# Braids by Mira
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
Public marketing site for Braids by Mira — landing page, product showcase, booking flow, and salon policies.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## Scripts
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
```
|
||||
- `npm run dev` — start the local dev server
|
||||
- `npm test` (or `npx vitest run`) — run the test suite
|
||||
- `npm run build` — type-check and build for production
|
||||
- `npm run lint` — run ESLint
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('Hero3D', () => {
|
||||
})
|
||||
const { default: Hero3D } = await import('./Hero3D')
|
||||
render(<Hero3D />)
|
||||
expect(screen.getByTestId('hero-3d').className).toMatch(/static/)
|
||||
expect(screen.getByTestId('hero-3d')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('hero-3d-motif').className).toMatch(/static/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,8 +9,8 @@ function Hero3D() {
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
return (
|
||||
<div className={`${styles.wrapper} static`} data-testid="hero-3d">
|
||||
<div className={styles.motif} />
|
||||
<div className={styles.wrapper} data-testid="hero-3d">
|
||||
<div className={styles.static} data-testid="hero-3d-motif" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,4 +32,17 @@ describe('InquiryModal', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /close/i }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose when Escape is pressed', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<InquiryModal product={product} onClose={onClose} />)
|
||||
await userEvent.keyboard('{Escape}')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exposes an accessible dialog labelled by its heading', () => {
|
||||
render(<InquiryModal product={product} onClose={vi.fn()} />)
|
||||
const dialog = screen.getByRole('dialog', { name: /ask about silk edge control/i })
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import type { Product } from '../data/products'
|
||||
import { isValidEmail } from '../lib/validation'
|
||||
import styles from './InquiryModal.module.css'
|
||||
|
||||
interface InquiryModalProps {
|
||||
@@ -12,9 +13,7 @@ interface Errors {
|
||||
email?: string
|
||||
}
|
||||
|
||||
function isValidEmail(email: string): boolean {
|
||||
return /\S+@\S+\.\S+/.test(email)
|
||||
}
|
||||
const HEADING_ID = 'inquiry-modal-heading'
|
||||
|
||||
function InquiryModal({ product, onClose }: InquiryModalProps) {
|
||||
const [name, setName] = useState('')
|
||||
@@ -22,6 +21,19 @@ function InquiryModal({ product, onClose }: InquiryModalProps) {
|
||||
const [notes, setNotes] = useState('')
|
||||
const [errors, setErrors] = useState<Errors>({})
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
nameInputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
@@ -35,8 +47,18 @@ function InquiryModal({ product, onClose }: InquiryModalProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.modal}>
|
||||
<div
|
||||
className={styles.overlay}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.modal}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={HEADING_ID}
|
||||
>
|
||||
{submitted ? (
|
||||
<>
|
||||
<p>Thanks — Mira will get back to you about {product.name} soon.</p>
|
||||
@@ -44,10 +66,15 @@ function InquiryModal({ product, onClose }: InquiryModalProps) {
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h3>Ask about {product.name}</h3>
|
||||
<h3 id={HEADING_ID}>Ask about {product.name}</h3>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="inquiry-name">Name</label>
|
||||
<input id="inquiry-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input
|
||||
id="inquiry-name"
|
||||
ref={nameInputRef}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
{errors.name && <span className={styles.error}>{errors.name}</span>}
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -13,7 +13,11 @@ function seedFromDate(date: string): number {
|
||||
}
|
||||
|
||||
export function getSlotsForDate(date: string): TimeSlot[] {
|
||||
const day = new Date(`${date}T00:00:00`).getDay()
|
||||
const parsed = new Date(`${date}T00:00:00`)
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return []
|
||||
}
|
||||
const day = parsed.getDay()
|
||||
if (day === 0) {
|
||||
// Sundays: closed, no slots at all
|
||||
return []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { TimeSlot } from '../data/availability'
|
||||
import { isValidEmail } from '../lib/validation'
|
||||
|
||||
export interface BookingContact {
|
||||
name: string
|
||||
@@ -30,10 +31,6 @@ function initialState(initialServiceId?: string): BookingState {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidEmail(email: string): boolean {
|
||||
return /\S+@\S+\.\S+/.test(email)
|
||||
}
|
||||
|
||||
export default function useBooking(initialServiceId?: string) {
|
||||
const [state, setState] = useState<BookingState>(() => initialState(initialServiceId))
|
||||
const [errors, setErrors] = useState<BookingErrors>({})
|
||||
|
||||
3
src/lib/validation.ts
Normal file
3
src/lib/validation.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return /\S+@\S+\.\S+/.test(email)
|
||||
}
|
||||
@@ -53,6 +53,7 @@ function Book() {
|
||||
id="booking-date"
|
||||
type="date"
|
||||
value={dateInput}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
onChange={(e) => {
|
||||
setDateInput(e.target.value)
|
||||
booking.selectDate(e.target.value)
|
||||
|
||||
Reference in New Issue
Block a user