create-appystack 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 (99) hide show
  1. package/README.md +54 -0
  2. package/bin/index.js +243 -0
  3. package/package.json +39 -0
  4. package/template/.claude/skills/recipe/SKILL.md +71 -0
  5. package/template/.claude/skills/recipe/domains/care-provider-operations.md +185 -0
  6. package/template/.claude/skills/recipe/domains/youtube-launch-optimizer.md +154 -0
  7. package/template/.claude/skills/recipe/references/file-crud.md +295 -0
  8. package/template/.claude/skills/recipe/references/nav-shell.md +233 -0
  9. package/template/.dockerignore +39 -0
  10. package/template/.env.example +13 -0
  11. package/template/.github/workflows/ci.yml +43 -0
  12. package/template/.husky/pre-commit +1 -0
  13. package/template/.prettierignore +7 -0
  14. package/template/.prettierrc +8 -0
  15. package/template/.vscode/launch.json +59 -0
  16. package/template/CLAUDE.md +114 -0
  17. package/template/Dockerfile +56 -0
  18. package/template/README.md +219 -0
  19. package/template/client/index.html +13 -0
  20. package/template/client/package.json +43 -0
  21. package/template/client/src/App.test.tsx +67 -0
  22. package/template/client/src/App.tsx +11 -0
  23. package/template/client/src/components/ErrorFallback.test.tsx +64 -0
  24. package/template/client/src/components/ErrorFallback.tsx +18 -0
  25. package/template/client/src/config/env.test.ts +64 -0
  26. package/template/client/src/config/env.ts +34 -0
  27. package/template/client/src/contexts/AppContext.test.tsx +81 -0
  28. package/template/client/src/contexts/AppContext.tsx +52 -0
  29. package/template/client/src/demo/ContactForm.test.tsx +97 -0
  30. package/template/client/src/demo/ContactForm.tsx +100 -0
  31. package/template/client/src/demo/DemoPage.tsx +56 -0
  32. package/template/client/src/demo/SocketDemo.test.tsx +160 -0
  33. package/template/client/src/demo/SocketDemo.tsx +65 -0
  34. package/template/client/src/demo/StatusGrid.test.tsx +181 -0
  35. package/template/client/src/demo/StatusGrid.tsx +77 -0
  36. package/template/client/src/demo/TechStackDisplay.test.tsx +63 -0
  37. package/template/client/src/demo/TechStackDisplay.tsx +75 -0
  38. package/template/client/src/hooks/useServerStatus.test.ts +133 -0
  39. package/template/client/src/hooks/useServerStatus.ts +67 -0
  40. package/template/client/src/hooks/useSocket.test.ts +152 -0
  41. package/template/client/src/hooks/useSocket.ts +43 -0
  42. package/template/client/src/lib/utils.test.ts +33 -0
  43. package/template/client/src/lib/utils.ts +14 -0
  44. package/template/client/src/main.test.tsx +113 -0
  45. package/template/client/src/main.tsx +14 -0
  46. package/template/client/src/pages/LandingPage.test.tsx +30 -0
  47. package/template/client/src/pages/LandingPage.tsx +29 -0
  48. package/template/client/src/styles/index.css +50 -0
  49. package/template/client/src/test/msw/browser.ts +4 -0
  50. package/template/client/src/test/msw/handlers.ts +12 -0
  51. package/template/client/src/test/msw/msw-example.test.ts +69 -0
  52. package/template/client/src/test/msw/server.ts +14 -0
  53. package/template/client/src/test/setup.ts +10 -0
  54. package/template/client/src/utils/api.test.ts +79 -0
  55. package/template/client/src/utils/api.ts +42 -0
  56. package/template/client/src/vite-env.d.ts +13 -0
  57. package/template/client/tsconfig.json +17 -0
  58. package/template/client/vite.config.ts +38 -0
  59. package/template/client/vitest.config.ts +36 -0
  60. package/template/docker-compose.yml +19 -0
  61. package/template/e2e/smoke.test.ts +95 -0
  62. package/template/e2e/socket.test.ts +96 -0
  63. package/template/eslint.config.js +2 -0
  64. package/template/package.json +50 -0
  65. package/template/playwright.config.ts +14 -0
  66. package/template/scripts/customize.ts +175 -0
  67. package/template/server/nodemon.json +5 -0
  68. package/template/server/package.json +45 -0
  69. package/template/server/src/app.test.ts +103 -0
  70. package/template/server/src/config/env.test.ts +97 -0
  71. package/template/server/src/config/env.ts +29 -0
  72. package/template/server/src/config/logger.test.ts +58 -0
  73. package/template/server/src/config/logger.ts +17 -0
  74. package/template/server/src/helpers/response.test.ts +53 -0
  75. package/template/server/src/helpers/response.ts +17 -0
  76. package/template/server/src/index.ts +118 -0
  77. package/template/server/src/middleware/errorHandler.test.ts +84 -0
  78. package/template/server/src/middleware/errorHandler.ts +27 -0
  79. package/template/server/src/middleware/rateLimiter.test.ts +68 -0
  80. package/template/server/src/middleware/rateLimiter.ts +8 -0
  81. package/template/server/src/middleware/requestLogger.test.ts +111 -0
  82. package/template/server/src/middleware/requestLogger.ts +17 -0
  83. package/template/server/src/middleware/validate.test.ts +213 -0
  84. package/template/server/src/middleware/validate.ts +23 -0
  85. package/template/server/src/routes/health.test.ts +17 -0
  86. package/template/server/src/routes/health.ts +12 -0
  87. package/template/server/src/routes/info.test.ts +20 -0
  88. package/template/server/src/routes/info.ts +19 -0
  89. package/template/server/src/shared.test.ts +53 -0
  90. package/template/server/src/shutdown.test.ts +98 -0
  91. package/template/server/src/socket.test.ts +185 -0
  92. package/template/server/src/static.test.ts +166 -0
  93. package/template/server/tsconfig.json +16 -0
  94. package/template/server/vitest.config.ts +22 -0
  95. package/template/shared/package.json +19 -0
  96. package/template/shared/src/constants.ts +11 -0
  97. package/template/shared/src/index.ts +8 -0
  98. package/template/shared/src/types.ts +33 -0
  99. package/template/shared/tsconfig.json +10 -0
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import express from 'express';
4
+ import type { Server } from 'node:http';
5
+ import App from './App.js';
6
+
7
+ // Capture the native fetch at module load time, before any test setup stubs it
8
+ const nativeFetch = globalThis.fetch;
9
+
10
+ let server: Server;
11
+ let serverPort: number;
12
+
13
+ beforeAll(
14
+ () =>
15
+ new Promise<void>((resolve) => {
16
+ const app = express();
17
+ app.get('/health', (_, res) =>
18
+ res.json({ status: 'ok', timestamp: new Date().toISOString() })
19
+ );
20
+ app.get('/api/info', (_, res) =>
21
+ res.json({
22
+ status: 'ok',
23
+ data: { nodeVersion: 'test', environment: 'test', port: 0, clientUrl: '', uptime: 0 },
24
+ })
25
+ );
26
+ server = app.listen(0, () => {
27
+ serverPort = (server.address() as { port: number }).port;
28
+ resolve();
29
+ });
30
+ })
31
+ );
32
+
33
+ beforeEach(() => {
34
+ globalThis.fetch = (input, init) => {
35
+ const url =
36
+ typeof input === 'string' && input.startsWith('/')
37
+ ? `http://localhost:${serverPort}${input}`
38
+ : input;
39
+ return nativeFetch(url, init);
40
+ };
41
+ });
42
+
43
+ afterAll(
44
+ () =>
45
+ new Promise<void>((resolve) => {
46
+ server?.close(() => resolve());
47
+ })
48
+ );
49
+
50
+ describe('App', () => {
51
+ it('renders the tagline from LandingPage', async () => {
52
+ render(<App />);
53
+ expect(screen.getByText(/Production-ready RVETS stack boilerplate/)).toBeInTheDocument();
54
+ });
55
+
56
+ it('displays the status grid from DemoPage (DEV mode)', async () => {
57
+ render(<App />);
58
+ await waitFor(() => expect(screen.getByTestId('status-grid')).toBeInTheDocument(), {
59
+ timeout: 5000,
60
+ });
61
+ });
62
+
63
+ it('displays the tech stack section from DemoPage (DEV mode)', async () => {
64
+ render(<App />);
65
+ await waitFor(() => expect(screen.getByTestId('tech-stack')).toBeInTheDocument());
66
+ });
67
+ });
@@ -0,0 +1,11 @@
1
+ import DemoPage from './demo/DemoPage.js';
2
+ import LandingPage from './pages/LandingPage.js';
3
+
4
+ export default function App() {
5
+ return (
6
+ <>
7
+ <LandingPage />
8
+ {import.meta.env.DEV && <DemoPage />}
9
+ </>
10
+ );
11
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import ErrorFallback from './ErrorFallback.js';
4
+
5
+ describe('ErrorFallback', () => {
6
+ it('renders the error message', () => {
7
+ const error = new Error('Something exploded');
8
+ const resetErrorBoundary = vi.fn();
9
+
10
+ render(<ErrorFallback error={error} resetErrorBoundary={resetErrorBoundary} />);
11
+
12
+ expect(screen.getByText('Something exploded')).toBeInTheDocument();
13
+ });
14
+
15
+ it('renders the "Something went wrong" heading', () => {
16
+ const error = new Error('Test error');
17
+ const resetErrorBoundary = vi.fn();
18
+
19
+ render(<ErrorFallback error={error} resetErrorBoundary={resetErrorBoundary} />);
20
+
21
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
22
+ });
23
+
24
+ it('renders the Try again button', () => {
25
+ const error = new Error('Test error');
26
+ const resetErrorBoundary = vi.fn();
27
+
28
+ render(<ErrorFallback error={error} resetErrorBoundary={resetErrorBoundary} />);
29
+
30
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
31
+ });
32
+
33
+ it('calls resetErrorBoundary when Try again is clicked', () => {
34
+ const error = new Error('Test error');
35
+ const resetErrorBoundary = vi.fn();
36
+
37
+ render(<ErrorFallback error={error} resetErrorBoundary={resetErrorBoundary} />);
38
+
39
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
40
+
41
+ expect(resetErrorBoundary).toHaveBeenCalledOnce();
42
+ });
43
+
44
+ it('has role="alert" on the container', () => {
45
+ const error = new Error('Alert test');
46
+ const resetErrorBoundary = vi.fn();
47
+
48
+ render(<ErrorFallback error={error} resetErrorBoundary={resetErrorBoundary} />);
49
+
50
+ expect(screen.getByRole('alert')).toBeInTheDocument();
51
+ });
52
+
53
+ it('renders non-Error values as strings', () => {
54
+ const error = 'plain string error';
55
+ const resetErrorBoundary = vi.fn();
56
+
57
+ // FallbackProps expects error to be an Error, but component handles non-Error via String()
58
+ render(
59
+ <ErrorFallback error={error as unknown as Error} resetErrorBoundary={resetErrorBoundary} />
60
+ );
61
+
62
+ expect(screen.getByText('plain string error')).toBeInTheDocument();
63
+ });
64
+ });
@@ -0,0 +1,18 @@
1
+ import type { FallbackProps } from 'react-error-boundary';
2
+
3
+ export default function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
4
+ return (
5
+ <div role="alert" className="p-6 bg-red-900/20 border border-red-500 rounded-lg text-center">
6
+ <h2 className="text-red-400 text-lg font-bold mb-2">Something went wrong</h2>
7
+ <pre className="text-red-300 text-sm mb-4">
8
+ {error instanceof Error ? error.message : String(error)}
9
+ </pre>
10
+ <button
11
+ onClick={resetErrorBoundary}
12
+ className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-500"
13
+ >
14
+ Try again
15
+ </button>
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+
3
+ describe('requireEnv', () => {
4
+ beforeEach(() => {
5
+ vi.resetModules();
6
+ });
7
+
8
+ it('throws with "Missing required environment variable: KEY" when key is absent', async () => {
9
+ vi.stubEnv('VITE_MISSING_KEY', undefined as unknown as string);
10
+ const { requireEnv } = await import(/* @vite-ignore */ './env.ts');
11
+ expect(() => requireEnv('VITE_MISSING_KEY')).toThrow(
12
+ 'Missing required environment variable: VITE_MISSING_KEY'
13
+ );
14
+ vi.unstubAllEnvs();
15
+ });
16
+
17
+ it('returns the value when key is present', async () => {
18
+ vi.stubEnv('VITE_MY_VAR', 'hello');
19
+ const { requireEnv } = await import(/* @vite-ignore */ './env.ts');
20
+ expect(requireEnv('VITE_MY_VAR')).toBe('hello');
21
+ vi.unstubAllEnvs();
22
+ });
23
+ });
24
+
25
+ describe('optionalEnv', () => {
26
+ beforeEach(() => {
27
+ vi.resetModules();
28
+ });
29
+
30
+ it('returns the default when key is absent', async () => {
31
+ vi.stubEnv('VITE_ABSENT_KEY', undefined as unknown as string);
32
+ const { optionalEnv } = await import(/* @vite-ignore */ './env.ts');
33
+ expect(optionalEnv('VITE_ABSENT_KEY', 'fallback')).toBe('fallback');
34
+ vi.unstubAllEnvs();
35
+ });
36
+
37
+ it('returns the actual value when key is present', async () => {
38
+ vi.stubEnv('VITE_PRESENT_KEY', 'actual');
39
+ const { optionalEnv } = await import(/* @vite-ignore */ './env.ts');
40
+ expect(optionalEnv('VITE_PRESENT_KEY', 'fallback')).toBe('actual');
41
+ vi.unstubAllEnvs();
42
+ });
43
+ });
44
+
45
+ describe('clientEnv', () => {
46
+ it('has apiUrl and appName fields', async () => {
47
+ const { clientEnv } = await import(/* @vite-ignore */ './env.ts');
48
+ expect(clientEnv).toHaveProperty('apiUrl');
49
+ expect(clientEnv).toHaveProperty('appName');
50
+ });
51
+
52
+ it('apiUrl defaults to empty string when VITE_API_URL is not set', async () => {
53
+ // In the test environment, VITE_API_URL is not set, so default applies
54
+ const { clientEnv } = await import(/* @vite-ignore */ './env.ts');
55
+ // The value is either '' (default) or whatever the test env has configured
56
+ expect(typeof clientEnv.apiUrl).toBe('string');
57
+ });
58
+
59
+ it('appName defaults to "AppyStack" when VITE_APP_NAME is not set', async () => {
60
+ const { clientEnv } = await import(/* @vite-ignore */ './env.ts');
61
+ // In the test environment VITE_APP_NAME is not set, so default should apply
62
+ expect(clientEnv.appName).toBe('AppyStack');
63
+ });
64
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Require an environment variable to be set.
3
+ * Throws at module load time if missing.
4
+ */
5
+ export function requireEnv(key: string): string {
6
+ const value = import.meta.env[key] as string | undefined;
7
+ if (!value) {
8
+ throw new Error(`Missing required environment variable: ${key}`);
9
+ }
10
+ return value;
11
+ }
12
+
13
+ /**
14
+ * Read an optional environment variable with a fallback default.
15
+ */
16
+ export function optionalEnv(key: string, defaultValue: string): string {
17
+ return (import.meta.env[key] as string | undefined) ?? defaultValue;
18
+ }
19
+
20
+ /**
21
+ * Validated client environment configuration.
22
+ * All VITE_ vars are accessed here — not scattered through the codebase.
23
+ *
24
+ * To add a new required var: clientEnv.myVar = requireEnv('VITE_MY_VAR')
25
+ * To add a new optional var: clientEnv.myVar = optionalEnv('VITE_MY_VAR', 'default')
26
+ */
27
+ export const clientEnv = {
28
+ /** Base URL for API requests. Empty string uses the Vite dev proxy. */
29
+ apiUrl: optionalEnv('VITE_API_URL', ''),
30
+ /** Application display name shown in the UI. */
31
+ appName: optionalEnv('VITE_APP_NAME', 'AppyStack'),
32
+ // TODO: Add required vars using requireEnv(), e.g.:
33
+ // myRequiredVar: requireEnv('VITE_MY_REQUIRED_VAR'),
34
+ };
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen, renderHook, act } from '@testing-library/react';
3
+ import { AppProvider, useApp } from './AppContext.js';
4
+
5
+ describe('AppProvider', () => {
6
+ it('renders children', () => {
7
+ render(
8
+ <AppProvider>
9
+ <span>hello</span>
10
+ </AppProvider>
11
+ );
12
+ expect(screen.getByText('hello')).toBeInTheDocument();
13
+ });
14
+
15
+ it('provides initial state with count 0', () => {
16
+ const { result } = renderHook(() => useApp(), {
17
+ wrapper: AppProvider,
18
+ });
19
+ expect(result.current.state.count).toBe(0);
20
+ });
21
+ });
22
+
23
+ describe('AppContext — actions', () => {
24
+ it('increment action increments count', () => {
25
+ const { result } = renderHook(() => useApp(), {
26
+ wrapper: AppProvider,
27
+ });
28
+
29
+ act(() => {
30
+ result.current.dispatch({ type: 'increment' });
31
+ });
32
+
33
+ expect(result.current.state.count).toBe(1);
34
+ });
35
+
36
+ it('decrement action decrements count', () => {
37
+ const { result } = renderHook(() => useApp(), {
38
+ wrapper: AppProvider,
39
+ });
40
+
41
+ act(() => {
42
+ result.current.dispatch({ type: 'decrement' });
43
+ });
44
+
45
+ expect(result.current.state.count).toBe(-1);
46
+ });
47
+
48
+ it('reset action resets count to 0', () => {
49
+ const { result } = renderHook(() => useApp(), {
50
+ wrapper: AppProvider,
51
+ });
52
+
53
+ act(() => {
54
+ result.current.dispatch({ type: 'increment' });
55
+ result.current.dispatch({ type: 'increment' });
56
+ result.current.dispatch({ type: 'increment' });
57
+ });
58
+
59
+ expect(result.current.state.count).toBe(3);
60
+
61
+ act(() => {
62
+ result.current.dispatch({ type: 'reset' });
63
+ });
64
+
65
+ expect(result.current.state.count).toBe(0);
66
+ });
67
+ });
68
+
69
+ describe('useApp — error boundary', () => {
70
+ it('throws when used outside an AppProvider', () => {
71
+ // Suppress the expected error output from React in the test console
72
+ const consoleError = console.error;
73
+ console.error = () => {};
74
+
75
+ expect(() => {
76
+ renderHook(() => useApp());
77
+ }).toThrow('useApp must be used within an AppProvider');
78
+
79
+ console.error = consoleError;
80
+ });
81
+ });
@@ -0,0 +1,52 @@
1
+ import { createContext, useContext, useReducer, type ReactNode } from 'react';
2
+
3
+ // TODO: Replace with your actual application state shape
4
+ interface State {
5
+ count: number;
6
+ }
7
+
8
+ // TODO: Replace with your actual action types
9
+ type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' };
10
+
11
+ const initialState: State = { count: 0 };
12
+
13
+ function reducer(state: State, action: Action): State {
14
+ switch (action.type) {
15
+ case 'increment':
16
+ return { count: state.count + 1 };
17
+ case 'decrement':
18
+ return { count: state.count - 1 };
19
+ case 'reset':
20
+ return initialState;
21
+ default:
22
+ return state;
23
+ }
24
+ }
25
+
26
+ interface AppContextValue {
27
+ state: State;
28
+ dispatch: React.Dispatch<Action>;
29
+ }
30
+
31
+ const AppContext = createContext<AppContextValue | null>(null);
32
+
33
+ /**
34
+ * Provides global application state to the component tree.
35
+ * Wrap your app root (or a section of it) with this provider.
36
+ */
37
+ export function AppProvider({ children }: { children: ReactNode }) {
38
+ const [state, dispatch] = useReducer(reducer, initialState);
39
+ return <AppContext.Provider value={{ state, dispatch }}>{children}</AppContext.Provider>;
40
+ }
41
+
42
+ /**
43
+ * Access global application state and dispatch.
44
+ * Must be used within an AppProvider.
45
+ */
46
+ export function useApp(): AppContextValue {
47
+ const ctx = useContext(AppContext);
48
+ if (!ctx) {
49
+ throw new Error('useApp must be used within an AppProvider');
50
+ }
51
+ return ctx;
52
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import ContactForm from './ContactForm.js';
5
+
6
+ describe('ContactForm — renders', () => {
7
+ it('renders the name field', () => {
8
+ render(<ContactForm />);
9
+ expect(screen.getByLabelText('Name')).toBeInTheDocument();
10
+ });
11
+
12
+ it('renders the email field', () => {
13
+ render(<ContactForm />);
14
+ expect(screen.getByLabelText('Email')).toBeInTheDocument();
15
+ });
16
+
17
+ it('renders the message field', () => {
18
+ render(<ContactForm />);
19
+ expect(screen.getByLabelText('Message')).toBeInTheDocument();
20
+ });
21
+
22
+ it('renders the submit button', () => {
23
+ render(<ContactForm />);
24
+ expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument();
25
+ });
26
+ });
27
+
28
+ describe('ContactForm — validation errors', () => {
29
+ it('shows validation error when name is too short', async () => {
30
+ const user = userEvent.setup();
31
+ render(<ContactForm />);
32
+
33
+ await user.type(screen.getByLabelText('Name'), 'A');
34
+ await user.click(screen.getByRole('button', { name: 'Send' }));
35
+
36
+ await waitFor(() => {
37
+ const alerts = screen.getAllByRole('alert');
38
+ const nameError = alerts.find(
39
+ (el) => el.textContent === 'Name must be at least 2 characters'
40
+ );
41
+ expect(nameError).toBeTruthy();
42
+ });
43
+ });
44
+
45
+ it('shows validation error for invalid email', async () => {
46
+ const user = userEvent.setup();
47
+ render(<ContactForm />);
48
+
49
+ await user.type(screen.getByLabelText('Email'), 'not-an-email');
50
+ await user.click(screen.getByRole('button', { name: 'Send' }));
51
+
52
+ await waitFor(() => {
53
+ const alerts = screen.getAllByRole('alert');
54
+ const emailError = alerts.find((el) => el.textContent === 'Invalid email address');
55
+ expect(emailError).toBeTruthy();
56
+ });
57
+ });
58
+
59
+ it('shows validation error when message is too short', async () => {
60
+ const user = userEvent.setup();
61
+ render(<ContactForm />);
62
+
63
+ await user.type(screen.getByLabelText('Message'), 'Too short');
64
+ await user.click(screen.getByRole('button', { name: 'Send' }));
65
+
66
+ await waitFor(() => {
67
+ const alerts = screen.getAllByRole('alert');
68
+ const msgError = alerts.find((el) =>
69
+ el.textContent?.includes('Message must be at least 10 characters')
70
+ );
71
+ expect(msgError).toBeTruthy();
72
+ });
73
+ });
74
+ });
75
+
76
+ describe('ContactForm — valid submission', () => {
77
+ it('calls onSubmit with correct data when form is valid', async () => {
78
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
79
+ const user = userEvent.setup();
80
+ render(<ContactForm />);
81
+
82
+ await user.type(screen.getByLabelText('Name'), 'Alice Example');
83
+ await user.type(screen.getByLabelText('Email'), 'alice@example.com');
84
+ await user.type(screen.getByLabelText('Message'), 'Hello, this is a valid test message.');
85
+ await user.click(screen.getByRole('button', { name: 'Send' }));
86
+
87
+ await waitFor(() => {
88
+ expect(consoleSpy).toHaveBeenCalledWith({
89
+ name: 'Alice Example',
90
+ email: 'alice@example.com',
91
+ message: 'Hello, this is a valid test message.',
92
+ });
93
+ });
94
+
95
+ consoleSpy.mockRestore();
96
+ });
97
+ });
@@ -0,0 +1,100 @@
1
+ import { useForm } from 'react-hook-form';
2
+ import { zodResolver } from '@hookform/resolvers/zod';
3
+ import { z } from 'zod';
4
+ import { cn } from '@/lib/utils.js';
5
+
6
+ const schema = z.object({
7
+ name: z.string().min(2, 'Name must be at least 2 characters'),
8
+ email: z.string().email('Invalid email address'),
9
+ message: z.string().min(10, 'Message must be at least 10 characters'),
10
+ });
11
+
12
+ type FormData = z.infer<typeof schema>;
13
+
14
+ export default function ContactForm() {
15
+ const {
16
+ register,
17
+ handleSubmit,
18
+ formState: { errors },
19
+ } = useForm<FormData>({
20
+ resolver: zodResolver(schema),
21
+ });
22
+
23
+ const onSubmit = (data: FormData) => {
24
+ console.log(data); // TODO: wire to your API
25
+ };
26
+
27
+ return (
28
+ <form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4 max-w-md">
29
+ <div>
30
+ <label htmlFor="name" className="block text-sm font-medium mb-1 text-muted-foreground">
31
+ Name
32
+ </label>
33
+ <input
34
+ id="name"
35
+ type="text"
36
+ placeholder="Your name"
37
+ {...register('name')}
38
+ className={cn(
39
+ 'w-full px-3 py-2 rounded text-sm bg-background text-foreground border',
40
+ errors.name ? 'border-destructive' : 'border-border'
41
+ )}
42
+ />
43
+ {errors.name && (
44
+ <span role="alert" className="text-red-400 text-xs mt-1 block">
45
+ {errors.name.message}
46
+ </span>
47
+ )}
48
+ </div>
49
+
50
+ <div>
51
+ <label htmlFor="email" className="block text-sm font-medium mb-1 text-muted-foreground">
52
+ Email
53
+ </label>
54
+ <input
55
+ id="email"
56
+ type="email"
57
+ placeholder="you@example.com"
58
+ {...register('email')}
59
+ className={cn(
60
+ 'w-full px-3 py-2 rounded text-sm bg-background text-foreground border',
61
+ errors.email ? 'border-destructive' : 'border-border'
62
+ )}
63
+ />
64
+ {errors.email && (
65
+ <span role="alert" className="text-red-400 text-xs mt-1 block">
66
+ {errors.email.message}
67
+ </span>
68
+ )}
69
+ </div>
70
+
71
+ <div>
72
+ <label htmlFor="message" className="block text-sm font-medium mb-1 text-muted-foreground">
73
+ Message
74
+ </label>
75
+ <textarea
76
+ id="message"
77
+ rows={4}
78
+ placeholder="Your message..."
79
+ {...register('message')}
80
+ className={cn(
81
+ 'w-full px-3 py-2 rounded text-sm resize-none bg-background text-foreground border',
82
+ errors.message ? 'border-destructive' : 'border-border'
83
+ )}
84
+ />
85
+ {errors.message && (
86
+ <span role="alert" className="text-red-400 text-xs mt-1 block">
87
+ {errors.message.message}
88
+ </span>
89
+ )}
90
+ </div>
91
+
92
+ <button
93
+ type="submit"
94
+ className="px-4 py-2 rounded text-sm font-medium transition-colors bg-primary text-primary-foreground"
95
+ >
96
+ Send
97
+ </button>
98
+ </form>
99
+ );
100
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * TEMPLATE DEMO PAGE — delete this file and its references when you start building your app.
3
+ * Shows all AppyStack template features working together.
4
+ */
5
+
6
+ import { useState } from 'react';
7
+ import ContactForm from './ContactForm.js';
8
+ import SocketDemo from './SocketDemo.js';
9
+ import StatusGrid from './StatusGrid.js';
10
+ import TechStackDisplay from './TechStackDisplay.js';
11
+
12
+ export default function DemoPage() {
13
+ const [showForm, setShowForm] = useState(false);
14
+
15
+ return (
16
+ <div className="border-t border-border">
17
+ <div className="max-w-5xl mx-auto px-6 py-4">
18
+ <p className="text-xs font-mono text-primary/70 uppercase tracking-widest">
19
+ Template Demo — delete when building your app
20
+ </p>
21
+ </div>
22
+
23
+ <main className="max-w-5xl mx-auto px-6 py-12 space-y-12">
24
+ <section>
25
+ <h2 className="text-2xl font-bold mb-6 text-foreground">System Status</h2>
26
+ <StatusGrid />
27
+ </section>
28
+
29
+ <section>
30
+ <h2 className="text-2xl font-bold mb-6 text-foreground">Socket.io</h2>
31
+ <SocketDemo />
32
+ </section>
33
+
34
+ <section>
35
+ <TechStackDisplay />
36
+ </section>
37
+
38
+ <section>
39
+ <button
40
+ onClick={() => setShowForm((prev) => !prev)}
41
+ className="text-sm font-medium px-4 py-2 rounded transition-colors bg-card border border-border text-muted-foreground"
42
+ >
43
+ {showForm ? 'Hide example form' : 'Show example form'}
44
+ </button>
45
+
46
+ {showForm && (
47
+ <div className="mt-4 rounded-xl p-6 bg-card border border-border">
48
+ <h2 className="text-lg font-semibold mb-4 text-foreground">React Hook Form + Zod</h2>
49
+ <ContactForm />
50
+ </div>
51
+ )}
52
+ </section>
53
+ </main>
54
+ </div>
55
+ );
56
+ }