@jeffrey2423/coding-standards 1.0.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.
- package/README.md +204 -0
- package/bin/cli.js +35 -0
- package/package.json +19 -0
- package/standards/architecture-patterns.md +444 -0
- package/standards/backend-standards.md +812 -0
- package/standards/database-conventions.md +667 -0
- package/standards/frontend-standards.md +1199 -0
- package/standards/mobile-flutter-standards.md +1292 -0
- package/standards/mobile-react-native-standards.md +1288 -0
- package/standards/technical-preferences-ux.md +400 -0
- package/standards/technology-stack.md +294 -0
- package/standards/vite-config-standard.md +531 -0
|
@@ -0,0 +1,1288 @@
|
|
|
1
|
+
# Mobile React Native Development Standards
|
|
2
|
+
|
|
3
|
+
> **Note**: For shared architectural principles (Clean Architecture, DDD, design philosophy), refer to [architecture-patterns.md](./architecture-patterns.md). For design system (colors, typography, UX), refer to [technical-preferences-ux.md](./technical-preferences-ux.md). React Native shares the same core architecture philosophy as the frontend standards in [frontend-standards.md](./frontend-standards.md) — adapted for native mobile.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Technology Stack
|
|
8
|
+
|
|
9
|
+
### Core Technologies
|
|
10
|
+
|
|
11
|
+
| Category | Technology | Version | Notes |
|
|
12
|
+
|----------|------------|---------|-------|
|
|
13
|
+
| **Framework** | React Native | 0.77+ | New Architecture default (Fabric + TurboModules) |
|
|
14
|
+
| **Platform** | Expo | SDK 53+ | Managed workflow with EAS Build |
|
|
15
|
+
| **Language** | TypeScript | 5+ | Strict mode, no `any` |
|
|
16
|
+
| **Router** | Expo Router | 4+ | File-based, type-safe, deep-link ready |
|
|
17
|
+
| **State (client)** | Zustand | 5+ | Same as frontend standards |
|
|
18
|
+
| **State (server)** | TanStack Query | 5+ | Same as frontend standards |
|
|
19
|
+
| **Styling** | NativeWind | 4+ | TailwindCSS utility classes for RN |
|
|
20
|
+
| **Components** | `@rn-primitives` | Latest | Headless accessible RN primitives |
|
|
21
|
+
| **HTTP** | Axios | Latest | With interceptors |
|
|
22
|
+
| **Validation** | Zod | 3+ | Same as frontend standards |
|
|
23
|
+
| **Forms** | React Hook Form | 7+ | Same as frontend standards |
|
|
24
|
+
| **Storage** | MMKV | 3+ | Sync key-value, 10x faster than AsyncStorage |
|
|
25
|
+
| **Secure Storage** | `expo-secure-store` | Latest | Keychain / Keystore backed |
|
|
26
|
+
| **Testing** | Jest + RNTL | Latest | Unit, component, integration |
|
|
27
|
+
|
|
28
|
+
### Development Tools
|
|
29
|
+
|
|
30
|
+
| Tool | Purpose |
|
|
31
|
+
|------|---------|
|
|
32
|
+
| Expo CLI | Project management and dev server |
|
|
33
|
+
| EAS CLI | Cloud builds and OTA updates |
|
|
34
|
+
| Expo DevTools | Inspector and debugger |
|
|
35
|
+
| Flipper (optional) | Native debugging |
|
|
36
|
+
| ESLint + Prettier | Same config as frontend |
|
|
37
|
+
| `expo-doctor` | Dependency health checks |
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Architecture
|
|
42
|
+
|
|
43
|
+
### Style: Clean Architecture + DDD + Feature-First
|
|
44
|
+
|
|
45
|
+
Identical philosophy to the frontend and backend standards. Business logic drives every decision. Dependencies always point inward.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
┌──────────────────────────────────────────────────────┐
|
|
49
|
+
│ PRESENTATION LAYER │
|
|
50
|
+
│ Screens, native components, Expo Router │
|
|
51
|
+
└──────────────────────────────────────────────────────┘
|
|
52
|
+
│
|
|
53
|
+
▼
|
|
54
|
+
┌──────────────────────────────────────────────────────┐
|
|
55
|
+
│ APPLICATION LAYER │
|
|
56
|
+
│ Use cases, Zustand stores, TanStack Query hooks │
|
|
57
|
+
└──────────────────────────────────────────────────────┘
|
|
58
|
+
│
|
|
59
|
+
▼
|
|
60
|
+
┌──────────────────────────────────────────────────────┐
|
|
61
|
+
│ INFRASTRUCTURE LAYER │
|
|
62
|
+
│ Repository implementations, APIs, MMKV │
|
|
63
|
+
└──────────────────────────────────────────────────────┘
|
|
64
|
+
│
|
|
65
|
+
▼
|
|
66
|
+
┌──────────────────────────────────────────────────────┐
|
|
67
|
+
│ DOMAIN LAYER │
|
|
68
|
+
│ Entities, value objects, repository interfaces │
|
|
69
|
+
└──────────────────────────────────────────────────────┘
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Folder Structure
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
├── app/ # Expo Router file-based routing
|
|
76
|
+
│ ├── _layout.tsx # Root layout (providers)
|
|
77
|
+
│ ├── index.tsx # Entry redirect
|
|
78
|
+
│ ├── (auth)/ # Public route group
|
|
79
|
+
│ │ ├── _layout.tsx
|
|
80
|
+
│ │ └── login.tsx # /login
|
|
81
|
+
│ └── (app)/ # Protected route group
|
|
82
|
+
│ ├── _layout.tsx # Tab / stack navigator
|
|
83
|
+
│ ├── dashboard.tsx
|
|
84
|
+
│ ├── sales/
|
|
85
|
+
│ │ ├── quotes/
|
|
86
|
+
│ │ │ ├── index.tsx # /sales/quotes
|
|
87
|
+
│ │ │ └── cart.tsx # /sales/quotes/cart
|
|
88
|
+
│ │ └── invoices.tsx
|
|
89
|
+
│ └── inventory/
|
|
90
|
+
│ └── products.tsx
|
|
91
|
+
│
|
|
92
|
+
├── modules/ # Business modules (DDD)
|
|
93
|
+
│ ├── sales/
|
|
94
|
+
│ │ ├── quotes/
|
|
95
|
+
│ │ │ ├── cart/
|
|
96
|
+
│ │ │ │ ├── domain/
|
|
97
|
+
│ │ │ │ │ ├── entities/
|
|
98
|
+
│ │ │ │ │ │ └── cart-item.ts
|
|
99
|
+
│ │ │ │ │ ├── repositories/
|
|
100
|
+
│ │ │ │ │ │ └── i-cart-repository.ts
|
|
101
|
+
│ │ │ │ │ ├── value-objects/
|
|
102
|
+
│ │ │ │ │ │ └── quantity.ts
|
|
103
|
+
│ │ │ │ │ └── types/
|
|
104
|
+
│ │ │ │ │ └── cart.types.ts
|
|
105
|
+
│ │ │ │ ├── application/
|
|
106
|
+
│ │ │ │ │ ├── use-cases/
|
|
107
|
+
│ │ │ │ │ │ ├── add-to-cart.ts
|
|
108
|
+
│ │ │ │ │ │ └── remove-from-cart.ts
|
|
109
|
+
│ │ │ │ │ ├── hooks/
|
|
110
|
+
│ │ │ │ │ │ ├── use-cart.ts
|
|
111
|
+
│ │ │ │ │ │ └── use-cart-mutations.ts
|
|
112
|
+
│ │ │ │ │ └── store/
|
|
113
|
+
│ │ │ │ │ └── cart.store.ts
|
|
114
|
+
│ │ │ │ ├── infrastructure/
|
|
115
|
+
│ │ │ │ │ ├── repositories/
|
|
116
|
+
│ │ │ │ │ │ └── cart-repository.ts
|
|
117
|
+
│ │ │ │ │ ├── api/
|
|
118
|
+
│ │ │ │ │ │ └── cart.api.ts
|
|
119
|
+
│ │ │ │ │ └── storage/
|
|
120
|
+
│ │ │ │ │ └── cart-storage.ts
|
|
121
|
+
│ │ │ │ └── presentation/
|
|
122
|
+
│ │ │ │ ├── screens/
|
|
123
|
+
│ │ │ │ │ └── cart-screen.tsx
|
|
124
|
+
│ │ │ │ └── components/
|
|
125
|
+
│ │ │ │ ├── cart-list.tsx
|
|
126
|
+
│ │ │ │ └── cart-item.tsx
|
|
127
|
+
│ │ │ └── products/
|
|
128
|
+
│ │ └── billing/
|
|
129
|
+
│ ├── inventory/
|
|
130
|
+
│ └── users/
|
|
131
|
+
│
|
|
132
|
+
├── shared/
|
|
133
|
+
│ ├── components/ # Reusable native components
|
|
134
|
+
│ │ ├── ui/ # Primitive wrappers
|
|
135
|
+
│ │ │ ├── button.tsx
|
|
136
|
+
│ │ │ ├── text.tsx
|
|
137
|
+
│ │ │ └── text-input.tsx
|
|
138
|
+
│ │ ├── feedback/
|
|
139
|
+
│ │ │ ├── skeleton.tsx
|
|
140
|
+
│ │ │ └── error-view.tsx
|
|
141
|
+
│ │ └── layout/
|
|
142
|
+
│ │ ├── screen.tsx # Safe-area aware screen wrapper
|
|
143
|
+
│ │ └── stack.tsx
|
|
144
|
+
│ ├── hooks/
|
|
145
|
+
│ │ ├── use-debounce.ts
|
|
146
|
+
│ │ └── use-app-state.ts
|
|
147
|
+
│ ├── lib/
|
|
148
|
+
│ │ ├── api-client.ts # Axios instance
|
|
149
|
+
│ │ ├── storage.ts # MMKV wrapper
|
|
150
|
+
│ │ └── env.ts # Typed env variables
|
|
151
|
+
│ ├── theme/
|
|
152
|
+
│ │ ├── colors.ts
|
|
153
|
+
│ │ ├── typography.ts
|
|
154
|
+
│ │ └── spacing.ts
|
|
155
|
+
│ ├── types/
|
|
156
|
+
│ │ └── common.types.ts
|
|
157
|
+
│ └── constants/
|
|
158
|
+
│ └── messages.ts # Spanish UI strings
|
|
159
|
+
│
|
|
160
|
+
├── infrastructure/
|
|
161
|
+
│ ├── api/
|
|
162
|
+
│ │ └── http-client.ts
|
|
163
|
+
│ └── storage/
|
|
164
|
+
│ ├── mmkv-client.ts
|
|
165
|
+
│ └── secure-storage.ts
|
|
166
|
+
│
|
|
167
|
+
├── assets/
|
|
168
|
+
│ ├── fonts/
|
|
169
|
+
│ │ ├── Inter_18pt-Light.ttf
|
|
170
|
+
│ │ ├── Inter_18pt-Regular.ttf
|
|
171
|
+
│ │ └── Inter_18pt-Bold.ttf
|
|
172
|
+
│ └── images/
|
|
173
|
+
│ └── logos/
|
|
174
|
+
│
|
|
175
|
+
├── __tests__/
|
|
176
|
+
│ └── modules/
|
|
177
|
+
│ └── sales/
|
|
178
|
+
├── jest.config.ts
|
|
179
|
+
├── app.config.ts # Expo dynamic config
|
|
180
|
+
├── tailwind.config.ts # NativeWind config
|
|
181
|
+
└── tsconfig.json # Strict mode
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Expo Router Conventions
|
|
185
|
+
|
|
186
|
+
Expo Router follows the same prefix system as TanStack Router (used in the frontend standards):
|
|
187
|
+
|
|
188
|
+
| Prefix | Effect | Example |
|
|
189
|
+
|--------|--------|---------|
|
|
190
|
+
| `(group)` | Route group — no URL segment, shared layout | `(auth)/_layout.tsx` |
|
|
191
|
+
| `[param]` | Dynamic segment | `[orderId].tsx` → `/:orderId` |
|
|
192
|
+
| `[...rest]` | Catch-all | `[...unmatched].tsx` |
|
|
193
|
+
| `+not-found.tsx` | 404 screen | App-level not found |
|
|
194
|
+
| `_layout.tsx` | Nested layout for a directory | Stack, Tabs, Drawer |
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Domain Layer
|
|
199
|
+
|
|
200
|
+
### Entities
|
|
201
|
+
|
|
202
|
+
Entities are plain TypeScript objects. Use `readonly` everywhere.
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
// modules/sales/quotes/cart/domain/entities/cart-item.ts
|
|
206
|
+
export interface CartItem {
|
|
207
|
+
readonly id: string;
|
|
208
|
+
readonly productId: string;
|
|
209
|
+
readonly productName: string;
|
|
210
|
+
readonly quantity: number;
|
|
211
|
+
readonly unitPrice: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Domain logic as pure functions — no classes required
|
|
215
|
+
export const getCartItemTotal = (item: CartItem): number =>
|
|
216
|
+
item.quantity * item.unitPrice;
|
|
217
|
+
|
|
218
|
+
export const isCartItemValid = (item: CartItem): boolean =>
|
|
219
|
+
item.quantity > 0 && item.unitPrice > 0;
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Value Objects
|
|
223
|
+
|
|
224
|
+
Value objects validate invariants at construction time. Use a `Result` discriminated union — never throw.
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
// shared/types/result.types.ts
|
|
228
|
+
export type Result<T, E = string> =
|
|
229
|
+
| { success: true; data: T }
|
|
230
|
+
| { success: false; error: E };
|
|
231
|
+
|
|
232
|
+
export const ok = <T>(data: T): Result<T> => ({ success: true, data });
|
|
233
|
+
export const err = <E>(error: E): Result<never, E> => ({ success: false, error });
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
// modules/sales/quotes/cart/domain/value-objects/quantity.ts
|
|
238
|
+
import { Result, ok, err } from '@/shared/types/result.types';
|
|
239
|
+
|
|
240
|
+
export type QuantityError = 'INVALID_QUANTITY';
|
|
241
|
+
|
|
242
|
+
export interface Quantity {
|
|
243
|
+
readonly value: number;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export const createQuantity = (input: number): Result<Quantity, QuantityError> => {
|
|
247
|
+
if (!Number.isInteger(input) || input < 1) {
|
|
248
|
+
return err('INVALID_QUANTITY');
|
|
249
|
+
}
|
|
250
|
+
return ok({ value: input });
|
|
251
|
+
};
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Repository Interfaces
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
// modules/sales/quotes/cart/domain/repositories/i-cart-repository.ts
|
|
258
|
+
import type { CartItem } from '../entities/cart-item';
|
|
259
|
+
import type { Result } from '@/shared/types/result.types';
|
|
260
|
+
|
|
261
|
+
export type CartFailure =
|
|
262
|
+
| { type: 'NETWORK_ERROR'; message: string }
|
|
263
|
+
| { type: 'NOT_FOUND'; id: string }
|
|
264
|
+
| { type: 'INVALID_QUANTITY' }
|
|
265
|
+
| { type: 'CART_LIMIT_EXCEEDED'; maxItems: number };
|
|
266
|
+
|
|
267
|
+
export interface ICartRepository {
|
|
268
|
+
getCartItems(): Promise<Result<CartItem[], CartFailure>>;
|
|
269
|
+
addItem(productId: string, quantity: number): Promise<Result<CartItem, CartFailure>>;
|
|
270
|
+
removeItem(itemId: string): Promise<Result<void, CartFailure>>;
|
|
271
|
+
clearCart(): Promise<Result<void, CartFailure>>;
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Zod Schemas (Shared Domain + Infrastructure)
|
|
276
|
+
|
|
277
|
+
Define Zod schemas alongside the domain types. They serve as both validation and the single source of truth for external data shapes.
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
// modules/sales/quotes/cart/domain/types/cart.types.ts
|
|
281
|
+
import { z } from 'zod';
|
|
282
|
+
|
|
283
|
+
export const CartItemSchema = z.object({
|
|
284
|
+
id: z.string().uuid(),
|
|
285
|
+
productId: z.string().uuid(),
|
|
286
|
+
productName: z.string().min(1),
|
|
287
|
+
quantity: z.number().int().positive(),
|
|
288
|
+
unitPrice: z.number().positive(),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
export type CartItemDto = z.infer<typeof CartItemSchema>;
|
|
292
|
+
|
|
293
|
+
export const CartResponseSchema = z.object({
|
|
294
|
+
items: z.array(CartItemSchema),
|
|
295
|
+
updatedAt: z.string().datetime(),
|
|
296
|
+
});
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## Application Layer
|
|
302
|
+
|
|
303
|
+
### Use Cases
|
|
304
|
+
|
|
305
|
+
Each use case is a single function. One file per use case.
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
// modules/sales/quotes/cart/application/use-cases/add-to-cart.ts
|
|
309
|
+
import type { ICartRepository, CartFailure } from '../../domain/repositories/i-cart-repository';
|
|
310
|
+
import { createQuantity } from '../../domain/value-objects/quantity';
|
|
311
|
+
import type { CartItem } from '../../domain/entities/cart-item';
|
|
312
|
+
import { err, type Result } from '@/shared/types/result.types';
|
|
313
|
+
|
|
314
|
+
export const addToCartUseCase = (repository: ICartRepository) =>
|
|
315
|
+
async (
|
|
316
|
+
productId: string,
|
|
317
|
+
quantity: number,
|
|
318
|
+
): Promise<Result<CartItem, CartFailure>> => {
|
|
319
|
+
const quantityResult = createQuantity(quantity);
|
|
320
|
+
|
|
321
|
+
if (!quantityResult.success) {
|
|
322
|
+
return err<CartFailure>({ type: 'INVALID_QUANTITY' });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return repository.addItem(productId, quantityResult.data.value);
|
|
326
|
+
};
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Zustand Store
|
|
330
|
+
|
|
331
|
+
Same pattern as frontend standards. Stores delegate to use cases.
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
// modules/sales/quotes/cart/application/store/cart.store.ts
|
|
335
|
+
import { create } from 'zustand';
|
|
336
|
+
import { devtools } from 'zustand/middleware';
|
|
337
|
+
import type { CartItem } from '../../domain/entities/cart-item';
|
|
338
|
+
import { addToCartUseCase } from '../use-cases/add-to-cart';
|
|
339
|
+
import { removeFromCartUseCase } from '../use-cases/remove-from-cart';
|
|
340
|
+
import { cartRepository } from '../../infrastructure/repositories/cart-repository';
|
|
341
|
+
import { mapCartFailureToMessage } from '../mappers/cart-failure.mapper';
|
|
342
|
+
|
|
343
|
+
const addToCart = addToCartUseCase(cartRepository);
|
|
344
|
+
const removeFromCart = removeFromCartUseCase(cartRepository);
|
|
345
|
+
|
|
346
|
+
interface CartState {
|
|
347
|
+
items: CartItem[];
|
|
348
|
+
loading: boolean;
|
|
349
|
+
error: string | null;
|
|
350
|
+
addItem: (productId: string, quantity: number) => Promise<void>;
|
|
351
|
+
removeItem: (itemId: string) => Promise<void>;
|
|
352
|
+
clearError: () => void;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export const useCartStore = create<CartState>()(
|
|
356
|
+
devtools(
|
|
357
|
+
(set) => ({
|
|
358
|
+
items: [],
|
|
359
|
+
loading: false,
|
|
360
|
+
error: null,
|
|
361
|
+
|
|
362
|
+
addItem: async (productId, quantity) => {
|
|
363
|
+
set({ loading: true, error: null });
|
|
364
|
+
const result = await addToCart(productId, quantity);
|
|
365
|
+
if (result.success) {
|
|
366
|
+
set((s) => ({ items: [...s.items, result.data], loading: false }));
|
|
367
|
+
} else {
|
|
368
|
+
set({ error: mapCartFailureToMessage(result.error), loading: false });
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
removeItem: async (itemId) => {
|
|
373
|
+
set({ loading: true, error: null });
|
|
374
|
+
const result = await removeFromCart(itemId);
|
|
375
|
+
if (result.success) {
|
|
376
|
+
set((s) => ({
|
|
377
|
+
items: s.items.filter((i) => i.id !== itemId),
|
|
378
|
+
loading: false,
|
|
379
|
+
}));
|
|
380
|
+
} else {
|
|
381
|
+
set({ error: mapCartFailureToMessage(result.error), loading: false });
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
clearError: () => set({ error: null }),
|
|
386
|
+
}),
|
|
387
|
+
{ name: 'cartStore' },
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### TanStack Query Hooks
|
|
393
|
+
|
|
394
|
+
Use TanStack Query for server state, Zustand for client/UI state.
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
// modules/sales/quotes/cart/application/hooks/use-cart.ts
|
|
398
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
399
|
+
import { cartApi } from '../../infrastructure/api/cart.api';
|
|
400
|
+
import { CartItemSchema } from '../../domain/types/cart.types';
|
|
401
|
+
import { z } from 'zod';
|
|
402
|
+
|
|
403
|
+
export const cartKeys = {
|
|
404
|
+
all: ['cart'] as const,
|
|
405
|
+
items: () => [...cartKeys.all, 'items'] as const,
|
|
406
|
+
item: (id: string) => [...cartKeys.items(), id] as const,
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
export const useCartItems = () =>
|
|
410
|
+
useQuery({
|
|
411
|
+
queryKey: cartKeys.items(),
|
|
412
|
+
queryFn: async () => {
|
|
413
|
+
const data = await cartApi.getCartItems();
|
|
414
|
+
return z.array(CartItemSchema).parse(data);
|
|
415
|
+
},
|
|
416
|
+
staleTime: 30_000,
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
export const useAddToCart = () => {
|
|
420
|
+
const queryClient = useQueryClient();
|
|
421
|
+
|
|
422
|
+
return useMutation({
|
|
423
|
+
mutationFn: ({ productId, quantity }: { productId: string; quantity: number }) =>
|
|
424
|
+
cartApi.addItem(productId, quantity),
|
|
425
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: cartKeys.items() }),
|
|
426
|
+
});
|
|
427
|
+
};
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## Infrastructure Layer
|
|
433
|
+
|
|
434
|
+
### HTTP Client
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
// infrastructure/api/http-client.ts
|
|
438
|
+
import axios, { AxiosError } from 'axios';
|
|
439
|
+
import { secureStorage } from '../storage/secure-storage';
|
|
440
|
+
|
|
441
|
+
export const httpClient = axios.create({
|
|
442
|
+
baseURL: process.env.EXPO_PUBLIC_API_BASE_URL,
|
|
443
|
+
timeout: 30_000,
|
|
444
|
+
headers: { 'Content-Type': 'application/json' },
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
httpClient.interceptors.request.use(async (config) => {
|
|
448
|
+
const token = await secureStorage.getToken();
|
|
449
|
+
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
450
|
+
return config;
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
httpClient.interceptors.response.use(
|
|
454
|
+
(r) => r,
|
|
455
|
+
async (error: AxiosError<{ message?: string }>) => {
|
|
456
|
+
if (error.response?.status === 401) {
|
|
457
|
+
await secureStorage.clearToken();
|
|
458
|
+
// Emit an event or use an auth store to navigate to login
|
|
459
|
+
}
|
|
460
|
+
const message = error.response?.data?.message ?? 'Error de conexión';
|
|
461
|
+
return Promise.reject(new Error(message));
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Repository Implementation
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
// modules/sales/quotes/cart/infrastructure/repositories/cart-repository.ts
|
|
470
|
+
import { cartApi } from '../api/cart.api';
|
|
471
|
+
import { cartStorage } from '../storage/cart-storage';
|
|
472
|
+
import type { ICartRepository, CartFailure } from '../../domain/repositories/i-cart-repository';
|
|
473
|
+
import type { CartItem } from '../../domain/entities/cart-item';
|
|
474
|
+
import { ok, err, type Result } from '@/shared/types/result.types';
|
|
475
|
+
import { CartItemSchema } from '../../domain/types/cart.types';
|
|
476
|
+
import { z } from 'zod';
|
|
477
|
+
|
|
478
|
+
export const cartRepository: ICartRepository = {
|
|
479
|
+
async getCartItems(): Promise<Result<CartItem[], CartFailure>> {
|
|
480
|
+
try {
|
|
481
|
+
const cached = cartStorage.getAll();
|
|
482
|
+
if (cached.length > 0) return ok(cached);
|
|
483
|
+
|
|
484
|
+
const data = await cartApi.getCartItems();
|
|
485
|
+
const items = z.array(CartItemSchema).parse(data);
|
|
486
|
+
cartStorage.saveAll(items);
|
|
487
|
+
return ok(items);
|
|
488
|
+
} catch (e) {
|
|
489
|
+
return err({ type: 'NETWORK_ERROR', message: (e as Error).message });
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
async addItem(productId, quantity): Promise<Result<CartItem, CartFailure>> {
|
|
494
|
+
try {
|
|
495
|
+
const data = await cartApi.addItem(productId, quantity);
|
|
496
|
+
const item = CartItemSchema.parse(data);
|
|
497
|
+
cartStorage.saveItem(item);
|
|
498
|
+
return ok(item);
|
|
499
|
+
} catch (e) {
|
|
500
|
+
return err({ type: 'NETWORK_ERROR', message: (e as Error).message });
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
async removeItem(itemId): Promise<Result<void, CartFailure>> {
|
|
505
|
+
try {
|
|
506
|
+
await cartApi.removeItem(itemId);
|
|
507
|
+
cartStorage.deleteItem(itemId);
|
|
508
|
+
return ok(undefined);
|
|
509
|
+
} catch (e) {
|
|
510
|
+
return err({ type: 'NETWORK_ERROR', message: (e as Error).message });
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
async clearCart(): Promise<Result<void, CartFailure>> {
|
|
515
|
+
try {
|
|
516
|
+
await cartApi.clearCart();
|
|
517
|
+
cartStorage.clearAll();
|
|
518
|
+
return ok(undefined);
|
|
519
|
+
} catch (e) {
|
|
520
|
+
return err({ type: 'NETWORK_ERROR', message: (e as Error).message });
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### MMKV Storage Wrapper
|
|
527
|
+
|
|
528
|
+
```typescript
|
|
529
|
+
// infrastructure/storage/mmkv-client.ts
|
|
530
|
+
import { MMKV } from 'react-native-mmkv';
|
|
531
|
+
|
|
532
|
+
export const storage = new MMKV({ id: 'app-storage' });
|
|
533
|
+
|
|
534
|
+
export const mmkvStorage = {
|
|
535
|
+
getString: (key: string): string | undefined => storage.getString(key),
|
|
536
|
+
setString: (key: string, value: string): void => storage.set(key, value),
|
|
537
|
+
delete: (key: string): void => storage.delete(key),
|
|
538
|
+
contains: (key: string): boolean => storage.contains(key),
|
|
539
|
+
|
|
540
|
+
getJson: <T>(key: string): T | null => {
|
|
541
|
+
const raw = storage.getString(key);
|
|
542
|
+
if (!raw) return null;
|
|
543
|
+
try { return JSON.parse(raw) as T; } catch { return null; }
|
|
544
|
+
},
|
|
545
|
+
setJson: <T>(key: string, value: T): void =>
|
|
546
|
+
storage.set(key, JSON.stringify(value)),
|
|
547
|
+
};
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### Secure Storage
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
// infrastructure/storage/secure-storage.ts
|
|
554
|
+
import * as SecureStore from 'expo-secure-store';
|
|
555
|
+
|
|
556
|
+
const TOKEN_KEY = 'auth_token';
|
|
557
|
+
|
|
558
|
+
export const secureStorage = {
|
|
559
|
+
getToken: (): Promise<string | null> =>
|
|
560
|
+
SecureStore.getItemAsync(TOKEN_KEY),
|
|
561
|
+
|
|
562
|
+
setToken: (token: string): Promise<void> =>
|
|
563
|
+
SecureStore.setItemAsync(TOKEN_KEY, token, {
|
|
564
|
+
keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK,
|
|
565
|
+
}),
|
|
566
|
+
|
|
567
|
+
clearToken: (): Promise<void> =>
|
|
568
|
+
SecureStore.deleteItemAsync(TOKEN_KEY),
|
|
569
|
+
};
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
574
|
+
## Presentation Layer
|
|
575
|
+
|
|
576
|
+
### Screens
|
|
577
|
+
|
|
578
|
+
Screens are thin. They connect stores/hooks to components and handle navigation.
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
// modules/sales/quotes/cart/presentation/screens/cart-screen.tsx
|
|
582
|
+
import React from 'react';
|
|
583
|
+
import { View, FlatList } from 'react-native';
|
|
584
|
+
import { useRouter } from 'expo-router';
|
|
585
|
+
import { useCartStore } from '../../application/store/cart.store';
|
|
586
|
+
import { CartItemComponent } from '../components/cart-item';
|
|
587
|
+
import { Screen } from '@/shared/components/layout/screen';
|
|
588
|
+
import { Button } from '@/shared/components/ui/button';
|
|
589
|
+
import { Text } from '@/shared/components/ui/text';
|
|
590
|
+
import { Skeleton } from '@/shared/components/feedback/skeleton';
|
|
591
|
+
import { MESSAGES } from '@/shared/constants/messages';
|
|
592
|
+
|
|
593
|
+
export function CartScreen() {
|
|
594
|
+
const router = useRouter();
|
|
595
|
+
const items = useCartStore((s) => s.items);
|
|
596
|
+
const loading = useCartStore((s) => s.loading);
|
|
597
|
+
const error = useCartStore((s) => s.error);
|
|
598
|
+
const removeItem = useCartStore((s) => s.removeItem);
|
|
599
|
+
|
|
600
|
+
if (loading) return <Skeleton rows={4} />;
|
|
601
|
+
|
|
602
|
+
if (error) {
|
|
603
|
+
return (
|
|
604
|
+
<Screen>
|
|
605
|
+
<Text className="text-red-500">{error}</Text>
|
|
606
|
+
</Screen>
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return (
|
|
611
|
+
<Screen>
|
|
612
|
+
<FlatList
|
|
613
|
+
data={items}
|
|
614
|
+
keyExtractor={(item) => item.id}
|
|
615
|
+
renderItem={({ item }) => (
|
|
616
|
+
<CartItemComponent
|
|
617
|
+
item={item}
|
|
618
|
+
onRemove={() => removeItem(item.id)}
|
|
619
|
+
/>
|
|
620
|
+
)}
|
|
621
|
+
ListEmptyComponent={
|
|
622
|
+
<Text className="text-slate-500 text-center mt-8">
|
|
623
|
+
{MESSAGES.CART.EMPTY}
|
|
624
|
+
</Text>
|
|
625
|
+
}
|
|
626
|
+
/>
|
|
627
|
+
<Button
|
|
628
|
+
onPress={() => router.push('/sales/checkout')}
|
|
629
|
+
disabled={items.length === 0}
|
|
630
|
+
>
|
|
631
|
+
{MESSAGES.CART.CHECKOUT}
|
|
632
|
+
</Button>
|
|
633
|
+
</Screen>
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
### Shared UI Primitives
|
|
639
|
+
|
|
640
|
+
Wrap native primitives with your design system. Never use raw `Text` or `TouchableOpacity` outside of `shared/components/ui/`.
|
|
641
|
+
|
|
642
|
+
```typescript
|
|
643
|
+
// shared/components/ui/text.tsx
|
|
644
|
+
import React from 'react';
|
|
645
|
+
import { Text as RNText, type TextProps } from 'react-native';
|
|
646
|
+
|
|
647
|
+
interface AppTextProps extends TextProps {
|
|
648
|
+
variant?: 'h1' | 'h2' | 'h3' | 'body' | 'bodySmall' | 'label' | 'caption';
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const variantClassMap: Record<NonNullable<AppTextProps['variant']>, string> = {
|
|
652
|
+
h1: 'text-4xl font-bold text-slate-900 dark:text-slate-50',
|
|
653
|
+
h2: 'text-3xl font-bold text-slate-900 dark:text-slate-50',
|
|
654
|
+
h3: 'text-2xl font-semibold text-slate-900 dark:text-slate-50',
|
|
655
|
+
body: 'text-base font-normal text-slate-700 dark:text-slate-300',
|
|
656
|
+
bodySmall: 'text-sm font-normal text-slate-700 dark:text-slate-300',
|
|
657
|
+
label: 'text-sm font-medium text-slate-700 dark:text-slate-300',
|
|
658
|
+
caption: 'text-xs font-light text-slate-500 dark:text-slate-400',
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
export function Text({
|
|
662
|
+
variant = 'body',
|
|
663
|
+
className,
|
|
664
|
+
...props
|
|
665
|
+
}: AppTextProps) {
|
|
666
|
+
return (
|
|
667
|
+
<RNText
|
|
668
|
+
className={`${variantClassMap[variant]} ${className ?? ''}`}
|
|
669
|
+
{...props}
|
|
670
|
+
/>
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
```typescript
|
|
676
|
+
// shared/components/ui/button.tsx
|
|
677
|
+
import React from 'react';
|
|
678
|
+
import { Pressable, type PressableProps } from 'react-native';
|
|
679
|
+
import { Text } from './text';
|
|
680
|
+
|
|
681
|
+
interface ButtonProps extends PressableProps {
|
|
682
|
+
variant?: 'primary' | 'secondary' | 'ghost';
|
|
683
|
+
children: string;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const variantMap = {
|
|
687
|
+
primary: 'bg-primary-500 active:bg-primary-600 rounded-lg px-4 py-3',
|
|
688
|
+
secondary: 'bg-slate-200 dark:bg-slate-700 active:bg-slate-300 rounded-lg px-4 py-3',
|
|
689
|
+
ghost: 'active:bg-slate-100 dark:active:bg-slate-800 rounded-lg px-4 py-3',
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const textVariantMap = {
|
|
693
|
+
primary: 'text-white font-semibold text-base',
|
|
694
|
+
secondary: 'text-slate-900 dark:text-slate-100 font-medium text-sm',
|
|
695
|
+
ghost: 'text-slate-700 dark:text-slate-300 font-normal text-base',
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
export function Button({ variant = 'primary', children, className, ...props }: ButtonProps) {
|
|
699
|
+
return (
|
|
700
|
+
<Pressable
|
|
701
|
+
className={`${variantMap[variant]} ${props.disabled ? 'opacity-50' : ''} ${className ?? ''}`}
|
|
702
|
+
accessibilityRole="button"
|
|
703
|
+
accessibilityLabel={children}
|
|
704
|
+
{...props}
|
|
705
|
+
>
|
|
706
|
+
<Text className={textVariantMap[variant]}>{children}</Text>
|
|
707
|
+
</Pressable>
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
### Safe-Area Screen Wrapper
|
|
713
|
+
|
|
714
|
+
```typescript
|
|
715
|
+
// shared/components/layout/screen.tsx
|
|
716
|
+
import React from 'react';
|
|
717
|
+
import { type ViewProps } from 'react-native';
|
|
718
|
+
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
719
|
+
|
|
720
|
+
interface ScreenProps extends ViewProps {
|
|
721
|
+
children: React.ReactNode;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export function Screen({ children, className, ...props }: ScreenProps) {
|
|
725
|
+
return (
|
|
726
|
+
<SafeAreaView
|
|
727
|
+
className={`flex-1 bg-white dark:bg-slate-950 px-4 ${className ?? ''}`}
|
|
728
|
+
edges={['top', 'bottom']}
|
|
729
|
+
{...props}
|
|
730
|
+
>
|
|
731
|
+
{children}
|
|
732
|
+
</SafeAreaView>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
---
|
|
738
|
+
|
|
739
|
+
## Navigation (Expo Router)
|
|
740
|
+
|
|
741
|
+
### Root Layout — Providers
|
|
742
|
+
|
|
743
|
+
```typescript
|
|
744
|
+
// app/_layout.tsx
|
|
745
|
+
import { useEffect } from 'react';
|
|
746
|
+
import { Stack } from 'expo-router';
|
|
747
|
+
import { useFonts } from 'expo-font';
|
|
748
|
+
import * as SplashScreen from 'expo-splash-screen';
|
|
749
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
750
|
+
import { ThemeProvider } from '@/shared/theme/theme-provider';
|
|
751
|
+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
752
|
+
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
753
|
+
|
|
754
|
+
SplashScreen.preventAutoHideAsync();
|
|
755
|
+
|
|
756
|
+
const queryClient = new QueryClient({
|
|
757
|
+
defaultOptions: {
|
|
758
|
+
queries: {
|
|
759
|
+
staleTime: 60_000,
|
|
760
|
+
retry: 2,
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
export default function RootLayout() {
|
|
766
|
+
const [loaded, error] = useFonts({
|
|
767
|
+
'Inter-Light': require('@/assets/fonts/Inter_18pt-Light.ttf'),
|
|
768
|
+
'Inter-Regular': require('@/assets/fonts/Inter_18pt-Regular.ttf'),
|
|
769
|
+
'Inter-Bold': require('@/assets/fonts/Inter_18pt-Bold.ttf'),
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
useEffect(() => {
|
|
773
|
+
if (loaded || error) SplashScreen.hideAsync();
|
|
774
|
+
}, [loaded, error]);
|
|
775
|
+
|
|
776
|
+
if (!loaded && !error) return null;
|
|
777
|
+
|
|
778
|
+
return (
|
|
779
|
+
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
780
|
+
<SafeAreaProvider>
|
|
781
|
+
<QueryClientProvider client={queryClient}>
|
|
782
|
+
<ThemeProvider>
|
|
783
|
+
<Stack screenOptions={{ headerShown: false }} />
|
|
784
|
+
</ThemeProvider>
|
|
785
|
+
</QueryClientProvider>
|
|
786
|
+
</SafeAreaProvider>
|
|
787
|
+
</GestureHandlerRootView>
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
### Auth Guard Layout
|
|
793
|
+
|
|
794
|
+
```typescript
|
|
795
|
+
// app/(app)/_layout.tsx
|
|
796
|
+
import { Redirect, Tabs } from 'expo-router';
|
|
797
|
+
import { useAuthStore } from '@/modules/users/authentication/login/application/store/auth.store';
|
|
798
|
+
|
|
799
|
+
export default function AppLayout() {
|
|
800
|
+
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
|
801
|
+
|
|
802
|
+
if (!isAuthenticated) return <Redirect href="/login" />;
|
|
803
|
+
|
|
804
|
+
return (
|
|
805
|
+
<Tabs screenOptions={{ headerShown: false }}>
|
|
806
|
+
<Tabs.Screen name="dashboard" options={{ title: 'Inicio' }} />
|
|
807
|
+
<Tabs.Screen name="sales" options={{ title: 'Ventas' }} />
|
|
808
|
+
<Tabs.Screen name="inventory" options={{ title: 'Inventario' }} />
|
|
809
|
+
</Tabs>
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
---
|
|
815
|
+
|
|
816
|
+
## Design System
|
|
817
|
+
|
|
818
|
+
Mirrors the shared design tokens from `technical-preferences-ux.md`. NativeWind is configured once and consumed everywhere with className strings.
|
|
819
|
+
|
|
820
|
+
### NativeWind + Tailwind Config
|
|
821
|
+
|
|
822
|
+
```typescript
|
|
823
|
+
// tailwind.config.ts
|
|
824
|
+
import type { Config } from 'tailwindcss';
|
|
825
|
+
|
|
826
|
+
const config: Config = {
|
|
827
|
+
content: ['./app/**/*.{ts,tsx}', './modules/**/*.{ts,tsx}', './shared/**/*.{ts,tsx}'],
|
|
828
|
+
presets: [require('nativewind/preset')],
|
|
829
|
+
darkMode: 'class',
|
|
830
|
+
theme: {
|
|
831
|
+
extend: {
|
|
832
|
+
colors: {
|
|
833
|
+
primary: {
|
|
834
|
+
50: '#eff8ff',
|
|
835
|
+
100: '#dbf0ff',
|
|
836
|
+
200: '#bfe3ff',
|
|
837
|
+
300: '#93d2ff',
|
|
838
|
+
400: '#60b6ff',
|
|
839
|
+
500: '#0E79FD', // Main brand color
|
|
840
|
+
600: '#0b6ae6',
|
|
841
|
+
700: '#0959c2',
|
|
842
|
+
800: '#0e4a9e',
|
|
843
|
+
900: '#123f80',
|
|
844
|
+
950: '#11274d',
|
|
845
|
+
},
|
|
846
|
+
secondary: {
|
|
847
|
+
50: '#f8f8f8',
|
|
848
|
+
100: '#f0f0f0',
|
|
849
|
+
200: '#e4e4e4',
|
|
850
|
+
300: '#d1d1d1',
|
|
851
|
+
400: '#b4b4b4',
|
|
852
|
+
500: '#9a9a9a',
|
|
853
|
+
600: '#818181',
|
|
854
|
+
700: '#6a6a6a',
|
|
855
|
+
800: '#5a5a5a',
|
|
856
|
+
900: '#4e4e4e',
|
|
857
|
+
950: '#000000', // Main secondary color
|
|
858
|
+
},
|
|
859
|
+
tertiary: {
|
|
860
|
+
700: '#154ca9', // Main tertiary color
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
fontFamily: {
|
|
864
|
+
'inter-light': ['Inter-Light'],
|
|
865
|
+
'inter': ['Inter-Regular'],
|
|
866
|
+
'inter-bold': ['Inter-Bold'],
|
|
867
|
+
},
|
|
868
|
+
},
|
|
869
|
+
},
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
export default config;
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
### Color Constants (non-NativeWind contexts)
|
|
876
|
+
|
|
877
|
+
```typescript
|
|
878
|
+
// shared/theme/colors.ts
|
|
879
|
+
export const Colors = {
|
|
880
|
+
primary500: '#0E79FD',
|
|
881
|
+
primary400: '#60B6FF',
|
|
882
|
+
tertiary700: '#154CA9',
|
|
883
|
+
secondary950: '#000000',
|
|
884
|
+
|
|
885
|
+
backgroundLight: '#FFFFFF',
|
|
886
|
+
surfaceLight: '#F8FAFC',
|
|
887
|
+
borderLight: '#E2E8F0',
|
|
888
|
+
|
|
889
|
+
backgroundDark: '#020617',
|
|
890
|
+
surfaceDark: '#0F172A',
|
|
891
|
+
borderDark: '#334155',
|
|
892
|
+
|
|
893
|
+
success: '#22C55E',
|
|
894
|
+
warning: '#F59E0B',
|
|
895
|
+
error: '#EF4444',
|
|
896
|
+
info: '#06B6D4',
|
|
897
|
+
} as const;
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
---
|
|
901
|
+
|
|
902
|
+
## Testing Standards
|
|
903
|
+
|
|
904
|
+
### Strategy
|
|
905
|
+
|
|
906
|
+
| Type | Tool | Coverage Target | What to Test |
|
|
907
|
+
|------|------|-----------------|--------------|
|
|
908
|
+
| **Unit** | Jest | > 80% | Entities, value objects, use cases, stores |
|
|
909
|
+
| **Component** | React Native Testing Library | Medium | Key UI interactions and accessibility |
|
|
910
|
+
| **Integration** | Jest + MSW | Medium | Full feature flows with mocked API |
|
|
911
|
+
| **E2E** | Maestro | Low | Critical user journeys on device |
|
|
912
|
+
|
|
913
|
+
### Jest Config
|
|
914
|
+
|
|
915
|
+
```typescript
|
|
916
|
+
// jest.config.ts
|
|
917
|
+
import type { Config } from 'jest';
|
|
918
|
+
|
|
919
|
+
const config: Config = {
|
|
920
|
+
preset: 'jest-expo',
|
|
921
|
+
setupFilesAfterFramework: ['<rootDir>/jest.setup.ts'],
|
|
922
|
+
moduleNameMapper: {
|
|
923
|
+
'^@/(.*)$': '<rootDir>/$1',
|
|
924
|
+
},
|
|
925
|
+
transformIgnorePatterns: [
|
|
926
|
+
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)',
|
|
927
|
+
],
|
|
928
|
+
collectCoverageFrom: [
|
|
929
|
+
'modules/**/*.{ts,tsx}',
|
|
930
|
+
'shared/**/*.{ts,tsx}',
|
|
931
|
+
'!**/*.d.ts',
|
|
932
|
+
'!**/index.ts',
|
|
933
|
+
],
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
export default config;
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
### Unit Test — Use Case
|
|
940
|
+
|
|
941
|
+
```typescript
|
|
942
|
+
// __tests__/modules/sales/quotes/cart/application/use-cases/add-to-cart.test.ts
|
|
943
|
+
import { addToCartUseCase } from '@/modules/sales/quotes/cart/application/use-cases/add-to-cart';
|
|
944
|
+
import type { ICartRepository } from '@/modules/sales/quotes/cart/domain/repositories/i-cart-repository';
|
|
945
|
+
import type { CartItem } from '@/modules/sales/quotes/cart/domain/entities/cart-item';
|
|
946
|
+
|
|
947
|
+
const mockItem: CartItem = {
|
|
948
|
+
id: 'item-1',
|
|
949
|
+
productId: 'prod-1',
|
|
950
|
+
productName: 'Producto Test',
|
|
951
|
+
quantity: 2,
|
|
952
|
+
unitPrice: 50,
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
const mockRepository: ICartRepository = {
|
|
956
|
+
getCartItems: jest.fn(),
|
|
957
|
+
addItem: jest.fn().mockResolvedValue({ success: true, data: mockItem }),
|
|
958
|
+
removeItem: jest.fn(),
|
|
959
|
+
clearCart: jest.fn(),
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
describe('addToCartUseCase', () => {
|
|
963
|
+
const addToCart = addToCartUseCase(mockRepository);
|
|
964
|
+
|
|
965
|
+
it('returns the added item when quantity is valid', async () => {
|
|
966
|
+
const result = await addToCart('prod-1', 2);
|
|
967
|
+
expect(result.success).toBe(true);
|
|
968
|
+
if (result.success) expect(result.data).toEqual(mockItem);
|
|
969
|
+
expect(mockRepository.addItem).toHaveBeenCalledWith('prod-1', 2);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it('returns INVALID_QUANTITY failure when quantity is 0', async () => {
|
|
973
|
+
const result = await addToCart('prod-1', 0);
|
|
974
|
+
expect(result.success).toBe(false);
|
|
975
|
+
if (!result.success) expect(result.error.type).toBe('INVALID_QUANTITY');
|
|
976
|
+
expect(mockRepository.addItem).not.toHaveBeenCalled();
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it('returns INVALID_QUANTITY failure for negative values', async () => {
|
|
980
|
+
const result = await addToCart('prod-1', -3);
|
|
981
|
+
expect(result.success).toBe(false);
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
```
|
|
985
|
+
|
|
986
|
+
### Component Test — React Native Testing Library
|
|
987
|
+
|
|
988
|
+
```typescript
|
|
989
|
+
// __tests__/modules/sales/quotes/cart/presentation/components/cart-item.test.tsx
|
|
990
|
+
import React from 'react';
|
|
991
|
+
import { render, screen, fireEvent } from '@testing-library/react-native';
|
|
992
|
+
import { CartItemComponent } from '@/modules/sales/quotes/cart/presentation/components/cart-item';
|
|
993
|
+
import type { CartItem } from '@/modules/sales/quotes/cart/domain/entities/cart-item';
|
|
994
|
+
|
|
995
|
+
const mockItem: CartItem = {
|
|
996
|
+
id: '1',
|
|
997
|
+
productId: 'p1',
|
|
998
|
+
productName: 'Producto Test',
|
|
999
|
+
quantity: 2,
|
|
1000
|
+
unitPrice: 50,
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
describe('CartItemComponent', () => {
|
|
1004
|
+
it('renders product name and total price', () => {
|
|
1005
|
+
render(<CartItemComponent item={mockItem} onRemove={jest.fn()} />);
|
|
1006
|
+
expect(screen.getByText('Producto Test')).toBeTruthy();
|
|
1007
|
+
expect(screen.getByText('$100.00')).toBeTruthy();
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it('calls onRemove with the item id when delete is pressed', () => {
|
|
1011
|
+
const onRemove = jest.fn();
|
|
1012
|
+
render(<CartItemComponent item={mockItem} onRemove={onRemove} />);
|
|
1013
|
+
fireEvent.press(screen.getByAccessibilityHint('Eliminar del carrito'));
|
|
1014
|
+
expect(onRemove).toHaveBeenCalledWith('1');
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
---
|
|
1020
|
+
|
|
1021
|
+
## Error Handling
|
|
1022
|
+
|
|
1023
|
+
### Global Error Boundary
|
|
1024
|
+
|
|
1025
|
+
React Native does not support class `ErrorBoundary` for native crashes. Use `expo-error-reporter` and a React error boundary for JS errors.
|
|
1026
|
+
|
|
1027
|
+
```typescript
|
|
1028
|
+
// shared/components/feedback/error-boundary.tsx
|
|
1029
|
+
import React, { Component, type ReactNode } from 'react';
|
|
1030
|
+
import { View } from 'react-native';
|
|
1031
|
+
import { Text } from '@/shared/components/ui/text';
|
|
1032
|
+
import { Button } from '@/shared/components/ui/button';
|
|
1033
|
+
|
|
1034
|
+
interface Props { children: ReactNode; }
|
|
1035
|
+
interface State { hasError: boolean; }
|
|
1036
|
+
|
|
1037
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
1038
|
+
state: State = { hasError: false };
|
|
1039
|
+
|
|
1040
|
+
static getDerivedStateFromError(): State {
|
|
1041
|
+
return { hasError: true };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
componentDidCatch(error: Error) {
|
|
1045
|
+
// Log to Sentry / Crashlytics
|
|
1046
|
+
console.error('[ErrorBoundary]', error);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
render() {
|
|
1050
|
+
if (this.state.hasError) {
|
|
1051
|
+
return (
|
|
1052
|
+
<View className="flex-1 items-center justify-center p-6 bg-white dark:bg-slate-950">
|
|
1053
|
+
<Text variant="h3" className="text-center mb-2">Algo salió mal</Text>
|
|
1054
|
+
<Text variant="body" className="text-center text-slate-500 mb-6">
|
|
1055
|
+
Por favor, intenta recargar la aplicación.
|
|
1056
|
+
</Text>
|
|
1057
|
+
<Button onPress={() => this.setState({ hasError: false })}>
|
|
1058
|
+
Reintentar
|
|
1059
|
+
</Button>
|
|
1060
|
+
</View>
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
return this.props.children;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
```
|
|
1067
|
+
|
|
1068
|
+
### User-Visible Error Messages
|
|
1069
|
+
|
|
1070
|
+
Map domain failures to Spanish strings in a dedicated mapper. Never expose raw error messages to the UI.
|
|
1071
|
+
|
|
1072
|
+
```typescript
|
|
1073
|
+
// modules/sales/quotes/cart/application/mappers/cart-failure.mapper.ts
|
|
1074
|
+
import type { CartFailure } from '../../domain/repositories/i-cart-repository';
|
|
1075
|
+
import { MESSAGES } from '@/shared/constants/messages';
|
|
1076
|
+
|
|
1077
|
+
export const mapCartFailureToMessage = (failure: CartFailure): string => {
|
|
1078
|
+
switch (failure.type) {
|
|
1079
|
+
case 'INVALID_QUANTITY':
|
|
1080
|
+
return 'La cantidad debe ser mayor a cero';
|
|
1081
|
+
case 'NOT_FOUND':
|
|
1082
|
+
return `El artículo ${failure.id} no fue encontrado`;
|
|
1083
|
+
case 'CART_LIMIT_EXCEEDED':
|
|
1084
|
+
return `El carrito no puede tener más de ${failure.maxItems} artículos`;
|
|
1085
|
+
case 'NETWORK_ERROR':
|
|
1086
|
+
return MESSAGES.ERROR.GENERIC;
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
---
|
|
1092
|
+
|
|
1093
|
+
## Security Standards
|
|
1094
|
+
|
|
1095
|
+
| Concern | Solution |
|
|
1096
|
+
|---------|---------|
|
|
1097
|
+
| Token storage | `expo-secure-store` (Keychain / AES Keystore) |
|
|
1098
|
+
| General storage | `react-native-mmkv` (encrypted instance) |
|
|
1099
|
+
| Certificate pinning | `react-native-ssl-pinning` or OkHttp config |
|
|
1100
|
+
| Screenshot prevention | `expo-screen-capture` — `preventScreenCaptureAsync()` |
|
|
1101
|
+
| Root / jailbreak detection | `jail-monkey` |
|
|
1102
|
+
| API keys | `EXPO_PUBLIC_*` only for non-sensitive; sensitive secrets stay server-side |
|
|
1103
|
+
| Obfuscation | ProGuard (Android) + Hermes bytecode |
|
|
1104
|
+
|
|
1105
|
+
---
|
|
1106
|
+
|
|
1107
|
+
## Performance Standards
|
|
1108
|
+
|
|
1109
|
+
| Strategy | Implementation |
|
|
1110
|
+
|----------|---------------|
|
|
1111
|
+
| List virtualization | Always `FlatList` or `FlashList` — never `ScrollView` with map |
|
|
1112
|
+
| Image caching | `expo-image` (built-in disk + memory cache) |
|
|
1113
|
+
| State granularity | Zustand selectors: `useStore((s) => s.specificField)` |
|
|
1114
|
+
| Re-render audit | React DevTools Profiler + `why-did-you-render` in dev |
|
|
1115
|
+
| Bundle size | `npx expo-optimize` + dynamic imports for heavy screens |
|
|
1116
|
+
| JS thread | Offload heavy work with `react-native-worklets-core` or `expo-task-manager` |
|
|
1117
|
+
| Startup | Defer non-critical logic after first frame with `InteractionManager.runAfterInteractions` |
|
|
1118
|
+
| Hermes | Always enabled (default in Expo SDK 50+) |
|
|
1119
|
+
|
|
1120
|
+
---
|
|
1121
|
+
|
|
1122
|
+
## Localization
|
|
1123
|
+
|
|
1124
|
+
All user-visible text must be in Spanish. Code, logs, comments, and git commits stay in English — identical to the frontend and backend standards.
|
|
1125
|
+
|
|
1126
|
+
```typescript
|
|
1127
|
+
// shared/constants/messages.ts
|
|
1128
|
+
export const MESSAGES = {
|
|
1129
|
+
ERROR: {
|
|
1130
|
+
GENERIC: 'Ha ocurrido un error. Por favor, intenta de nuevo.',
|
|
1131
|
+
NOT_FOUND: 'El recurso solicitado no fue encontrado.',
|
|
1132
|
+
UNAUTHORIZED: 'No tienes permisos para realizar esta acción.',
|
|
1133
|
+
NETWORK: 'Sin conexión. Verifica tu red e intenta de nuevo.',
|
|
1134
|
+
},
|
|
1135
|
+
LOADING: {
|
|
1136
|
+
DEFAULT: 'Cargando...',
|
|
1137
|
+
SAVING: 'Guardando...',
|
|
1138
|
+
PROCESSING: 'Procesando...',
|
|
1139
|
+
},
|
|
1140
|
+
CART: {
|
|
1141
|
+
EMPTY: 'Tu carrito está vacío.',
|
|
1142
|
+
CHECKOUT: 'Finalizar compra',
|
|
1143
|
+
REMOVE: 'Eliminar del carrito',
|
|
1144
|
+
ADD: 'Agregar al carrito',
|
|
1145
|
+
},
|
|
1146
|
+
AUTH: {
|
|
1147
|
+
LOGIN: 'Iniciar sesión',
|
|
1148
|
+
LOGOUT: 'Cerrar sesión',
|
|
1149
|
+
},
|
|
1150
|
+
} as const;
|
|
1151
|
+
```
|
|
1152
|
+
|
|
1153
|
+
For multi-language support, use `i18next` + `react-i18next` with `.json` resource files organized by locale.
|
|
1154
|
+
|
|
1155
|
+
---
|
|
1156
|
+
|
|
1157
|
+
## Expo Configuration
|
|
1158
|
+
|
|
1159
|
+
```typescript
|
|
1160
|
+
// app.config.ts
|
|
1161
|
+
import type { ExpoConfig } from 'expo/config';
|
|
1162
|
+
|
|
1163
|
+
const config: ExpoConfig = {
|
|
1164
|
+
name: 'YourApp',
|
|
1165
|
+
slug: 'your-app',
|
|
1166
|
+
version: '1.0.0',
|
|
1167
|
+
orientation: 'portrait',
|
|
1168
|
+
icon: './assets/images/logos/icon.png',
|
|
1169
|
+
userInterfaceStyle: 'automatic', // Supports dark mode
|
|
1170
|
+
splash: {
|
|
1171
|
+
image: './assets/images/logos/splash.png',
|
|
1172
|
+
resizeMode: 'contain',
|
|
1173
|
+
backgroundColor: '#FFFFFF',
|
|
1174
|
+
},
|
|
1175
|
+
ios: {
|
|
1176
|
+
supportsTablet: false,
|
|
1177
|
+
bundleIdentifier: 'com.yourcompany.yourapp',
|
|
1178
|
+
infoPlist: {
|
|
1179
|
+
NSCameraUsageDescription: 'Se usa para escanear códigos de barras.',
|
|
1180
|
+
},
|
|
1181
|
+
},
|
|
1182
|
+
android: {
|
|
1183
|
+
package: 'com.yourcompany.yourapp',
|
|
1184
|
+
adaptiveIcon: {
|
|
1185
|
+
foregroundImage: './assets/images/logos/adaptive-icon.png',
|
|
1186
|
+
backgroundColor: '#0E79FD',
|
|
1187
|
+
},
|
|
1188
|
+
permissions: ['CAMERA'],
|
|
1189
|
+
},
|
|
1190
|
+
plugins: [
|
|
1191
|
+
'expo-router',
|
|
1192
|
+
'expo-secure-store',
|
|
1193
|
+
'expo-font',
|
|
1194
|
+
['expo-screen-orientation', { initialOrientation: 'PORTRAIT' }],
|
|
1195
|
+
],
|
|
1196
|
+
experiments: {
|
|
1197
|
+
typedRoutes: true,
|
|
1198
|
+
},
|
|
1199
|
+
extra: {
|
|
1200
|
+
eas: { projectId: 'your-eas-project-id' },
|
|
1201
|
+
},
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
export default config;
|
|
1205
|
+
```
|
|
1206
|
+
|
|
1207
|
+
---
|
|
1208
|
+
|
|
1209
|
+
## package.json Reference
|
|
1210
|
+
|
|
1211
|
+
```json
|
|
1212
|
+
{
|
|
1213
|
+
"dependencies": {
|
|
1214
|
+
"expo": "~53.0.0",
|
|
1215
|
+
"expo-router": "~4.0.0",
|
|
1216
|
+
"expo-font": "~12.0.0",
|
|
1217
|
+
"expo-splash-screen": "~0.27.0",
|
|
1218
|
+
"expo-secure-store": "~13.0.0",
|
|
1219
|
+
"expo-image": "~2.0.0",
|
|
1220
|
+
"expo-screen-capture": "~5.0.0",
|
|
1221
|
+
"react": "18.3.1",
|
|
1222
|
+
"react-native": "0.77.0",
|
|
1223
|
+
"react-native-safe-area-context": "4.12.0",
|
|
1224
|
+
"react-native-screens": "~4.4.0",
|
|
1225
|
+
"react-native-gesture-handler": "~2.20.0",
|
|
1226
|
+
"react-native-reanimated": "~3.16.0",
|
|
1227
|
+
"react-native-mmkv": "^3.1.0",
|
|
1228
|
+
"nativewind": "^4.1.0",
|
|
1229
|
+
"zustand": "^5.0.0",
|
|
1230
|
+
"@tanstack/react-query": "^5.62.0",
|
|
1231
|
+
"axios": "^1.7.0",
|
|
1232
|
+
"zod": "^3.23.0",
|
|
1233
|
+
"react-hook-form": "^7.53.0",
|
|
1234
|
+
"@hookform/resolvers": "^3.9.0"
|
|
1235
|
+
},
|
|
1236
|
+
"devDependencies": {
|
|
1237
|
+
"@types/react": "~18.3.0",
|
|
1238
|
+
"typescript": "~5.3.0",
|
|
1239
|
+
"eslint": "^8.57.0",
|
|
1240
|
+
"eslint-config-expo": "~8.0.0",
|
|
1241
|
+
"prettier": "^3.3.0",
|
|
1242
|
+
"jest": "^29.7.0",
|
|
1243
|
+
"jest-expo": "~53.0.0",
|
|
1244
|
+
"@testing-library/react-native": "^12.7.0",
|
|
1245
|
+
"tailwindcss": "^3.4.0"
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
---
|
|
1251
|
+
|
|
1252
|
+
## Implementation Checklist
|
|
1253
|
+
|
|
1254
|
+
### Project Setup
|
|
1255
|
+
- [ ] `expo-doctor` passes with zero warnings
|
|
1256
|
+
- [ ] TypeScript strict mode enabled in `tsconfig.json`
|
|
1257
|
+
- [ ] NativeWind configured with `tailwind.config.ts` and brand colors matching `technical-preferences-ux.md`
|
|
1258
|
+
- [ ] Inter font assets loaded in `app/_layout.tsx`
|
|
1259
|
+
- [ ] `QueryClientProvider` and `SafeAreaProvider` in root layout
|
|
1260
|
+
- [ ] `ErrorBoundary` wrapping the navigator in `app/_layout.tsx`
|
|
1261
|
+
- [ ] `expo-secure-store` for token storage
|
|
1262
|
+
- [ ] MMKV for general storage
|
|
1263
|
+
- [ ] Auth guard in `app/(app)/_layout.tsx`
|
|
1264
|
+
- [ ] Typed Expo Router routes enabled (`experiments.typedRoutes: true`)
|
|
1265
|
+
- [ ] EAS project configured with `eas.json`
|
|
1266
|
+
|
|
1267
|
+
### Per Feature
|
|
1268
|
+
- [ ] Domain entity with `readonly` fields
|
|
1269
|
+
- [ ] Value object returning `Result<T, E>` — never throws
|
|
1270
|
+
- [ ] Repository interface in domain layer
|
|
1271
|
+
- [ ] Use case(s) as pure functions, injected repository
|
|
1272
|
+
- [ ] Zustand store delegating to use cases
|
|
1273
|
+
- [ ] TanStack Query hooks for server state
|
|
1274
|
+
- [ ] Repository implementation with Zod parsing on all API responses
|
|
1275
|
+
- [ ] Domain failure mapper to Spanish strings
|
|
1276
|
+
- [ ] Screen using selectors (`useStore((s) => s.field)`)
|
|
1277
|
+
- [ ] Shared `Screen`, `Text`, `Button` primitives — no raw RN components in screens
|
|
1278
|
+
- [ ] Unit tests for use cases and value objects
|
|
1279
|
+
- [ ] Component tests for key user interactions
|
|
1280
|
+
|
|
1281
|
+
### Quality
|
|
1282
|
+
- [ ] `eslint` and `prettier` return zero warnings
|
|
1283
|
+
- [ ] No `any` types anywhere
|
|
1284
|
+
- [ ] No raw `console.log` in production code
|
|
1285
|
+
- [ ] `FlatList` or `FlashList` for every dynamic list
|
|
1286
|
+
- [ ] `accessibilityRole` and `accessibilityLabel` on all interactive elements
|
|
1287
|
+
- [ ] All user-visible strings in `MESSAGES` constants (Spanish)
|
|
1288
|
+
- [ ] No API keys or secrets in client code
|