@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,153 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import { useRegisterForm } from '@/hooks/useRegisterForm';
5
+
6
+ describe('useRegisterForm', () => {
7
+ it('returns form object with expected properties', () => {
8
+ const { result } = renderHook(() => useRegisterForm());
9
+
10
+ expect(result.current.form).toBeDefined();
11
+ expect(result.current.onSubmit).toBeDefined();
12
+ expect(result.current.isSubmitting).toBe(false);
13
+ expect(result.current.errors).toBeDefined();
14
+ expect(result.current.reset).toBeDefined();
15
+ });
16
+
17
+ it('initializes with empty default values', () => {
18
+ const { result } = renderHook(() => useRegisterForm());
19
+
20
+ expect(result.current.form.getValues()).toEqual({
21
+ username: '',
22
+ email: '',
23
+ password: '',
24
+ confirmPassword: '',
25
+ });
26
+ });
27
+
28
+ it('validates username minimum length', async () => {
29
+ const { result } = renderHook(() => useRegisterForm());
30
+
31
+ await act(async () => {
32
+ result.current.form.setValue('username', 'ab');
33
+ await result.current.form.trigger('username');
34
+ });
35
+
36
+ expect(result.current.errors.username?.message).toBe('Username must be at least 3 characters');
37
+ });
38
+
39
+ it('validates username maximum length', async () => {
40
+ const { result } = renderHook(() => useRegisterForm());
41
+
42
+ await act(async () => {
43
+ result.current.form.setValue('username', 'a'.repeat(21));
44
+ await result.current.form.trigger('username');
45
+ });
46
+
47
+ expect(result.current.errors.username?.message).toBe('Username must be at most 20 characters');
48
+ });
49
+
50
+ it('validates username format (alphanumeric and underscore only)', async () => {
51
+ const { result } = renderHook(() => useRegisterForm());
52
+
53
+ await act(async () => {
54
+ result.current.form.setValue('username', 'user@name');
55
+ await result.current.form.trigger('username');
56
+ });
57
+
58
+ expect(result.current.errors.username?.message).toBe('Username can only contain letters, numbers, and underscores');
59
+ });
60
+
61
+ it('validates email format', async () => {
62
+ const { result } = renderHook(() => useRegisterForm());
63
+
64
+ await act(async () => {
65
+ result.current.form.setValue('email', 'invalid-email');
66
+ await result.current.form.trigger('email');
67
+ });
68
+
69
+ expect(result.current.errors.email?.message).toBe('Please enter a valid email address');
70
+ });
71
+
72
+ it('validates password requires uppercase letter', async () => {
73
+ const { result } = renderHook(() => useRegisterForm());
74
+
75
+ await act(async () => {
76
+ result.current.form.setValue('password', 'lowercase1');
77
+ await result.current.form.trigger('password');
78
+ });
79
+
80
+ expect(result.current.errors.password?.message).toBe('Password must contain at least one uppercase letter');
81
+ });
82
+
83
+ it('validates password requires lowercase letter', async () => {
84
+ const { result } = renderHook(() => useRegisterForm());
85
+
86
+ await act(async () => {
87
+ result.current.form.setValue('password', 'UPPERCASE1');
88
+ await result.current.form.trigger('password');
89
+ });
90
+
91
+ expect(result.current.errors.password?.message).toBe('Password must contain at least one lowercase letter');
92
+ });
93
+
94
+ it('validates password requires number', async () => {
95
+ const { result } = renderHook(() => useRegisterForm());
96
+
97
+ await act(async () => {
98
+ result.current.form.setValue('password', 'NoNumbers');
99
+ await result.current.form.trigger('password');
100
+ });
101
+
102
+ expect(result.current.errors.password?.message).toBe('Password must contain at least one number');
103
+ });
104
+
105
+ it('calls onSubmit with valid data', async () => {
106
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
107
+ const { result } = renderHook(() => useRegisterForm());
108
+
109
+ await act(async () => {
110
+ result.current.form.setValue('username', 'validuser');
111
+ result.current.form.setValue('email', 'test@example.com');
112
+ result.current.form.setValue('password', 'Password1');
113
+ result.current.form.setValue('confirmPassword', 'Password1');
114
+ });
115
+
116
+ await act(async () => {
117
+ await result.current.onSubmit();
118
+ });
119
+
120
+ await waitFor(() => {
121
+ expect(consoleSpy).toHaveBeenCalledWith('Registration submitted:', {
122
+ username: 'validuser',
123
+ email: 'test@example.com',
124
+ password: 'Password1',
125
+ confirmPassword: 'Password1',
126
+ });
127
+ });
128
+
129
+ consoleSpy.mockRestore();
130
+ });
131
+
132
+ it('resets form to default values', async () => {
133
+ const { result } = renderHook(() => useRegisterForm());
134
+
135
+ await act(async () => {
136
+ result.current.form.setValue('username', 'testuser');
137
+ result.current.form.setValue('email', 'test@example.com');
138
+ });
139
+
140
+ expect(result.current.form.getValues().username).toBe('testuser');
141
+
142
+ act(() => {
143
+ result.current.reset();
144
+ });
145
+
146
+ expect(result.current.form.getValues()).toEqual({
147
+ username: '',
148
+ email: '',
149
+ password: '',
150
+ confirmPassword: '',
151
+ });
152
+ });
153
+ });
@@ -1,32 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { contactFormSchema, registerFormSchema } from '@/lib/validations';
4
-
5
- describe('contactFormSchema', () => {
6
- const validContact = {
7
- name: 'John Doe',
8
- email: 'john@example.com',
9
- message: 'This is a valid message that is long enough.',
10
- };
11
-
12
- it('accepts valid data', () => {
13
- expect(contactFormSchema.safeParse(validContact).success).toBe(true);
14
- });
15
-
16
- it.each([
17
- { field: 'name', value: 'J', errorContains: 'at least 2 characters' },
18
- { field: 'email', value: 'not-an-email', errorContains: 'valid email' },
19
- { field: 'message', value: 'Short', errorContains: 'at least 10 characters' },
20
- ])('rejects invalid $field', ({ field, value, errorContains }) => {
21
- const data = { ...validContact, [field]: value };
22
- const result = contactFormSchema.safeParse(data);
23
-
24
- expect(result.success).toBe(false);
25
- if (!result.success) {
26
- expect(result.error.issues[0].message).toContain(errorContains);
27
- }
28
- });
29
- });
3
+ import { registerFormSchema } from '@/lib/validations';
30
4
 
31
5
  describe('registerFormSchema', () => {
32
6
  const validRegister = {
@@ -41,16 +15,31 @@ describe('registerFormSchema', () => {
41
15
  });
42
16
 
43
17
  it.each([
18
+ { field: 'username', value: 'ab', errorContains: 'at least 3 characters' },
44
19
  { field: 'username', value: 'john@doe', errorContains: 'letters, numbers, and underscores' },
45
- { field: 'password', value: 'weakpass', errorContains: null },
46
- { fields: { confirmPassword: 'DifferentPass123' }, errorContains: "don't match" },
47
- ])('rejects invalid input', ({ field, fields, value, errorContains }) => {
48
- const data = field ? { ...validRegister, [field]: value } : { ...validRegister, ...fields };
20
+ { field: 'email', value: 'not-an-email', errorContains: 'valid email' },
21
+ { field: 'password', value: 'short', errorContains: 'at least 8 characters' },
22
+ { field: 'password', value: 'alllowercase1', errorContains: 'uppercase letter' },
23
+ { field: 'password', value: 'ALLUPPERCASE1', errorContains: 'lowercase letter' },
24
+ { field: 'password', value: 'NoNumbersHere', errorContains: 'one number' },
25
+ ])('rejects invalid $field with value "$value"', ({ field, value, errorContains }) => {
26
+ const data = { ...validRegister, [field]: value };
27
+ const result = registerFormSchema.safeParse(data);
28
+
29
+ expect(result.success).toBe(false);
30
+ if (!result.success) {
31
+ const errorMessages = result.error.issues.map((i) => i.message).join(' ');
32
+ expect(errorMessages).toContain(errorContains);
33
+ }
34
+ });
35
+
36
+ it('rejects mismatched passwords', () => {
37
+ const data = { ...validRegister, confirmPassword: 'DifferentPass123' };
49
38
  const result = registerFormSchema.safeParse(data);
50
39
 
51
40
  expect(result.success).toBe(false);
52
- if (!result.success && errorContains) {
53
- expect(result.error.issues[0].message).toContain(errorContains);
41
+ if (!result.success) {
42
+ expect(result.error.issues[0].message).toContain("don't match");
54
43
  }
55
44
  });
56
45
  });
@@ -1,6 +1,7 @@
1
1
  import { act } from '@testing-library/react';
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
3
 
4
+ import { STORAGE_KEYS } from '@/lib/storageKeys';
4
5
  import { initPreferencesSync, usePreferencesStore } from '@/stores/preferencesStore';
5
6
  import { mockMatchMedia } from '@/test';
6
7
 
@@ -71,5 +72,85 @@ describe('preferencesStore', () => {
71
72
 
72
73
  expect(removeSpy).toHaveBeenCalledWith('storage', expect.any(Function));
73
74
  });
75
+
76
+ it('updates store when valid storage event is received', () => {
77
+ usePreferencesStore.setState({ theme: 'light' });
78
+ initPreferencesSync();
79
+
80
+ const storageEvent = new StorageEvent('storage', {
81
+ key: STORAGE_KEYS.preferences,
82
+ newValue: JSON.stringify({ state: { theme: 'dark' } }),
83
+ });
84
+
85
+ act(() => {
86
+ window.dispatchEvent(storageEvent);
87
+ });
88
+
89
+ expect(usePreferencesStore.getState().theme).toBe('dark');
90
+ });
91
+
92
+ it('ignores storage events for other keys', () => {
93
+ usePreferencesStore.setState({ theme: 'light' });
94
+ initPreferencesSync();
95
+
96
+ const storageEvent = new StorageEvent('storage', {
97
+ key: 'other-key',
98
+ newValue: JSON.stringify({ state: { theme: 'dark' } }),
99
+ });
100
+
101
+ act(() => {
102
+ window.dispatchEvent(storageEvent);
103
+ });
104
+
105
+ expect(usePreferencesStore.getState().theme).toBe('light');
106
+ });
107
+
108
+ it('ignores storage events with null newValue', () => {
109
+ usePreferencesStore.setState({ theme: 'light' });
110
+ initPreferencesSync();
111
+
112
+ const storageEvent = new StorageEvent('storage', {
113
+ key: STORAGE_KEYS.preferences,
114
+ newValue: null,
115
+ });
116
+
117
+ act(() => {
118
+ window.dispatchEvent(storageEvent);
119
+ });
120
+
121
+ expect(usePreferencesStore.getState().theme).toBe('light');
122
+ });
123
+
124
+ it('ignores storage events with invalid JSON', () => {
125
+ usePreferencesStore.setState({ theme: 'light' });
126
+ initPreferencesSync();
127
+
128
+ const storageEvent = new StorageEvent('storage', {
129
+ key: STORAGE_KEYS.preferences,
130
+ newValue: 'invalid-json',
131
+ });
132
+
133
+ act(() => {
134
+ window.dispatchEvent(storageEvent);
135
+ });
136
+
137
+ expect(usePreferencesStore.getState().theme).toBe('light');
138
+ });
139
+
140
+ it('ignores storage events without state property', () => {
141
+ usePreferencesStore.setState({ theme: 'light' });
142
+ initPreferencesSync();
143
+
144
+ const storageEvent = new StorageEvent('storage', {
145
+ key: STORAGE_KEYS.preferences,
146
+ newValue: JSON.stringify({ foo: 'bar' }),
147
+ });
148
+
149
+ act(() => {
150
+ window.dispatchEvent(storageEvent);
151
+ });
152
+
153
+ expect(usePreferencesStore.getState().theme).toBe('light');
154
+ });
74
155
  });
75
156
  });
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "@react-spa-scaffold/tsconfig/react",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "paths": {
6
+ "@/*": ["./src/*"]
7
+ }
8
+ },
9
+ "include": ["src", "tests"]
10
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "@react-spa-scaffold/tsconfig/react",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "paths": {
6
+ "@/*": ["./src/*"]
7
+ }
8
+ },
9
+ "files": [],
10
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
11
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "@react-spa-scaffold/tsconfig/vite",
3
+ "include": ["vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
4
+ }
@@ -0,0 +1,54 @@
1
+ import path from 'path';
2
+
3
+ import { lingui } from '@lingui/vite-plugin';
4
+ import { sentryVitePlugin } from '@sentry/vite-plugin';
5
+ import tailwindcss from '@tailwindcss/vite';
6
+ import react from '@vitejs/plugin-react';
7
+ import { defineConfig } from 'vite';
8
+
9
+ export default defineConfig({
10
+ plugins: [
11
+ react({
12
+ babel: {
13
+ plugins: ['@lingui/babel-plugin-lingui-macro'],
14
+ },
15
+ }),
16
+ lingui(),
17
+ tailwindcss(),
18
+ // Sentry source map upload (CI only, requires SENTRY_AUTH_TOKEN)
19
+ process.env.SENTRY_AUTH_TOKEN
20
+ ? sentryVitePlugin({
21
+ org: process.env.SENTRY_ORG,
22
+ project: process.env.SENTRY_PROJECT,
23
+ authToken: process.env.SENTRY_AUTH_TOKEN,
24
+ sourcemaps: {
25
+ filesToDeleteAfterUpload: ['./dist/**/*.map'],
26
+ },
27
+ telemetry: false,
28
+ })
29
+ : null,
30
+ ].filter(Boolean),
31
+ resolve: {
32
+ alias: {
33
+ '@': path.resolve(__dirname, './src'),
34
+ },
35
+ },
36
+ css: {
37
+ devSourcemap: true,
38
+ },
39
+ build: {
40
+ // Source maps: 'hidden' for Sentry (uploaded then deleted), false otherwise
41
+ // Set to true locally if needed for debugging: VITE_SOURCEMAP=true npm run build
42
+ sourcemap: process.env.SENTRY_AUTH_TOKEN ? 'hidden' : process.env.VITE_SOURCEMAP === 'true',
43
+ chunkSizeWarningLimit: 500,
44
+ rollupOptions: {
45
+ output: {
46
+ manualChunks: {
47
+ vendor: ['react', 'react-dom', 'react-router'],
48
+ i18n: ['@lingui/core', '@lingui/react'],
49
+ ui: ['@radix-ui/react-slot', 'class-variance-authority'],
50
+ },
51
+ },
52
+ },
53
+ },
54
+ });
@@ -0,0 +1,38 @@
1
+ import path from 'path';
2
+
3
+ import react from '@vitejs/plugin-react';
4
+ import { defineConfig } from 'vitest/config';
5
+
6
+ export default defineConfig({
7
+ plugins: [
8
+ react({
9
+ babel: {
10
+ plugins: ['@lingui/babel-plugin-lingui-macro'],
11
+ },
12
+ }),
13
+ ],
14
+ test: {
15
+ globals: true,
16
+ environment: 'jsdom',
17
+ setupFiles: ['./src/test-setup.ts'],
18
+ include: ['src/**/*.test.{ts,tsx}', 'tests/**/*.test.{ts,tsx}'],
19
+ clearMocks: true,
20
+ restoreMocks: true,
21
+ coverage: {
22
+ provider: 'v8',
23
+ reporter: ['text', 'json', 'html', 'lcov'],
24
+ exclude: ['**/*.test.{ts,tsx}', '**/index.ts', 'src/types/**', 'src/components/ui/**', 'src/mocks/**'],
25
+ thresholds: {
26
+ lines: 80,
27
+ functions: 80,
28
+ statements: 80,
29
+ branches: 80,
30
+ },
31
+ },
32
+ },
33
+ resolve: {
34
+ alias: {
35
+ '@': path.resolve(__dirname, './src'),
36
+ },
37
+ },
38
+ });
@@ -1,33 +0,0 @@
1
- import { zodResolver } from '@hookform/resolvers/zod';
2
- import { useForm } from 'react-hook-form';
3
-
4
- import { type ContactFormData, contactFormSchema } from '@/lib/validations';
5
-
6
- /**
7
- * Example React Hook Form + Zod hook for a contact form.
8
- * Demonstrates the pattern for form validation.
9
- */
10
- export function useContactForm() {
11
- const form = useForm<ContactFormData>({
12
- resolver: zodResolver(contactFormSchema),
13
- defaultValues: {
14
- name: '',
15
- email: '',
16
- message: '',
17
- },
18
- });
19
-
20
- const onSubmit = async (data: ContactFormData) => {
21
- // Replace with your actual form submission logic
22
- // eslint-disable-next-line no-console
23
- console.log('Form submitted:', data);
24
- // await api.submitContactForm(data);
25
- };
26
-
27
- return {
28
- form,
29
- onSubmit: form.handleSubmit(onSubmit),
30
- isSubmitting: form.formState.isSubmitting,
31
- errors: form.formState.errors,
32
- };
33
- }
@@ -1,8 +0,0 @@
1
- export const TIMING = {
2
- DEBOUNCE_MS: 300,
3
- TOAST_DURATION_MS: 5000,
4
- } as const;
5
-
6
- export const UI = {
7
- MAX_WIDTH: 1280,
8
- } as const;
@@ -1,119 +0,0 @@
1
- /**
2
- * Formatting utilities for dates, numbers, and currencies.
3
- * All formatters are locale-aware.
4
- */
5
-
6
- /**
7
- * Format a date with locale support
8
- */
9
- export function formatDate(
10
- date: Date | string | number,
11
- options: Intl.DateTimeFormatOptions = {},
12
- locale?: string,
13
- ): string {
14
- const dateObj = date instanceof Date ? date : new Date(date);
15
-
16
- if (isNaN(dateObj.getTime())) {
17
- return 'Invalid date';
18
- }
19
-
20
- const defaultOptions: Intl.DateTimeFormatOptions = {
21
- year: 'numeric',
22
- month: 'short',
23
- day: 'numeric',
24
- ...options,
25
- };
26
-
27
- return new Intl.DateTimeFormat(locale, defaultOptions).format(dateObj);
28
- }
29
-
30
- /**
31
- * Format a date with time
32
- */
33
- export function formatDateTime(
34
- date: Date | string | number,
35
- options: Intl.DateTimeFormatOptions = {},
36
- locale?: string,
37
- ): string {
38
- return formatDate(
39
- date,
40
- {
41
- hour: '2-digit',
42
- minute: '2-digit',
43
- ...options,
44
- },
45
- locale,
46
- );
47
- }
48
-
49
- /**
50
- * Format relative time (e.g., "2 hours ago", "in 3 days")
51
- */
52
- export function formatRelativeTime(date: Date | string | number, locale?: string): string {
53
- const dateObj = date instanceof Date ? date : new Date(date);
54
-
55
- if (isNaN(dateObj.getTime())) {
56
- return 'Invalid date';
57
- }
58
-
59
- const now = new Date();
60
- const diffInSeconds = Math.floor((dateObj.getTime() - now.getTime()) / 1000);
61
- const absoluteDiff = Math.abs(diffInSeconds);
62
-
63
- const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
64
-
65
- if (absoluteDiff < 60) {
66
- return rtf.format(diffInSeconds, 'second');
67
- } else if (absoluteDiff < 3600) {
68
- return rtf.format(Math.floor(diffInSeconds / 60), 'minute');
69
- } else if (absoluteDiff < 86400) {
70
- return rtf.format(Math.floor(diffInSeconds / 3600), 'hour');
71
- } else if (absoluteDiff < 2592000) {
72
- return rtf.format(Math.floor(diffInSeconds / 86400), 'day');
73
- } else if (absoluteDiff < 31536000) {
74
- return rtf.format(Math.floor(diffInSeconds / 2592000), 'month');
75
- } else {
76
- return rtf.format(Math.floor(diffInSeconds / 31536000), 'year');
77
- }
78
- }
79
-
80
- /**
81
- * Format a number with locale support
82
- */
83
- export function formatNumber(value: number, options: Intl.NumberFormatOptions = {}, locale?: string): string {
84
- return new Intl.NumberFormat(locale, options).format(value);
85
- }
86
-
87
- /**
88
- * Format a number as currency
89
- */
90
- export function formatCurrency(value: number, currency = 'USD', locale?: string): string {
91
- return new Intl.NumberFormat(locale, {
92
- style: 'currency',
93
- currency,
94
- }).format(value);
95
- }
96
-
97
- /**
98
- * Format a number as percentage
99
- */
100
- export function formatPercent(value: number, decimals = 0, locale?: string): string {
101
- return new Intl.NumberFormat(locale, {
102
- style: 'percent',
103
- minimumFractionDigits: decimals,
104
- maximumFractionDigits: decimals,
105
- }).format(value);
106
- }
107
-
108
- /**
109
- * Format bytes to human readable string
110
- */
111
- export function formatBytes(bytes: number, decimals = 2): string {
112
- if (bytes === 0) return '0 Bytes';
113
-
114
- const k = 1024;
115
- const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
116
- const i = Math.floor(Math.log(bytes) / Math.log(k));
117
-
118
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
119
- }
@@ -1,60 +0,0 @@
1
- import { act, renderHook, waitFor } from '@testing-library/react';
2
- import { describe, expect, it } from 'vitest';
3
-
4
- import { useContactForm } from '@/hooks/useContactForm';
5
-
6
- describe('useContactForm', () => {
7
- describe('initial state', () => {
8
- it('initializes with empty values and no errors', () => {
9
- const { result } = renderHook(() => useContactForm());
10
-
11
- expect(result.current.form.getValues()).toEqual({ name: '', email: '', message: '' });
12
- expect(result.current.errors).toEqual({});
13
- expect(result.current.isSubmitting).toBe(false);
14
- expect(typeof result.current.onSubmit).toBe('function');
15
- });
16
- });
17
-
18
- describe('validation', () => {
19
- it.each([
20
- { field: 'name', value: 'J', valid: { email: 'test@example.com', message: 'Valid message here' } },
21
- { field: 'email', value: 'invalid', valid: { name: 'John', message: 'Valid message here' } },
22
- { field: 'message', value: 'Short', valid: { name: 'John', email: 'test@example.com' } },
23
- ])('rejects invalid $field', async ({ field, value, valid }) => {
24
- const { result } = renderHook(() => useContactForm());
25
-
26
- act(() => {
27
- result.current.form.setValue(field as 'name' | 'email' | 'message', value);
28
- Object.entries(valid).forEach(([k, v]) => {
29
- result.current.form.setValue(k as 'name' | 'email' | 'message', v);
30
- });
31
- });
32
-
33
- await act(async () => {
34
- await result.current.form.trigger();
35
- });
36
-
37
- await waitFor(() => {
38
- expect(result.current.errors[field as keyof typeof result.current.errors]).toBeDefined();
39
- });
40
- });
41
-
42
- it('passes with valid data', async () => {
43
- const { result } = renderHook(() => useContactForm());
44
-
45
- act(() => {
46
- result.current.form.setValue('name', 'John Doe');
47
- result.current.form.setValue('email', 'test@example.com');
48
- result.current.form.setValue('message', 'This is a valid message that is long enough.');
49
- });
50
-
51
- await act(async () => {
52
- await result.current.form.trigger();
53
- });
54
-
55
- await waitFor(() => {
56
- expect(result.current.errors).toEqual({});
57
- });
58
- });
59
- });
60
- });