@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.
- package/README.md +42 -20
- package/dist/features/registry.d.ts.map +1 -1
- package/dist/features/registry.js +59 -25
- package/dist/features/registry.js.map +1 -1
- package/dist/features/types.d.ts +1 -0
- package/dist/features/types.d.ts.map +1 -1
- package/dist/features/versions.json +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/get-example.d.ts +4 -6
- package/dist/tools/get-example.d.ts.map +1 -1
- package/dist/tools/get-example.js +2 -2
- package/dist/tools/get-example.js.map +1 -1
- package/dist/tools/get-scaffold.d.ts +3 -10
- package/dist/tools/get-scaffold.d.ts.map +1 -1
- package/dist/tools/get-scaffold.js +44 -20
- package/dist/tools/get-scaffold.js.map +1 -1
- package/dist/utils/examples.d.ts.map +1 -1
- package/dist/utils/examples.js +19 -16
- package/dist/utils/examples.js.map +1 -1
- package/dist/utils/paths.d.ts +2 -1
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +15 -2
- package/dist/utils/paths.js.map +1 -1
- package/dist/utils/scaffold.d.ts +6 -1
- package/dist/utils/scaffold.d.ts.map +1 -1
- package/dist/utils/scaffold.js +86 -13
- package/dist/utils/scaffold.js.map +1 -1
- package/package.json +2 -2
- package/templates/.env.example +21 -0
- package/templates/.github/PULL_REQUEST_TEMPLATE.md +24 -0
- package/templates/.github/actions/setup-node-deps/action.yml +32 -0
- package/templates/.github/dependabot.yml +104 -0
- package/templates/.github/workflows/ci.yml +156 -0
- package/templates/.husky/commit-msg +1 -0
- package/templates/.husky/pre-commit +2 -0
- package/templates/.nvmrc +1 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/commitlint.config.js +1 -0
- package/templates/components.json +21 -0
- package/templates/docs/API_REFERENCE.md +0 -1
- package/templates/docs/INTERNATIONALIZATION.md +26 -0
- package/templates/e2e/fixtures/index.ts +10 -0
- package/templates/e2e/tests/home.spec.ts +42 -0
- package/templates/e2e/tests/language.spec.ts +42 -0
- package/templates/e2e/tests/navigation.spec.ts +18 -0
- package/templates/e2e/tests/theme.spec.ts +35 -0
- package/templates/eslint.config.js +42 -0
- package/templates/gitignore +33 -0
- package/templates/index.html +13 -0
- package/templates/lighthouse-budget.json +17 -0
- package/templates/lighthouserc.json +23 -0
- package/templates/lingui.config.js +18 -0
- package/templates/package.json +125 -0
- package/templates/playwright.config.ts +30 -0
- package/templates/prettier.config.js +1 -0
- package/templates/public/favicon.svg +4 -0
- package/templates/src/components/shared/RegisterForm/RegisterForm.tsx +91 -0
- package/templates/src/components/shared/RegisterForm/index.ts +1 -0
- package/templates/src/components/shared/index.ts +1 -0
- package/templates/src/components/ui/card.tsx +70 -0
- package/templates/src/components/ui/input.tsx +19 -0
- package/templates/src/components/ui/label.tsx +19 -0
- package/templates/src/hooks/index.ts +1 -1
- package/templates/src/hooks/useRegisterForm.ts +36 -0
- package/templates/src/lib/index.ts +1 -11
- package/templates/src/lib/validations.ts +6 -13
- package/templates/src/pages/Home.tsx +29 -10
- package/templates/tests/unit/components/RegisterForm.test.tsx +105 -0
- package/templates/tests/unit/hooks/useRegisterForm.test.tsx +153 -0
- package/templates/tests/unit/lib/validations.test.ts +22 -33
- package/templates/tests/unit/stores/preferencesStore.test.ts +81 -0
- package/templates/tsconfig.app.json +10 -0
- package/templates/tsconfig.json +11 -0
- package/templates/tsconfig.node.json +4 -0
- package/templates/vite.config.ts +54 -0
- package/templates/vitest.config.ts +38 -0
- package/templates/src/hooks/useContactForm.ts +0 -33
- package/templates/src/lib/constants.ts +0 -8
- package/templates/src/lib/format.ts +0 -119
- package/templates/tests/unit/hooks/useContactForm.test.ts +0 -60
- 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,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';
|
|
@@ -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 {
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
});
|