@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,99 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { useQueryClient } from '@tanstack/react-query'
3
+ import { Controller, useForm } from 'react-hook-form'
4
+ import { View } from 'react-native'
5
+ import { z } from 'zod'
6
+ import { ApiError } from '@/api-contract'
7
+ import { Button } from '@/components/ui/button'
8
+ import { Field } from '@/components/ui/field'
9
+ import { FormError } from '@/features/forms/form-error'
10
+ import { applyServerError } from '@/features/forms/server-errors'
11
+ import type { SessionAuth } from './auth-client'
12
+ import { sessionQuery } from './session'
13
+
14
+ const schema = z.object({
15
+ name: z.string().min(1).max(80),
16
+ email: z.email(),
17
+ password: z.string().min(8),
18
+ })
19
+
20
+ type SignUpValues = z.infer<typeof schema>
21
+
22
+ export function SignUpForm({ auth, onSignedUp }: { auth: SessionAuth; onSignedUp: () => void }) {
23
+ const queryClient = useQueryClient()
24
+ const form = useForm<SignUpValues>({
25
+ resolver: zodResolver(schema),
26
+ mode: 'onTouched',
27
+ reValidateMode: 'onChange',
28
+ defaultValues: { name: '', email: '', password: '' },
29
+ })
30
+ const { errors, isSubmitting } = form.formState
31
+
32
+ async function onSubmit(values: SignUpValues) {
33
+ const { error } = await auth.signUp.email(values)
34
+
35
+ if (error !== null) {
36
+ applyServerError(ApiError.fromAuthError(error), ['name', 'email', 'password'], form.setError)
37
+ return
38
+ }
39
+
40
+ await queryClient.invalidateQueries({ queryKey: sessionQuery(auth).queryKey })
41
+ onSignedUp()
42
+ }
43
+
44
+ return (
45
+ <View className="gap-6">
46
+ <FormError errors={errors} />
47
+ <Controller
48
+ control={form.control}
49
+ name="name"
50
+ render={({ field }) => (
51
+ <Field
52
+ label="Name"
53
+ autoComplete="name"
54
+ value={field.value}
55
+ onChangeText={field.onChange}
56
+ onBlur={field.onBlur}
57
+ error={errors.name?.message}
58
+ />
59
+ )}
60
+ />
61
+ <Controller
62
+ control={form.control}
63
+ name="email"
64
+ render={({ field }) => (
65
+ <Field
66
+ label="Email"
67
+ autoCapitalize="none"
68
+ autoComplete="email"
69
+ keyboardType="email-address"
70
+ value={field.value}
71
+ onChangeText={field.onChange}
72
+ onBlur={field.onBlur}
73
+ error={errors.email?.message}
74
+ />
75
+ )}
76
+ />
77
+ <Controller
78
+ control={form.control}
79
+ name="password"
80
+ render={({ field }) => (
81
+ <Field
82
+ label="Password"
83
+ secureTextEntry
84
+ autoComplete="new-password"
85
+ value={field.value}
86
+ onChangeText={field.onChange}
87
+ onBlur={field.onBlur}
88
+ error={errors.password?.message}
89
+ />
90
+ )}
91
+ />
92
+ <Button
93
+ label="Create account"
94
+ disabled={isSubmitting}
95
+ onPress={form.handleSubmit(onSubmit)}
96
+ />
97
+ </View>
98
+ )
99
+ }
@@ -0,0 +1,21 @@
1
+ import { createContext, type ReactNode, useContext } from 'react'
2
+ import type { ApiClient } from '@/api-contract'
3
+ import type { AuthClient } from '@/features/auth/auth-client'
4
+
5
+ type Clients = { api: ApiClient; auth: AuthClient }
6
+
7
+ const ClientsContext = createContext<Clients | undefined>(undefined)
8
+
9
+ export function ClientsProvider({ clients, children }: { clients: Clients; children: ReactNode }) {
10
+ return <ClientsContext.Provider value={clients}>{children}</ClientsContext.Provider>
11
+ }
12
+
13
+ export function useClients(): Clients {
14
+ const clients = useContext(ClientsContext)
15
+
16
+ if (clients === undefined) {
17
+ throw new Error('useClients must be used inside ClientsProvider')
18
+ }
19
+
20
+ return clients
21
+ }
@@ -0,0 +1,16 @@
1
+ import type { FieldErrors } from 'react-hook-form'
2
+ import { Text } from 'react-native'
3
+
4
+ export function FormError({ errors }: { errors: FieldErrors }) {
5
+ const message = errors.root?.server?.message
6
+
7
+ if (message === undefined) {
8
+ return null
9
+ }
10
+
11
+ return (
12
+ <Text accessibilityRole="alert" className="text-sm text-red-600">
13
+ {message}
14
+ </Text>
15
+ )
16
+ }
@@ -0,0 +1,33 @@
1
+ import type { FieldValues, Path, UseFormSetError } from 'react-hook-form'
2
+ import { ApiError } from '@/api-contract'
3
+
4
+ export const FORM_ERROR = 'root.server'
5
+
6
+ export function applyServerError<T extends FieldValues>(
7
+ error: unknown,
8
+ fields: readonly Path<T>[],
9
+ setError: UseFormSetError<T>,
10
+ ): void {
11
+ if (!(error instanceof ApiError)) {
12
+ throw error
13
+ }
14
+
15
+ const unmatched: string[] = []
16
+
17
+ for (const [field, messages] of Object.entries(error.errors)) {
18
+ const message = messages.join(' ')
19
+
20
+ if ((fields as readonly string[]).includes(field)) {
21
+ setError(field as Path<T>, { type: 'server', message })
22
+ } else {
23
+ unmatched.push(message)
24
+ }
25
+ }
26
+
27
+ if (unmatched.length > 0 || Object.keys(error.errors).length === 0) {
28
+ const detail = unmatched.length > 0 ? unmatched.join(' ') : (error.detail ?? error.title)
29
+ const reference = error.requestId === undefined ? '' : ` (${error.requestId})`
30
+
31
+ setError(FORM_ERROR as Path<T>, { type: 'server', message: `${detail}${reference}` })
32
+ }
33
+ }
@@ -0,0 +1,58 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { render, screen, userEvent, within } from '@testing-library/react-native'
3
+ import { createClient, type Profile } from '@/api-contract'
4
+ import { API_URL, fakeTransport, json } from '../../../test/fake-transport'
5
+ import { ProfileForm } from './profile-form'
6
+
7
+ const profile: Profile = {
8
+ id: '01a09ffe-635c-77e5-9aeb-d3e9d1a7ee24',
9
+ userId: 'ef51f640-611b-4b9a-a188-510c455539eb',
10
+ displayName: 'Ana',
11
+ locale: 'en',
12
+ timezone: 'UTC',
13
+ createdAt: '2026-09-14T12:57:24.057Z',
14
+ updatedAt: '2026-09-14T12:57:24.057Z',
15
+ }
16
+
17
+ describe('ProfileForm', () => {
18
+ it('puts a server validation message on the field it names', async () => {
19
+ const api = createClient({
20
+ baseUrl: API_URL,
21
+ fetch: fakeTransport({
22
+ 'PATCH /api/v1/users/me': () =>
23
+ json(
24
+ 400,
25
+ {
26
+ type: 'about:blank',
27
+ title: 'Bad Request',
28
+ status: 400,
29
+ detail: 'Validation failed',
30
+ instance: '/api/v1/users/me',
31
+ requestId: 'req-1',
32
+ errors: { timezone: ['timezone must be a valid IANA zone'] },
33
+ },
34
+ 'application/problem+json',
35
+ ),
36
+ }),
37
+ })
38
+
39
+ await render(
40
+ <QueryClientProvider client={new QueryClient()}>
41
+ <ProfileForm api={api} profile={profile} />
42
+ </QueryClientProvider>,
43
+ )
44
+
45
+ await userEvent.setup().press(screen.getByRole('button', { name: 'Save' }))
46
+
47
+ expect(
48
+ await within(screen.getByTestId('field-Timezone')).findByText(
49
+ 'timezone must be a valid IANA zone',
50
+ ),
51
+ ).toBeOnTheScreen()
52
+ expect(
53
+ within(screen.getByTestId('field-Display name')).queryByText(
54
+ 'timezone must be a valid IANA zone',
55
+ ),
56
+ ).toBeNull()
57
+ })
58
+ })
@@ -0,0 +1,96 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
3
+ import { Controller, useForm } from 'react-hook-form'
4
+ import { View } from 'react-native'
5
+ import { z } from 'zod'
6
+ import { type ApiClient, LOCALES, type Profile, profileQuery, updateProfile } from '@/api-contract'
7
+ import { Button } from '@/components/ui/button'
8
+ import { Field } from '@/components/ui/field'
9
+ import { FormError } from '@/features/forms/form-error'
10
+ import { applyServerError } from '@/features/forms/server-errors'
11
+
12
+ const schema = z.object({
13
+ displayName: z.string().min(1).max(80),
14
+ locale: z.enum(LOCALES),
15
+ timezone: z.string().min(1).max(64),
16
+ })
17
+
18
+ type ProfileValues = z.infer<typeof schema>
19
+
20
+ const FIELDS = ['displayName', 'locale', 'timezone'] as const
21
+
22
+ export function ProfileForm({ api, profile }: { api: ApiClient; profile: Profile }) {
23
+ const queryClient = useQueryClient()
24
+ const form = useForm<ProfileValues>({
25
+ resolver: zodResolver(schema),
26
+ mode: 'onTouched',
27
+ reValidateMode: 'onChange',
28
+ defaultValues: {
29
+ displayName: profile.displayName,
30
+ locale: profile.locale,
31
+ timezone: profile.timezone,
32
+ },
33
+ })
34
+ const { errors, isSubmitting } = form.formState
35
+
36
+ const mutation = useMutation({
37
+ mutationFn: (values: ProfileValues) => updateProfile(api, values),
38
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: profileQuery(api).queryKey }),
39
+ })
40
+
41
+ async function onSubmit(values: ProfileValues) {
42
+ try {
43
+ await mutation.mutateAsync(values)
44
+ } catch (error) {
45
+ applyServerError(error, FIELDS, form.setError)
46
+ }
47
+ }
48
+
49
+ return (
50
+ <View className="gap-6">
51
+ <FormError errors={errors} />
52
+ <Controller
53
+ control={form.control}
54
+ name="displayName"
55
+ render={({ field }) => (
56
+ <Field
57
+ label="Display name"
58
+ value={field.value}
59
+ onChangeText={field.onChange}
60
+ onBlur={field.onBlur}
61
+ error={errors.displayName?.message}
62
+ />
63
+ )}
64
+ />
65
+ <Controller
66
+ control={form.control}
67
+ name="locale"
68
+ render={({ field }) => (
69
+ <Field
70
+ label="Locale"
71
+ autoCapitalize="none"
72
+ value={field.value}
73
+ onChangeText={field.onChange}
74
+ onBlur={field.onBlur}
75
+ error={errors.locale?.message}
76
+ />
77
+ )}
78
+ />
79
+ <Controller
80
+ control={form.control}
81
+ name="timezone"
82
+ render={({ field }) => (
83
+ <Field
84
+ label="Timezone"
85
+ autoCapitalize="none"
86
+ value={field.value}
87
+ onChangeText={field.onChange}
88
+ onBlur={field.onBlur}
89
+ error={errors.timezone?.message}
90
+ />
91
+ )}
92
+ />
93
+ <Button label="Save" disabled={isSubmitting} onPress={form.handleSubmit(onSubmit)} />
94
+ </View>
95
+ )
96
+ }
@@ -0,0 +1,15 @@
1
+ import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
2
+ import { storage, USER_KEYS } from './storage'
3
+
4
+ export const QUERY_CACHE_MAX_AGE = 24 * 60 * 60 * 1000
5
+
6
+ export const queryPersister = createSyncStoragePersister({
7
+ key: USER_KEYS.queryCache,
8
+ storage: {
9
+ getItem: (key) => storage.getString(key) ?? null,
10
+ setItem: (key, value) => storage.set(key, value),
11
+ removeItem: (key) => {
12
+ storage.remove(key)
13
+ },
14
+ },
15
+ })
@@ -0,0 +1,11 @@
1
+ import { createMMKV } from 'react-native-mmkv'
2
+
3
+ export const storage = createMMKV({ id: 'app' })
4
+
5
+ export const USER_KEYS = { queryCache: 'user.query-cache' } as const
6
+
7
+ export function clearUserData(): void {
8
+ for (const key of Object.values(USER_KEYS)) {
9
+ storage.remove(key)
10
+ }
11
+ }
@@ -0,0 +1,3 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
@@ -0,0 +1,9 @@
1
+ /** @type {import('tailwindcss').Config} */
2
+ module.exports = {
3
+ content: ['./src/**/*.{ts,tsx}'],
4
+ presets: [require('nativewind/preset')],
5
+ theme: {
6
+ extend: {},
7
+ },
8
+ plugins: [],
9
+ }
@@ -0,0 +1,23 @@
1
+ import type { Transport } from '@/api-contract'
2
+
3
+ export const API_URL = 'http://api.test'
4
+
5
+ type Handler = (request: Request) => Response | Promise<Response>
6
+
7
+ export function json(status: number, body: unknown, contentType = 'application/json'): Response {
8
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': contentType } })
9
+ }
10
+
11
+ export function fakeTransport(routes: Record<string, Handler>): Transport {
12
+ return async (input, init) => {
13
+ const request = new Request(input, init)
14
+ const key = `${request.method} ${new URL(request.url).pathname}`
15
+ const handler = routes[key]
16
+
17
+ if (handler === undefined) {
18
+ throw new Error(`No fake response for ${key}`)
19
+ }
20
+
21
+ return handler(request)
22
+ }
23
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "noUncheckedIndexedAccess": true,
6
+ "noFallthroughCasesInSwitch": true,
7
+ "forceConsistentCasingInFileNames": true,
8
+ "paths": {
9
+ "@/*": ["./src/*"]
10
+ },
11
+ "types": ["jest", "node"]
12
+ },
13
+ "include": [
14
+ "**/*.ts",
15
+ "**/*.tsx",
16
+ ".expo/types/**/*.ts",
17
+ "expo-env.d.ts",
18
+ "nativewind-env.d.ts",
19
+ "global.d.ts"
20
+ ]
21
+ }
@@ -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,28 @@
1
+ # site
2
+
3
+ ## Requirements
4
+
5
+ Node 22.17 or later, and pnpm. The build downloads its font from Google Fonts, so it needs network access.
6
+
7
+ ## Running it
8
+
9
+ ```sh
10
+ pnpm install
11
+ pnpm dev
12
+ ```
13
+
14
+ The site runs on `http://localhost:3000`. It calls no API yet; when a page needs data, it fetches it on the server.
15
+
16
+ ## Everyday commands
17
+
18
+ | Command | What it does |
19
+ |---|---|
20
+ | `pnpm test` | Checks that each indexable page exports a title and a description |
21
+ | `pnpm lint` | Biome |
22
+ | `pnpm typecheck` | Generates Next's route types, then `tsc --noEmit`; also run before every push |
23
+ | `pnpm build` · `pnpm start` | Production build, then serves it |
24
+
25
+ ## Conventions
26
+
27
+ The rules this project follows live in `.prumo/`, and `AGENTS.md` points to them. Read those before
28
+ changing how something is done.
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.5.13/schema.json",
3
+ "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
4
+ "files": { "includes": ["**", "!.next", "!dist", "!coverage"] },
5
+ "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
6
+ "linter": {
7
+ "rules": { "preset": "recommended" },
8
+ "domains": { "next": "recommended", "react": "recommended" }
9
+ },
10
+ "javascript": { "formatter": { "quoteStyle": "single", "semicolons": "asNeeded" } },
11
+ "css": { "parser": { "tailwindDirectives": true } }
12
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "base-nova",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/app/globals.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,7 @@
1
+ node_modules
2
+ .next
3
+ out
4
+ coverage
5
+ *.tsbuildinfo
6
+ next-env.d.ts
7
+ .env*
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from 'next'
2
+
3
+ const nextConfig: NextConfig = {
4
+ agentRules: false,
5
+ }
6
+
7
+ export default nextConfig
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "site",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "next dev",
8
+ "build": "next build",
9
+ "start": "next start",
10
+ "lint": "biome check",
11
+ "typecheck": "next typegen && tsc --noEmit",
12
+ "test": "vitest run",
13
+ "prepare": "node .githooks/install.mjs"
14
+ },
15
+ "dependencies": {
16
+ "@base-ui/react": "1.8.0",
17
+ "class-variance-authority": "0.7.1",
18
+ "cn": "0.3.0",
19
+ "lucide-react": "1.45.0",
20
+ "next": "16.3.5",
21
+ "react": "19.2.3",
22
+ "react-dom": "19.2.3",
23
+ "shadcn": "4.21.0",
24
+ "tw-animate-css": "1.4.0"
25
+ },
26
+ "devDependencies": {
27
+ "@biomejs/biome": "2.5.13",
28
+ "@tailwindcss/postcss": "4.3.3",
29
+ "@types/node": "24.13.4",
30
+ "@types/react": "19.2.18",
31
+ "@types/react-dom": "19.2.7",
32
+ "@vitejs/plugin-react": "6.1.1",
33
+ "tailwindcss": "4.3.3",
34
+ "typescript": "6.0.3",
35
+ "vitest": "4.1.11"
36
+ },
37
+ "engines": {
38
+ "node": ">=22.17.0"
39
+ }
40
+ }
@@ -0,0 +1,5 @@
1
+ allowBuilds:
2
+ # Denied by create-next-app itself; Next resolves sharp's platform binary without running its install script.
3
+ sharp: false
4
+ # Denied by create-next-app itself; its prebuilt binary arrives as an optional dependency.
5
+ unrs-resolver: false
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ '@tailwindcss/postcss': {},
4
+ },
5
+ }
6
+
7
+ export default config