umpordez 1.1.0 → 1.2.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.
- package/README.md +14 -0
- package/package.json +1 -1
- package/template/AGENTS.md +80 -1
- package/template/mobile/{{PROJECT_SLUG}}/App.tsx +25 -12
- package/template/mobile/{{PROJECT_SLUG}}/CLAUDE.md +11 -4
- package/template/mobile/{{PROJECT_SLUG}}/app.json +3 -0
- package/template/mobile/{{PROJECT_SLUG}}/index.ts +47 -1
- package/template/mobile/{{PROJECT_SLUG}}/package.json +21 -17
- package/template/mobile/{{PROJECT_SLUG}}/src/components/AnimatedSplash.tsx +10 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/contexts/AuthContext.tsx +2 -3
- package/template/ui/admin/CLAUDE.md +2 -2
- package/template/ui/admin/src/lib/fetch-api.ts +19 -7
package/README.md
CHANGED
|
@@ -198,6 +198,20 @@ umpordez --version check version
|
|
|
198
198
|
| Uploads | AWS S3 with signed URLs |
|
|
199
199
|
| Email | Nodemailer + HTML templates |
|
|
200
200
|
|
|
201
|
+
## What changed in 1.2
|
|
202
|
+
|
|
203
|
+
- **Non-Negotiables** section in AGENTS.md — codifies rules per layer
|
|
204
|
+
(server, admin UI, site, mobile) so teams don't have to rediscover
|
|
205
|
+
them.
|
|
206
|
+
- **Key Patterns (TL;DR)** — quick reference for adding entities,
|
|
207
|
+
context DI, route handlers, and transactions.
|
|
208
|
+
- **i18n Coverage** table — shows which layers speak which
|
|
209
|
+
languages at a glance.
|
|
210
|
+
- **Anonymous-first mobile** — synthetic device-account on first
|
|
211
|
+
boot, never gate behind sign-in.
|
|
212
|
+
- **fetch-api** now supports PUT and PATCH methods plus safer
|
|
213
|
+
URL parameter filtering (skips null/undefined values).
|
|
214
|
+
|
|
201
215
|
## What changed in 1.1
|
|
202
216
|
|
|
203
217
|
The big one. v1.0.x was server + web only. v1.1 adds:
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -276,7 +276,86 @@ const ok = await confirm({
|
|
|
276
276
|
if (!ok) return;
|
|
277
277
|
```
|
|
278
278
|
|
|
279
|
-
##
|
|
279
|
+
## Non-Negotiables
|
|
280
|
+
|
|
281
|
+
### Code Style
|
|
282
|
+
- 4-space indent, semicolons, max 80 cols, LF, always braces
|
|
283
|
+
- TypeScript strict (no `any`, use `unknown`)
|
|
284
|
+
- Code in English; user-facing strings in Portuguese (pt-BR)
|
|
285
|
+
- Path aliases: `#core/*`, `#shared/*`, `#api/*` (server), `@/*` (admin UI / mobile)
|
|
286
|
+
- No relative paths beyond single `../`
|
|
287
|
+
|
|
288
|
+
### Server
|
|
289
|
+
- Routes go through models (never `req.context.knex()` in routes)
|
|
290
|
+
- `buildHandler()` wraps every async handler; Zod validates input
|
|
291
|
+
- Raw SQL in migrations (`knex.schema.raw()`)
|
|
292
|
+
- Models extend `BaseModel` — get `this.knex`, `this.context`, `this.db`
|
|
293
|
+
- Errors: `ValidationError` (400), `NotFoundError` (404), `ConflictError` (409), `ForbiddenError` (403)
|
|
294
|
+
- Pagination: `{ rows, total, page, limit, totalPages }`
|
|
295
|
+
- Every table: `id UUID`, `utc_created_on`, `utc_updated_on`; multi-tenant tables also `account_id`
|
|
296
|
+
|
|
297
|
+
### Admin UI
|
|
298
|
+
- `fetchApi()` for every API call (handles cookies, JSON, FormData)
|
|
299
|
+
- React Query for all server state; `keepPreviousData` on paginated queries
|
|
300
|
+
- Query keys centralized in `lib/query-keys.ts`
|
|
301
|
+
- shadcn/ui primitives under `components/ui/` (compose, don't fork)
|
|
302
|
+
- `useConfirm()` instead of `window.confirm()`, `toast` from sonner
|
|
303
|
+
|
|
304
|
+
### Site
|
|
305
|
+
- Every string through `t()` — no bare text in templates
|
|
306
|
+
- URL strategy: `/` → pt-BR, `/en/...` → English (middleware strips prefix)
|
|
307
|
+
- Hreflang alternates + canonical + sitemap.xml on every page
|
|
308
|
+
|
|
309
|
+
### Mobile
|
|
310
|
+
- **Free-first**: never gate behind sign-in; anonymous usage is the default
|
|
311
|
+
- **Anonymous-first**: synthetic UUID device-account on first boot, never gate behind login
|
|
312
|
+
- **Services own data access** — pages never call `getDb()` directly
|
|
313
|
+
- **User-scoped writes check `demandSigned()`** — reads always work, writes no-op when gate closed
|
|
314
|
+
- Every string through `t()` — no bare text in JSX
|
|
315
|
+
- NitroModules crash in Expo Go — lazy-load with `try/require`
|
|
316
|
+
- Never `expo prebuild --clean` (wipes native code; use `scripts/prebuild.sh`)
|
|
317
|
+
|
|
318
|
+
### i18n Coverage
|
|
319
|
+
| Layer | pt-BR | en | Additional |
|
|
320
|
+
|-------|-------|----|------------|
|
|
321
|
+
| Server | ✓ | ✓ | Extensible via i18next |
|
|
322
|
+
| Admin UI | ✓ | ✗ | pt-BR only (Brazil market) |
|
|
323
|
+
| Site | ✓ | ✓ | URL-prefixed (/ vs /en/) |
|
|
324
|
+
| Mobile | ✓ | ✓ | Extensible per project |
|
|
325
|
+
|
|
326
|
+
## Key Patterns (TL;DR)
|
|
327
|
+
|
|
328
|
+
### Adding a domain entity
|
|
329
|
+
1. `migrations/YYYYMMDDHHMMSS_create-thing.ts` — raw SQL
|
|
330
|
+
2. `core/models/thing.ts` — extends `BaseModel`
|
|
331
|
+
3. Register on `Context` in `core/context.ts`
|
|
332
|
+
4. `apps/api/routes/thing.ts` — `buildHandler()` + Zod
|
|
333
|
+
5. Admin UI: page + `hooks/queries/use-thing.ts`
|
|
334
|
+
6. Mobile (optional): `src/services/thing/`
|
|
335
|
+
|
|
336
|
+
### Context DI
|
|
337
|
+
```typescript
|
|
338
|
+
const users = await req.context.user.list({ page: 1 });
|
|
339
|
+
this.user = new UserModel(this); // in context.ts
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Route handler
|
|
343
|
+
```typescript
|
|
344
|
+
router.post('/things', buildHandler(async (req, res) => {
|
|
345
|
+
const data = z.object({ name: z.string().min(1) }).parse(req.body);
|
|
346
|
+
const result = await req.context.thing.create(req.accountId!, data);
|
|
347
|
+
res.json({ ok: true, result });
|
|
348
|
+
}));
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Transaction
|
|
352
|
+
```typescript
|
|
353
|
+
await req.context.knexTransaction(async () => {
|
|
354
|
+
const user = await req.context.user.create(data);
|
|
355
|
+
await req.context.account.addMember(accountId, { ... });
|
|
356
|
+
});
|
|
357
|
+
```
|
|
358
|
+
|
|
280
359
|
|
|
281
360
|
```
|
|
282
361
|
users (global identity) → user_in_accounts (many-to-many + role) → accounts (tenant) → [domain entities]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { AppState } from 'react-native';
|
|
3
3
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
4
|
+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
4
5
|
import Toast from 'react-native-toast-message';
|
|
5
6
|
import * as SplashScreen from 'expo-splash-screen';
|
|
6
7
|
|
|
@@ -28,6 +29,13 @@ export default function App() {
|
|
|
28
29
|
// `splashDone` flips, and screens read from SQLite which
|
|
29
30
|
// serves cached data while the network sync runs.
|
|
30
31
|
SplashScreen.hideAsync().catch(() => { /* best-effort */ });
|
|
32
|
+
|
|
33
|
+
// Force-hide the splash after 5s no matter what, so a stuck
|
|
34
|
+
// animation never keeps the user out of the app. In Release
|
|
35
|
+
// there's no red box — without this timeout a render-time
|
|
36
|
+
// crash is invisible.
|
|
37
|
+
const fallback = setTimeout(() => setSplashDone(true), 5000);
|
|
38
|
+
|
|
31
39
|
(async () => {
|
|
32
40
|
try {
|
|
33
41
|
await initDb();
|
|
@@ -55,21 +63,26 @@ export default function App() {
|
|
|
55
63
|
});
|
|
56
64
|
}
|
|
57
65
|
});
|
|
58
|
-
return () =>
|
|
66
|
+
return () => {
|
|
67
|
+
sub.remove();
|
|
68
|
+
clearTimeout(fallback);
|
|
69
|
+
};
|
|
59
70
|
}, []);
|
|
60
71
|
|
|
61
72
|
return (
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
<
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
74
|
+
<SafeAreaProvider>
|
|
75
|
+
<ThemeProvider>
|
|
76
|
+
<AuthProvider>
|
|
77
|
+
<Routes />
|
|
78
|
+
<Toast config={toastConfig} />
|
|
79
|
+
</AuthProvider>
|
|
80
|
+
</ThemeProvider>
|
|
69
81
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
{!splashDone && (
|
|
83
|
+
<AnimatedSplash onDone={() => setSplashDone(true)} />
|
|
84
|
+
)}
|
|
85
|
+
</SafeAreaProvider>
|
|
86
|
+
</GestureHandlerRootView>
|
|
74
87
|
);
|
|
75
88
|
}
|
|
@@ -63,10 +63,17 @@ hasCompletedOnboarding
|
|
|
63
63
|
render an "auth required" screen at the entry point. Wrap
|
|
64
64
|
premium features in `<PremiumGate onUpgrade={openAuth} />`.
|
|
65
65
|
|
|
66
|
-
- **
|
|
66
|
+
- **Anonymous-first.** `AuthContext` fabricates a synthetic device-
|
|
67
|
+
account on first boot (`services/auth/anonymous.ts` returns a
|
|
68
|
+
UUID v4 stored in AsyncStorage). `signed: true` always;
|
|
69
|
+
`isAnonymous: true` for the default state. Never gate app entry
|
|
70
|
+
behind sign-in. The opt-in AuthStack lives in Profile's footer
|
|
71
|
+
link only.
|
|
72
|
+
|
|
73
|
+
- **Services own data access** — same rule as server. Mobile
|
|
67
74
|
doesn't have models; the equivalent is: **services own data
|
|
68
|
-
access**. Pages call `
|
|
69
|
-
`getDb().
|
|
75
|
+
access**. Pages call `services/crops`, `services/events`, … —
|
|
76
|
+
never `getDb()` directly. New SQLite reads/writes go in the
|
|
70
77
|
matching `services/<thing>/` folder.
|
|
71
78
|
|
|
72
79
|
- **User-scoped writes check `demandSigned()`** at the top of the
|
|
@@ -150,7 +157,7 @@ Client → /api/auth/google/app
|
|
|
150
157
|
```
|
|
151
158
|
|
|
152
159
|
`signOut()` clears auth + onboarding state so the next user starts
|
|
153
|
-
from Welcome (
|
|
160
|
+
from Welcome (onboarding is per-account).
|
|
154
161
|
|
|
155
162
|
## Premium
|
|
156
163
|
|
|
@@ -20,7 +20,53 @@ if (typeof globalThis.document === 'undefined') {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
import { registerRootComponent } from 'expo';
|
|
23
|
-
import
|
|
23
|
+
import { createElement, ComponentType } from 'react';
|
|
24
|
+
import { ScrollView, Text } from 'react-native';
|
|
25
|
+
import * as SplashScreen from 'expo-splash-screen';
|
|
26
|
+
|
|
27
|
+
// Boot-crash catcher: if ANY module in the app's import graph
|
|
28
|
+
// throws at load time, render the error on screen instead of
|
|
29
|
+
// hanging forever on the native splash. In Release there is no
|
|
30
|
+
// red box — without this, a load-time crash is invisible.
|
|
31
|
+
let App: ComponentType;
|
|
32
|
+
try {
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
34
|
+
App = (require('./App') as { default: ComponentType }).default;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
const message = err instanceof Error
|
|
37
|
+
? `${err.message}\n\n${err.stack ?? ''}`
|
|
38
|
+
: String(err);
|
|
39
|
+
App = function BootError() {
|
|
40
|
+
SplashScreen.hideAsync().catch(() => { /* best-effort */ });
|
|
41
|
+
return createElement(
|
|
42
|
+
ScrollView,
|
|
43
|
+
{
|
|
44
|
+
style: { flex: 1, backgroundColor: '#0F1416' },
|
|
45
|
+
contentContainerStyle: {
|
|
46
|
+
padding: 24,
|
|
47
|
+
paddingTop: 100
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
createElement(
|
|
51
|
+
Text,
|
|
52
|
+
{
|
|
53
|
+
style: {
|
|
54
|
+
color: '#f87171',
|
|
55
|
+
fontSize: 16,
|
|
56
|
+
fontWeight: '700',
|
|
57
|
+
marginBottom: 12
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
'Erro ao iniciar o aplicativo'
|
|
61
|
+
),
|
|
62
|
+
createElement(
|
|
63
|
+
Text,
|
|
64
|
+
{ style: { color: '#e4e8f2', fontSize: 12 } },
|
|
65
|
+
message
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
}
|
|
24
70
|
|
|
25
71
|
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
|
26
72
|
// It also ensures that whether you load the app in Expo Go or in a native build,
|
|
@@ -18,37 +18,41 @@
|
|
|
18
18
|
"@react-navigation/bottom-tabs": "^7.7.3",
|
|
19
19
|
"@react-navigation/native": "^7.1.19",
|
|
20
20
|
"@react-navigation/native-stack": "^7.6.2",
|
|
21
|
-
"expo": "~
|
|
22
|
-
"expo-apple-authentication": "~
|
|
21
|
+
"expo": "~57.0.2",
|
|
22
|
+
"expo-apple-authentication": "~57.0.0",
|
|
23
23
|
"expo-auth-session": "~7.0.10",
|
|
24
|
-
"expo-build-properties": "~
|
|
25
|
-
"expo-constants": "~
|
|
26
|
-
"expo-file-system": "~
|
|
27
|
-
"expo-
|
|
28
|
-
"expo-
|
|
29
|
-
"expo-
|
|
30
|
-
"expo-
|
|
31
|
-
"expo-
|
|
32
|
-
"expo-
|
|
24
|
+
"expo-build-properties": "~57.0.3",
|
|
25
|
+
"expo-constants": "~57.0.3",
|
|
26
|
+
"expo-file-system": "~57.0.0",
|
|
27
|
+
"expo-linking": "~57.0.1",
|
|
28
|
+
"expo-localization": "~57.0.0",
|
|
29
|
+
"expo-share-intent": "^8.0.0",
|
|
30
|
+
"expo-splash-screen": "~57.0.2",
|
|
31
|
+
"expo-sqlite": "~57.0.0",
|
|
32
|
+
"expo-status-bar": "~57.0.0",
|
|
33
|
+
"expo-system-ui": "~57.0.0",
|
|
34
|
+
"expo-web-browser": "~57.0.0",
|
|
33
35
|
"i18next": "^23.16.5",
|
|
34
36
|
"lucide-react-native": "^1.8.0",
|
|
35
37
|
"react": "19.1.0",
|
|
36
|
-
"react-native": "0.
|
|
38
|
+
"react-native": "0.83.10",
|
|
37
39
|
"react-i18next": "^15.1.1",
|
|
40
|
+
"react-native-gesture-handler": "~3.0.2",
|
|
38
41
|
"react-native-health": "^1.19.0",
|
|
39
42
|
"react-native-health-connect": "^3.5.0",
|
|
40
43
|
"react-native-iap": "^15.2.0",
|
|
41
44
|
"react-native-nitro-modules": "^0.35.4",
|
|
42
|
-
"react-native-reanimated": "~4.
|
|
43
|
-
"react-native-safe-area-context": "^5.
|
|
44
|
-
"react-native-screens": "~4.
|
|
45
|
+
"react-native-reanimated": "~4.5.1",
|
|
46
|
+
"react-native-safe-area-context": "^5.8.0",
|
|
47
|
+
"react-native-screens": "~4.25.2",
|
|
45
48
|
"react-native-svg": "15.12.1",
|
|
46
|
-
"react-native-toast-message": "^2.3.3"
|
|
49
|
+
"react-native-toast-message": "^2.3.3",
|
|
50
|
+
"react-native-worklets": "~0.10.1"
|
|
47
51
|
},
|
|
48
52
|
"devDependencies": {
|
|
49
53
|
"@stylistic/eslint-plugin": "^2.13.0",
|
|
50
54
|
"@types/react": "~19.1.0",
|
|
51
|
-
"babel-preset-expo": "~
|
|
55
|
+
"babel-preset-expo": "~57.0.1",
|
|
52
56
|
"eslint": "^9.20.1",
|
|
53
57
|
"typescript": "~5.7.0",
|
|
54
58
|
"typescript-eslint": "^8.24.0"
|
|
@@ -48,6 +48,13 @@ export default function AnimatedSplash({ onDone }: Props) {
|
|
|
48
48
|
const exitSc = useRef(new Animated.Value(1)).current;
|
|
49
49
|
|
|
50
50
|
useEffect(() => {
|
|
51
|
+
// Safety timeout: if the animation callback never fires
|
|
52
|
+
// (which can happen in Release builds with Hermes/new arch),
|
|
53
|
+
// force `onDone` after 4s so the app never hangs forever.
|
|
54
|
+
const fallback = setTimeout(() => {
|
|
55
|
+
onDone?.();
|
|
56
|
+
}, 4000);
|
|
57
|
+
|
|
51
58
|
Animated.sequence([
|
|
52
59
|
// Phase 1: entrance
|
|
53
60
|
Animated.parallel([
|
|
@@ -126,10 +133,13 @@ export default function AnimatedSplash({ onDone }: Props) {
|
|
|
126
133
|
})
|
|
127
134
|
])
|
|
128
135
|
]).start(() => {
|
|
136
|
+
clearTimeout(fallback);
|
|
129
137
|
if (onDone) {
|
|
130
138
|
onDone();
|
|
131
139
|
}
|
|
132
140
|
});
|
|
141
|
+
|
|
142
|
+
return () => clearTimeout(fallback);
|
|
133
143
|
}, []);
|
|
134
144
|
|
|
135
145
|
return (
|
|
@@ -287,9 +287,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
287
287
|
console.warn('[auth] clearUserData error:', err);
|
|
288
288
|
}
|
|
289
289
|
// Clear onboarding state too: signing out should send the next
|
|
290
|
-
// person back through Welcome → Onboarding → FirstAction.
|
|
291
|
-
//
|
|
292
|
-
// per-device.
|
|
290
|
+
// person back through Welcome → Onboarding → FirstAction.
|
|
291
|
+
// Onboarding is per-account, not per-device.
|
|
293
292
|
await AsyncStorage.multiRemove([
|
|
294
293
|
KEYS.token,
|
|
295
294
|
KEYS.refresh,
|
|
@@ -70,8 +70,8 @@ Path alias: `@/*` → `./src/*`. Use it everywhere.
|
|
|
70
70
|
|
|
71
71
|
Admin UI ships pt-BR strings inline today (the admin is for the
|
|
72
72
|
operator, not the customer). If you need EN admin support later,
|
|
73
|
-
mirror the
|
|
74
|
-
`react-i18next` + `i18next`.
|
|
73
|
+
mirror the pattern: `src/i18n/{index,locales/*}` with
|
|
74
|
+
`react-i18next` + `i18next`.
|
|
75
75
|
|
|
76
76
|
## Charts
|
|
77
77
|
|
|
@@ -14,7 +14,7 @@ class FetchApi {
|
|
|
14
14
|
async doRequest(
|
|
15
15
|
url: string,
|
|
16
16
|
body: unknown,
|
|
17
|
-
method: 'GET' | 'POST' | 'DELETE',
|
|
17
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
18
18
|
headers: Record<string, string> = {}
|
|
19
19
|
): Promise<any> {
|
|
20
20
|
const isFormData = body instanceof FormData;
|
|
@@ -25,17 +25,29 @@ class FetchApi {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
let parsedBody = body;
|
|
28
|
-
|
|
29
28
|
if (parsedBody && typeof parsedBody === 'string') {
|
|
30
|
-
|
|
29
|
+
try {
|
|
30
|
+
parsedBody = JSON.parse(parsedBody);
|
|
31
|
+
} catch {
|
|
32
|
+
// Keep original string if parse fails
|
|
33
|
+
}
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
let requestUrl = `${API_BASE_URL}/api/${url}`;
|
|
34
37
|
let requestBody: string | FormData | null = null;
|
|
35
38
|
|
|
36
|
-
if (method === 'GET' && parsedBody) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
if (method === 'GET' && parsedBody && typeof parsedBody === 'object') {
|
|
40
|
+
const params = new URLSearchParams();
|
|
41
|
+
for (const [key, val] of Object.entries(parsedBody as Record<string, unknown>)) {
|
|
42
|
+
if (val !== undefined && val !== null) {
|
|
43
|
+
params.append(key, String(val));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const qs = params.toString();
|
|
47
|
+
if (qs) {
|
|
48
|
+
requestUrl = `${requestUrl}?${qs}`;
|
|
49
|
+
}
|
|
50
|
+
} else if (method !== 'GET' && parsedBody) {
|
|
39
51
|
requestBody = isFormData
|
|
40
52
|
? (parsedBody as FormData)
|
|
41
53
|
: JSON.stringify(parsedBody);
|
|
@@ -92,7 +104,7 @@ class FetchApi {
|
|
|
92
104
|
export default function fetchApi(
|
|
93
105
|
url: string,
|
|
94
106
|
body?: unknown,
|
|
95
|
-
method: 'GET' | 'POST' | 'DELETE' = 'GET',
|
|
107
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET',
|
|
96
108
|
headers?: Record<string, string>,
|
|
97
109
|
abortController?: AbortController
|
|
98
110
|
): Promise<any> {
|