@react-spa-scaffold/mcp 0.3.0 → 1.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 (83) hide show
  1. package/README.md +42 -20
  2. package/dist/features/registry.d.ts.map +1 -1
  3. package/dist/features/registry.js +59 -25
  4. package/dist/features/registry.js.map +1 -1
  5. package/dist/features/types.d.ts +1 -0
  6. package/dist/features/types.d.ts.map +1 -1
  7. package/dist/features/versions.json +1 -1
  8. package/dist/server.d.ts.map +1 -1
  9. package/dist/server.js +2 -1
  10. package/dist/server.js.map +1 -1
  11. package/dist/tools/get-example.d.ts +4 -6
  12. package/dist/tools/get-example.d.ts.map +1 -1
  13. package/dist/tools/get-example.js +2 -2
  14. package/dist/tools/get-example.js.map +1 -1
  15. package/dist/tools/get-scaffold.d.ts +3 -10
  16. package/dist/tools/get-scaffold.d.ts.map +1 -1
  17. package/dist/tools/get-scaffold.js +44 -20
  18. package/dist/tools/get-scaffold.js.map +1 -1
  19. package/dist/utils/examples.d.ts.map +1 -1
  20. package/dist/utils/examples.js +19 -16
  21. package/dist/utils/examples.js.map +1 -1
  22. package/dist/utils/paths.d.ts +2 -1
  23. package/dist/utils/paths.d.ts.map +1 -1
  24. package/dist/utils/paths.js +15 -2
  25. package/dist/utils/paths.js.map +1 -1
  26. package/dist/utils/scaffold.d.ts +6 -1
  27. package/dist/utils/scaffold.d.ts.map +1 -1
  28. package/dist/utils/scaffold.js +86 -13
  29. package/dist/utils/scaffold.js.map +1 -1
  30. package/package.json +2 -2
  31. package/templates/.env.example +21 -0
  32. package/templates/.github/PULL_REQUEST_TEMPLATE.md +24 -0
  33. package/templates/.github/actions/setup-node-deps/action.yml +32 -0
  34. package/templates/.github/dependabot.yml +104 -0
  35. package/templates/.github/workflows/ci.yml +156 -0
  36. package/templates/.husky/commit-msg +1 -0
  37. package/templates/.husky/pre-commit +2 -0
  38. package/templates/.nvmrc +1 -0
  39. package/templates/CLAUDE.md +4 -2
  40. package/templates/commitlint.config.js +1 -0
  41. package/templates/components.json +21 -0
  42. package/templates/docs/API_REFERENCE.md +0 -1
  43. package/templates/docs/INTERNATIONALIZATION.md +26 -0
  44. package/templates/e2e/fixtures/index.ts +10 -0
  45. package/templates/e2e/tests/home.spec.ts +42 -0
  46. package/templates/e2e/tests/language.spec.ts +42 -0
  47. package/templates/e2e/tests/navigation.spec.ts +18 -0
  48. package/templates/e2e/tests/theme.spec.ts +35 -0
  49. package/templates/eslint.config.js +42 -0
  50. package/templates/gitignore +33 -0
  51. package/templates/index.html +13 -0
  52. package/templates/lighthouse-budget.json +17 -0
  53. package/templates/lighthouserc.json +23 -0
  54. package/templates/lingui.config.js +18 -0
  55. package/templates/package.json +125 -0
  56. package/templates/playwright.config.ts +30 -0
  57. package/templates/prettier.config.js +1 -0
  58. package/templates/public/favicon.svg +4 -0
  59. package/templates/src/components/shared/RegisterForm/RegisterForm.tsx +91 -0
  60. package/templates/src/components/shared/RegisterForm/index.ts +1 -0
  61. package/templates/src/components/shared/index.ts +1 -0
  62. package/templates/src/components/ui/card.tsx +70 -0
  63. package/templates/src/components/ui/input.tsx +19 -0
  64. package/templates/src/components/ui/label.tsx +19 -0
  65. package/templates/src/hooks/index.ts +1 -1
  66. package/templates/src/hooks/useRegisterForm.ts +36 -0
  67. package/templates/src/lib/index.ts +1 -11
  68. package/templates/src/lib/validations.ts +6 -13
  69. package/templates/src/pages/Home.tsx +29 -10
  70. package/templates/tests/unit/components/RegisterForm.test.tsx +105 -0
  71. package/templates/tests/unit/hooks/useRegisterForm.test.tsx +153 -0
  72. package/templates/tests/unit/lib/validations.test.ts +22 -33
  73. package/templates/tests/unit/stores/preferencesStore.test.ts +81 -0
  74. package/templates/tsconfig.app.json +10 -0
  75. package/templates/tsconfig.json +11 -0
  76. package/templates/tsconfig.node.json +4 -0
  77. package/templates/vite.config.ts +54 -0
  78. package/templates/vitest.config.ts +38 -0
  79. package/templates/src/hooks/useContactForm.ts +0 -33
  80. package/templates/src/lib/constants.ts +0 -8
  81. package/templates/src/lib/format.ts +0 -119
  82. package/templates/tests/unit/hooks/useContactForm.test.ts +0 -60
  83. package/templates/tests/unit/lib/format.test.ts +0 -100
@@ -0,0 +1,125 @@
1
+ {
2
+ "name": "react-spa-scaffold",
3
+ "private": true,
4
+ "description": "Production-ready React 19 + TypeScript + Vite 7 starter template",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mkaczkowski/react-spa-scaffold.git"
9
+ },
10
+ "homepage": "https://github.com/mkaczkowski/react-spa-scaffold#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/mkaczkowski/react-spa-scaffold/issues"
13
+ },
14
+ "keywords": [
15
+ "react",
16
+ "typescript",
17
+ "vite",
18
+ "starter",
19
+ "template",
20
+ "boilerplate",
21
+ "react-19",
22
+ "shadcn",
23
+ "tailwind"
24
+ ],
25
+ "version": "0.5.0",
26
+ "type": "module",
27
+ "workspaces": [
28
+ "packages/*"
29
+ ],
30
+ "scripts": {
31
+ "dev": "vite",
32
+ "build": "npm run typecheck && vite build",
33
+ "preview": "vite preview",
34
+ "typecheck": "tsc -p tsconfig.app.json && tsc -p tsconfig.node.json",
35
+ "lint": "eslint .",
36
+ "lint:fix": "eslint . --fix",
37
+ "format": "prettier --write .",
38
+ "format:check": "prettier --check .",
39
+ "test": "vitest run",
40
+ "test:watch": "vitest",
41
+ "test:coverage": "vitest run --coverage",
42
+ "e2e": "playwright test",
43
+ "e2e:ui": "playwright test --ui",
44
+ "i18n:extract": "lingui extract",
45
+ "prepare": "husky",
46
+ "mcp:build": "npm run build -w @react-spa-scaffold/mcp",
47
+ "mcp:dev": "npm run dev -w @react-spa-scaffold/mcp",
48
+ "mcp:start": "npm run start -w @react-spa-scaffold/mcp",
49
+ "mcp:inspect": "npm run inspect -w @react-spa-scaffold/mcp"
50
+ },
51
+ "dependencies": {
52
+ "@fontsource-variable/inter": "^5.2.5",
53
+ "@hookform/resolvers": "^5.0.1",
54
+ "@lingui/core": "^5.7.0",
55
+ "@lingui/react": "^5.7.0",
56
+ "@radix-ui/react-slot": "^1.2.3",
57
+ "@sentry/react": "^10.32.1",
58
+ "@tanstack/react-query": "^5.90.14",
59
+ "class-variance-authority": "^0.7.1",
60
+ "clsx": "^2.1.1",
61
+ "lucide-react": "^0.562.0",
62
+ "radix-ui": "^1.4.3",
63
+ "react": "^19.1.0",
64
+ "react-dom": "^19.1.0",
65
+ "react-hook-form": "^7.58.0",
66
+ "react-router": "^7.11.0",
67
+ "sonner": "^2.0.7",
68
+ "tailwind-merge": "^3.3.0",
69
+ "tw-animate-css": "^1.2.9",
70
+ "zod": "^4.2.1",
71
+ "zustand": "^5.0.9"
72
+ },
73
+ "devDependencies": {
74
+ "@commitlint/config-conventional": "^20.2.0",
75
+ "@react-spa-scaffold/eslint-config": "*",
76
+ "@react-spa-scaffold/prettier-config": "*",
77
+ "@react-spa-scaffold/tsconfig": "*",
78
+ "shadcn": "^3.6.2",
79
+ "@eslint/js": "^9.28.0",
80
+ "@lingui/babel-plugin-lingui-macro": "^5.7.0",
81
+ "@lingui/cli": "^5.7.0",
82
+ "@lingui/vite-plugin": "^5.7.0",
83
+ "@playwright/test": "^1.52.0",
84
+ "@sentry/vite-plugin": "^4.6.1",
85
+ "@tailwindcss/vite": "^4.1.17",
86
+ "@testing-library/jest-dom": "^6.6.3",
87
+ "@testing-library/react": "^16.3.0",
88
+ "@testing-library/user-event": "^14.6.1",
89
+ "@types/node": "^22.15.0",
90
+ "@types/react": "^19.1.8",
91
+ "@types/react-dom": "^19.1.6",
92
+ "@vitejs/plugin-react": "^5.1.2",
93
+ "@vitest/coverage-v8": "^4.0.16",
94
+ "babel-plugin-macros": "^3.1.0",
95
+ "commitlint": "^20.2.0",
96
+ "eslint": "^9.28.0",
97
+ "eslint-config-prettier": "^10.1.0",
98
+ "eslint-plugin-lingui": "^0.11.0",
99
+ "eslint-plugin-react-hooks": "^7.0.1",
100
+ "eslint-plugin-react-refresh": "^0.4.20",
101
+ "husky": "^9.1.7",
102
+ "jsdom": "^27.4.0",
103
+ "lint-staged": "^16.1.0",
104
+ "msw": "^2.12.7",
105
+ "prettier": "^3.5.3",
106
+ "prettier-plugin-tailwindcss": "^0.7.2",
107
+ "tailwindcss": "^4.1.17",
108
+ "typescript": "~5.9.0",
109
+ "typescript-eslint": "^8.33.0",
110
+ "vite": "^7.0.0",
111
+ "vitest": "^4.0.16"
112
+ },
113
+ "lint-staged": {
114
+ "*.{ts,tsx,js}": [
115
+ "eslint --fix",
116
+ "prettier --write"
117
+ ],
118
+ "*.{json,md,yml,yaml,css}": [
119
+ "prettier --write"
120
+ ]
121
+ },
122
+ "engines": {
123
+ "node": ">=22.0.0"
124
+ }
125
+ }
@@ -0,0 +1,30 @@
1
+ import { defineConfig, devices } from '@playwright/test';
2
+
3
+ export default defineConfig({
4
+ testDir: './e2e/tests',
5
+ fullyParallel: true,
6
+ forbidOnly: !!process.env.CI,
7
+ retries: process.env.CI ? 2 : 0,
8
+ workers: process.env.CI ? 1 : undefined,
9
+ reporter: process.env.CI ? 'github' : [['list'], ['html']],
10
+ use: {
11
+ baseURL: 'http://localhost:5173',
12
+ trace: 'on-first-retry',
13
+ screenshot: 'only-on-failure',
14
+ },
15
+ expect: {
16
+ timeout: 10000,
17
+ },
18
+ projects: [
19
+ {
20
+ name: 'chromium',
21
+ use: { ...devices['Desktop Chrome'] },
22
+ },
23
+ ],
24
+ webServer: {
25
+ command: 'npm run dev',
26
+ url: 'http://localhost:5173',
27
+ reuseExistingServer: !process.env.CI,
28
+ timeout: 120000,
29
+ },
30
+ });
@@ -0,0 +1 @@
1
+ export { default } from '@react-spa-scaffold/prettier-config/tailwind';
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
2
+ <rect width="32" height="32" rx="6" fill="#3B82F6"/>
3
+ <path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2" stroke-linecap="round"/>
4
+ </svg>
@@ -0,0 +1,91 @@
1
+ import { Trans, useLingui } from '@lingui/react/macro';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import { FieldErrorMessage } from '@/components/ui/form-error';
5
+ import { Input } from '@/components/ui/input';
6
+ import { Label } from '@/components/ui/label';
7
+ import { useRegisterForm } from '@/hooks';
8
+
9
+ /**
10
+ * Registration form component demonstrating cross-field validation.
11
+ * Shows password confirmation with Zod's refine() for matching passwords.
12
+ */
13
+ export function RegisterForm() {
14
+ const { t } = useLingui();
15
+ const { form, onSubmit, isSubmitting, errors } = useRegisterForm();
16
+ const { register } = form;
17
+
18
+ return (
19
+ <form onSubmit={onSubmit} className="space-y-4">
20
+ <div className="space-y-2">
21
+ <Label htmlFor="username">
22
+ <Trans comment="Register form username field label">Username</Trans>
23
+ </Label>
24
+ <Input
25
+ id="username"
26
+ placeholder={t({ message: 'johndoe', comment: 'Username placeholder example' })}
27
+ autoComplete="username"
28
+ aria-invalid={!!errors.username}
29
+ {...register('username')}
30
+ />
31
+ <FieldErrorMessage error={errors.username} />
32
+ </div>
33
+
34
+ <div className="space-y-2">
35
+ <Label htmlFor="register-email">
36
+ <Trans comment="Register form email field label">Email</Trans>
37
+ </Label>
38
+ <Input
39
+ id="register-email"
40
+ type="email"
41
+ placeholder={t({ message: 'john@example.com', comment: 'Email placeholder example' })}
42
+ autoComplete="email"
43
+ aria-invalid={!!errors.email}
44
+ {...register('email')}
45
+ />
46
+ <FieldErrorMessage error={errors.email} />
47
+ </div>
48
+
49
+ <div className="space-y-2">
50
+ <Label htmlFor="password">
51
+ <Trans comment="Register form password field label">Password</Trans>
52
+ </Label>
53
+ <Input
54
+ id="password"
55
+ type="password"
56
+ placeholder={t({
57
+ message: 'Min 8 chars, uppercase, lowercase, number',
58
+ comment: 'Password requirements hint',
59
+ })}
60
+ autoComplete="new-password"
61
+ aria-invalid={!!errors.password}
62
+ {...register('password')}
63
+ />
64
+ <FieldErrorMessage error={errors.password} />
65
+ </div>
66
+
67
+ <div className="space-y-2">
68
+ <Label htmlFor="confirmPassword">
69
+ <Trans comment="Register form confirm password field label">Confirm Password</Trans>
70
+ </Label>
71
+ <Input
72
+ id="confirmPassword"
73
+ type="password"
74
+ placeholder={t({ message: 'Re-enter your password', comment: 'Confirm password placeholder' })}
75
+ autoComplete="new-password"
76
+ aria-invalid={!!errors.confirmPassword}
77
+ {...register('confirmPassword')}
78
+ />
79
+ <FieldErrorMessage error={errors.confirmPassword} />
80
+ </div>
81
+
82
+ <Button type="submit" disabled={isSubmitting} className="w-full">
83
+ {isSubmitting ? (
84
+ <Trans comment="Register form submitting state">Creating account...</Trans>
85
+ ) : (
86
+ <Trans comment="Register form submit button">Create Account</Trans>
87
+ )}
88
+ </Button>
89
+ </form>
90
+ );
91
+ }
@@ -0,0 +1 @@
1
+ export { RegisterForm } from './RegisterForm';
@@ -2,3 +2,4 @@ export { ThemeToggle } from './ThemeToggle';
2
2
  export { ErrorBoundary } from './ErrorBoundary';
3
3
  export { SEO } from './SEO';
4
4
  export { LanguageSwitcher } from './LanguageSwitcher';
5
+ export { RegisterForm } from './RegisterForm';
@@ -0,0 +1,70 @@
1
+ import * as React from 'react';
2
+
3
+ import { cn } from '@/lib/utils';
4
+
5
+ function Card({ className, size = 'default', ...props }: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
6
+ return (
7
+ <div
8
+ data-slot="card"
9
+ data-size={size}
10
+ className={cn(
11
+ 'ring-foreground/10 bg-card text-card-foreground group/card flex flex-col gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
12
+ className,
13
+ )}
14
+ {...props}
15
+ />
16
+ );
17
+ }
18
+
19
+ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
20
+ return (
21
+ <div
22
+ data-slot="card-header"
23
+ className={cn(
24
+ 'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3',
25
+ className,
26
+ )}
27
+ {...props}
28
+ />
29
+ );
30
+ }
31
+
32
+ function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
33
+ return (
34
+ <div
35
+ data-slot="card-title"
36
+ className={cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm', className)}
37
+ {...props}
38
+ />
39
+ );
40
+ }
41
+
42
+ function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
43
+ return <div data-slot="card-description" className={cn('text-muted-foreground text-sm', className)} {...props} />;
44
+ }
45
+
46
+ function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
47
+ return (
48
+ <div
49
+ data-slot="card-action"
50
+ className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
51
+ {...props}
52
+ />
53
+ );
54
+ }
55
+
56
+ function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
57
+ return <div data-slot="card-content" className={cn('px-4 group-data-[size=sm]/card:px-3', className)} {...props} />;
58
+ }
59
+
60
+ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
61
+ return (
62
+ <div
63
+ data-slot="card-footer"
64
+ className={cn('bg-muted/50 flex items-center rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3', className)}
65
+ {...props}
66
+ />
67
+ );
68
+ }
69
+
70
+ export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
@@ -0,0 +1,19 @@
1
+ import * as React from 'react';
2
+
3
+ import { cn } from '@/lib/utils';
4
+
5
+ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
6
+ return (
7
+ <input
8
+ type={type}
9
+ data-slot="input"
10
+ className={cn(
11
+ 'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-[3px] md:text-sm',
12
+ className,
13
+ )}
14
+ {...props}
15
+ />
16
+ );
17
+ }
18
+
19
+ export { Input };
@@ -0,0 +1,19 @@
1
+ import * as React from 'react';
2
+ import { Label as LabelPrimitive } from 'radix-ui';
3
+
4
+ import { cn } from '@/lib/utils';
5
+
6
+ function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
7
+ return (
8
+ <LabelPrimitive.Root
9
+ data-slot="label"
10
+ className={cn(
11
+ 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
12
+ className,
13
+ )}
14
+ {...props}
15
+ />
16
+ );
17
+ }
18
+
19
+ export { Label };
@@ -3,5 +3,5 @@ export { useThemeEffect } from './useThemeEffect';
3
3
  export { useTouchSizes } from './useTouchSizes';
4
4
  export { useLanguage } from './useLanguage';
5
5
  export { useExampleQuery } from './useExampleQuery';
6
- export { useContactForm } from './useContactForm';
6
+ export { useRegisterForm } from './useRegisterForm';
7
7
  export { useMobileContext } from '@/contexts/mobileContext';
@@ -0,0 +1,36 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod';
2
+ import { useForm } from 'react-hook-form';
3
+
4
+ import { type RegisterFormData, registerFormSchema } from '@/lib/validations';
5
+
6
+ /**
7
+ * React Hook Form + Zod hook for registration.
8
+ * Demonstrates cross-field validation (password confirmation).
9
+ */
10
+ export function useRegisterForm() {
11
+ const form = useForm<RegisterFormData>({
12
+ resolver: zodResolver(registerFormSchema),
13
+ defaultValues: {
14
+ username: '',
15
+ email: '',
16
+ password: '',
17
+ confirmPassword: '',
18
+ },
19
+ });
20
+
21
+ const onSubmit = async (data: RegisterFormData) => {
22
+ // Simulate API call delay
23
+ await new Promise((resolve) => setTimeout(resolve, 1000));
24
+ // Replace with your actual registration logic
25
+ // eslint-disable-next-line no-console
26
+ console.log('Registration submitted:', data);
27
+ };
28
+
29
+ return {
30
+ form,
31
+ onSubmit: form.handleSubmit(onSubmit),
32
+ isSubmitting: form.formState.isSubmitting,
33
+ errors: form.formState.errors,
34
+ reset: form.reset,
35
+ };
36
+ }
@@ -4,7 +4,6 @@
4
4
  */
5
5
 
6
6
  export { cn } from './utils';
7
- export { TIMING, UI } from './constants';
8
7
  export { STORAGE_KEYS, isAppKey } from './storageKeys';
9
8
  export { APP_CONFIG, SENTRY_CONFIG } from './config';
10
9
  export { API_CONFIG } from './api';
@@ -12,13 +11,4 @@ export { ROUTES, type AppRoute } from './routes';
12
11
  export { env, validateEnv, type Env } from './env';
13
12
  export { api, ApiClientError } from './api';
14
13
  export { getStorageItem, setStorageItem, removeStorageItem, clearAppStorage } from './storage';
15
- export {
16
- formatDate,
17
- formatDateTime,
18
- formatRelativeTime,
19
- formatNumber,
20
- formatCurrency,
21
- formatPercent,
22
- formatBytes,
23
- } from './format';
24
- export { contactFormSchema, registerFormSchema, type ContactFormData, type RegisterFormData } from './validations';
14
+ export { registerFormSchema, type RegisterFormData } from './validations';
@@ -1,19 +1,12 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  /**
4
- * Example validation schema for a contact form.
5
- * Extend or replace with your own schemas.
6
- */
7
- export const contactFormSchema = z.object({
8
- name: z.string().min(2, 'Name must be at least 2 characters'),
9
- email: z.string().email('Please enter a valid email address'),
10
- message: z.string().min(10, 'Message must be at least 10 characters'),
11
- });
12
-
13
- export type ContactFormData = z.infer<typeof contactFormSchema>;
14
-
15
- /**
16
- * Example validation schema for user registration.
4
+ * Registration form validation schema.
5
+ * Demonstrates Zod validation patterns:
6
+ * - Basic validations: min, max, email, regex
7
+ * - Cross-field validation with refine() (password confirmation)
8
+ * - Custom error messages
9
+ * - Type inference with z.infer<>
17
10
  */
18
11
  export const registerFormSchema = z
19
12
  .object({
@@ -1,12 +1,13 @@
1
1
  import { Trans, useLingui } from '@lingui/react/macro';
2
2
 
3
- import { SEO } from '@/components/shared';
3
+ import { RegisterForm, SEO } from '@/components/shared';
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
4
5
 
5
6
  export function HomePage() {
6
7
  const { t } = useLingui();
7
8
 
8
9
  return (
9
- <div className="container mx-auto px-4 py-8">
10
+ <div className="container mx-auto max-w-lg px-4 py-8">
10
11
  <SEO
11
12
  title={t({ message: 'Home', comment: 'Home page title for SEO' })}
12
13
  description={t({
@@ -14,14 +15,32 @@ export function HomePage() {
14
15
  comment: 'Home page meta description for SEO',
15
16
  })}
16
17
  />
17
- <h1 className="text-3xl font-bold">
18
- <Trans comment="Main heading on the home page">Welcome to My App</Trans>
19
- </h1>
20
- <p className="text-muted-foreground mt-2">
21
- <Trans comment="Instructions for developers on how to start customizing the app">
22
- Get started by editing <code className="bg-muted rounded px-1">src/App.tsx</code>
23
- </Trans>
24
- </p>
18
+
19
+ <div className="mb-8 text-center">
20
+ <h1 className="text-3xl font-bold">
21
+ <Trans comment="Main heading on the home page">Welcome to My App</Trans>
22
+ </h1>
23
+ <p className="text-muted-foreground mt-2">
24
+ <Trans comment="Instructions for developers on how to start customizing the app">
25
+ Get started by editing <code className="bg-muted rounded px-1">src/App.tsx</code>
26
+ </Trans>
27
+ </p>
28
+ </div>
29
+
30
+ {/* Form validation demo */}
31
+ <Card>
32
+ <CardHeader>
33
+ <CardTitle>
34
+ <Trans comment="Register form card title">Registration Form</Trans>
35
+ </CardTitle>
36
+ <CardDescription>
37
+ <Trans comment="Register form card description">React Hook Form + Zod validation demo</Trans>
38
+ </CardDescription>
39
+ </CardHeader>
40
+ <CardContent>
41
+ <RegisterForm />
42
+ </CardContent>
43
+ </Card>
25
44
  </div>
26
45
  );
27
46
  }
@@ -0,0 +1,105 @@
1
+ import { screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import { RegisterForm } from '@/components/shared/RegisterForm';
6
+ import { render } from '@/test';
7
+
8
+ describe('RegisterForm', () => {
9
+ it('renders all form fields', () => {
10
+ render(<RegisterForm />);
11
+
12
+ expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
13
+ expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
14
+ expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
15
+ expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
16
+ expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument();
17
+ });
18
+
19
+ it('shows validation errors for empty fields on submit', async () => {
20
+ const user = userEvent.setup();
21
+ render(<RegisterForm />);
22
+
23
+ await user.click(screen.getByRole('button', { name: /create account/i }));
24
+
25
+ await waitFor(() => {
26
+ expect(screen.getByText(/username must be at least 3 characters/i)).toBeInTheDocument();
27
+ });
28
+
29
+ expect(screen.getByText(/please enter a valid email address/i)).toBeInTheDocument();
30
+ expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument();
31
+ });
32
+
33
+ it('shows password mismatch error when passwords differ', async () => {
34
+ const user = userEvent.setup();
35
+ render(<RegisterForm />);
36
+
37
+ await user.type(screen.getByLabelText(/username/i), 'testuser');
38
+ await user.type(screen.getByLabelText(/email/i), 'test@example.com');
39
+ await user.type(screen.getByLabelText(/^password$/i), 'Password1');
40
+ await user.type(screen.getByLabelText(/confirm password/i), 'Password2');
41
+ await user.click(screen.getByRole('button', { name: /create account/i }));
42
+
43
+ await waitFor(() => {
44
+ expect(screen.getByText(/passwords don't match/i)).toBeInTheDocument();
45
+ });
46
+ });
47
+
48
+ it('submits form with valid data', async () => {
49
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
50
+ const user = userEvent.setup();
51
+ render(<RegisterForm />);
52
+
53
+ await user.type(screen.getByLabelText(/username/i), 'testuser');
54
+ await user.type(screen.getByLabelText(/email/i), 'test@example.com');
55
+ await user.type(screen.getByLabelText(/^password$/i), 'Password1');
56
+ await user.type(screen.getByLabelText(/confirm password/i), 'Password1');
57
+ await user.click(screen.getByRole('button', { name: /create account/i }));
58
+
59
+ await waitFor(() => {
60
+ expect(screen.getByRole('button', { name: /creating account/i })).toBeInTheDocument();
61
+ });
62
+
63
+ await waitFor(
64
+ () => {
65
+ expect(consoleSpy).toHaveBeenCalledWith('Registration submitted:', {
66
+ username: 'testuser',
67
+ email: 'test@example.com',
68
+ password: 'Password1',
69
+ confirmPassword: 'Password1',
70
+ });
71
+ },
72
+ { timeout: 2000 },
73
+ );
74
+
75
+ consoleSpy.mockRestore();
76
+ });
77
+
78
+ it('disables submit button while submitting', async () => {
79
+ const user = userEvent.setup();
80
+ render(<RegisterForm />);
81
+
82
+ await user.type(screen.getByLabelText(/username/i), 'testuser');
83
+ await user.type(screen.getByLabelText(/email/i), 'test@example.com');
84
+ await user.type(screen.getByLabelText(/^password$/i), 'Password1');
85
+ await user.type(screen.getByLabelText(/confirm password/i), 'Password1');
86
+ await user.click(screen.getByRole('button', { name: /create account/i }));
87
+
88
+ await waitFor(() => {
89
+ expect(screen.getByRole('button', { name: /creating account/i })).toBeDisabled();
90
+ });
91
+ });
92
+
93
+ it('sets aria-invalid on fields with errors', async () => {
94
+ const user = userEvent.setup();
95
+ render(<RegisterForm />);
96
+
97
+ await user.click(screen.getByRole('button', { name: /create account/i }));
98
+
99
+ await waitFor(() => {
100
+ expect(screen.getByLabelText(/username/i)).toHaveAttribute('aria-invalid', 'true');
101
+ expect(screen.getByLabelText(/email/i)).toHaveAttribute('aria-invalid', 'true');
102
+ expect(screen.getByLabelText(/^password$/i)).toHaveAttribute('aria-invalid', 'true');
103
+ });
104
+ });
105
+ });