@stjoseph/prumo 0.0.1

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 (220) hide show
  1. package/.prumo-templates/api/auth.md +130 -0
  2. package/.prumo-templates/api/config.md +85 -0
  3. package/.prumo-templates/api/errors.md +82 -0
  4. package/.prumo-templates/api/logging.md +79 -0
  5. package/.prumo-templates/api/modules.md +117 -0
  6. package/.prumo-templates/api/observability.md +91 -0
  7. package/.prumo-templates/api/pagination.md +71 -0
  8. package/.prumo-templates/api/routes.md +89 -0
  9. package/.prumo-templates/client/data.md +124 -0
  10. package/.prumo-templates/client/forms.md +88 -0
  11. package/.prumo-templates/core/conventions.md +74 -0
  12. package/.prumo-templates/core/testing.md +100 -0
  13. package/.prumo-templates/core/tooling.md +118 -0
  14. package/.prumo-templates/database/entities.md +97 -0
  15. package/.prumo-templates/database/migrations.md +74 -0
  16. package/.prumo-templates/database/queries.md +79 -0
  17. package/.prumo-templates/database/testing.md +82 -0
  18. package/.prumo-templates/database/transactions.md +68 -0
  19. package/.prumo-templates/mobile/components.md +83 -0
  20. package/.prumo-templates/mobile/config.md +84 -0
  21. package/.prumo-templates/mobile/routing.md +74 -0
  22. package/.prumo-templates/mobile/storage.md +84 -0
  23. package/.prumo-templates/mobile/structure.md +79 -0
  24. package/.prumo-templates/monorepo/dependencies.md +84 -0
  25. package/.prumo-templates/monorepo/layout.md +89 -0
  26. package/.prumo-templates/monorepo/tasks.md +76 -0
  27. package/.prumo-templates/multi-tenancy/client.md +83 -0
  28. package/.prumo-templates/multi-tenancy/isolation.md +104 -0
  29. package/.prumo-templates/site/rendering.md +83 -0
  30. package/.prumo-templates/site/structure.md +68 -0
  31. package/.prumo-templates/web/components.md +106 -0
  32. package/.prumo-templates/web/routing.md +82 -0
  33. package/.prumo-templates/web/structure.md +92 -0
  34. package/LICENSE +21 -0
  35. package/README.md +110 -0
  36. package/dist/asker.js +28 -0
  37. package/dist/cli.js +62 -0
  38. package/dist/compose.js +222 -0
  39. package/dist/context.js +38 -0
  40. package/dist/generate.js +60 -0
  41. package/dist/jsonc.js +76 -0
  42. package/dist/names.js +10 -0
  43. package/dist/questions.js +63 -0
  44. package/package.json +55 -0
  45. package/templates/api/.env.example +12 -0
  46. package/templates/api/.githooks/install.mjs +18 -0
  47. package/templates/api/.githooks/pre-commit +2 -0
  48. package/templates/api/.githooks/pre-push +2 -0
  49. package/templates/api/README.md +35 -0
  50. package/templates/api/biome.jsonc +18 -0
  51. package/templates/api/docker/init/01-auth-schema.sql +1 -0
  52. package/templates/api/docker-compose.yml +20 -0
  53. package/templates/api/gitignore +5 -0
  54. package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
  55. package/templates/api/nest-cli.json +10 -0
  56. package/templates/api/package.json +69 -0
  57. package/templates/api/pnpm-workspace.yaml +9 -0
  58. package/templates/api/src/app.module.ts +46 -0
  59. package/templates/api/src/auth/auth.constants.ts +2 -0
  60. package/templates/api/src/auth/auth.controller.ts +19 -0
  61. package/templates/api/src/auth/auth.factory.spec.ts +29 -0
  62. package/templates/api/src/auth/auth.factory.ts +52 -0
  63. package/templates/api/src/auth/auth.guard.ts +43 -0
  64. package/templates/api/src/auth/auth.module.ts +30 -0
  65. package/templates/api/src/auth/auth.ts +4 -0
  66. package/templates/api/src/auth/current-user.decorator.ts +9 -0
  67. package/templates/api/src/auth/express.d.ts +10 -0
  68. package/templates/api/src/auth/public.decorator.ts +4 -0
  69. package/templates/api/src/config/env.module.ts +11 -0
  70. package/templates/api/src/config/env.ts +81 -0
  71. package/templates/api/src/errors/problem-details.filter.ts +86 -0
  72. package/templates/api/src/errors/validation-exception.factory.ts +30 -0
  73. package/templates/api/src/health/health.controller.ts +32 -0
  74. package/templates/api/src/health/health.module.ts +9 -0
  75. package/templates/api/src/main.ts +43 -0
  76. package/templates/api/src/mikro-orm.config.ts +4 -0
  77. package/templates/api/src/mikro-orm.factory.ts +14 -0
  78. package/templates/api/src/request-context/request-context.module.ts +9 -0
  79. package/templates/api/src/request-context/request-context.service.ts +17 -0
  80. package/templates/api/src/request-context/request-id.middleware.ts +19 -0
  81. package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
  82. package/templates/api/src/users/entities/profile.entity.ts +27 -0
  83. package/templates/api/src/users/users.controller.ts +23 -0
  84. package/templates/api/src/users/users.module.ts +10 -0
  85. package/templates/api/src/users/users.service.spec.ts +44 -0
  86. package/templates/api/src/users/users.service.ts +36 -0
  87. package/templates/api/test/factories/profile.factory.ts +26 -0
  88. package/templates/api/test/factories/user.factory.ts +21 -0
  89. package/templates/api/test/global-setup.ts +20 -0
  90. package/templates/api/test/setup.ts +80 -0
  91. package/templates/api/tsconfig.build.json +8 -0
  92. package/templates/api/tsconfig.json +29 -0
  93. package/templates/api/vitest.config.mts +17 -0
  94. package/templates/mobile/.env.example +1 -0
  95. package/templates/mobile/.githooks/install.mjs +18 -0
  96. package/templates/mobile/.githooks/pre-commit +2 -0
  97. package/templates/mobile/.githooks/pre-push +2 -0
  98. package/templates/mobile/README.md +30 -0
  99. package/templates/mobile/app.config.ts +13 -0
  100. package/templates/mobile/app.json +39 -0
  101. package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
  102. package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
  103. package/templates/mobile/assets/expo.icon/icon.json +35 -0
  104. package/templates/mobile/assets/images/android-icon-background.png +0 -0
  105. package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
  106. package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
  107. package/templates/mobile/assets/images/icon.png +0 -0
  108. package/templates/mobile/assets/images/splash-icon.png +0 -0
  109. package/templates/mobile/babel.config.js +6 -0
  110. package/templates/mobile/biome.jsonc +21 -0
  111. package/templates/mobile/gitignore +11 -0
  112. package/templates/mobile/global.d.ts +1 -0
  113. package/templates/mobile/jest.config.js +21 -0
  114. package/templates/mobile/metro.config.js +4 -0
  115. package/templates/mobile/nativewind-env.d.ts +1 -0
  116. package/templates/mobile/package.json +63 -0
  117. package/templates/mobile/src/api-contract/api-error.ts +51 -0
  118. package/templates/mobile/src/api-contract/client.ts +26 -0
  119. package/templates/mobile/src/api-contract/index.ts +10 -0
  120. package/templates/mobile/src/api-contract/profile.ts +27 -0
  121. package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
  122. package/templates/mobile/src/app/(app)/index.tsx +18 -0
  123. package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
  124. package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
  125. package/templates/mobile/src/app/_layout.tsx +54 -0
  126. package/templates/mobile/src/components/ui/button.tsx +36 -0
  127. package/templates/mobile/src/components/ui/field.tsx +30 -0
  128. package/templates/mobile/src/config.ts +3 -0
  129. package/templates/mobile/src/features/auth/auth-client.ts +27 -0
  130. package/templates/mobile/src/features/auth/intended-route.ts +11 -0
  131. package/templates/mobile/src/features/auth/session.ts +17 -0
  132. package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
  133. package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
  134. package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
  135. package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
  136. package/templates/mobile/src/features/forms/form-error.tsx +16 -0
  137. package/templates/mobile/src/features/forms/server-errors.ts +33 -0
  138. package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
  139. package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
  140. package/templates/mobile/src/features/storage/query-persister.ts +15 -0
  141. package/templates/mobile/src/features/storage/storage.ts +11 -0
  142. package/templates/mobile/src/global.css +3 -0
  143. package/templates/mobile/tailwind.config.js +9 -0
  144. package/templates/mobile/test/fake-transport.ts +23 -0
  145. package/templates/mobile/tsconfig.json +21 -0
  146. package/templates/site/.githooks/install.mjs +18 -0
  147. package/templates/site/.githooks/pre-commit +2 -0
  148. package/templates/site/.githooks/pre-push +2 -0
  149. package/templates/site/README.md +28 -0
  150. package/templates/site/biome.jsonc +12 -0
  151. package/templates/site/components.json +25 -0
  152. package/templates/site/gitignore +7 -0
  153. package/templates/site/next.config.ts +7 -0
  154. package/templates/site/package.json +40 -0
  155. package/templates/site/pnpm-workspace.yaml +5 -0
  156. package/templates/site/postcss.config.mjs +7 -0
  157. package/templates/site/src/app/globals.css +129 -0
  158. package/templates/site/src/app/layout.tsx +13 -0
  159. package/templates/site/src/app/page.spec.ts +10 -0
  160. package/templates/site/src/app/page.tsx +17 -0
  161. package/templates/site/src/lib/utils.ts +1 -0
  162. package/templates/site/tsconfig.json +34 -0
  163. package/templates/site/vitest.config.mts +8 -0
  164. package/templates/web/.env.example +1 -0
  165. package/templates/web/.githooks/install.mjs +18 -0
  166. package/templates/web/.githooks/pre-commit +2 -0
  167. package/templates/web/.githooks/pre-push +2 -0
  168. package/templates/web/README.md +29 -0
  169. package/templates/web/biome.jsonc +24 -0
  170. package/templates/web/components.json +25 -0
  171. package/templates/web/gitignore +6 -0
  172. package/templates/web/index.html +12 -0
  173. package/templates/web/package.json +55 -0
  174. package/templates/web/src/api-contract/api-error.ts +51 -0
  175. package/templates/web/src/api-contract/client.ts +26 -0
  176. package/templates/web/src/api-contract/index.ts +10 -0
  177. package/templates/web/src/api-contract/profile.ts +27 -0
  178. package/templates/web/src/app.spec.tsx +12 -0
  179. package/templates/web/src/app.tsx +14 -0
  180. package/templates/web/src/components/ui/button.tsx +57 -0
  181. package/templates/web/src/components/ui/field.tsx +224 -0
  182. package/templates/web/src/components/ui/input.tsx +19 -0
  183. package/templates/web/src/components/ui/label.tsx +17 -0
  184. package/templates/web/src/components/ui/separator.tsx +20 -0
  185. package/templates/web/src/config.ts +7 -0
  186. package/templates/web/src/features/auth/auth-client.ts +12 -0
  187. package/templates/web/src/features/auth/session.ts +17 -0
  188. package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
  189. package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
  190. package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
  191. package/templates/web/src/features/forms/form-error.tsx +15 -0
  192. package/templates/web/src/features/forms/server-errors.ts +33 -0
  193. package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
  194. package/templates/web/src/features/profile/profile-form.tsx +74 -0
  195. package/templates/web/src/index.css +130 -0
  196. package/templates/web/src/lib/utils.ts +1 -0
  197. package/templates/web/src/main.tsx +28 -0
  198. package/templates/web/src/routeTree.gen.ts +125 -0
  199. package/templates/web/src/router.tsx +21 -0
  200. package/templates/web/src/routes/__root.tsx +10 -0
  201. package/templates/web/src/routes/_authenticated/index.tsx +21 -0
  202. package/templates/web/src/routes/_authenticated.tsx +15 -0
  203. package/templates/web/src/routes/sign-in.tsx +20 -0
  204. package/templates/web/src/routes/sign-up.tsx +17 -0
  205. package/templates/web/test/fake-transport.ts +23 -0
  206. package/templates/web/test/render-app.tsx +17 -0
  207. package/templates/web/test/setup.ts +7 -0
  208. package/templates/web/tsconfig.json +21 -0
  209. package/templates/web/vite.config.ts +18 -0
  210. package/templates/workspace/.githooks/install.mjs +18 -0
  211. package/templates/workspace/.githooks/pre-commit +2 -0
  212. package/templates/workspace/.githooks/pre-push +2 -0
  213. package/templates/workspace/README.md +25 -0
  214. package/templates/workspace/biome.jsonc +8 -0
  215. package/templates/workspace/gitignore +5 -0
  216. package/templates/workspace/package.json +17 -0
  217. package/templates/workspace/packages/api-contract/package.json +20 -0
  218. package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
  219. package/templates/workspace/pnpm-workspace.yaml +3 -0
  220. package/templates/workspace/tsconfig.base.json +10 -0
@@ -0,0 +1,129 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+ @import "shadcn/tailwind.css";
4
+
5
+ @custom-variant dark (&:is(.dark *));
6
+
7
+ @theme inline {
8
+ --font-heading: var(--font-sans);
9
+ --font-sans: var(--font-sans);
10
+ --color-sidebar-ring: var(--sidebar-ring);
11
+ --color-sidebar-border: var(--sidebar-border);
12
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
13
+ --color-sidebar-accent: var(--sidebar-accent);
14
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
15
+ --color-sidebar-primary: var(--sidebar-primary);
16
+ --color-sidebar-foreground: var(--sidebar-foreground);
17
+ --color-sidebar: var(--sidebar);
18
+ --color-chart-5: var(--chart-5);
19
+ --color-chart-4: var(--chart-4);
20
+ --color-chart-3: var(--chart-3);
21
+ --color-chart-2: var(--chart-2);
22
+ --color-chart-1: var(--chart-1);
23
+ --color-ring: var(--ring);
24
+ --color-input: var(--input);
25
+ --color-border: var(--border);
26
+ --color-destructive: var(--destructive);
27
+ --color-accent-foreground: var(--accent-foreground);
28
+ --color-accent: var(--accent);
29
+ --color-muted-foreground: var(--muted-foreground);
30
+ --color-muted: var(--muted);
31
+ --color-secondary-foreground: var(--secondary-foreground);
32
+ --color-secondary: var(--secondary);
33
+ --color-primary-foreground: var(--primary-foreground);
34
+ --color-primary: var(--primary);
35
+ --color-popover-foreground: var(--popover-foreground);
36
+ --color-popover: var(--popover);
37
+ --color-card-foreground: var(--card-foreground);
38
+ --color-card: var(--card);
39
+ --color-foreground: var(--foreground);
40
+ --color-background: var(--background);
41
+ --radius-sm: calc(var(--radius) * 0.6);
42
+ --radius-md: calc(var(--radius) * 0.8);
43
+ --radius-lg: var(--radius);
44
+ --radius-xl: calc(var(--radius) * 1.4);
45
+ --radius-2xl: calc(var(--radius) * 1.8);
46
+ --radius-3xl: calc(var(--radius) * 2.2);
47
+ --radius-4xl: calc(var(--radius) * 2.6);
48
+ }
49
+
50
+ :root {
51
+ --background: oklch(1 0 0);
52
+ --foreground: oklch(0.145 0 0);
53
+ --card: oklch(1 0 0);
54
+ --card-foreground: oklch(0.145 0 0);
55
+ --popover: oklch(1 0 0);
56
+ --popover-foreground: oklch(0.145 0 0);
57
+ --primary: oklch(0.205 0 0);
58
+ --primary-foreground: oklch(0.985 0 0);
59
+ --secondary: oklch(0.97 0 0);
60
+ --secondary-foreground: oklch(0.205 0 0);
61
+ --muted: oklch(0.97 0 0);
62
+ --muted-foreground: oklch(0.556 0 0);
63
+ --accent: oklch(0.97 0 0);
64
+ --accent-foreground: oklch(0.205 0 0);
65
+ --destructive: oklch(0.577 0.245 27.325);
66
+ --border: oklch(0.922 0 0);
67
+ --input: oklch(0.922 0 0);
68
+ --ring: oklch(0.708 0 0);
69
+ --chart-1: oklch(0.87 0 0);
70
+ --chart-2: oklch(0.556 0 0);
71
+ --chart-3: oklch(0.439 0 0);
72
+ --chart-4: oklch(0.371 0 0);
73
+ --chart-5: oklch(0.269 0 0);
74
+ --radius: 0.625rem;
75
+ --sidebar: oklch(0.985 0 0);
76
+ --sidebar-foreground: oklch(0.145 0 0);
77
+ --sidebar-primary: oklch(0.205 0 0);
78
+ --sidebar-primary-foreground: oklch(0.985 0 0);
79
+ --sidebar-accent: oklch(0.97 0 0);
80
+ --sidebar-accent-foreground: oklch(0.205 0 0);
81
+ --sidebar-border: oklch(0.922 0 0);
82
+ --sidebar-ring: oklch(0.708 0 0);
83
+ }
84
+
85
+ .dark {
86
+ --background: oklch(0.145 0 0);
87
+ --foreground: oklch(0.985 0 0);
88
+ --card: oklch(0.205 0 0);
89
+ --card-foreground: oklch(0.985 0 0);
90
+ --popover: oklch(0.205 0 0);
91
+ --popover-foreground: oklch(0.985 0 0);
92
+ --primary: oklch(0.922 0 0);
93
+ --primary-foreground: oklch(0.205 0 0);
94
+ --secondary: oklch(0.269 0 0);
95
+ --secondary-foreground: oklch(0.985 0 0);
96
+ --muted: oklch(0.269 0 0);
97
+ --muted-foreground: oklch(0.708 0 0);
98
+ --accent: oklch(0.269 0 0);
99
+ --accent-foreground: oklch(0.985 0 0);
100
+ --destructive: oklch(0.704 0.191 22.216);
101
+ --border: oklch(1 0 0 / 10%);
102
+ --input: oklch(1 0 0 / 15%);
103
+ --ring: oklch(0.556 0 0);
104
+ --chart-1: oklch(0.87 0 0);
105
+ --chart-2: oklch(0.556 0 0);
106
+ --chart-3: oklch(0.439 0 0);
107
+ --chart-4: oklch(0.371 0 0);
108
+ --chart-5: oklch(0.269 0 0);
109
+ --sidebar: oklch(0.205 0 0);
110
+ --sidebar-foreground: oklch(0.985 0 0);
111
+ --sidebar-primary: oklch(0.488 0.243 264.376);
112
+ --sidebar-primary-foreground: oklch(0.985 0 0);
113
+ --sidebar-accent: oklch(0.269 0 0);
114
+ --sidebar-accent-foreground: oklch(0.985 0 0);
115
+ --sidebar-border: oklch(1 0 0 / 10%);
116
+ --sidebar-ring: oklch(0.556 0 0);
117
+ }
118
+
119
+ @layer base {
120
+ * {
121
+ @apply border-border outline-ring/50;
122
+ }
123
+ body {
124
+ @apply bg-background text-foreground;
125
+ }
126
+ html {
127
+ @apply font-sans;
128
+ }
129
+ }
@@ -0,0 +1,13 @@
1
+ import './globals.css'
2
+ import { Geist } from 'next/font/google'
3
+ import { cn } from '@/lib/utils'
4
+
5
+ const geist = Geist({ subsets: ['latin'], variable: '--font-sans' })
6
+
7
+ export default function RootLayout({ children }: LayoutProps<'/'>) {
8
+ return (
9
+ <html lang="en" className={cn('font-sans', geist.variable)}>
10
+ <body>{children}</body>
11
+ </html>
12
+ )
13
+ }
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { metadata } from './page'
3
+
4
+ describe('home page metadata', () => {
5
+ it('gives search results a title and a description', () => {
6
+ expect(metadata.title).toEqual(expect.any(String))
7
+ expect(metadata.description).toEqual(expect.any(String))
8
+ expect(String(metadata.description).length).toBeGreaterThan(0)
9
+ })
10
+ })
@@ -0,0 +1,17 @@
1
+ import type { Metadata } from 'next'
2
+
3
+ export const metadata: Metadata = {
4
+ title: 'Site',
5
+ description: 'Replace this with one sentence that tells a search result what this site is.',
6
+ }
7
+
8
+ export default function HomePage() {
9
+ return (
10
+ <main className="mx-auto flex min-h-svh max-w-2xl flex-col justify-center gap-4 p-6">
11
+ <h1 className="text-4xl font-semibold tracking-tight">Site</h1>
12
+ <p className="text-lg text-muted-foreground">
13
+ This page is rendered once, at build time, and served as static HTML.
14
+ </p>
15
+ </main>
16
+ )
17
+ }
@@ -0,0 +1 @@
1
+ export { cn } from 'cn'
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "noFallthroughCasesInSwitch": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "noEmit": true,
12
+ "esModuleInterop": true,
13
+ "module": "esnext",
14
+ "moduleResolution": "bundler",
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true,
17
+ "jsx": "react-jsx",
18
+ "incremental": true,
19
+ "plugins": [{ "name": "next" }],
20
+ "paths": {
21
+ "@/*": ["./src/*"]
22
+ },
23
+ "types": ["vitest/globals"]
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }
@@ -0,0 +1,8 @@
1
+ import react from '@vitejs/plugin-react'
2
+ import { defineConfig } from 'vitest/config'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ resolve: { alias: { '@': `${import.meta.dirname}/src` } },
7
+ test: { globals: true, include: ['src/**/*.spec.{ts,tsx}'] },
8
+ })
@@ -0,0 +1 @@
1
+ VITE_API_URL=http://localhost:3000
@@ -0,0 +1,18 @@
1
+ import { execFileSync } from 'node:child_process'
2
+
3
+ function insideRepository() {
4
+ try {
5
+ execFileSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' })
6
+ return true
7
+ } catch (error) {
8
+ // No git, or no repository: an install from an archive or inside a container, where hooks have no use.
9
+ if (error.code === 'ENOENT' || error.status === 128) {
10
+ return false
11
+ }
12
+ throw error
13
+ }
14
+ }
15
+
16
+ if (insideRepository()) {
17
+ execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { stdio: 'inherit' })
18
+ }
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ pnpm biome check --staged --write
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ pnpm typecheck
@@ -0,0 +1,29 @@
1
+ # web
2
+
3
+ ## Requirements
4
+
5
+ Node 22.17 or later, pnpm, and the API running.
6
+
7
+ ## Running it
8
+
9
+ ```sh
10
+ pnpm install
11
+ cp .env.example .env # VITE_API_URL points at the API
12
+ pnpm dev
13
+ ```
14
+
15
+ The app runs on `http://localhost:5173`, which is the origin the API trusts by default (`WEB_ORIGIN`).
16
+
17
+ ## Everyday commands
18
+
19
+ | Command | What it does |
20
+ |---|---|
21
+ | `pnpm test` | Renders components against a fake transport; no API needed |
22
+ | `pnpm lint` | Biome |
23
+ | `pnpm typecheck` | `tsc --noEmit`, also run before every push |
24
+ | `pnpm build` | Production build into `dist/`; `pnpm preview` serves it |
25
+
26
+ ## Conventions
27
+
28
+ The rules this project follows live in `.prumo/`, and `AGENTS.md` points to them. Read those before
29
+ changing how something is done.
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.5.13/schema.json",
3
+ "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
4
+ "files": { "includes": ["**", "!dist", "!coverage", "!src/routeTree.gen.ts"] },
5
+ "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
6
+ "linter": { "rules": { "preset": "recommended" } },
7
+ "javascript": { "formatter": { "quoteStyle": "single", "semicolons": "asNeeded" } },
8
+ "css": { "parser": { "tailwindDirectives": true } },
9
+ "overrides": [
10
+ {
11
+ "includes": ["src/components/ui/**"],
12
+ "linter": {
13
+ "rules": {
14
+ "a11y": {
15
+ // A shadcn primitive forwards htmlFor through props, which the rule cannot see.
16
+ "noLabelWithoutControl": "off",
17
+ // shadcn's Field uses role="group" on purpose; a fieldset would change layout and semantics.
18
+ "useSemanticElements": "off"
19
+ }
20
+ }
21
+ }
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "base-nova",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/index.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "rtl": false,
15
+ "aliases": {
16
+ "components": "@/components",
17
+ "utils": "@/lib/utils",
18
+ "ui": "@/components/ui",
19
+ "lib": "@/lib",
20
+ "hooks": "@/hooks"
21
+ },
22
+ "menuColor": "default",
23
+ "menuAccent": "subtle",
24
+ "registries": {}
25
+ }
@@ -0,0 +1,6 @@
1
+ node_modules
2
+ dist
3
+ coverage
4
+ *.tsbuildinfo
5
+ .env
6
+ .env.local
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Web</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "web",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "lint": "biome check",
11
+ "typecheck": "tsc --noEmit",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest",
14
+ "test:cov": "vitest run --coverage",
15
+ "prepare": "node .githooks/install.mjs"
16
+ },
17
+ "dependencies": {
18
+ "@base-ui/react": "1.8.0",
19
+ "@fontsource-variable/geist": "5.3.0",
20
+ "@hookform/resolvers": "5.9.1",
21
+ "@tanstack/react-query": "5.102.8",
22
+ "@tanstack/react-router": "1.170.36",
23
+ "better-auth": "1.7.4",
24
+ "class-variance-authority": "0.7.1",
25
+ "cn": "0.3.0",
26
+ "lucide-react": "1.45.0",
27
+ "react": "19.2.3",
28
+ "react-dom": "19.2.3",
29
+ "react-hook-form": "7.88.0",
30
+ "shadcn": "4.21.0",
31
+ "tw-animate-css": "1.4.0",
32
+ "zod": "4.6.4"
33
+ },
34
+ "devDependencies": {
35
+ "@biomejs/biome": "2.5.13",
36
+ "@tailwindcss/vite": "4.3.3",
37
+ "@tanstack/router-plugin": "1.168.38",
38
+ "@testing-library/jest-dom": "7.0.1",
39
+ "@testing-library/react": "16.3.3",
40
+ "@testing-library/user-event": "14.6.7",
41
+ "@types/node": "24.13.4",
42
+ "@types/react": "19.2.18",
43
+ "@types/react-dom": "19.2.7",
44
+ "@vitejs/plugin-react": "6.1.1",
45
+ "@vitest/coverage-v8": "4.1.11",
46
+ "jsdom": "30.0.1",
47
+ "tailwindcss": "4.3.3",
48
+ "typescript": "6.0.3",
49
+ "vite": "8.3.0",
50
+ "vitest": "4.1.11"
51
+ },
52
+ "engines": {
53
+ "node": ">=22.17.0"
54
+ }
55
+ }
@@ -0,0 +1,51 @@
1
+ export type FieldErrors = Record<string, string[]>
2
+
3
+ type ProblemDocument = {
4
+ title?: string
5
+ status?: number
6
+ detail?: string
7
+ requestId?: string
8
+ errors?: FieldErrors
9
+ }
10
+
11
+ type AuthClientError = {
12
+ status: number
13
+ statusText: string
14
+ message?: string
15
+ code?: string
16
+ }
17
+
18
+ export class ApiError extends Error {
19
+ constructor(
20
+ readonly status: number,
21
+ readonly title: string,
22
+ readonly detail: string | undefined,
23
+ readonly requestId: string | undefined,
24
+ readonly errors: FieldErrors,
25
+ ) {
26
+ super(detail ?? title)
27
+ }
28
+
29
+ static async fromResponse(response: Response): Promise<ApiError> {
30
+ const requestId = response.headers.get('x-request-id') ?? undefined
31
+ const isProblem = response.headers.get('content-type')?.includes('application/problem+json')
32
+
33
+ if (!isProblem) {
34
+ return new ApiError(response.status, response.statusText, undefined, requestId, {})
35
+ }
36
+
37
+ const problem = (await response.json()) as ProblemDocument
38
+
39
+ return new ApiError(
40
+ response.status,
41
+ problem.title ?? response.statusText,
42
+ problem.detail,
43
+ problem.requestId ?? requestId,
44
+ problem.errors ?? {},
45
+ )
46
+ }
47
+
48
+ static fromAuthError(error: AuthClientError): ApiError {
49
+ return new ApiError(error.status, error.code ?? error.statusText, error.message, undefined, {})
50
+ }
51
+ }
@@ -0,0 +1,26 @@
1
+ import { ApiError } from './api-error'
2
+
3
+ export type Transport = typeof fetch
4
+
5
+ export type ApiClient = ReturnType<typeof createClient>
6
+
7
+ export function createClient({ baseUrl, fetch }: { baseUrl: string; fetch: Transport }) {
8
+ async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
9
+ const response = await fetch(`${baseUrl}/api/v1${path}`, {
10
+ method,
11
+ headers: body === undefined ? undefined : { 'content-type': 'application/json' },
12
+ body: body === undefined ? undefined : JSON.stringify(body),
13
+ })
14
+
15
+ if (!response.ok) {
16
+ throw await ApiError.fromResponse(response)
17
+ }
18
+
19
+ return (await response.json()) as T
20
+ }
21
+
22
+ return {
23
+ get: <T>(path: string) => request<T>('GET', path),
24
+ patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
25
+ }
26
+ }
@@ -0,0 +1,10 @@
1
+ export { ApiError, type FieldErrors } from './api-error'
2
+ export { type ApiClient, createClient, type Transport } from './client'
3
+ export {
4
+ LOCALES,
5
+ type Locale,
6
+ type Profile,
7
+ profileQuery,
8
+ type UpdateProfileRequest,
9
+ updateProfile,
10
+ } from './profile'
@@ -0,0 +1,27 @@
1
+ import { queryOptions } from '@tanstack/react-query'
2
+ import type { ApiClient } from './client'
3
+
4
+ export const LOCALES = ['en', 'pt-BR'] as const
5
+
6
+ export type Locale = (typeof LOCALES)[number]
7
+
8
+ export type Profile = {
9
+ id: string
10
+ userId: string
11
+ displayName: string
12
+ locale: Locale
13
+ timezone: string
14
+ createdAt: string
15
+ updatedAt: string
16
+ }
17
+
18
+ export type UpdateProfileRequest = Partial<Pick<Profile, 'displayName' | 'locale' | 'timezone'>>
19
+
20
+ export const profileQuery = (client: ApiClient) =>
21
+ queryOptions({
22
+ queryKey: ['profile', 'me'],
23
+ queryFn: () => client.get<Profile>('/users/me'),
24
+ })
25
+
26
+ export const updateProfile = (client: ApiClient, changes: UpdateProfileRequest) =>
27
+ client.patch<Profile>('/users/me', changes)
@@ -0,0 +1,12 @@
1
+ import { screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { fakeTransport, json } from '../test/fake-transport'
4
+ import { renderApp } from '../test/render-app'
5
+
6
+ describe('App', () => {
7
+ it('sends a visitor without a session to sign in', async () => {
8
+ renderApp('/', fakeTransport({ 'GET /api/auth/get-session': () => json(200, null) }))
9
+
10
+ expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
11
+ })
12
+ })
@@ -0,0 +1,14 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
+ import { type RouterHistory, RouterProvider } from '@tanstack/react-router'
3
+ import { useState } from 'react'
4
+ import { createAppRouter, type RouterContext } from './router'
5
+
6
+ export function App({ context, history }: { context: RouterContext; history?: RouterHistory }) {
7
+ const [router] = useState(() => createAppRouter(context, history))
8
+
9
+ return (
10
+ <QueryClientProvider client={context.queryClient}>
11
+ <RouterProvider router={router} />
12
+ </QueryClientProvider>
13
+ )
14
+ }
@@ -0,0 +1,57 @@
1
+ import { Button as ButtonPrimitive } from '@base-ui/react/button'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+ import { cn } from 'cn'
4
+
5
+ const buttonVariants = cva(
6
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: 'bg-primary text-primary-foreground hover:bg-primary/80',
11
+ outline:
12
+ 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
13
+ secondary:
14
+ 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
15
+ ghost:
16
+ 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
17
+ destructive:
18
+ 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
19
+ link: 'text-primary underline-offset-4 hover:underline',
20
+ },
21
+ size: {
22
+ default:
23
+ 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
24
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
25
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
26
+ lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
27
+ icon: 'size-8',
28
+ 'icon-xs':
29
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
30
+ 'icon-sm':
31
+ 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
32
+ 'icon-lg': 'size-9',
33
+ },
34
+ },
35
+ defaultVariants: {
36
+ variant: 'default',
37
+ size: 'default',
38
+ },
39
+ },
40
+ )
41
+
42
+ function Button({
43
+ className,
44
+ variant = 'default',
45
+ size = 'default',
46
+ ...props
47
+ }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
48
+ return (
49
+ <ButtonPrimitive
50
+ data-slot="button"
51
+ className={cn(buttonVariants({ variant, size, className }))}
52
+ {...props}
53
+ />
54
+ )
55
+ }
56
+
57
+ export { Button, buttonVariants }