Marketplace de servicios locales · cliente móvil (solo consumo de API)
Este documento describe el frontend: la app de React Native + Expo que la gente lleva en el bolsillo. No van a construir el servidor ni la base de datos — eso vive en el gist del backend. Aquí el contrato de la API es su única fuente de verdad: todo lo que reciben, lo validan; todo lo que muestran, sale de ahí.
Una plataforma los contrató para construir la app de Cerca, un mercado de dos caras donde la gente encuentra y ofrece servicios locales: un fontanero, una profesora de guitarra, alguien que pasea perros.
La app tiene que funcionar en un teléfono real, en dos idiomas, con dinero de varias monedas, y con búsquedas que devuelven miles de resultados sin que el móvil se ahogue. El backend ya existe y expone una API REST versionada; ustedes la consumen.
Cuando terminen, alguien que nunca vio la app tiene que poder instalarla desde un enlace, buscar un servicio cerca de sí, publicar el suyo en cuatro pasos, reservar, y dejar una reseña — y todo eso sin que la app se rompa cuando le nieguen la ubicación, se caiga la red, o el sistema esté en alemán.
Consumir una API no es lo difícil. Lo difícil es hacerlo bajo estas cuatro condiciones, que son las de cualquier app móvil de producto real.
La app oculta y deshabilita botones según lo que una cuenta puede hacer. Eso es experiencia de usuario, no seguridad. Si alguien manipula el estado del cliente y hace aparecer un botón que no le corresponde, no pasa nada: el servidor rechaza la petición.
Esto los obliga a separar dos cosas que se confunden siempre: la lógica que decide qué se pinta (vive en el cliente, para que la experiencia sea buena) y la lógica que decide qué se permite (vive en el servidor, y el cliente solo la refleja). Nunca escriban una comprobación de permiso en el cliente pensando que protege algo. No protege nada.
Todo lo que empaquetan en el binario es visible. Una clave en una variable EXPO_PUBLIC_* se lee abriendo el bundle con un editor de texto.
Esto significa que ninguna credencial sensible vive en la app. El único secreto que la app maneja es el token de sesión del usuario, y va en almacenamiento seguro del dispositivo, no en AsyncStorage. Toda operación privilegiada la hace el servidor con las credenciales del servidor, jamás la app.
El usuario está en el metro, con una barra de señal. La app tiene que sentirse instantánea cuando la red no lo es: mostrar datos en caché mientras revalida, aplicar cambios de forma optimista y revertirlos si el servidor dice que no, y no pedir la misma cosa dos veces.
Esto los obliga a tratar el estado del servidor como lo que es —una copia local que puede estar vieja— y no como variables de estado normales. Es el trabajo de la capa de datos, y las decisiones sobre las claves de caché las toman al principio, no cuando la app ya parpadea.
Un Android de gama media tiene poca memoria y una pantalla que la mitad de la gente ve al 200% de tamaño de fuente. Una lista de miles de tarjetas con foto no se pinta con un map: se virtualiza. Una imagen de 4000×3000 para una tarjeta de 120 píxeles descomprime unos 48 MB en memoria, y en una lista eso es un crash.
Esto significa que el rendimiento y la accesibilidad no son un pulido final: son restricciones de diseño desde la primera pantalla.
Un marketplace de dos caras tiene una propiedad que casi ninguna app de práctica tiene: los permisos no se resuelven con un rol.
Marta publica clases de guitarra y contrata a un fontanero. No es "cliente" ni "proveedora": es las dos a la vez. Así que la pregunta "¿qué rol tiene este usuario?" está mal planteada. La pregunta correcta es siempre "¿este usuario puede hacer esto con este recurso?".
Una cuenta tiene un conjunto de capacidades (customer, provider — puede tener las dos) y, por separado, un rol de plataforma (user, moderator, admin). Un moderador también contrata servicios como cualquiera; por eso el rol de plataforma va en un campo aparte, no mezclado con las capacidades.
// @cerca/contract · src/auth/actor.ts (paquete compartido con el backend)
export type Capacity = 'customer' | 'provider';
export type PlatformRole = 'user' | 'moderator' | 'admin';
export interface Actor {
readonly id: UserId;
readonly capacities: readonly Capacity[]; // puede tener las dos
readonly platformRole: PlatformRole;
}
export const has = (a: Actor, c: Capacity) => a.capacities.includes(c);
export function can(a: Actor, p: Permission): boolean {
const fromCapacities = a.capacities.some((c) => CAPACITY_PERMISSIONS[c].includes(p));
const fromRole = PLATFORM_PERMISSIONS[a.platformRole].includes(p);
return fromCapacities || fromRole;
}El error que van a cometer: modelar role: 'customer' | 'provider'. Con eso Marta no cabe, y la solución que se les ocurre —role: 'both'— no escala a una tercera capacidad. "Mis anuncios" solo aparece si tienes la capacidad de proveedora, y hacerse proveedora es una acción dentro de la app (POST /me/capacities/provider), no un registro distinto.
Todo permiso de este proyecto se descompone en cuatro capas. La primera sirve para pintar el menú; las otras tres solo se pueden evaluar cuando tienes el recurso delante.
1. CAPACIDAD can(actor, 'listing:update') ¿en general?
2. PROPIEDAD listing.ownerId === actor.id ¿es mío?
3. RELACIÓN soy parte de esta reserva ¿participo?
4. ESTADO/TIEMPO status === completed, plazo ¿ahora se puede?
En el cliente, estas capas deciden cómo se ve un control, y hay una distinción que casi nadie hace:
- Sin capacidad → oculta el botón. No le interesa y no lo va a conseguir. Mostrarlo solo confunde.
- Bloqueado por una política (2, 3 o 4) → deshabilita y explica. "Ya reseñaste esta reserva" enseña la regla. Un botón que desaparece deja al usuario preguntándose qué pasó.
En el servidor, estas capas deciden qué se permite, y son la autoridad. El cliente nunca las sustituye; las refleja.
Es el contrato que la app comparte con el backend. Se dibuja antes de escribir una pantalla.
| Permiso | customer | provider | moderator | admin | Capa extra |
|---|---|---|---|---|---|
listing:read |
✅ | ✅ | ✅ | ✅ | — |
listing:create |
— | ✅ | — | ✅ | — |
listing:update |
— | ✅ | — | ✅ | propiedad |
listing:moderate |
— | — | ✅ | ✅ | — |
booking:request |
✅ | ✅ | ✅ | ✅ | no tu anuncio |
booking:accept |
— | ✅ | — | — | propiedad |
review:write |
✅ | ✅ | ✅ | ✅ | relación + estado + plazo |
review:moderate |
— | — | ✅ | ✅ | no ser el autor |
report:resolve |
— | — | ✅ | ✅ | — |
user:suspend |
— | — | — | ✅ | — |
La mitad de las filas tienen capa extra. Ahí vive la dificultad real, y ahí es donde las apps de verdad tienen agujeros.
La regla más difícil del producto: no puedes reseñar un servicio si no eres tú quien lo contrató (relación), si la reserva no está completada (estado), si ya la reseñaste (unicidad), o si han pasado más de 30 días (tiempo). Cuatro condiciones de cuatro tipos distintos, y ningún rol las resuelve.
Vive en el paquete compartido, y el servidor importa exactamente este archivo. Un sitio define la regla, dos la aplican.
// @cerca/contract · src/review/review.policy.ts
export type ReviewBlockedReason =
| 'not_your_booking' | 'not_completed' | 'already_reviewed' | 'window_closed';
export type ReviewEligibility = { ok: true } | { ok: false; reason: ReviewBlockedReason };
export const REVIEW_WINDOW_DAYS = 30;
export function canReviewBooking(actor: Actor, booking: Booking, now: Date): ReviewEligibility {
if (booking.customerId !== actor.id) return { ok: false, reason: 'not_your_booking' };
if (booking.status.kind !== 'completed') return { ok: false, reason: 'not_completed' };
if (booking.reviewId !== null) return { ok: false, reason: 'already_reviewed' };
if (daysBetween(booking.status.completedAt, now) > REVIEW_WINDOW_DAYS)
return { ok: false, reason: 'window_closed' };
return { ok: true };
}Tres decisiones que tienen consecuencias directas en la app:
- Devuelve un motivo, no un booleano. Con
falseno pueden decirle al usuario por qué, y acaban duplicando la lógica en la pantalla para poder mostrar el mensaje. - El motivo es una clave de i18n. La pantalla hace
t(\review.blocked.${reason}`)`. El dominio no sabe en qué idioma se muestra. nowes un parámetro, nonew Date()por dentro. La función es pura y el test de "ventana cerrada" no necesita simular relojes.
Esa línea —t(\review.blocked.${reason}`)`— es la simbiosis de todo el proyecto: dominio puro → clave de i18n → mensaje exacto, sin duplicar una sola comprobación.
El backend les entrega estas formas. Las validan al recibirlas y las tipan con TypeScript.
Un anuncio y una reserva son máquinas de estados. Modelarlos con campos opcionales permite estados imposibles (una reserva "completada" sin fecha de finalización, o "rechazada" con fecha de aceptación). Con una unión discriminada, esos estados no existen ni compilan.
export type ListingStatus =
| { kind: 'draft' }
| { kind: 'published'; publishedAt: string }
| { kind: 'paused' }
| { kind: 'under_review'; reportId: ReportId }
| { kind: 'removed'; removedBy: UserId; reason: RemovalReason };
export type BookingStatus =
| { kind: 'requested'; requestedAt: string }
| { kind: 'accepted'; acceptedAt: string; scheduledFor: string }
| { kind: 'declined'; reason: DeclineReason }
| { kind: 'completed'; completedAt: string }
| { kind: 'cancelled'; cancelledBy: UserId; at: string };Al consumirlas, un switch sobre status.kind con un default: assertNever(status) obliga a cubrir todos los casos: si el backend añade una variante, la app deja de compilar señalando los sitios exactos que hay que tocar.
Pricing es una unión discriminada de negocio, y es el corazón del formulario de publicación. Si el modelo es "presupuesto", el precio ni existe — ni en el tipo, ni en la pantalla.
export type Pricing =
| { model: 'fixed'; price: Money }
| { model: 'hourly'; hourlyRate: Money; minimumHours: number }
| { model: 'quote'; startingFrom?: Money };Este es el mejor ejercicio de i18n del proyecto, porque un anuncio en pesos mexicanos, visto por alguien con el móvil en alemán, rompe casi todas las implementaciones ingenuas.
export interface Money {
readonly amountMinor: number; // 129990, nunca un float
readonly currency: CurrencyCode; // 'MXN'
}
export function formatMoney(m: Money, locale: string): string {
const digits = minorUnitDigits(m.currency); // JPY 0 · MXN 2 · KWD 3
return new Intl.NumberFormat(locale, { style: 'currency', currency: m.currency })
.format(m.amountMinor / 10 ** digits);
}Tres cosas que hay que interiorizar:
- Nunca un
float.0.1 + 0.2 !== 0.3. El dinero son enteros en unidades menores. - Dividir entre 100 está mal en general. El yen no tiene decimales; el dinar kuwaití tiene tres. De ahí
minorUnitDigits. - Moneda ≠ locale. La moneda es un dato del anuncio (la fija quien publica). El formato es preferencia de quien mira.
MXN 129990se ve$1,299.90enes-MX,MX$1,299.90enen-USy1.299,90 MX$ende-DE. La distancia es igual: "a 3 km" no se traduce, se reformatea a millas en un locale imperial.
Escríbanlo en AGENTS.md: prohibido un number que represente dinero fuera de Money.
Esta es la superficie completa que la app toca. El detalle de request/response vive en el paquete @cerca/contract como schemas de Zod, y cada respuesta se valida con parse en el límite — nunca con as.
Base: http://localhost:3000/v1 (dev) · https://api.cerca.app/v1 (prod)
Autenticación: Authorization: Bearer <accessToken> en toda ruta privada.
Errores: application/problem+json (RFC 9457). Los 403/409 de dominio traen un campo reason legible por máquina que coincide con los motivos de las políticas (already_reviewed, window_closed, not_owner, has_pending_bookings, …).
Paginación: por cursor. ?cursor=&limit=; respuesta { items, nextCursor }.
Idempotencia: cabecera Idempotency-Key en POST /bookings y POST /bookings/{id}/review.
| Método y ruta | Para qué | Permiso / capa |
|---|---|---|
POST /auth/sign-up |
Crear cuenta | público |
POST /auth/sign-in |
Iniciar sesión → { accessToken, refreshToken, actor } |
público |
POST /auth/refresh |
Rotar tokens | refresh token |
POST /auth/sign-out |
Cerrar sesión | sesión |
GET /me |
La cuenta actual (Actor) |
sesión |
POST /me/capacities/provider |
Hacerse proveedor | sesión |
GET /categories |
Catálogo de categorías | público |
GET /listings?query=&categoryId=&lat=&lng=&radiusKm=&cursor= |
Búsqueda geolocalizada, ordenada por distancia | público |
GET /listings/{id} |
Detalle de un anuncio | público |
GET /me/listings |
Mis anuncios | listing:read + propiedad |
POST /listings |
Publicar un anuncio | listing:create |
PATCH /listings/{id} |
Editar un anuncio | listing:update + propiedad |
POST /listings/{id}/publish · /pause |
Cambiar estado | propiedad |
POST /listings/{id}/photos:presign |
Pedir URL de subida directa → { uploadUrl, key } |
propiedad |
POST /listings/{id}/favorite · DELETE …/favorite |
Guardar/quitar favorito | sesión |
POST /listings/{id}/report |
Denunciar un anuncio | sesión |
POST /listings/{id}/moderate |
Poner under_review/removed (pantalla de moderación) |
listing:moderate |
POST /bookings |
Solicitar una reserva | booking:request + no tu anuncio |
GET /bookings?role=customer|provider |
Mis reservas por lado | relación |
GET /bookings/{id} |
Detalle de una reserva | relación |
POST /bookings/{id}/accept · /decline · /complete |
Gestionar reserva | propiedad del anuncio |
POST /bookings/{id}/cancel |
Cancelar | relación + estado |
POST /bookings/{id}/review |
Reseñar (aplica canReviewBooking) |
relación + estado + unicidad + plazo |
GET /listings/{id}/reviews |
Reseñas de un anuncio | público |
POST /reviews/{id}/moderate |
Moderar una reseña | review:moderate + no ser el autor |
GET /reports · POST /reports/{id}/resolve |
Cola de moderación | report:resolve |
POST /users/{id}/suspend |
Suspender una cuenta | user:suspend |
// infrastructure/api/listing.gateway.ts
const raw: unknown = await http.get(`/listings/${id}`);
return listingDetailSchema.parse(raw); // no `as ListingDetail`
ases una promesa que le hacen al compilador y que nadie verifica.parsees una comprobación real. Si el backend cambia un campo obligatorio, se enteran aquí, con un mensaje claro, y no tres pantallas después.
Cada historia trae su criterio de aceptación: una frase que se convierte en un test o en un clic. "Que sea rápido" o "que quede bonito" no lo son.
| ID | Como… quiero… | Criterio de aceptación | Enseña |
|---|---|---|---|
| US-01 | Como usuario quiero iniciar sesión y que mi sesión sobreviva a un reinicio | Tras cerrar y reabrir la app, sigo dentro; sin sesión, cualquier ruta redirige a login sin parpadeo | expo-secure-store, arranque limpio |
| US-02 | Como usuario quiero buscar servicios cerca de mí con filtros | Los resultados salen ordenados por distancia; mover el mapa un poco no recarga; hay carga, error, vacío inicial y vacío por filtro | Geolocalización, virtualización, claves de caché |
| US-03 | Como proveedor quiero publicar un anuncio en 4 pasos | Elegir "por hora" pide horas mínimas; "presupuesto" no pide precio; las fotos suben; el borrador se puede retomar | RHF multipaso, Pricing, subida de fotos |
| US-04 | Como proveedor quiero editar solo mis propios anuncios | El botón de editar no aparece en el anuncio de otro; si fuerzo la petición, el servidor la rechaza | Propiedad (capa 2) |
| US-05 | Como cliente quiero solicitar una reserva | La solicitud se envía una sola vez aunque pulse dos; el estado se refleja al volver atrás | Mutación, estados, idempotencia |
| US-06 | Como cliente quiero reseñar una reserva completada, una sola vez | Reseñar dos veces muestra "ya reseñaste esta reserva"; fuera de plazo muestra su propio mensaje | La política estrella |
| US-07 | Como usuario quiero ver los precios en el formato de mi locale | El mismo precio se ve bien en es-MX, en-US y de-DE; la distancia en km o millas según locale |
Intl, Money |
| US-08 | Como usuario quiero que la app funcione sin permiso de ubicación | Al negar la ubicación, la app ofrece un selector de ciudad en vez de quedarse en blanco | Degradación elegante |
| US-09 | Como moderador quiero retirar un anuncio denunciado | Desde la cola de moderación puedo poner un anuncio under_review o removed |
Rol de plataforma, moderación |
| US-10 | Como equipo queremos instalar una build de QA desde un enlace | Existe un enlace de preview que otra persona instala y abre |
EAS |
| US-11 | (fuera de alcance) Chat en tiempo real entre las partes | — | Alcance declarado |
| US-12 | (fuera de alcance) Pagos y liquidaciones | — | Alcance declarado |
Las dos últimas existen para aprender a dejarlas fuera y decirlo. Saber decir "esto no entra" es parte del trabajo.
Toda pantalla que muestra datos tiene cuatro caminos, no uno. En un buscador, el estado vacío no es un caso raro: es lo que ve la mitad de la gente que filtra demasiado.
| Estado | Qué se muestra |
|---|---|
| Carga | Skeleton con la forma de las tarjetas reales, no un spinner centrado. El spinner no informa; el skeleton comunica qué va a aparecer. |
| Error | Lenguaje llano y un botón de reintento. "No pudimos cargar los servicios", no "Error 500". |
| Vacío inicial | "Todavía no hay servicios en tu zona", con opción de ampliar el radio. |
| Vacío por filtro | "Ningún servicio coincide con estos filtros", con botón de limpiarlos. Un botón de "ampliar a 20 km" convierte un callejón sin salida en una acción. |
La tarjeta de anuncio tiene tres niveles de jerarquía, no uno: título semibold, precio en su propio nivel tipográfico (en un marketplace el precio es información de primer orden), y distancia y valoración atenuadas. El estado nunca se comunica solo por color: un anuncio pausado lleva badge con texto, no solo gris. El precio lleva su contexto: "$450 / hora · mínimo 2 h", no "$450". La valoración lleva su recuento: "4,8 · 200 reseñas", con plural correcto.
El árbol de navegación usa grupos por capacidad (los paréntesis agrupan sin aparecer en la URL), la guarda de cada grupo vive en su _layout (un solo sitio protege todas las rutas que cuelgan), y el detalle de un anuncio es una ruta dinámica con deep link — compartir un anuncio por WhatsApp es una función central del producto, no un extra.
app/
├── _layout.tsx # providers + guardas + estado de carga inicial
├── (auth)/ # sin sesión: sign-in, sign-up
├── (app)/ # con sesión: <Tabs>
│ ├── search/ # /search + filters (modal)
│ ├── listings/[id].tsx # detalle + deep link cerca://listing/abc
│ └── bookings/[id]/review.tsx
└── (provider)/ # requiere la capacidad de proveedor
├── my-listings.tsx
└── listings/new.tsx # publicar (4 pasos)
El parpadeo de login: mientras leen el token de expo-secure-store no saben si hay sesión. Sin un estado de carga inicial, la app enseña el login 200 ms y parece un bug.
El estado del servidor es una copia local que puede estar vieja. La app lo gestiona con React Query (TanStack Query), y dos decisiones concentran casi todo el valor.
Con ocho filtros y coordenadas continuas, una clave ingenua genera una entrada nueva por cada píxel que se mueve el mapa. La solución es redondear las coordenadas antes de la clave.
export const listingKeys = {
all: ['listings'] as const,
searches: () => [...listingKeys.all, 'search'] as const,
search: (f: SearchFilters) => [...listingKeys.searches(), f] as const,
details: () => [...listingKeys.all, 'detail'] as const,
detail: (id: ListingId) => [...listingKeys.details(), id] as const,
mine: () => [...listingKeys.all, 'mine'] as const,
} as const;
// 19.432608, -99.133209 → clave A
// 19.432611, -99.133210 → clave B ← una entrada de caché por micromovimiento
export const snapToGrid = (c: Coords) => ({
lat: Math.round(c.lat * 100) / 100, // ~1 km
lng: Math.round(c.lng * 100) / 100,
});return useMutation({
mutationFn: ({ id, next }) => api.setFavorite(id, next),
onMutate: async ({ id, next }) => {
await qc.cancelQueries({ queryKey: listingKeys.detail(id) }); // no es opcional
const prev = qc.getQueryData(listingKeys.detail(id));
qc.setQueryData(listingKeys.detail(id), (o) => (o ? { ...o, isFavorite: next } : o));
return { prev };
},
onError: (_e, { id }, ctx) => {
if (ctx?.prev) qc.setQueryData(listingKeys.detail(id), ctx.prev);
},
onSettled: (_d, _e, { id }) => {
qc.invalidateQueries({ queryKey: listingKeys.detail(id) });
qc.invalidateQueries({ queryKey: listingKeys.searches() }); // ← el olvido universal
},
});El error que comete todo el mundo: invalidar el detalle y olvidar las búsquedas, donde el corazón del favorito también se pinta. Pregúntense siempre qué otras vistas muestran este dato. Y retry: false para 401/403: reintentar un permiso denegado tres veces es un antipatrón.
Cada tarjeta es una jerarquía de ~7 vistas nativas más una imagen descodificada. Cinco mil tarjetas en una ScrollView son 35.000 vistas montadas y 5.000 imágenes en memoria: en gama baja, un crash. Virtualizar no es una optimización: es la forma correcta de renderizar una lista.
FlatListcongetItemLayoutykeyExtractorestable. El índice nunca sirve comokey.- Las tres estabilizaciones, o
memo()no sirve de nada:memoen la tarjeta,useCallbackenrenderItem,useCallbacken el handler del padre. Si dejan una inline, las otras dos sobran. Compruébenlo conconsole.count("ListingCard")mientras teclean en el buscador: si el número se dispara, elmemo()está muerto. - La causa nº 1 de crashes en este proyecto son las fotos.
expo-imageconcachePolicy: "memory-disk"y blurhash. Una imagen de 4000×3000 para una tarjeta de 120 px descomprime ~48 MB. - Dos hilos: si cae el FPS de JS, es su código; si cae el de UI, es layout, sombras o imágenes. Saber cuál cae es el 80% del diagnóstico.
Presupuestos: scroll ≥ 55 FPS con 5.000 tarjetas en gama media · respuesta a un toque < 100 ms · primeros resultados visibles < 400 ms. No optimicen sin medir, y midan en un dispositivo de gama media: el portátil y el emulador les mienten los dos.
NativeWind compila a StyleSheet en tiempo de build. No hay CSS, no hay cascada, no hay motor en runtime — por eso grid, float, position: fixed y before: no existen. El estilo se organiza así:
- Colores semánticos, no descriptivos.
text-status-removedcomunica intención;text-red-500solo comunica color. Cuando diseño cambie el rojo, tocan una línea. El precio tiene su propio nivel tipográfico y el área táctil mínima es un token con nombre (min-h-touch, 44 puntos). classNamepara lo declarativo,StyleSheet/stylesolo para números que se conocen en runtime. Si venstyle={{ backgroundColor: '#fff' }}, está mal: eso esbg-surface. El hexadecimal solo vive en el tema.- Reanimated obliga a
useAnimatedStyle: el worklet corre en el hilo de UI y necesita un objeto plano;classNamese resuelve en build y ahí no existe. - La trampa de Android: sombra y
overflow: hiddenen el mismo nodo hacen desaparecer la sombra. Un contenedor exterior para la sombra, otro interior para el recorte. twMerge+cva. Concatenar strings de clases es el bug silencioso más común: dos paddings, gana el último, y no saben cuál es.cvaconVariantProps<typeof button>deriva los tipos solos.- En móvil no hay
:hover. El equivalente útil esactive:, y sí hay que usarlo: sin feedback al pulsar, la app se siente rota y la gente pulsa dos veces.
i18n con i18next para el texto e Intl para dinero, distancias y fechas. Los siete olvidos: plurales con _one/_other, interpolación, Intl para formatos, RTL con ps-/pe-, la expansión del alemán (el layout tiene que aguantar textos más largos), claves tipadas, y los metadatos de la app. Los mensajes de error de Zod son claves, no texto: el schema corre en el servidor, en un worker y en un test, donde no hay idioma.
Accesibilidad y testing son el mismo trabajo: si el test encuentra un botón con getByRole, VoiceOver también lo encuentra; si necesitan testID, ese botón tampoco es accesible.
- Una tarjeta = una parada del lector (
accessiblepara agrupar). Sin agrupar, revisar 200 resultados son 1.400 paradas. - Anunciar el primer error al enviar un formulario.
maxFontSizeMultiplier, nuncaallowFontScaling={false}. La app tiene que aguantar la fuente al 200% sin cortes.- Ningún estado se comunica solo por color. Área táctil ≥ 44×44.
| Capa | Herramienta | Por qué |
|---|---|---|
| Plataforma | Expo SDK 57 (development build) | Ciclo de trabajo rápido; a partir del día 2 Expo Go no sirve porque hay código nativo de terceros |
| Lenguaje | TypeScript (estricto) | Uniones discriminadas, satisfies, tipos derivados de Zod |
| Navegación | Expo Router | Rutas por archivos, grupos por capacidad, rutas tipadas, deep links |
| Estilos | NativeWind (declarativo) + StyleSheet/Reanimated (runtime) | Frontera deliberada: className describe, style transporta números |
| Formularios | React Hook Form + Zod (zodResolver) |
El schema es la única fuente de verdad; la unión discriminada dirige la UI |
| i18n / formato | i18next + Intl | Texto traducido y dinero/distancias/fechas por locale |
| Datos remotos | TanStack Query (React Query) | Caché, revalidación, mutaciones optimistas, claves jerárquicas |
| Contrato | @cerca/contract (paquete compartido) |
Tipos, schemas de Zod, matriz de permisos y políticas puras, importadas también por el backend |
| Autorización (UX) | useCan() · <Can> · withCapacity() |
Oculta o deshabilita; nunca es seguridad |
| Nativo distintivo | expo-location (con degradación), expo-image, expo-secure-store | Geolocalización, fotos y token de sesión |
| Listas | FlatList virtualizada | Memoria constante con miles de tarjetas |
| Calidad | ESLint + Prettier + Husky + lint-staged, verify.sh |
Un commit con any no entra |
| Pruebas | Vitest + Testing Library + renderHook, Maestro (E2E) |
Umbrales por capa; dominio al 100% |
| Entrega | EAS Build / Update | Build instalable por enlace; OTA para JS/estilos/traducciones |
Clean Architecture, con la regla de dependencia protegida por el linter: las dependencias apuntan hacia dentro; el dominio no sabe que existe React.
apps/mobile/
├── app/ # rutas finas de Expo Router
└── src/
├── domain/ # entidades, políticas, schemas de Zod, errores. TS puro, cero imports de React
├── application/ # casos de uso, puertos (interfaces)
├── infrastructure/ # cliente de API, secure storage, expo-location — IMPLEMENTA los puertos
└── presentation/ # screens, components, hooks de UI, navegación
packages/contract/ # @cerca/contract — compartido con apps/api
└── src/ # Actor, Permission, Money, pricingSchema, canReviewBooking, canEditListing…
La regla, ejecutable:
// eslint.config.js
'import/no-restricted-paths': ['error', {
zones: [
{ target: './src/domain', from: './src/presentation', message: 'Domain must not depend on UI.' },
{ target: './src/domain', from: './src/infrastructure' },
{ target: './src/domain', from: './src/application' },
{ target: './src/application', from: './src/presentation' },
],
}]La demostración de 60 segundos: importen View de react-native dentro de src/domain/ y miren caer el linter. Una regla de arquitectura que solo vive en un diagrama se rompe en dos semanas; una que vive en el linter y en CI, no.
El código en inglés, la app bilingüe. Identificadores, archivos, ramas, commits, tests y claves de i18n en inglés. El texto que ve el usuario vive en en.json y es.json.
Nada entra a main sin verify.sh. Corre tipos, linter, formato y tests, el mismo archivo en su máquina y en CI. Los hooks tienen presupuesto (pre-commit < 5 s, pre-push < 60 s) porque un hook lento acaba en --no-verify y ahí perdieron.
Si no lo pueden explicar, no lo entregan. Examen oral aleatorio. No poder explicar una función propia línea a línea cuenta como no entregada, por bien que funcione la app.
El contrato no se toca a la ligera. Los schemas de @cerca/contract los comparten con el backend. Si cambian una forma, se coordina, porque de eso depende que las dos piezas encajen.
En el móvil no hay secretos del lado del cliente. Compilen y busquen EXPO_PUBLIC_API_KEY dentro del bundle: se ve. Toda operación privilegiada la hace el servidor.
No trabajen en esto, aunque les sobre tiempo:
- Chat en tiempo real entre las partes (US-11).
- Pagos y liquidaciones (US-12).
- Lógica de autorización que pretenda proteger algo desde el cliente. La autoridad es el servidor.
- Un backend propio. La API ya existe; ustedes la consumen.
Si terminan el alcance base, vayan hacia profundidad, no hacia funciones nuevas: más estados cubiertos, mejor accesibilidad, animaciones más pulidas, un buscador más robusto ante la red mala.
Parte del DoD es este checklist, verificado en un dispositivo físico.
FUNCIONAL
[ ] Los cuatro estados en búsqueda: carga, error, vacío inicial, vacío por filtro
[ ] El vacío por filtro ofrece ampliar el radio o limpiar filtros
[ ] Botón deshabilitado mientras la petición vuela
[ ] Volver atrás tras una mutación muestra el estado nuevo
PERMISOS (reflejo en UI; la autoridad es el servidor)
[ ] Un proveedor no ve editar en el anuncio de otro
[ ] Reseñar dos veces muestra el motivo, no un botón ausente
[ ] Reseñar fuera de plazo muestra su propio mensaje
[ ] Denegar la ubicación en Ajustes: la app ofrece selector de ciudad
DINERO E i18n
[ ] El mismo precio en es-MX, en-US y de-DE
[ ] Modelo de precio visible: "/hora · mínimo 2 h"
[ ] Distancia en km o millas según locale
[ ] Plurales correctos con 0, 1 y 2 reseñas
[ ] Layout intacto en alemán
ACCESIBILIDAD
[ ] Una tarjeta = una parada del lector
[ ] Errores anunciados al enviar
[ ] Fuente al 200% sin cortes
[ ] Ningún estado comunicado solo por color
[ ] Área táctil ≥ 44×44
RENDIMIENTO
[ ] Scroll ≥ 55 FPS con 5.000 tarjetas en gama media
[ ] Memoria estable al hacer scroll largo
[ ] Modo avión a mitad de una reserva: mensaje claro, sin pérdida
ENTREGA
[ ] Probado en dispositivo físico
[ ] Instalado desde una build de preview por alguien que no lo escribió
[ ] `./scripts/verify.sh` en verde y `main` protegido
Al cierre, su equipo tiene que poder pararse frente a alguien que nunca vio la app, en un teléfono real, y mostrarle:
- que puede buscar cerca de sí, con filtros, entre miles de resultados, y abrir un detalle — con sus cuatro estados cubiertos;
- que puede publicar un anuncio en cuatro pasos, con precio y moneda, zona y fotos;
- que puede reservar y reseñar, y que reseñar dos veces o fuera de plazo muestra el motivo exacto, no un botón que desaparece;
- que niegan la ubicación y la app sigue siendo usable;
- que el mismo precio se ve bien en tres locales y la distancia se reformatea a millas;
- que la lista de 5.000 tarjetas va fluida y el lector de pantalla la recorre una tarjeta por parada;
- y que otra persona instala la app desde un enlace y la abre sin que ustedes toquen nada.
Lo que se llevan no es la app: es el criterio para consumir cualquier API con cabeza —validar el límite, separar experiencia de seguridad, tratar la caché como una copia que caduca— en el siguiente proyecto, con otro dominio y otras herramientas.