Features manifest
Mirrored from docs/FEATURES.md. Each section is a thin index — read the colocated FEATURE.md (path stated under each section) for the implementation deep-dive.
Infrastructure & foundation
Section titled “Infrastructure & foundation”Turborepo monorepo on Bun. Active apps: apps/svelte-web (web), apps/ios-user + apps/android/app-user + apps/ios-agent + apps/android/app-agent (native Swift/Kotlin mobile apps — customer + agent; plain directories, not bun-workspace members), apps/worker (BullMQ), apps/api (Hono tRPC + Better Auth host), and apps/docs (this site). Eleven shared packages (incl. @repo/contract — Zod wire schemas → native Swift/Kotlin model codegen). PostgreSQL + PostGIS, Docker images deployed to Dokploy (Hostinger VPS) via its GitHub App, GitHub Actions CI. No IaC — Dokploy’s GitHub App deploys development directly. BullMQ workers (search-indexing, leads, alerts, ai, ai-staging, billing) run alongside Bull Board for live inspection. Bull Board is gated by HTTP Basic auth (BULL_BOARD_USER/BULL_BOARD_PASSWORD) and fails closed: the worker refuses to start the dashboard without credentials. That is also what keeps it out of PRD, where the vars are deliberately unset — queue admin there is scripts/queue-admin.ts instead, with failed auth attempts limited to 10/min per client IP followed by a 5-minute pre-auth lockout; the worker verifies its Valkey TLS connection via VALKEY_TLS_REJECT_UNAUTHORIZED / VALKEY_CA_CERT.
@repo/server-services collects cross-app server helpers (IP-trust gate, email transport, IP rate limiter) so the same logic backs both apps/svelte-web and apps/api.
@repo/api is split into a pure-type root barrel and a runtime subpath (@repo/api/server) so client bundles that type-import AppRouter don’t drag the router runtime + service runtime into the browser graph.
Deep dives: Architecture → Overview, Roadmap.
Authentication & security
Section titled “Authentication & security”Passwordless email OTP (Resend) + Google OAuth (popup + BroadcastChannel) via Better Auth. Cookie-based sessions, security headers (CSP nonce, HSTS, COOP), tiered rate limiting (Valkey-backed + in-memory fallback), per-IP auth throttling on /api/auth/*, production fail-closed IP trust (TRUSTED_PROXY_CIDRS), agent profile IDOR fix (updateProfile resolves from session), dashboard AGENT role gate, explicit social account-linking policy (Google only, no cross-email linking), input sanitization, Postgres RLS (85+ policies across 28 tables), Sentry error tracking.
The Better Auth auth.handler is mounted on both apps/svelte-web and apps/api. The SvelteKit /api/trpc/[...path]/+server.ts handler was deleted in Phase 5 step F — all browser and mobile tRPC traffic now goes cross-origin to apps/api.
CSRF is handled by SameSite=Lax cookies (web↔api are same-site) plus an Origin allowlist enforced inside tRPC (enforceOrigin) and as browserOriginGuard on the apps/api image-write routes (/api/avatar, /api/agent-avatar, /api/property-images); native clients send no Origin and are exempt. There is no token / double-submit.
Session IP-binding is disabled (advanced.ipAddress.disableIpTracking: true) — false-positive logouts under Cloudflare edge routing + CGNAT outweighed the speculative defense. Primary defenses (SameSite=Lax cookie attrs + Origin allowlist) are intact.
Deep dive: packages/auth/FEATURE.md. See also Architecture → Authentication.
Internationalization (i18n)
Section titled “Internationalization (i18n)”Bilingual (es-MX primary, en-US secondary) via Paraglide JS compile-time translations (600+ keys). Cookie-based locale (no URL prefix). Zod validation i18n adapter. Knock notification translations shared via @repo/notifications. Locale mapping 'es' → 'es-MX' for Intl.NumberFormat / Intl.DateTimeFormat.
The i18n compile-outdir gotcha lives in Conventions → Monorepo.
UI & design system
Section titled “UI & design system”Tailwind CSS 4.1 with CSS custom properties (light + dark mode); design tokens in apps/svelte-web/src/routes/layout.css. Web: bits-ui headless primitives + Inter / Source Serif 4. LaunchpadTile component library (7 variants). Native mobile: per-app hand-built primitive libraries (SwiftUI / Compose) reading generated design tokens. S-Tier SaaS standards (Stripe / Airbnb / Linear), WCAG 2.1 AA, mobile-first responsive.
Native design tokens: committed Tokens.swift + Tokens.kt (light + dark palettes) are generated from web’s OKLCH source of truth (layout.css), so the native iOS/Android apps share the palette, a bundled-Inter type ramp, and a shared 12 dp button shape. The active palette is read through the token environment (@Environment(\.tokens) / LocalTokens.current) and switches live with the chosen appearance (System / Light / Dark).
Design intent lives in context/style-guide.md (local-only).
Property management
Section titled “Property management”10-step creation wizard, section-based edit/manage, DRAFT → ACTIVE → PAUSED → SOLD | RENTED | ARCHIVED lifecycle, multi-unit support, rental terms, BullMQ async search index sync, AI-assisted description generation (Gemini 2.5 Flash) and AI virtual room staging (Replicate), homeowner / agent assignment, curated lists, bulk operations, price history tracking with price-drop badges.
Publish gated by 80% completion score: getPublishability() in packages/api/src/services/listings-publishability.ts returns { isPublishable, score, missing[] }; the wizard / mobile review screen surface the missing list and disable Publish below threshold; the gate is re-enforced server-side inside publishListing().
Deep dive: packages/api/src/services/listings.FEATURE.md.
Search & discovery
Section titled “Search & discovery”/properties split-layout map + filterable grid. Backend on @repo/redis-search (Valkey GEOSEARCH + numeric range filters with multi-unit overlap matching). Google Maps with MarkerClusterer, zoom-adaptive markers, viewport-tracked URL params. Full filter bar (price range slider, beds, baths, area, features, listing/property type, sort). Accumulated pagination (24/page), CDN caching for anonymous traffic, favorites overlay for authenticated. Server-side bounds: radiusKm cap, viewport / GEOSEARCH fan-out clamps, coordinate jitter when showExactLocation is false.
Deep dive: packages/api/src/services/properties.FEATURE.md#search--discovery.
Property detail
Section titled “Property detail”/properties/[listingId] full page: gallery (swipe + lightbox + tabbed Images / Floor Plans / Videos / 360° modal with AI-staged variants dialog), specs + features by category, location map, contact agent (chat), tour request, similar properties (same city + type, ±20% price, ±1 bedroom), multi-unit selector, rental terms, share dialog, price history timeline, area price insights funnel (city → municipality → neighborhood), SEO meta tags, address privacy.
Deep dive: packages/api/src/services/properties.FEATURE.md#property-detail.
Financing & affordability
Section titled “Financing & affordability”Buyer-facing financing planner at /mortgage-calculator (public, usable signed out). Default view is affordability-first (max buy/rent from income, debts and savings, cash-capped on the buy side) with a second “Check a property” mode for the price-first calculator, simplified to price, down payment, rate and term up front. For-sale property detail pages still mount a teaser that links out with the listing prefilled into check mode. Signed-in users save a financial profile and named price-based scenarios at /portal/mortgage-scenarios.
The math is a pure, dependency-free engine in @repo/validation/mortgage-rules that runs client-side; only inputs are persisted and results are always recomputed. Scope is one fixed-rate MXN bank loan — the loan model is a tranche array and the assumption catalog is versioned by effective date, which is where Infonavit/Cofinavit will land. Results are always described as estimates, never an approval, and the indicative effective-cost figure is explicitly not Banco de México’s CAT.
Deep dive: packages/api/src/services/mortgage.FEATURE.md.
User profile & account
Section titled “User profile & account”Customer profile at /portal/profile/ (slim), agent profile at /dashboard/profile/ (rich: license, bio, specializations, service areas, granular office address), agent settings at /dashboard/settings/. Become-agent global Dialog mounted at root layout, opened from three entry points. Avatar pipeline (S3 + imgproxy, separate user/agent endpoints).
Mobile parity (May 2026): users.updateProfile accepts firstName / lastName / phone alongside name + image. Edit Profile sheet matches web /portal/profile/. Role badges + member-since at top of sheet. Addresses + Settings screens added (CRUD against addresses.{list,create,update,delete} shared with web).
Deep dive: packages/api/src/services/portal.FEATURE.md#user-profile--account.
Favorites & engagement
Section titled “Favorites & engagement”Server-side favorites (toggle, SSR grid at /portal/favorites/) with selection mode, side-by-side comparison (2–4 properties), share collections (link / WhatsApp / email, public page at /shared/favorites?ids=...). Saved searches with alertsEnabled (BullMQ cron */30 * * * * matches and notifies via Knock). Client-side recently-viewed (localStorage, 20 cap) and recent-searches (localStorage, 5 cap). Price-drop and listing-status-change alerts for favoriters.
Deep dive: packages/api/src/services/portal.FEATURE.md#favorites--engagement.
Agent directory
Section titled “Agent directory”Public directory at /agents with Meilisearch fuzzy search (name + location modes), agent profile pages, curated property lists (public / private with shareable URLs at /agents/[agentId]/lists/[slug]), become-agent flow via global Dialog. Background indexing keeps AGENTS_INDEX in sync via BullMQ. Public reads carry per-IP publicRateLimit.
Deep dive: packages/api/src/services/agents.FEATURE.md.
Unified Team model (Agency, Group, or Construction Company). Tabbed dashboard with 14 form actions. 6 granular per-member permissions, configurable SLA (30 / 60 / 120 / 240 / 480 min) with 3-tier escalation chain. Team analytics + leaderboard with period filter (7d / 30d / 90d / all). Member detail sheet, public agency page at /agencies/[teamId], one-team-per-agent constraint, BullMQ Meilisearch sync.
Deep dive: packages/api/src/services/teams.FEATURE.md.
Lead management
Section titled “Lead management”Inquiry pipeline with 5-status workflow (NEW → CONTACTED → IN_PROGRESS → CLOSED → CONVERTED), event-driven audit trail, per-team SLA escalation (3 tiers), reliable BullMQ ingestion with DLQ, disposition-required terminal states. Ingested from chat (first property message), tour requests, and the contact form. Idempotent ingestion via Inquiry.dedupeKey + stable BullMQ jobId; daily dlq-purge job (DLQ_RETENTION_DAYS).
Deep dive: packages/api/src/services/leads.FEATURE.md.
Calendar & tours
Section titled “Calendar & tours”Tour request lifecycle (REQUESTED → CONFIRMED | RESCHEDULED → COMPLETED, with CANCELLED / DENIED terminal outs). Agent calendar at /dashboard/calendar (FullCalendar imperative API + custom toolbar + hover tooltips). Tour creation fans out to chat (creates GetStream channel + formatted bilingual message), leads (creates Inquiry), notifications (tour-requested, tour-rescheduled, tour-denied, tour-confirmed).
Deep dive: packages/api/src/services/tours.FEATURE.md.
Real-time chat
Section titled “Real-time chat”GetStream.io property-scoped (p-{propId16}-{pairHash}) and direct (d-{pairHash}) channels with deterministic IDs (DJB2 + FNV-1a). Role-split inboxes (/dashboard/inbox vs /portal/inbox), reactive unread counts (Svelte 5 runes) wired into Header / Sidebar / MobileDock, toast notifications, 5 emoji reactions, read receipts, 5-min soft-delete window. First property message auto-creates an Inquiry + fires new-inquiry Knock workflow. Server-trusted ORIGIN for notification links; link segments rendered without {@html}; hardened Stream webhook dedupe + timestamp checks.
Mobile: chat lands in a later native phase (Stream SDKs) — see Conventions → Mobile (Native). Token mint via apps/api reads PUBLIC_GETSTREAM_API_KEY + GETSTREAM_API_SECRET from repo-root .env.local only; the server-issued user token remains the only mint path.
Deep dive: packages/api/src/services/chat.FEATURE.md.
Notifications
Section titled “Notifications”Knock-backed in-app feed + email. 13 workflows wired across web (chat, tours, teams, lead assignment) and worker (SLA breach + 3-tier escalation, ingestion DLQ, price drop, status change, saved-search matches). Shared @repo/notifications package owns translations (es + en) and branded HTML email layout — consumed by both apps so worker-triggered notifications localize per recipient. Resend is the email channel provider. Outbound notification links are built from a server-trusted ORIGIN (never client input — chat / tours / teams previously accepted a client origin); the GetStream webhook verifies its HMAC, requires created_at, and dedupes all event types within a 5-minute replay window.
Mobile push: the server-side path is a notifications.registerDevice tRPC mutation that routes a device token to the Knock FCM (Android) / APNs (iOS) channel by the request’s app field (agent → KNOCK_APNS_CHANNEL_ID, user → KNOCK_APNS_USER_CHANNEL_ID). The native apps wire their client side (native Firebase / APNs SDKs) in a later phase; APNs delivery is gated server-side until the matching Knock APNs channel is provisioned. See Notifications → Mobile push.
Deep dive: packages/notifications/FEATURE.md.
Maps & location
Section titled “Maps & location”Meilisearch LOCATIONS_INDEX (localities + municipalities + cities, sourced from SEPOMEX XML) + SERVICE_AREAS_INDEX. Google Places autocomplete (country-restricted to MX / US) — exposed both as web /api/places/* proxies and as locations.placesAutocomplete / placesDetails tRPC procedures so the mobile address form keeps the API key server-side. Google Geocoding (ZIP → city/state, reverse geocode). Property coordinates via PostGIS ST_MakePoint. User-saved addresses with granular fields.
Deep dive: packages/api/src/services/locations.FEATURE.md.
Image system
Section titled “Image system”S3 upload (Cloudflare R2) + on-demand resize / format via imgproxy with HMAC-signed URLs. Property images (with reorder + AI-staged variants), floor plan PDFs, external video URLs (YouTube / Vimeo / Matterport, whitelisted), user / agent / team avatars + banners. Responsive presets (400×300 / 800×600 / 1200×800), WebP at quality 80, ownership-checked write paths, rate-limited uploads. sanitizeImageUpload (sharp) on ingest; UUID S3 keys for avatars; signed URL expiry (IMGPROXY_URL_TTL_SECONDS); clamped /api/images/* transform params. Mobile-facing write routes (/api/avatar, /api/agent-avatar, /api/property-images, /api/floor-plans, /api/property-videos) are mounted on apps/api alongside svelte-web’s, so the native mobile apps upload to the same host they authenticate against.
Deep dive: apps/svelte-web/src/lib/server/images.FEATURE.md.
SEO & web standards
Section titled “SEO & web standards”Open Graph + Twitter Cards on property detail, canonical URLs (ORIGIN env), dynamic sitemap.xml (static + ACTIVE property URLs with lastmod), semantic HTML hierarchy, responsive images with lazy-loading + fetchpriority="high" on hero, streamed SSR responses.
Planned: JSON-LD structured data for property listings; robots.txt customization.
Analytics & monitoring
Section titled “Analytics & monitoring”Sentry error tracking + session replay + performance monitoring, one project per deployable (svelte-web, apps/api, apps/worker, + the native mobile apps ios-user / android-user / ios-agent / android-agent, all under org na-v1f). All non-mobile inits (apps/api, svelte-web server + client, apps/worker) install a beforeSend scrubber that redacts session cookie / authorization / csrf headers and password|token|otp|secret body fields; the native apps use sentry-cocoa / sentry-android with manual init + a header-scrubbing beforeSend and sendDefaultPii: false. Server-side tRPC monitoring (Jun 2026): every procedure is wrapped in a trpc/<path> span via Sentry’s trpcMiddleware({ attachRpcInput: true }), injected through a zero-SDK setMonitoringMiddleware seam in @repo/api; all tRPC errors are captured with the input attached to a scrubbed trpc context, at 100% dev / 30% prod trace sampling. The native clients retry the transport-failure class and report survivors to Sentry. apps/api raises Bun.serve idleTimeout to 120s. PostHog consent-gated product analytics. PropertyView fire-and-forget tracking with 24h dedup.
Team-level reporting tab is live (see Teams). The broader /dashboard/analytics page is a placeholder pending real cross-team aggregation (packages/api/src/services/analytics.ts is currently a stub).
Rental Companion (deferred)
Section titled “Rental Companion (deferred)”Implemented through Phases 1–3 then removed in April 2026. The RentalTerms model and listing-wizard Step 8 remain. Planned for re-implementation as a multi-phase workstream (renter application, guarantor invitations, contract upload + DocuSeal signing, Typst PDF generation, room-by-room inventory).
Subscriptions & billing
Section titled “Subscriptions & billing”M1 (Aug 2026) is live: plan catalog + MXN pricing (INDIVIDUAL/TEAM × FREE/BASIC/PREMIUM/ENTERPRISE; AI is paid-only by decision), context-scoped entitlement resolution behind a short Valkey cache, six server-enforced plan gates (plan_limit:<gate> — active listings at publish, featured slots, AI description/staging quotas, team seats, analytics window clamp), a 14-day INDIVIDUAL PREMIUM trial with worker scan + Knock notifications, an admin grant/revoke UI with audit trail, and read-only billing/plan pages with usage meters. Money moves out-of-band in M1 — an ADMIN assigns plans.
M2 (Aug 2026) is live: Stripe-hosted self-serve checkout from both billing pages (cards-only MXN, prices resolved by lookup_key with a blocking amount check), a signature-verified webhook whose sync re-fetches the subscription’s current state on every event, dunning (PAST_DUE stays entitled through Smart Retries), payment-failed / subscription-canceled Knock workflows, billing-anchor quota windows, a daily stripe-reconcile job, native planGate upgrade sheets in both agent apps, and per-seat TEAM checkout on the teams billing tab.
CFDI facturación (Aug 2026) closes the Mexican tax side: Stripe MX stamps nothing at the SAT, so every paid Stripe invoice is issued as a CFDI 4.0 through FacturAPI — invoice.paid records the document, a 15-minute worker scan stamps it and FacturAPI emails the PDF + XML. Receivers manage their fiscal data (razón social, RFC, régimen fiscal, CP, uso CFDI) and download their facturas from the same billing pages; a team’s facturas use the team’s own fiscal identity and are LEAD-only.
A refund undoes the factura too. charge.refunded re-fetches the charge rather than trusting the event — refunds accumulate, so two partials become a full one and only the charge’s current state can say so — and a full refund queues the SAT cancellation for the same 15-minute scan (motive ‘03’, “la operación no se llevó a cabo”). Refunded before anything was stamped, the payment is cancelled outright and can no longer be invoiced: a factura for money already returned is exactly what must not be issuable. Partial refunds stop at recording the amount and raising an alert — netting a CFDI de ingreso takes a CFDI de egreso, whose amount and timing are the issuer’s fiscal call. FacturAPI’s Stripe marketplace app is deliberately not installed; it reacts to paid invoices the same way this integration does, so running both would double-stamp every payment.
When the SAT refuses to stamp — a wrong RFC, régimen or CP — the receiver hears about it instead of having to notice on the billing page. A rejection of the receiver’s own data fires the cfdi-failed Knock workflow (email + in-app), quoting the SAT’s own message and linking to the fiscal form to fix it; the payment itself is unaffected and the copy says so. Team facturas notify the founding LEAD, the same person Stripe dunning mail reaches. Failures that are ours rather than the receiver’s stay in Sentry, and that line is drawn deliberately: not just a transport fault that exhausted its retries, but a bad API key or an exhausted FacturAPI account too. Those are just as permanent as a bad RFC, so “is retrying pointless” is the wrong question to gate a customer email on — “fix your tax details” would be a lie, and quoting the raw error would hand a customer our own configuration. The billing page draws the same line: an invoice that failed for a reason of ours says so generically instead of printing the error, and the fiscal details are not marked rejected, so a key of ours breaking never takes away a badge the customer legitimately earned. Saving a tax profile also mirrors the RFC and postal code onto the Stripe Customer, so Stripe’s own hosted receipts, portal and dunning mail carry the fiscal data, and Stripe Tax has an MX address and tax id waiting if the IVA question is ever answered that way.
Self-serve plan change (Aug 2026) closes the last gap: subscribers move between plans in-app instead of cancelling and re-buying. Both directions mutate the existing Stripe subscription — never a second Checkout. A shared, browser-safe classifier decides direction from (tier, seats, interval), so the button’s label and the server’s decision come from one function: an upgrade applies immediately and bills the prorated difference, a downgrade is scheduled for the end of the paid period and mirrored locally so the billing page can say when it lands. One preview function is the authority for both the rendered CTA and the server’s answer; it refuses trials and manual comps (those buy through checkout), subscriptions in dunning or pending cancellation, plans that aren’t sold, and any team seat count below the greater of the plan’s minimum and the current roster. A downgrade that would leave you over the smaller plan’s limits is refused rather than remediated — nothing is ever auto-unpublished or a member evicted. Web-only, and available to every individual subscriber (Plus ↔ Premium, plus Premium’s monthly ↔ yearly) from either billing page, and to team leads (level and seat count). Stripe’s Customer Portal keeps card, invoices and cancellation; plan switching is deliberately turned off there because the portal cannot enforce the roster floor.
One ladder, two contexts (Aug 2026)
Section titled “One ladder, two contexts (Aug 2026)”Individual users and agents buy the same plans. The audience used to be derived from the AGENT role at checkout and pinned on the subscription, so becoming an agent never upgraded what you had already bought — and the same product carried different limits depending on when you bought it. There is now a single ladder: Free (2 active listings) → Plus ($299/mo) → Premium ($699/mo or $6,990/yr), sold from the agent dashboard and the consumer portal alike.
Entitlements are scoped by context rather than blended. Your own subscription covers the properties you list yourself; your seat in an agency covers the properties attributed to that agency; neither raises the other. So an agent can belong to an agency and keep a personal plan for their own or freelance listings, and that plan is worth exactly what it says — while an agency seat no longer quietly pays for a member’s private inventory. Moving a live listing between the two is metered like a publish, so the boundary can’t be walked around.
Featured add-ons
Section titled “Featured add-ons”A plan includes a fixed number of featured listings (Free 0, Plus 1, Premium 5). Anyone who wants more buys them as a monthly add-on at $149 per slot. The extra slots behave exactly like the ones a plan includes — they show in the same usage meter, are freed when you unfeature, and stop when the add-on does — so there is nothing to spend and nothing to expire.
It is one subscription, not a shelf of packs: a stepper picks how many slots you want, and changing that number later is a quantity change on the same subscription, billed pro-rata either way. Cancelling happens in the payment portal. Free users can buy too, which is the only way that tier reaches featured listings at all. A member’s personal purchase never enlarges what their agency’s seat allows.
The earlier +1 / +3 / +5 packs are no longer sold; subscriptions still on them keep billing and keep granting their slots until they are cancelled.
Canonical doc (and mirror-of-record for the catalog + pricing): packages/api/src/services/subscriptions.FEATURE.md.
Transactions & payments
Section titled “Transactions & payments”Not yet implemented. Planned: offer submission + negotiation, transaction lifecycle, commission calculator, document upload. Subscription billing is tracked in Subscriptions & billing.
Developer experience
Section titled “Developer experience”Turborepo build caching, the one-command dev:refresh environment reset, db:reset workflow, Playwright E2E framework, Bun Test for unit tests, design-review agent. GitHub Actions gates apps/api, @repo/server-services, @repo/validation, worker, and @repo/contract typechecks (ci.yml).
Commands live in Conventions → Monorepo; the maintenance-script catalog lives in Operations → Scripts & maintenance CLIs.
Mobile app (agent)
Section titled “Mobile app (agent)”apps/ios-agent (Swift + SwiftUI) + apps/android/app-agent (Kotlin + Compose) — the native agent dashboard, on the same stack as the user apps (hand-written tRPC-over-HTTP, cookie auth, generated tokens), sharing the @repo/contract generated model + request DTOs + typed Procedures registry. Landed: Phase 0 (scaffold + auth + agent gate) and Phase 1 — 4-tab shell, Dashboard, Leads (reads + writes), and a read-only Listings list + detail hub. Keeps com.realestatecore.agent / expo-agent:// (retained deep-link scheme) for in-place store updates.
Deep dive: apps/ios-agent/CLAUDE.md / apps/android/app-agent/CLAUDE.md + Conventions → Mobile (Native).
Mobile app (user)
Section titled “Mobile app (user)”apps/ios-user (Swift + SwiftUI, min iOS 17) + apps/android/app-user (Kotlin + Jetpack Compose, min SDK 26) — the native customer app. Both consume the existing apps/api backend via hand-written thin tRPC-over-HTTP clients (SuperJSON envelope, no batching), the same cookie-based Better Auth flow (email OTP live; Google sign-in via native SDK idToken), generated design tokens, and i18n catalogs synced from the shared i18n/user/ source. They keep the com.realestatecore.user identity + expo-user:// (retained deep-link scheme) to ship as in-place store updates. Phase 1 is code-complete on both platforms — browsable tabs + Home, list + map search, property detail + tour request, favorites, My Tours + reschedule, profile/settings + avatar + become-agent, live theme/language, and expo-user:// deep links — on the Phase 0 foundations (Sentry crash reporting + the @repo/contract generated model layer, with committed decode-test fixtures as a secondary guard). Both also preload ahead of the user — gallery/lightbox + card-carousel adjacent photos and a search/home/favorites/recently-viewed image look-ahead (cookie-aware Kingfisher/Coil prefetch), plus a map-marker → getDetail payload prefetch into a small LRU (cache-first detail VM) so opening a previewed property is instant. Phase 2 (chat, push, addresses, saved searches, compare) and the Phase 3 store cutover remain.
Deep dive: apps/ios-user/CLAUDE.md / apps/android/app-user/CLAUDE.md + Conventions → Mobile (Native).