From 1db550a9bf032c2e07a79ec6538bec1376b19bf2 Mon Sep 17 00:00:00 2001 From: Eric Mota Tejeda Date: Fri, 24 Jul 2026 13:40:50 -0600 Subject: [PATCH 01/22] fix(aws-migration): trustProxy 1-hop + Node 22 in CI (A6, A11) trustProxy: true trusted the leftmost X-Forwarded-For entry, which a client behind the ALB can spoof to evade per-IP rate limits. Pin to a single proxy hop instead. Node 20 is EOL; CI now builds with Node 22 to match the production container runtime. --- .github/workflows/ci.yml | 4 ++-- micopay/backend/src/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f77ea083..5a109749 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: micopay/backend/package-lock.json - run: npm ci @@ -43,7 +43,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: micopay/frontend/package-lock.json # El lock se genera en Windows y omite los binarios opcionales de Linux diff --git a/micopay/backend/src/index.ts b/micopay/backend/src/index.ts index e01145ed..d2688a9f 100644 --- a/micopay/backend/src/index.ts +++ b/micopay/backend/src/index.ts @@ -31,7 +31,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = join(__dirname, '..', 'public'); const app = Fastify({ - trustProxy: true, + trustProxy: 1, logger: process.env.NODE_ENV === 'development' ? { level: 'info', transport: { From 02232b469df656a020b6b69e45ab4129d8c85871 Mon Sep 17 00:00:00 2001 From: Eric Mota Tejeda Date: Fri, 24 Jul 2026 13:55:10 -0600 Subject: [PATCH 02/22] fix(aws-migration): embed RDS CA bundle, verify-full DB TLS (A5) Testing the ECS infra directly against RDS revealed pg-connection-string now treats sslmode=require as an alias for verify-full, so the app rejected RDS's cert as self-signed and looped through its 5 connect retries before exiting. The plan had this as a deferred hardening step (A5/Fase 9) but it's required now for the app to boot at all. Downloads the RDS global CA bundle at build time and chmods it readable by the non-root `node` user. DATABASE_URL in SSM updated separately to sslmode=verify-full&sslrootcert=/app/rds-global-bundle.pem. Verified against the real micopay-prod RDS instance via a standalone ECS task. --- micopay/backend/Dockerfile | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 micopay/backend/Dockerfile diff --git a/micopay/backend/Dockerfile b/micopay/backend/Dockerfile new file mode 100644 index 00000000..9201f262 --- /dev/null +++ b/micopay/backend/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1 +# +# Imagen de producción de micopay-backend. +# +# IMPORTANTE — el contexto de build es `micopay/`, NO `micopay/backend/`: +# +# docker build -f micopay/backend/Dockerfile -t micopay-backend micopay +# +# El runner de migraciones (src/db/migrate.ts) resuelve `../../../sql` desde +# su propia ubicación en `dist/db/`, o sea `/app/sql`. Ese directorio vive +# fuera de `backend/`, así que un contexto acotado a `backend/` no podría +# copiarlo y el arranque quedaría sin esquema (el error se loguea pero NO +# tumba el proceso — ver index.ts, boot migrations —, así que la falla sería +# silenciosa hasta la primera query real). + +# ── Etapa 1: compilar TypeScript ────────────────────────────────────────── +FROM node:22-bookworm-slim AS build +WORKDIR /app/backend +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci +COPY backend/tsconfig.json ./ +COPY backend/src ./src +RUN npm run build + +# ── Etapa 2: dependencias de runtime ────────────────────────────────────── +# Nota: package.json declara typescript/tsx/@types en `dependencies`, así que +# --omit=dev no los elimina (solo saca pino-pretty). No se toca package.json +# aquí para no alterar el build de otros entornos; el costo es ~50 MB de imagen. +FROM node:22-bookworm-slim AS deps +WORKDIR /app/backend +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci --omit=dev + +# ── Etapa 3: imagen final ───────────────────────────────────────────────── +FROM node:22-bookworm-slim AS runtime +ENV NODE_ENV=production +WORKDIR /app/backend + +COPY --from=deps /app/backend/node_modules ./node_modules +COPY --from=build /app/backend/dist ./dist + +# package.json es obligatorio en runtime: su `"type": "module"` es lo que hace +# que Node interprete dist/*.js como ESM. +COPY backend/package.json ./ + +# assetlinks.json para App Links de Android (servido desde /.well-known/). +COPY backend/public ./public + +# Migraciones SQL — ver la nota del encabezado sobre el contexto de build. +COPY sql /app/sql + +# CA bundle global de RDS — necesario para sslmode=verify-full. La versión de +# pg-connection-string en uso ya trata sslmode=require como alias de +# verify-full (advertencia en boot), así que sin este bundle la conexión se +# rechaza como "self-signed certificate in certificate chain". Bundle global +# cubre la rotación automática de la CA de RDS, por eso es un COPY de una vez. +ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem /app/rds-global-bundle.pem +RUN chmod 644 /app/rds-global-bundle.pem + +USER node +EXPOSE 3000 +CMD ["node", "dist/index.js"] From a763cf1b95721e717ae41a6485813bdf4239c0c8 Mon Sep 17 00:00:00 2001 From: Eric Mota Tejeda Date: Sat, 25 Jul 2026 17:52:18 -0600 Subject: [PATCH 03/22] fix(map): remove hardcoded online:true fake signal from offer UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /merchants/available already filters by merchant_available=true, so every merchant returned is available by definition. The online field on Offer/OfferConfirmData and the agentStatus badge in TradeConfirmation were an invented signal (audit G2) — remove them instead of deriving a fake always-true value. --- micopay/frontend/src/App.tsx | 45 +++++++++++++------ micopay/frontend/src/pages/ExploreMap.tsx | 6 --- .../frontend/src/pages/TradeConfirmation.tsx | 10 ----- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/micopay/frontend/src/App.tsx b/micopay/frontend/src/App.tsx index 165635dc..b04b012f 100644 --- a/micopay/frontend/src/App.tsx +++ b/micopay/frontend/src/App.tsx @@ -38,12 +38,11 @@ import ReceivePayment from "./pages/ReceivePayment"; import Privacy from "./pages/Privacy"; import Terms from "./pages/Terms"; import Profile from "./pages/Profile"; -import ClaimQR from "./pages/ClaimQR"; import Login from "./pages/Login"; import Register from "./pages/Register"; import MerchantSettings from "./pages/MerchantSettings"; import BottomNav from "./components/BottomNav"; -import DebugOverlay from "./components/DebugOverlay"; +import { ConnectionBanner } from "./components/ConnectionBanner"; import { registerUser, @@ -109,7 +108,6 @@ interface AppCtx { isMockStellar: boolean; backendConnected: boolean; backendHealth: any; - setDebugOpen: (b: boolean) => void; } export const AppContext = createContext(null); @@ -272,7 +270,6 @@ function MapRoute() { amountMxn: activeAmount, flow: 'cashout', nearbyCount: offer.nearbyCount, - merchantOnline: offer.online, }, }); }} @@ -303,7 +300,6 @@ function ConfirmRoute() { amountMxn: number; flow: 'cashout' | 'deposit'; nearbyCount: number; - merchantOnline?: boolean; } | null; if (!state?.merchantId) { @@ -319,7 +315,6 @@ function ConfirmRoute() { amountMxn={state.amountMxn} flow={state.flow ?? 'cashout'} nearbyCount={state.nearbyCount} - merchantOnline={state.merchantOnline ?? true} loading={tradeLoading} errorMessage={tradeError?.message ?? null} onBack={() => navigate(-1)} @@ -674,16 +669,12 @@ const HIDE_BOTTOMNAV_ROUTES = new Set([ "/terms", ]); -// Claim screens also hide the bottom nav (standalone deep-link UI). -const HIDE_BOTTOMNAV_PREFIX = ['/claim/']; - function BottomNavAdapter() { const navigate = useNavigate(); const location = useLocation(); const { sellerUser } = useAppCtx(); if (HIDE_BOTTOMNAV_ROUTES.has(location.pathname)) return null; - if (HIDE_BOTTOMNAV_PREFIX.some((p) => location.pathname.startsWith(p))) return null; const navMap: Record = { home: "/", @@ -703,6 +694,29 @@ function BottomNavAdapter() { ); } +// ── Connection banner host ─────────────────────────────────────────────────── +// Tracks browser/WebView online-offline state directly (navigator.onLine + +// the online/offline events) — deliberately independent of the merchant +// offline-mutation queue (services/offlineQueue*.ts), which is a different, +// narrower concern (queueing merchant config writes) than "is this device +// connected to the internet at all". +function ConnectionBannerHost() { + const [isOnline, setIsOnline] = useState(navigator.onLine); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + return ; +} + // ── Root App ───────────────────────────────────────────────────────────────── function App() { @@ -731,7 +745,6 @@ function App() { const [isDemoMode, setIsDemoMode] = useState(true); const [isMockStellar, setIsMockStellar] = useState(true); const [backendUrl, setBackendUrl] = useState(""); - const [debugOpen, setDebugOpen] = useState(false); const envName = import.meta.env.MODE; useEffect(() => { @@ -786,8 +799,12 @@ function App() { console.warn("Backend not reachable during startup:", err); setBackendConnected(false); - // In production, force-block if backend is down. - if (envName === 'production') { + // Force-block if backend is down in any strict (non-demo) build — + // `build:mainnet` sets MODE to 'mainnet', not 'production', so both + // must be checked or a mainnet APK silently falls back to local + // demo mocks when the backend is unreachable (see + // docs/AUDIT_MOBILE_MAINNET.md, "guard de arranque no cubre modo mainnet"). + if (envName === 'production' || envName === 'mainnet') { setStartupError({ title: "Servidor Inalcanzable", message: "No se pudo conectar al servidor de Micopay.", @@ -1001,7 +1018,6 @@ function App() { isMockStellar, backendConnected, backendHealth, - setDebugOpen, }; if (startupError) { @@ -1049,6 +1065,7 @@ function App() {
+ } /> } /> diff --git a/micopay/frontend/src/pages/ExploreMap.tsx b/micopay/frontend/src/pages/ExploreMap.tsx index 92499b3c..567050c3 100644 --- a/micopay/frontend/src/pages/ExploreMap.tsx +++ b/micopay/frontend/src/pages/ExploreMap.tsx @@ -41,7 +41,6 @@ interface Offer { tradesCompleted?: number; tier?: string; isBusiness?: boolean; - online?: boolean; } function merchantToOffer(m: AvailableMerchant, index: number): Offer { @@ -59,7 +58,6 @@ function merchantToOffer(m: AvailableMerchant, index: number): Offer { tradesCompleted: m.trades_completed ?? 0, tier: m.tier ?? undefined, isBusiness: (m.seller_type === 'business') || (m.is_business === true) || false, - online: true, }; } @@ -69,7 +67,6 @@ export interface OfferConfirmData { receiveMxn: number; commissionPct: number; nearbyCount: number; - online?: boolean; } interface ExploreMapProps { @@ -304,7 +301,6 @@ const ExploreMap = ({ receiveMxn: offer.receiveMxn, commissionPct: offer.commissionPct, nearbyCount: offers.length, - online: (offer as any).online ?? true, }); } else { onSelectOffer(offer.id); @@ -383,8 +379,6 @@ const ExploreMap = ({ receiveMxn: offer.receiveMxn, commissionPct: offer.commissionPct, nearbyCount: offers.length, - online: (offer as any).online ?? true, - }); } else { onSelectOffer(offer.id); diff --git a/micopay/frontend/src/pages/TradeConfirmation.tsx b/micopay/frontend/src/pages/TradeConfirmation.tsx index f91d5345..6d9ee293 100644 --- a/micopay/frontend/src/pages/TradeConfirmation.tsx +++ b/micopay/frontend/src/pages/TradeConfirmation.tsx @@ -13,7 +13,6 @@ export interface TradeConfirmationPageProps { amountMxn: number; flow: 'cashout' | 'deposit'; nearbyCount: number; - merchantOnline?: boolean; onBack: () => void; onConfirm: () => Promise; loading?: boolean; @@ -29,7 +28,6 @@ export default function TradeConfirmationPage({ amountMxn, flow, nearbyCount, - merchantOnline = true, onBack, onConfirm, loading = false, @@ -118,14 +116,6 @@ export default function TradeConfirmationPage({
-
-
{t('confirm.agentStatus')}
-
- - {merchantOnline ? t('confirm.online') : t('confirm.offline')} -
-
-
{t('confirm.nearbyProviders')}
From db7455b64396d47698c552e399b101bbddc5a2b4 Mon Sep 17 00:00:00 2001 From: Eric Mota Tejeda Date: Sat, 25 Jul 2026 17:57:16 -0600 Subject: [PATCH 04/22] fix(privacy): rate-limit and coarsen /merchants/available discovery (G1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /merchants/available was public, unauthenticated, with no rate limit, and returned exact lat/lng — letting anyone scrape the full census of merchant locations. Add a 30 req/min per-IP rate limiter and round the publicly returned latitude/longitude to 3 decimals (~110m); distance_km still uses the exact stored coordinates via the existing SQL Haversine. Exact coordinates remain available to a counterparty only inside an accepted trade. Note: micopay/backend/package.json also carries pre-existing unrelated script additions (test:trade-auth, test:refund, test:challenge) from outside this change set — verified harmless/compatible, included because they share the same file/hunk as the new test:discovery script. --- micopay/backend/package.json | 6 +- micopay/backend/src/routes/merchants.ts | 8 + .../backend/src/services/merchant.service.ts | 9 +- .../src/tests/merchant.discovery.test.ts | 163 ++++++++++++++++++ 4 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 micopay/backend/src/tests/merchant.discovery.test.ts diff --git a/micopay/backend/package.json b/micopay/backend/package.json index effd622f..53454340 100644 --- a/micopay/backend/package.json +++ b/micopay/backend/package.json @@ -14,7 +14,11 @@ "test:kyc-gate": "node --import tsx src/tests/kyc-gate.service.test.ts", "test:compliance": "node --import tsx src/tests/compliance.test.ts", "test:kyc-didit": "node --import tsx src/tests/kyc-didit.test.ts", - "test:security": "node --import tsx src/tests/security.test.ts" + "test:security": "node --import tsx src/tests/security.test.ts", + "test:trade-auth": "ALLOW_IN_MEMORY_DB=true MOCK_STELLAR=true SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 node --import tsx src/tests/tradeAuth.test.ts", + "test:refund": "ALLOW_IN_MEMORY_DB=true MOCK_STELLAR=true SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 node --import tsx src/tests/refund.test.ts", + "test:challenge": "ALLOW_IN_MEMORY_DB=true MOCK_STELLAR=true SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 node --import tsx src/tests/challenge.service.test.ts", + "test:discovery": "ALLOW_IN_MEMORY_DB=true MOCK_STELLAR=true SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 node --import tsx src/tests/merchant.discovery.test.ts" }, "dependencies": { "@fastify/cors": "^8.5.0", diff --git a/micopay/backend/src/routes/merchants.ts b/micopay/backend/src/routes/merchants.ts index 20311fca..1d427a39 100644 --- a/micopay/backend/src/routes/merchants.ts +++ b/micopay/backend/src/routes/merchants.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify'; import { authMiddleware } from '../middleware/auth.middleware.js'; +import { createRateLimiter } from '../middleware/rateLimit.middleware.js'; import { getOrCreateMerchantConfig, updateMerchantConfig, @@ -7,6 +8,12 @@ import { } from '../services/merchant.service.js'; import db from '../db/schema.js'; +// G1: /merchants/available is public and unauthenticated — without a rate +// limit it lets anyone scrape the full census of merchant locations by +// sweeping lat/lng. 30 req/min per IP is generous for legitimate use (the +// app makes one request per search). +const discoveryRateLimit = createRateLimiter({ windowMs: 60_000, max: 30 }); + export async function merchantRoutes(app: FastifyInstance) { /** * GET /merchants/available @@ -20,6 +27,7 @@ export async function merchantRoutes(app: FastifyInstance) { * flow – 'cashout' | 'deposit' (optional, reserved) */ app.get('/merchants/available', { + preHandler: [discoveryRateLimit], schema: { querystring: { type: 'object', diff --git a/micopay/backend/src/services/merchant.service.ts b/micopay/backend/src/services/merchant.service.ts index a50e153e..a752e71b 100644 --- a/micopay/backend/src/services/merchant.service.ts +++ b/micopay/backend/src/services/merchant.service.ts @@ -220,8 +220,13 @@ export async function getAvailableMerchants( min_trade_mxn: r.min_trade_mxn, max_trade_mxn: r.max_trade_mxn, daily_cap_mxn: r.daily_cap_mxn, - latitude: parseFloat(r.latitude as unknown as string), - longitude: parseFloat(r.longitude as unknown as string), + // G1 privacy: coarsen public discovery coordinates to ~110m (3 decimals). + // Exact coordinates are only revealed to a counterparty inside an + // accepted trade, never at discovery time. distance_km above is + // already computed in SQL from the exact, unrounded columns, so it + // stays accurate — only these two output fields are rounded. + latitude: Math.round(parseFloat(r.latitude as unknown as string) * 1000) / 1000, + longitude: Math.round(parseFloat(r.longitude as unknown as string) * 1000) / 1000, address_text: r.address_text, distance_km: Math.round(distanceKm * 1000) / 1000, payout_mxn: payoutMxn, diff --git a/micopay/backend/src/tests/merchant.discovery.test.ts b/micopay/backend/src/tests/merchant.discovery.test.ts new file mode 100644 index 00000000..d646e748 --- /dev/null +++ b/micopay/backend/src/tests/merchant.discovery.test.ts @@ -0,0 +1,163 @@ +/** + * G1 — /merchants/available is public, unauthenticated and (before this fix) + * had no rate limit and returned exact lat/lng, letting anyone scrape the + * full census of merchant locations. + * + * This test covers the two mitigations from docs/PLAN_MAPA_REAL_2026-07.md + * WP3: + * (a) getAvailableMerchants() rounds the *returned* latitude/longitude to + * 3 decimals (~110m) while distance_km keeps its existing precision. + * (b) the discoveryRateLimit limiter (createRateLimiter({ windowMs: 60_000, + * max: 30 })) throws a RateLimitError (429, Retry-After) once a single + * IP exceeds `max` requests inside the window. + * + * Runs against the in-memory DB (ALLOW_IN_MEMORY_DB=true, no PostgreSQL + * needed), following the pattern of tradeAuth.test.ts / refund.test.ts. + * + * NOTE on (a): the in-memory SQL shim in src/db/schema.ts is a small regex + * based mock. It does not evaluate computed SQL columns (the HAVERSINE_SQL + * expression aliased as distance_km, or the seller_id/username/trades_* + * subqueries), only special-cases LEFT JOIN (not the plain INNER JOIN this + * query uses against `users`), and — critically — its WHERE-clause regex + * (`/\bWHERE\b.../`) matches the *first* literal "WHERE" in the raw SQL + * text, which here is the one inside the nested trades_completed/ + * trades_terminal subqueries, not the query's real WHERE. Seeding rows into + * merchant_configs and calling getAvailableMerchants() end-to-end therefore + * can't reliably exercise this query against the mock — it's a limitation of + * the mock, not of getAvailableMerchants() itself (against real PostgreSQL + * the query runs as written). + * + * So instead this test stubs `db.getMany` for the duration of the call, + * returning exactly the shape PostgreSQL would for one seeded merchant, and + * asserts on what getAvailableMerchants() does with that row — i.e. it + * targets the actual code under test (the rounding in the .map() in + * src/services/merchant.service.ts), independent of the mock SQL engine. + */ + +import { strictEqual, ok, notStrictEqual } from "assert"; +import db from "../db/schema.js"; +import { getAvailableMerchants } from "../services/merchant.service.js"; +import { InMemoryStore, createRateLimiter } from "../middleware/rateLimit.middleware.js"; +import { RateLimitError } from "../utils/errors.js"; + +// ── (a) coordinate rounding ───────────────────────────────────────────────── + +async function testAvailableMerchantsRoundsCoordinates() { + const preciseLat = 19.432608123; // exact GPS reading, many decimals + const preciseLng = -99.133209456; + const preciseDistanceKm = 12.34567; // exact haversine result, as Postgres would compute it + + const originalGetMany = db.getMany; + db.getMany = (async (_text: string, _params?: any[]) => [ + { + seller_id: "user-discovery-1", + username: "merchant_discovery_1", + rate_percent: "1.5", + min_trade_mxn: 100, + max_trade_mxn: 50000, + daily_cap_mxn: 250000, + latitude: String(preciseLat), + longitude: String(preciseLng), + address_text: "CDMX", + distance_km: String(preciseDistanceKm), + trades_completed: "3", + trades_terminal: "3", + }, + ]) as typeof db.getMany; + + let results: Awaited>; + try { + results = await getAvailableMerchants({ + lat: preciseLat, + lng: preciseLng, + radius_km: 5, + amount_mxn: 500, + }); + } finally { + db.getMany = originalGetMany; + } + + ok(results.length >= 1, "expected the seeded merchant to be returned"); + const merchant = results.find((m) => m.address_text === "CDMX"); + ok(merchant, "expected to find the seeded merchant by address_text"); + + const expectedLat = Math.round(preciseLat * 1000) / 1000; + const expectedLng = Math.round(preciseLng * 1000) / 1000; + + strictEqual(merchant!.latitude, expectedLat, "latitude must be rounded to 3 decimals"); + strictEqual(merchant!.longitude, expectedLng, "longitude must be rounded to 3 decimals"); + notStrictEqual(merchant!.latitude, preciseLat, "rounded latitude must differ from the precise input"); + notStrictEqual(merchant!.longitude, preciseLng, "rounded longitude must differ from the precise input"); + + // decimal-place check: no more than 3 digits after the decimal point + const decimalsOf = (n: number) => (String(n).split(".")[1] ?? "").length; + ok(decimalsOf(merchant!.latitude) <= 3, "latitude must have at most 3 decimal places"); + ok(decimalsOf(merchant!.longitude) <= 3, "longitude must have at most 3 decimal places"); + + // distance_km keeps its own (already existing) 3-decimal rounding and is + // NOT derived from the coarsened lat/lng — it stays independently accurate. + strictEqual( + merchant!.distance_km, + Math.round(preciseDistanceKm * 1000) / 1000, + "distance_km must reflect the precise coordinates, unaffected by public lat/lng rounding", + ); + + console.log(" ✓ getAvailableMerchants() rounds public latitude/longitude to 3 decimals, distance_km unaffected"); +} + +// ── (b) discovery rate limiter ───────────────────────────────────────────── + +async function testDiscoveryRateLimiterBlocksAfterMax() { + const store = new InMemoryStore(); + const windowMs = 1000; + const max = 30; + + // Same construction as the discoveryRateLimit wired into + // src/routes/merchants.ts (createRateLimiter({ windowMs: 60_000, max: 30 })), + // using a shorter window here so the test doesn't need to wait a full minute. + const discoveryRateLimit = createRateLimiter({ + windowMs, + max, + store, + keyGenerator: (req) => req.ip, + }); + + const mockReq = { ip: "203.0.113.7" }; + const mockReply = { + header: (_name: string, _value: any) => {}, + }; + + for (let i = 0; i < max; i++) { + await (discoveryRateLimit as any)(mockReq, mockReply); + } + console.log(` ✓ ${max} requests from the same IP within the window are allowed`); + + let threw = false; + try { + await (discoveryRateLimit as any)(mockReq, mockReply); + } catch (err) { + threw = true; + ok(err instanceof RateLimitError, `expected RateLimitError, got ${(err as Error)?.constructor?.name}`); + strictEqual((err as RateLimitError).statusCode, 429, "rate-limited response must be 429"); + ok((err as RateLimitError).retryAfter !== undefined, "rate-limited response must carry retryAfter"); + } + ok(threw, `request ${max + 1} should have thrown RateLimitError`); + console.log(" ✓ request past max is rejected with 429 and Retry-After"); + + // A different IP is unaffected by the first IP's exhausted budget. + const otherReq = { ip: "203.0.113.99" }; + await (discoveryRateLimit as any)(otherReq, mockReply); + console.log(" ✓ a different IP is not affected by another IP's rate limit"); +} + +async function main() { + console.log("\nMerchant discovery privacy & rate-limit tests\n"); + await testAvailableMerchantsRoundsCoordinates(); + await testDiscoveryRateLimiterBlocksAfterMax(); + console.log("\nAll merchant.discovery tests passed.\n"); +} + +main().catch((err) => { + console.error("❌ merchant.discovery tests failed:", err); + process.exit(1); +}); From f706c7fe96f697d8daf16829759397c8a556e516 Mon Sep 17 00:00:00 2001 From: Eric Mota Tejeda Date: Sat, 25 Jul 2026 18:02:38 -0600 Subject: [PATCH 05/22] feat(map): real MapLibre GL map component (WP1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the simulated PNG map (MapSim, bounding-box-normalized fake pins, user always centered, hardcoded "CDMX · ZONA CENTRO" / "Agentes reales cercanos") with MapReal: real tiles, real GPS-centered user position, real merchant coordinates, pan/zoom via MapLibre GL. - useMerchantsAvailable now exposes userPosition in its success state (previously resolved lat/lng then discarded them). - ExploreMap and DepositMap swapped to MapReal; MapSim marked @deprecated but kept (referenced elsewhere, removal is WP5). - VITE_MAP_STYLE_URL added to .env.testnet/.env.mainnet (empty — pending a MapTiler key from Eric); MapReal falls back to the public MapLibre demo style + a small "dev map" notice until it's set. - npm i maplibre-gl. --- micopay/frontend/.env.mainnet | 4 + micopay/frontend/.env.testnet | 4 + micopay/frontend/package-lock.json | 201 ++++++++++++++++- micopay/frontend/package.json | 1 + micopay/frontend/src/components/MapReal.tsx | 207 ++++++++++++++++++ micopay/frontend/src/components/MapSim.tsx | 6 + .../src/hooks/useMerchantsAvailable.ts | 8 +- micopay/frontend/src/i18n/en.json | 2 + micopay/frontend/src/i18n/es.json | 2 + micopay/frontend/src/pages/DepositMap.tsx | 8 +- micopay/frontend/src/pages/ExploreMap.tsx | 5 +- 11 files changed, 441 insertions(+), 7 deletions(-) create mode 100644 micopay/frontend/src/components/MapReal.tsx diff --git a/micopay/frontend/.env.mainnet b/micopay/frontend/.env.mainnet index ab131dfd..05e4db3f 100644 --- a/micopay/frontend/.env.mainnet +++ b/micopay/frontend/.env.mainnet @@ -8,3 +8,7 @@ VITE_USDC_ISSUER=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN # Filled in after `stellar contract deploy --network mainnet` (see scripts/deploy-mainnet.sh) — unused by # the signing flow itself (backend builds the XDR), kept here only for reference/debugging. VITE_ESCROW_CONTRACT_ID= +# MapReal tile style (MapTiler free-tier style URL). Pending: Eric needs to +# provide a MapTiler API key/style — until set, MapReal falls back to the +# public MapLibre demo style and shows a small "dev map" notice. +VITE_MAP_STYLE_URL= diff --git a/micopay/frontend/.env.testnet b/micopay/frontend/.env.testnet index a5aef7e1..bee57b6a 100644 --- a/micopay/frontend/.env.testnet +++ b/micopay/frontend/.env.testnet @@ -6,3 +6,7 @@ VITE_MXNE_CONTRACT_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC VITE_MXNE_ISSUER_ADDRESS=GBZXN7PIRZGNMHGA7MUUUF4GWMTISGNQ5E72TFL6GDWPE6K4RCAVOALV VITE_USDC_ISSUER=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 VITE_CETES_ISSUER=GCRYUGD5NVARGXT56XEZI5CIFCQETYHAPQQTHO2O3IQZTHDH4LATMYWC +# MapReal tile style (MapTiler free-tier style URL). Pending: Eric needs to +# provide a MapTiler API key/style — until set, MapReal falls back to the +# public MapLibre demo style and shows a small "dev map" notice. +VITE_MAP_STYLE_URL= diff --git a/micopay/frontend/package-lock.json b/micopay/frontend/package-lock.json index 75e44f9b..26ef5fa9 100644 --- a/micopay/frontend/package-lock.json +++ b/micopay/frontend/package-lock.json @@ -23,6 +23,7 @@ "clsx": "^2.1.1", "i18next": "^26.3.4", "lucide-react": "^0.577.0", + "maplibre-gl": "^6.0.0", "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -2375,6 +2376,92 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz", + "integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.3", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, "node_modules/@noble/curves": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", @@ -3522,6 +3609,12 @@ "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -5518,6 +5611,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.344", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", @@ -6260,6 +6359,12 @@ "dev": true, "license": "MIT" }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -6925,6 +7030,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -6984,6 +7095,12 @@ "node": "*" } }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -7390,6 +7507,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/maplibre-gl": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.0.0.tgz", + "integrity": "sha512-1wBgEhzTZfA+cDpFdt0fM1mJA5mJ90fifORJ7D8JcKLJCvPT/iTx9bNxkP7DqEYde65DJd7gE7h83khwNRqdyg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.1", + "@maplibre/maplibre-gl-style-spec": "^26.1.0", + "@maplibre/mlt": "^1.1.12", + "@maplibre/vt-pbf": "^4.3.2", + "@types/geojson": "^7946.0.16", + "earcut": "^3.2.3", + "gl-matrix": "^3.4.4", + "kdbush": "^4.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^5.1.2", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7721,7 +7870,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7799,6 +7947,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8329,6 +8483,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/pbkdf2": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.6.tgz", @@ -8530,6 +8696,12 @@ "dev": true, "license": "MIT" }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -8665,6 +8837,12 @@ "node": ">=6" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -8774,6 +8952,12 @@ "node": ">=8" } }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -9270,6 +9454,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/rimraf": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", @@ -10281,6 +10474,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", diff --git a/micopay/frontend/package.json b/micopay/frontend/package.json index a7d4469a..81ae52d0 100644 --- a/micopay/frontend/package.json +++ b/micopay/frontend/package.json @@ -30,6 +30,7 @@ "clsx": "^2.1.1", "i18next": "^26.3.4", "lucide-react": "^0.577.0", + "maplibre-gl": "^6.0.0", "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/micopay/frontend/src/components/MapReal.tsx b/micopay/frontend/src/components/MapReal.tsx new file mode 100644 index 00000000..b11824c5 --- /dev/null +++ b/micopay/frontend/src/components/MapReal.tsx @@ -0,0 +1,207 @@ +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import * as maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import type { AvailableMerchant } from '../services/api'; + +interface MapRealProps { + type?: 'cashout' | 'deposit'; + merchants?: AvailableMerchant[]; + selectedMerchantId?: string | null; + onSelectMerchant?: (merchantId: string) => void; + /** Real user position; if null, fit-bounds only over merchants (or default view if none). */ + userPosition?: { lat: number; lng: number } | null; +} + +const mushroomImages = ['/mushroom_red.png', '/mushroom_green.png', '/mushroom_gold.png']; + +const DEMO_STYLE_URL = 'https://demotiles.maplibre.org/style.json'; + +function buildMerchantMarkerElement( + merchant: AvailableMerchant, + image: string, + isSelected: boolean, + onSelectMerchant?: (merchantId: string) => void, +): HTMLElement { + const wrapper = document.createElement('button'); + wrapper.type = 'button'; + wrapper.setAttribute('aria-label', `Seleccionar ${merchant.username}`); + wrapper.className = 'flex flex-col items-center focus:outline-none'; + wrapper.style.background = 'transparent'; + wrapper.style.border = 'none'; + wrapper.style.padding = '0'; + wrapper.style.cursor = onSelectMerchant ? 'pointer' : 'default'; + + const pinWrap = document.createElement('span'); + pinWrap.className = `relative w-14 h-14 block transition-transform ${isSelected ? 'scale-125' : ''}`; + + const glow = document.createElement('span'); + glow.className = `absolute inset-0 rounded-full blur-md animate-pulse ${isSelected ? 'bg-primary/40' : 'bg-primary/20'}`; + pinWrap.appendChild(glow); + + const img = document.createElement('img'); + img.src = image; + img.alt = ''; + img.className = 'w-full h-full object-contain relative z-10 drop-shadow-lg'; + pinWrap.appendChild(img); + + const label = document.createElement('span'); + label.className = `backdrop-blur-sm px-3 py-1 rounded-full mt-1 shadow-md border text-[9px] font-bold whitespace-nowrap block ${isSelected ? 'bg-primary text-white border-primary' : 'bg-white/95 text-on-surface border-outline-variant/20'}`; + label.textContent = merchant.username; + + wrapper.appendChild(pinWrap); + wrapper.appendChild(label); + + wrapper.addEventListener('click', () => onSelectMerchant?.(merchant.seller_id)); + + return wrapper; +} + +function buildUserMarkerElement(): HTMLElement { + const container = document.createElement('div'); + container.className = 'relative flex items-center justify-center'; + container.style.width = '64px'; + container.style.height = '64px'; + + const pulse = document.createElement('div'); + pulse.className = 'w-16 h-16 bg-primary/20 rounded-full animate-ping absolute'; + container.appendChild(pulse); + + const dot = document.createElement('div'); + dot.className = 'w-6 h-6 bg-primary rounded-full border-2 border-white shadow-[0_0_15px_rgba(0,105,76,0.5)] relative z-10'; + container.appendChild(dot); + + return container; +} + +/** + * Real MapLibre GL map. Drop-in replacement for the deprecated `MapSim` + * (same visual footprint + prop-compatible superset), but renders actual + * tiles/pan/zoom centered on the user's real GPS position instead of a + * static PNG. + */ +const MapReal = ({ + type = 'cashout', + merchants = [], + selectedMerchantId, + onSelectMerchant, + userPosition = null, +}: MapRealProps) => { + const { t } = useTranslation(); + const containerRef = useRef(null); + const mapRef = useRef(null); + const markersRef = useRef([]); + const userMarkerRef = useRef(null); + + const styleUrl = import.meta.env.VITE_MAP_STYLE_URL || DEMO_STYLE_URL; + const usingFallbackStyle = !import.meta.env.VITE_MAP_STYLE_URL; + + // Create the map once on mount. + useEffect(() => { + if (!containerRef.current) return; + + const map = new maplibregl.Map({ + container: containerRef.current, + style: styleUrl, + center: [-99.1332, 19.4326], // Mexico City default, used only until fitBounds/setCenter runs below. + zoom: 11, + attributionControl: false, + }); + + mapRef.current = map; + + return () => { + markersRef.current.forEach((marker) => marker.remove()); + markersRef.current = []; + userMarkerRef.current?.remove(); + userMarkerRef.current = null; + map.remove(); + mapRef.current = null; + }; + // Intentionally only on mount: style URL is effectively static per build. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Update markers + camera whenever merchants/selection/user position change. + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const applyUpdate = () => { + // Clear previous merchant markers. + markersRef.current.forEach((marker) => marker.remove()); + markersRef.current = []; + + const validMerchants = merchants.filter( + (merchant) => Number.isFinite(merchant.latitude) && Number.isFinite(merchant.longitude), + ); + + validMerchants.forEach((merchant, index) => { + const isSelected = selectedMerchantId === merchant.seller_id; + const image = type === 'deposit' ? '/mushroom_green.png' : mushroomImages[index % mushroomImages.length]; + const element = buildMerchantMarkerElement(merchant, image, isSelected, onSelectMerchant); + + const marker = new maplibregl.Marker({ element }) + .setLngLat([merchant.longitude, merchant.latitude]) + .addTo(map); + + markersRef.current.push(marker); + }); + + // User marker. + userMarkerRef.current?.remove(); + userMarkerRef.current = null; + if (userPosition) { + const userEl = buildUserMarkerElement(); + userMarkerRef.current = new maplibregl.Marker({ element: userEl }) + .setLngLat([userPosition.lng, userPosition.lat]) + .addTo(map); + } + + // Camera. + if (userPosition && validMerchants.length > 0) { + const bounds = new maplibregl.LngLatBounds(); + bounds.extend([userPosition.lng, userPosition.lat]); + validMerchants.forEach((merchant) => bounds.extend([merchant.longitude, merchant.latitude])); + map.fitBounds(bounds, { padding: 48, maxZoom: 16 }); + } else if (validMerchants.length > 0) { + const bounds = new maplibregl.LngLatBounds(); + validMerchants.forEach((merchant) => bounds.extend([merchant.longitude, merchant.latitude])); + map.fitBounds(bounds, { padding: 48, maxZoom: 16 }); + } else if (userPosition) { + map.setCenter([userPosition.lng, userPosition.lat]); + map.setZoom(14); + } + // Neither user nor merchants: leave the map at its default style center/zoom. + }; + + if (map.isStyleLoaded()) { + applyUpdate(); + } else { + map.once('load', applyUpdate); + } + }, [merchants, selectedMerchantId, userPosition, type, onSelectMerchant]); + + return ( +
+
+ + {merchants.length > 0 && ( +
+ location_on +

+ {t('map.agentsNearby', { count: merchants.length })} +

+
+ )} + + {usingFallbackStyle && ( +
+

{t('map.devMapNotice')}

+
+ )} +
+ ); +}; + +export default MapReal; diff --git a/micopay/frontend/src/components/MapSim.tsx b/micopay/frontend/src/components/MapSim.tsx index 696df7e4..2af996e5 100644 --- a/micopay/frontend/src/components/MapSim.tsx +++ b/micopay/frontend/src/components/MapSim.tsx @@ -46,6 +46,12 @@ function getMerchantPins(merchants: AvailableMerchant[]): MerchantPin[] { const mushroomImages = ['/mushroom_red.png', '/mushroom_green.png', '/mushroom_gold.png']; +/** + * @deprecated Superseded by `MapReal` (real MapLibre GL tiles + real GPS + * centering). Kept temporarily for reference; scheduled for removal in a + * later work package (WP5 of docs/PLAN_MAPA_REAL_2026-07.md). Do not add + * new usages. + */ const MapSim = ({ type = 'cashout', merchants = [], diff --git a/micopay/frontend/src/hooks/useMerchantsAvailable.ts b/micopay/frontend/src/hooks/useMerchantsAvailable.ts index bbb582ed..d5bc2da5 100644 --- a/micopay/frontend/src/hooks/useMerchantsAvailable.ts +++ b/micopay/frontend/src/hooks/useMerchantsAvailable.ts @@ -9,7 +9,7 @@ export type MerchantsState = | { status: 'location_denied'; error: string } | { status: 'error'; error: string } | { status: 'empty' } - | { status: 'success'; merchants: AvailableMerchant[] }; + | { status: 'success'; merchants: AvailableMerchant[]; userPosition: { lat: number; lng: number } }; interface Options { amount_mxn: number; @@ -124,7 +124,11 @@ export function useMerchantsAvailable(options: Options): { if (cancelled) return; - setState(merchants.length === 0 ? { status: 'empty' } : { status: 'success', merchants }); + setState( + merchants.length === 0 + ? { status: 'empty' } + : { status: 'success', merchants, userPosition: { lat, lng } }, + ); } catch { if (!cancelled) { setState({ diff --git a/micopay/frontend/src/i18n/en.json b/micopay/frontend/src/i18n/en.json index 95d4d60e..c53edb01 100644 --- a/micopay/frontend/src/i18n/en.json +++ b/micopay/frontend/src/i18n/en.json @@ -230,6 +230,8 @@ }, "map": { "title": "Convert to cash", + "agentsNearby": "{{count}} agents nearby", + "devMapNotice": "development map", "offer": "offer", "offers": "offers", "for": "for ${{amount}} MXN", diff --git a/micopay/frontend/src/i18n/es.json b/micopay/frontend/src/i18n/es.json index 1867e2e8..8486d765 100644 --- a/micopay/frontend/src/i18n/es.json +++ b/micopay/frontend/src/i18n/es.json @@ -230,6 +230,8 @@ }, "map": { "title": "Convertir a efectivo", + "agentsNearby": "{{count}} agentes cerca", + "devMapNotice": "mapa de desarrollo", "offer": "oferta", "offers": "ofertas", "for": "para ${{amount}} MXN", diff --git a/micopay/frontend/src/pages/DepositMap.tsx b/micopay/frontend/src/pages/DepositMap.tsx index f4d5bfe5..5be3dfca 100644 --- a/micopay/frontend/src/pages/DepositMap.tsx +++ b/micopay/frontend/src/pages/DepositMap.tsx @@ -1,4 +1,4 @@ -import MapSim from '../components/MapSim'; +import MapReal from '../components/MapReal'; import { useMerchantsAvailable } from '../hooks/useMerchantsAvailable'; import { effectiveFeePercent, @@ -457,7 +457,11 @@ const DepositMap = ({ {/* Map View Section */}
- +
{/* Offers List */} diff --git a/micopay/frontend/src/pages/ExploreMap.tsx b/micopay/frontend/src/pages/ExploreMap.tsx index 567050c3..60c14b1f 100644 --- a/micopay/frontend/src/pages/ExploreMap.tsx +++ b/micopay/frontend/src/pages/ExploreMap.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import MapSim from '../components/MapSim'; +import MapReal from '../components/MapReal'; import { useMerchantsAvailable } from '../hooks/useMerchantsAvailable'; import { effectiveFeePercent, @@ -197,10 +197,11 @@ const ExploreMap = ({ {/* Map Section */}
-
From 1cf99eb86fd6fb9d4599a5636c09cda74796e929 Mon Sep 17 00:00:00 2001 From: Eric Mota Tejeda Date: Sat, 25 Jul 2026 18:10:04 -0600 Subject: [PATCH 06/22] feat(map): merchant location capture flow (WP2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend already exposed PATCH /merchants/me/location, validated and authenticated, but the frontend never called it — so no real merchant could ever appear on the map (audit §3.3), only the 4 seed-demo ones. - api.ts: updateMerchantLocation() + MerchantLocation type; MerchantConfig now types the latitude/longitude/address_text fields the backend's GET /merchants/me/config already returns. - MapReal: new pickerMode/pickerPosition/onPickerPositionChange props for a single draggable pin, additive only — existing merchant/camera effect bails out early when pickerMode is set, non-picker behavior unchanged. - MerchantSettings: new "Mi ubicación" section — CTA using useGeolocation to get a GPS fix, MapReal picker to drag-adjust, optional address text, save via updateMerchantLocation. Location kept in separate state from `form` (PUT /merchants/me/config has additionalProperties:false, so merging would break the existing rate/limits save). - MerchantAvailabilityToggle: optional hasLocation prop drives a non-blocking warning when a merchant activates availability without a fixed location (soft gate per plan — does not block activation). - i18n: new merchantSettings.location.* keys (es/en). --- micopay/frontend/src/components/MapReal.tsx | 74 +++++++- .../components/MerchantAvailabilityToggle.tsx | 16 ++ micopay/frontend/src/i18n/en.json | 19 +++ micopay/frontend/src/i18n/es.json | 19 +++ .../frontend/src/pages/MerchantSettings.tsx | 158 +++++++++++++++++- micopay/frontend/src/services/api.ts | 19 +++ 6 files changed, 303 insertions(+), 2 deletions(-) diff --git a/micopay/frontend/src/components/MapReal.tsx b/micopay/frontend/src/components/MapReal.tsx index b11824c5..8eea6e9a 100644 --- a/micopay/frontend/src/components/MapReal.tsx +++ b/micopay/frontend/src/components/MapReal.tsx @@ -11,6 +11,12 @@ interface MapRealProps { onSelectMerchant?: (merchantId: string) => void; /** Real user position; if null, fit-bounds only over merchants (or default view if none). */ userPosition?: { lat: number; lng: number } | null; + /** When true, renders a single draggable pin instead of merchant markers (location picker use case). */ + pickerMode?: boolean; + /** Current picker pin position; if null while pickerMode is on, falls back to userPosition as the initial center. */ + pickerPosition?: { lat: number; lng: number } | null; + /** Called with the new position when the picker pin is dragged. */ + onPickerPositionChange?: (position: { lat: number; lng: number }) => void; } const mushroomImages = ['/mushroom_red.png', '/mushroom_green.png', '/mushroom_gold.png']; @@ -57,6 +63,26 @@ function buildMerchantMarkerElement( return wrapper; } +function buildPickerMarkerElement(): HTMLElement { + const container = document.createElement('div'); + container.className = 'relative flex flex-col items-center'; + container.style.width = '48px'; + container.style.cursor = 'grab'; + + const glow = document.createElement('span'); + glow.className = 'absolute -top-1 w-12 h-12 rounded-full bg-primary/30 blur-md animate-pulse'; + container.appendChild(glow); + + const pin = document.createElement('div'); + pin.className = 'relative z-10 w-9 h-9 rounded-full bg-primary border-4 border-white shadow-[0_0_15px_rgba(0,105,76,0.5)] flex items-center justify-center'; + const dot = document.createElement('span'); + dot.className = 'w-2.5 h-2.5 rounded-full bg-white'; + pin.appendChild(dot); + container.appendChild(pin); + + return container; +} + function buildUserMarkerElement(): HTMLElement { const container = document.createElement('div'); container.className = 'relative flex items-center justify-center'; @@ -86,12 +112,20 @@ const MapReal = ({ selectedMerchantId, onSelectMerchant, userPosition = null, + pickerMode = false, + pickerPosition = null, + onPickerPositionChange, }: MapRealProps) => { const { t } = useTranslation(); const containerRef = useRef(null); const mapRef = useRef(null); const markersRef = useRef([]); const userMarkerRef = useRef(null); + const pickerMarkerRef = useRef(null); + // Keep the latest callback in a ref so the marker's dragend listener (bound once + // per marker instance) always calls the current handler without re-creating the marker. + const onPickerPositionChangeRef = useRef(onPickerPositionChange); + onPickerPositionChangeRef.current = onPickerPositionChange; const styleUrl = import.meta.env.VITE_MAP_STYLE_URL || DEMO_STYLE_URL; const usingFallbackStyle = !import.meta.env.VITE_MAP_STYLE_URL; @@ -115,6 +149,8 @@ const MapReal = ({ markersRef.current = []; userMarkerRef.current?.remove(); userMarkerRef.current = null; + pickerMarkerRef.current?.remove(); + pickerMarkerRef.current = null; map.remove(); mapRef.current = null; }; @@ -122,8 +158,44 @@ const MapReal = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Picker mode: single draggable pin, no merchant markers, no fitBounds-over-merchants logic. + useEffect(() => { + if (!pickerMode) return; + const map = mapRef.current; + if (!map) return; + + const applyPickerUpdate = () => { + const initialPosition = pickerPosition ?? userPosition; + + if (!pickerMarkerRef.current) { + if (!initialPosition) return; + const element = buildPickerMarkerElement(); + const marker = new maplibregl.Marker({ element, draggable: true }) + .setLngLat([initialPosition.lng, initialPosition.lat]) + .addTo(map); + marker.on('dragend', () => { + const lngLat = marker.getLngLat(); + onPickerPositionChangeRef.current?.({ lat: lngLat.lat, lng: lngLat.lng }); + }); + pickerMarkerRef.current = marker; + map.setCenter([initialPosition.lng, initialPosition.lat]); + map.setZoom(16); + } else if (pickerPosition) { + pickerMarkerRef.current.setLngLat([pickerPosition.lng, pickerPosition.lat]); + } + }; + + if (map.isStyleLoaded()) { + applyPickerUpdate(); + } else { + map.once('load', applyPickerUpdate); + } + }, [pickerMode, pickerPosition, userPosition]); + // Update markers + camera whenever merchants/selection/user position change. + // Skipped entirely in picker mode — the picker effect above owns the map in that case. useEffect(() => { + if (pickerMode) return; const map = mapRef.current; if (!map) return; @@ -180,7 +252,7 @@ const MapReal = ({ } else { map.once('load', applyUpdate); } - }, [merchants, selectedMerchantId, userPosition, type, onSelectMerchant]); + }, [merchants, selectedMerchantId, userPosition, type, onSelectMerchant, pickerMode]); return (
diff --git a/micopay/frontend/src/components/MerchantAvailabilityToggle.tsx b/micopay/frontend/src/components/MerchantAvailabilityToggle.tsx index 22b7b2bc..5c0a7b04 100644 --- a/micopay/frontend/src/components/MerchantAvailabilityToggle.tsx +++ b/micopay/frontend/src/components/MerchantAvailabilityToggle.tsx @@ -5,6 +5,7 @@ */ import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { updateMerchantAvailabilityWithOfflineSupport } from '../services/api'; import { useOfflineQueue } from '../hooks/useOfflineQueue'; @@ -13,6 +14,13 @@ interface MerchantAvailabilityToggleProps { initialAvailable: boolean; onAvailabilityChange?: (available: boolean) => void; disabled?: boolean; + /** + * Whether the merchant already has a location set (from `getMerchantConfig().latitude`). + * Optional and soft: when omitted, the no-location warning is simply skipped — this + * component does not fetch merchant config itself. Does NOT block activation either way + * (decision: minimal friction, see docs/PLAN_MAPA_REAL_2026-07.md WP2). + */ + hasLocation?: boolean; } export default function MerchantAvailabilityToggle({ @@ -20,7 +28,9 @@ export default function MerchantAvailabilityToggle({ initialAvailable, onAvailabilityChange, disabled = false, + hasLocation, }: MerchantAvailabilityToggleProps) { + const { t } = useTranslation(); const [available, setAvailable] = useState(initialAvailable); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -85,6 +95,12 @@ export default function MerchantAvailabilityToggle({

)} + {available && hasLocation === false && ( +

+ ⚠️ {t('merchantSettings.location.noLocationWarning')} +

+ )} + {offlineQueue.hasPending && (

⏳ {available ? 'Cambio a disponible' : 'Cambio a no disponible'} pendiente de sincronizar diff --git a/micopay/frontend/src/i18n/en.json b/micopay/frontend/src/i18n/en.json index c53edb01..f8f579f3 100644 --- a/micopay/frontend/src/i18n/en.json +++ b/micopay/frontend/src/i18n/en.json @@ -438,5 +438,24 @@ "generic": { "fallback": { "title": "Something went wrong", "message": "We couldn't finish this action. Try again.", "action": "If the problem continues, contact support." } } + }, + "merchantSettings": { + "location": { + "title": "My location", + "notSet": "You haven't set your location yet. Customers can't find you on the map.", + "useCurrent": "Use my current location", + "dragHint": "Drag the pin to adjust", + "addressLabel": "Address (optional)", + "addressPlaceholder": "E.g. Av. Insurgentes Sur 123, Col. Roma", + "save": "Save location", + "saving": "Saving…", + "change": "Change location", + "cancel": "Cancel", + "saveSuccess": "Location saved successfully.", + "saveError": "Couldn't save the location. Please try again.", + "gettingLocation": "Getting your location…", + "locationError": "Couldn't get your location. Check your GPS permissions.", + "noLocationWarning": "Without a location set you won't appear on the map. Go to Settings to set it." + } } } diff --git a/micopay/frontend/src/i18n/es.json b/micopay/frontend/src/i18n/es.json index 8486d765..4b8f0ee8 100644 --- a/micopay/frontend/src/i18n/es.json +++ b/micopay/frontend/src/i18n/es.json @@ -438,5 +438,24 @@ "generic": { "fallback": { "title": "Algo salió mal", "message": "No pudimos terminar esta acción. Intenta de nuevo.", "action": "Si el problema sigue, contacta soporte." } } + }, + "merchantSettings": { + "location": { + "title": "Mi ubicación", + "notSet": "Aún no has fijado tu ubicación. Los clientes no pueden encontrarte en el mapa.", + "useCurrent": "Usar mi ubicación actual", + "dragHint": "Arrastra el pin para ajustar", + "addressLabel": "Dirección (opcional)", + "addressPlaceholder": "Ej. Av. Insurgentes Sur 123, Col. Roma", + "save": "Guardar ubicación", + "saving": "Guardando…", + "change": "Cambiar ubicación", + "cancel": "Cancelar", + "saveSuccess": "Ubicación guardada exitosamente.", + "saveError": "No se pudo guardar la ubicación. Intenta de nuevo.", + "gettingLocation": "Obteniendo tu ubicación…", + "locationError": "No se pudo obtener tu ubicación. Verifica los permisos de GPS.", + "noLocationWarning": "Sin ubicación fijada no apareces en el mapa. Ve a Ajustes para fijarla." + } } } diff --git a/micopay/frontend/src/pages/MerchantSettings.tsx b/micopay/frontend/src/pages/MerchantSettings.tsx index c1368bd2..38a3c596 100644 --- a/micopay/frontend/src/pages/MerchantSettings.tsx +++ b/micopay/frontend/src/pages/MerchantSettings.tsx @@ -1,17 +1,27 @@ import { useEffect, useState } from 'react'; -import { getMerchantConfig, updateMerchantConfigWithOfflineSupport, getCurrentUser, setAvailability, MerchantConfig } from '../services/api'; +import { useTranslation } from 'react-i18next'; +import { getMerchantConfig, updateMerchantConfigWithOfflineSupport, updateMerchantLocation, getCurrentUser, setAvailability, MerchantConfig } from '../services/api'; import { resolveErrorMessage } from '../constants/errorMap'; import { useOfflineQueue } from '../hooks/useOfflineQueue'; +import { useGeolocation } from '../hooks/useGeolocation'; +import MapReal from '../components/MapReal'; interface MerchantSettingsProps { token: string | null; onBack: () => void; } +interface LocationState { + latitude: number | null; + longitude: number | null; + address_text: string | null; +} + export default function MerchantSettings({ token, onBack, }: MerchantSettingsProps) { + const { t } = useTranslation(); const [form, setForm] = useState({ rate_percent: 1, min_trade_mxn: 100, @@ -27,6 +37,15 @@ export default function MerchantSettings({ const [messageType, setMessageType] = useState<'success' | 'error' | 'warning' | null>(null); const offlineQueue = useOfflineQueue(token); + // Location (WP2): loaded from the same GET /merchants/me/config call, kept separate + // from `form` because PUT /merchants/me/config rejects unknown properties. + const [location, setLocation] = useState({ latitude: null, longitude: null, address_text: null }); + const [editingLocation, setEditingLocation] = useState(false); + const [pickerPosition, setPickerPosition] = useState<{ lat: number; lng: number } | null>(null); + const [addressText, setAddressText] = useState(''); + const [savingLocation, setSavingLocation] = useState(false); + const geo = useGeolocation(false); + useEffect(() => { if (!token) { setLoading(false); @@ -40,6 +59,12 @@ export default function MerchantSettings({ getCurrentUser(token), ]); setForm(config); + setLocation({ + latitude: config.latitude ?? null, + longitude: config.longitude ?? null, + address_text: config.address_text ?? null, + }); + setAddressText(config.address_text ?? ''); const status = (user as any).verification_status; setAvailabilityState( status === "verified" @@ -57,6 +82,50 @@ export default function MerchantSettings({ load(); }, [token]); + // Once GPS coords arrive from the CTA, seed the picker with them. + useEffect(() => { + if (geo.lat != null && geo.lng != null) { + setPickerPosition({ lat: geo.lat, lng: geo.lng }); + } + }, [geo.lat, geo.lng]); + + const startLocationEdit = () => { + setEditingLocation(true); + if (location.latitude != null && location.longitude != null) { + setPickerPosition({ lat: location.latitude, lng: location.longitude }); + } + }; + + const saveLocation = async () => { + if (!token || !pickerPosition) return; + setSavingLocation(true); + setMessage(null); + setMessageType(null); + try { + const result = await updateMerchantLocation( + { + latitude: pickerPosition.lat, + longitude: pickerPosition.lng, + address_text: addressText.trim() ? addressText.trim() : undefined, + }, + token, + ); + setLocation({ + latitude: result.latitude, + longitude: result.longitude, + address_text: result.address_text, + }); + setEditingLocation(false); + setMessage(t('merchantSettings.location.saveSuccess')); + setMessageType('success'); + } catch (err: any) { + setMessage(resolveErrorMessage(err).message); + setMessageType('error'); + } finally { + setSavingLocation(false); + } + }; + const togglePause = async () => { if (!token) return; const next = availability === "paused" ? "online" : "paused"; @@ -168,6 +237,93 @@ export default function MerchantSettings({ } /> +

+

{t('merchantSettings.location.title')}

+ + {!editingLocation && location.latitude != null && location.longitude != null ? ( +
+ + {location.address_text && ( +

{location.address_text}

+ )} + +
+ ) : ( +
+ {!pickerPosition && ( + <> +

{t('merchantSettings.location.notSet')}

+ + {geo.error && ( +

{t('merchantSettings.location.locationError')}

+ )} + + )} + + {pickerPosition && ( +
+ +

{t('merchantSettings.location.dragHint')}

+ + + +
+ {editingLocation && ( + + )} + +
+
+ )} +
+ )} +
+ - ); - })} - - {/* Location Label Floating */} -
- location_on -

CDMX · ZONA CENTRO

-
- - {/* Live Indicator */} -
-
-

Agentes reales cercanos

-
- - -
- ); -}; - -export default MapSim; From 07695654dc6a984c5b68af13a94a5c7aa4e4a8d2 Mon Sep 17 00:00:00 2001 From: Eric Mota Tejeda Date: Sat, 25 Jul 2026 18:27:25 -0600 Subject: [PATCH 08/22] fix(map): street-level fallback style (OpenFreeMap) instead of demotiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit demotiles.maplibre.org only carries country-level geometry, so at the street zooms fitBounds produces in a town the map rendered as an empty background — first real-device test in Huatusco showed no map at all. OpenFreeMap's liberty style has full OSM street data, needs no API key, and permits production use. Compact attribution control added (OSM license requires visible credit); the "dev map" notice is gone since the fallback is no longer a dev-only style. --- micopay/frontend/src/components/MapReal.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/micopay/frontend/src/components/MapReal.tsx b/micopay/frontend/src/components/MapReal.tsx index 8eea6e9a..8c07e455 100644 --- a/micopay/frontend/src/components/MapReal.tsx +++ b/micopay/frontend/src/components/MapReal.tsx @@ -21,7 +21,10 @@ interface MapRealProps { const mushroomImages = ['/mushroom_red.png', '/mushroom_green.png', '/mushroom_gold.png']; -const DEMO_STYLE_URL = 'https://demotiles.maplibre.org/style.json'; +// OpenFreeMap: OSM completo a nivel calle, sin API key, uso en producción permitido. +// demotiles.maplibre.org NO sirve como fallback: solo tiene fronteras de países, +// a zoom de calle renderiza un fondo vacío (visto en Huatusco, 2026-07-25). +const FALLBACK_STYLE_URL = 'https://tiles.openfreemap.org/styles/liberty'; function buildMerchantMarkerElement( merchant: AvailableMerchant, @@ -127,8 +130,7 @@ const MapReal = ({ const onPickerPositionChangeRef = useRef(onPickerPositionChange); onPickerPositionChangeRef.current = onPickerPositionChange; - const styleUrl = import.meta.env.VITE_MAP_STYLE_URL || DEMO_STYLE_URL; - const usingFallbackStyle = !import.meta.env.VITE_MAP_STYLE_URL; + const styleUrl = import.meta.env.VITE_MAP_STYLE_URL || FALLBACK_STYLE_URL; // Create the map once on mount. useEffect(() => { @@ -139,7 +141,8 @@ const MapReal = ({ style: styleUrl, center: [-99.1332, 19.4326], // Mexico City default, used only until fitBounds/setCenter runs below. zoom: 11, - attributionControl: false, + // OSM exige atribución visible; compact la deja como un botón ⓘ discreto. + attributionControl: { compact: true }, }); mapRef.current = map; @@ -267,11 +270,6 @@ const MapReal = ({
)} - {usingFallbackStyle && ( -
-

{t('map.devMapNotice')}

-
- )}
); }; From 3f2090dd9071202e570f5905127dad3d3cb3126f Mon Sep 17 00:00:00 2001 From: Eric Mota Tejeda Date: Sat, 25 Jul 2026 22:11:26 -0600 Subject: [PATCH 09/22] fix(map): pin maplibre-gl to v5, switch fallback style to OpenFreeMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-device testing in Huatusco showed the map rendering nothing: no tiles, no roads, blank canvas, despite the network layer and WebGL context both working fine. Root cause, confirmed via remote DevTools attached to the WebView: maplibre-gl v6.0.0 (a fresh major with no patch releases yet) changed its internal tile-parsing worker to an ES module. Under Capacitor's https://localhost custom scheme, that worker's relative imports never resolve — no Worker target even showed up in DevTools, no thrown exception, but map.isStyleLoaded() stayed false forever and 'load'/ 'idle' never fired. A plain non-module Worker roundtrip worked fine in the same WebView, isolating the failure to v6's module-worker bundling specifically, not Workers in general. Fix: pin to maplibre-gl@5.24.0, the last major before the ESM-worker rewrite, which uses a classic importScripts worker with no such resolution issue. After downgrading, a Worker target appeared in DevTools and 'load'/'idle' fired normally; verified visually via a captured canvas screenshot with real street tiles. Also swaps the fallback style from demotiles.maplibre.org (country borders only — renders blank at street zoom, a second, independent gap found during the same session) to OpenFreeMap's `liberty` style (full OSM street data, no API key, production-safe). VITE_MAP_STYLE_URL is effectively no longer required — MapTiler handoff from the plan is now optional, not blocking. --- micopay/frontend/package-lock.json | 91 +++++++++++++++------ micopay/frontend/package.json | 2 +- micopay/frontend/src/components/MapReal.tsx | 5 +- 3 files changed, 71 insertions(+), 27 deletions(-) diff --git a/micopay/frontend/package-lock.json b/micopay/frontend/package-lock.json index 26ef5fa9..2ecd55c3 100644 --- a/micopay/frontend/package-lock.json +++ b/micopay/frontend/package-lock.json @@ -23,7 +23,7 @@ "clsx": "^2.1.1", "i18next": "^26.3.4", "lucide-react": "^0.577.0", - "maplibre-gl": "^6.0.0", + "maplibre-gl": "^5.24.0", "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -2398,20 +2398,41 @@ "license": "BSD-2-Clause" }, "node_modules/@mapbox/unitbezier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", - "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", "license": "BSD-2-Clause" }, "node_modules/@mapbox/vector-tile": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", - "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", "license": "BSD-3-Clause", "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", - "pbf": "^5.0.0" + "pbf": "^4.0.2" + } + }, + "node_modules/@mapbox/vector-tile/node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" } }, "node_modules/@maplibre/geojson-vt": { @@ -2424,12 +2445,12 @@ } }, "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "26.2.1", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz", - "integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==", + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", + "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", "license": "ISC", "dependencies": { - "@mapbox/jsonlint-lines-primitives": "^2.0.3", + "@mapbox/jsonlint-lines-primitives": "~2.0.2", "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", @@ -2442,6 +2463,12 @@ "gl-style-validate": "dist/gl-style-validate.mjs" } }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, "node_modules/@maplibre/mlt": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", @@ -7508,25 +7535,27 @@ } }, "node_modules/maplibre-gl": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.0.0.tgz", - "integrity": "sha512-1wBgEhzTZfA+cDpFdt0fM1mJA5mJ90fifORJ7D8JcKLJCvPT/iTx9bNxkP7DqEYde65DJd7gE7h83khwNRqdyg==", + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", "license": "BSD-3-Clause", "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", - "@mapbox/tiny-sdf": "^2.2.0", - "@mapbox/unitbezier": "^1.0.0", - "@mapbox/vector-tile": "^3.0.0", - "@maplibre/geojson-vt": "^6.1.1", - "@maplibre/maplibre-gl-style-spec": "^26.1.0", - "@maplibre/mlt": "^1.1.12", - "@maplibre/vt-pbf": "^4.3.2", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", "@types/geojson": "^7946.0.16", - "earcut": "^3.2.3", + "earcut": "^3.0.2", "gl-matrix": "^3.4.4", - "kdbush": "^4.1.0", + "kdbush": "^4.0.2", "murmurhash-js": "^1.0.0", - "pbf": "^5.1.2", + "pbf": "^4.0.1", "potpack": "^2.1.0", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" @@ -7539,6 +7568,18 @@ "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" } }, + "node_modules/maplibre-gl/node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/micopay/frontend/package.json b/micopay/frontend/package.json index 81ae52d0..26b03596 100644 --- a/micopay/frontend/package.json +++ b/micopay/frontend/package.json @@ -30,7 +30,7 @@ "clsx": "^2.1.1", "i18next": "^26.3.4", "lucide-react": "^0.577.0", - "maplibre-gl": "^6.0.0", + "maplibre-gl": "^5.24.0", "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/micopay/frontend/src/components/MapReal.tsx b/micopay/frontend/src/components/MapReal.tsx index 8c07e455..7dc0c9f9 100644 --- a/micopay/frontend/src/components/MapReal.tsx +++ b/micopay/frontend/src/components/MapReal.tsx @@ -146,6 +146,7 @@ const MapReal = ({ }); mapRef.current = map; + map.on('error', (e) => console.error('[MapReal] tile/style error', e?.error?.message ?? e)); return () => { markersRef.current.forEach((marker) => marker.remove()); @@ -259,7 +260,9 @@ const MapReal = ({ return (
-
+ {/* w-full/h-full explícitos: maplibre-gl.css fuerza position:relative sobre + .maplibregl-map y anula el `absolute` de Tailwind, colapsando el alto a 0. */} +
{merchants.length > 0 && (
From 4824bf88ee89849c9b5a86d1e1cd2d358eb7a7e8 Mon Sep 17 00:00:00 2001 From: Eric Mota Tejeda Date: Sat, 25 Jul 2026 22:22:22 -0600 Subject: [PATCH 10/22] =?UTF-8?q?fix(cetes):=20route=20"=C2=BFSin=20cripto?= =?UTF-8?q?=3F"=20CTA=20to=20the=20Etherfuse=20SPEI=20ramp,=20not=20the=20?= =?UTF-8?q?cash-agent=20network?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "¿Sin cripto? Conecta tu banco vía SPEI" button on CETESScreen sent users to /deposit — the P2P cash-agent discovery flow (DepositMap, farmacia_guadalupe, etc.) — instead of the real Etherfuse onramp that's already built into this same screen (payMethod === 'spei', the getRampQuote('onramp', ...) path). Two completely different products; this CTA promises a bank connection and delivered a cash meetup. The SPEI payment method tab requires canDepositSpei (approved KYC), so the fix is conditional: if the user already has approved KYC, the click now reveals the in-page SPEI tab directly (setTab('buy') + setPayMethod('spei')); otherwise it navigates to /kyc — the actual prerequisite for connecting a bank — instead of the unrelated agent flow. The CTA also hides itself once the SPEI tab is already showing, since it'd otherwise sit there redundantly pointing at itself. --- micopay/frontend/src/App.tsx | 6 +++- micopay/frontend/src/pages/CETESScreen.tsx | 40 +++++++++++++++------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/micopay/frontend/src/App.tsx b/micopay/frontend/src/App.tsx index b04b012f..0b27443b 100644 --- a/micopay/frontend/src/App.tsx +++ b/micopay/frontend/src/App.tsx @@ -539,7 +539,11 @@ function CetesRoute() { return ( navigate('/explore')} - onBanco={() => navigate('/deposit')} + // "¿Sin cripto?" without approved KYC: KYC is the actual prerequisite + // for the Etherfuse SPEI ramp (see canDepositSpei in CETESScreen), not + // the P2P cash-agent flow at /deposit — that CTA used to send users + // there by mistake. + onBanco={() => navigate('/kyc')} userToken={buyerUser?.token} showDefi={import.meta.env.VITE_ENABLE_DEFI_TRADING === 'true'} showSpeiRamp={import.meta.env.VITE_ENABLE_SPEI_RAMP === 'true'} diff --git a/micopay/frontend/src/pages/CETESScreen.tsx b/micopay/frontend/src/pages/CETESScreen.tsx index 8f188b63..9643f886 100644 --- a/micopay/frontend/src/pages/CETESScreen.tsx +++ b/micopay/frontend/src/pages/CETESScreen.tsx @@ -812,19 +812,33 @@ const CETESScreen = ({ onBack, onBanco, userToken, showDefi = true, showSpeiRamp )}
- + {/* "¿Sin cripto?" — entry point to the real Etherfuse SPEI ramp built into + this screen (payMethod === 'spei' above), not the P2P cash-agent + network. If KYC is already approved, reveal that tab in place; if + not, KYC is the actual prerequisite for connecting a bank via SPEI, + so send the user there instead of the unrelated /deposit flow. */} + {!(tab === 'buy' && payMethod === 'spei') && ( + + )}

{t('cetes.footer', { network: rate?.network ?? 'TESTNET' })} From 6be7b309e87db2851e1ce9af40a156835a9e3269 Mon Sep 17 00:00:00 2001 From: Eric Mota Tejeda Date: Sat, 25 Jul 2026 22:59:17 -0600 Subject: [PATCH 11/22] fix(kyc): send required Etherfuse email, surface onboarding errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Etherfuse's POST /ramp/onboarding-url now rejects requests without userInfo.email (their docs had flagged it "optional, will become required in a future release" — that release landed in sandbox 2026-07-25). MicoPay's Stellar-keypair auth never collected an email from anyone, so every /defi/kyc/start call was failing with a 502 wrapping "Json deserialize error: missing field `email`". Backend: adds a nullable users.email column (migration), accepts an optional email in the POST body, persists it once set, and returns a new EMAIL_REQUIRED error if neither the column nor the request has one. Frontend: KYCScreen prompts for an email inline when EMAIL_REQUIRED comes back, then retries startKYC with it. Also fixes a second, adjacent bug found while diagnosing this: handleOpenHostedFlow had no catch block at all, so any failed startKYC() (this one included) silently opened nothing and left the user staring at an unresponsive button — this is what was actually reported ("no abre nada en el navegador"). extractApiErrorPayload only read response.data.error, but the backend's error handler sends `code` (see index.ts setErrorHandler) — fixed so EMAIL_REQUIRED and every other error code the backend already sends are actually reachable from the frontend, not just the message string. Verified by reproducing the exact 502 via curl against the real sandbox and reading the underlying Etherfuse error from CloudWatch logs before writing the fix. --- micopay/backend/src/routes/kyc.ts | 36 +++++- micopay/frontend/src/i18n/en.json | 7 +- micopay/frontend/src/i18n/es.json | 7 +- micopay/frontend/src/pages/KYCScreen.tsx | 107 ++++++++++++++---- micopay/frontend/src/services/api.ts | 3 +- micopay/frontend/src/utils/apiError.ts | 7 +- .../20260726040000_users_email.down.sql | 2 + .../20260726040000_users_email.up.sql | 7 ++ 8 files changed, 148 insertions(+), 28 deletions(-) create mode 100644 micopay/sql/migrations/20260726040000_users_email.down.sql create mode 100644 micopay/sql/migrations/20260726040000_users_email.up.sql diff --git a/micopay/backend/src/routes/kyc.ts b/micopay/backend/src/routes/kyc.ts index 5419515f..1878ffc9 100644 --- a/micopay/backend/src/routes/kyc.ts +++ b/micopay/backend/src/routes/kyc.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import { authMiddleware } from '../middleware/auth.middleware.js'; import db from '../db/schema.js'; -import { UpstreamError, NotFoundError } from '../utils/errors.js'; +import { UpstreamError, NotFoundError, BadRequestError } from '../utils/errors.js'; import { createOnboardingUrl, getKycStatus } from '../services/etherfuse.service.js'; import { createDiditSession, mapDiditStatus } from '../services/didit.service.js'; import { verifyDiditWebhookSignature } from '../lib/webhook-auth.js'; @@ -17,10 +17,15 @@ interface UserRow { id: string; stellar_address: string; username: string | null; + email: string | null; etherfuse_customer_id: string | null; etherfuse_bank_account_id: string | null; } +// RFC 5322 is a whole thing; this just catches obvious typos before we round-trip +// to Etherfuse (which does its own real validation server-side). +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + interface DiditSessionRow { session_id: string; user_id: string; @@ -54,13 +59,38 @@ async function startEtherfuseKyc(request: any) { const userId = request.user.id; const user = await db.getOne( - 'SELECT id, stellar_address, username, etherfuse_customer_id, etherfuse_bank_account_id FROM users WHERE id = $1', + 'SELECT id, stellar_address, username, email, etherfuse_customer_id, etherfuse_bank_account_id FROM users WHERE id = $1', [userId], ); if (!user) { throw new NotFoundError('User not found'); } + // Etherfuse's userInfo.email was optional until 2026-07-25, when it started + // rejecting onboarding-url requests without it. MicoPay's Stellar-keypair + // auth never collected an email from anyone, so the first time a user hits + // this route we need one — either already on file, or freshly submitted. + let email = user.email; + const submittedEmail = typeof request.body?.email === 'string' ? request.body.email.trim() : undefined; + if (!email && submittedEmail) { + if (!EMAIL_RE.test(submittedEmail)) { + throw new BadRequestError( + 'INVALID_EMAIL', + 'El correo no parece válido.', + `Rejected malformed email for user ${userId}`, + ); + } + await db.execute('UPDATE users SET email = $1 WHERE id = $2', [submittedEmail, userId]); + email = submittedEmail; + } + if (!email) { + throw new BadRequestError( + 'EMAIL_REQUIRED', + 'Etherfuse necesita un correo para verificar tu identidad.', + `User ${userId} has no email on file and none was submitted`, + ); + } + let { etherfuse_customer_id: customerId, etherfuse_bank_account_id: bankAccountId } = user; if (!customerId || !bankAccountId) { customerId = customerId ?? randomUUID(); @@ -76,7 +106,7 @@ async function startEtherfuseKyc(request: any) { customerId, bankAccountId, publicKey: user.stellar_address, - userInfo: { displayName: user.username ?? undefined }, + userInfo: { email, displayName: user.username ?? undefined }, }); const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); return { onboardingUrl, expiresAt }; diff --git a/micopay/frontend/src/i18n/en.json b/micopay/frontend/src/i18n/en.json index f8f579f3..081eded7 100644 --- a/micopay/frontend/src/i18n/en.json +++ b/micopay/frontend/src/i18n/en.json @@ -397,7 +397,12 @@ "couldNotQuery": "Could not query", "sessionError": "Session not available. Log in again and try again.", "openingProvider": "Opening {{provider}}…", - "pollError": "Error checking verification status." + "pollError": "Error checking verification status.", + "emailRequiredTitle": "We need your email", + "emailRequiredDesc": "{{provider}} requires it to verify your identity. Used only once, for this.", + "emailPlaceholder": "you@email.com", + "emailInvalid": "That email doesn't look valid.", + "emailContinue": "Continue" }, "errors": { "network": { diff --git a/micopay/frontend/src/i18n/es.json b/micopay/frontend/src/i18n/es.json index 4b8f0ee8..0d01e883 100644 --- a/micopay/frontend/src/i18n/es.json +++ b/micopay/frontend/src/i18n/es.json @@ -397,7 +397,12 @@ "couldNotQuery": "No se pudo consultar", "sessionError": "Sesión no disponible. Vuelve a iniciar sesión e intenta de nuevo.", "openingProvider": "Abriendo {{provider}}…", - "pollError": "Error al consultar el estado de verificación." + "pollError": "Error al consultar el estado de verificación.", + "emailRequiredTitle": "Necesitamos tu correo", + "emailRequiredDesc": "{{provider}} lo requiere para verificar tu identidad. Solo se usa una vez, para esto.", + "emailPlaceholder": "tu@correo.com", + "emailInvalid": "Ese correo no parece válido.", + "emailContinue": "Continuar" }, "errors": { "network": { diff --git a/micopay/frontend/src/pages/KYCScreen.tsx b/micopay/frontend/src/pages/KYCScreen.tsx index f2ea4d8e..b43328d5 100644 --- a/micopay/frontend/src/pages/KYCScreen.tsx +++ b/micopay/frontend/src/pages/KYCScreen.tsx @@ -4,6 +4,9 @@ import { App as CapApp } from '@capacitor/app'; import { startKYC, getKYCStatus, type KYCProvider, type KYCStatus, type KYCStatusResponse } from '../services/api'; import { readJSON, writeJSON } from '../services/secureStorage'; +import { extractApiErrorPayload } from '../utils/apiError'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const PROVIDER_NAMES: Record = { etherfuse: 'Etherfuse', @@ -71,6 +74,13 @@ export default function KYCScreen({ onApproved, token, provider = 'etherfuse' }: const [statusPollingError, setStatusPollingError] = useState(null); + // Etherfuse's onboarding call now requires an email MicoPay's Stellar-keypair + // auth never collects; POST /defi/kyc/start responds EMAIL_REQUIRED the first + // time, and we prompt for it inline instead of failing silently. + const [needsEmail, setNeedsEmail] = useState(false); + const [email, setEmail] = useState(''); + const [emailError, setEmailError] = useState(null); + const loadCachedStatus = async () => { const cached = await readJSON<{ status: KYCStatus; reason?: string | null }>(secureStorageKey(provider)); if (cached?.status === 'approved') { @@ -85,7 +95,7 @@ export default function KYCScreen({ onApproved, token, provider = 'etherfuse' }: // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleOpenHostedFlow = async () => { + const handleOpenHostedFlow = async (emailOverride?: string) => { if (!token) { setStatusPollingError(t('kyc.sessionError')); return; @@ -95,7 +105,8 @@ export default function KYCScreen({ onApproved, token, provider = 'etherfuse' }: setLoading(true); try { - const { onboardingUrl } = await startKYC(token, provider); + const { onboardingUrl } = await startKYC(token, provider, emailOverride); + setNeedsEmail(false); startedAtRef.current = Date.now(); setStartingToken(onboardingUrl); @@ -115,11 +126,30 @@ export default function KYCScreen({ onApproved, token, provider = 'etherfuse' }: // Fallback for web builds / when plugin is not present. window.open(onboardingUrl, '_blank', 'noopener,noreferrer'); } + } catch (err) { + // Previously uncaught: a failed startKYC() (e.g. Etherfuse rejecting the + // request) silently opened nothing and left no trace for the user. + const payload = extractApiErrorPayload(err); + if (payload.error === 'EMAIL_REQUIRED') { + setNeedsEmail(true); + } else { + setStatusPollingError(payload.message); + } } finally { setLoading(false); } }; + const handleSubmitEmail = () => { + const trimmed = email.trim(); + if (!EMAIL_RE.test(trimmed)) { + setEmailError(t('kyc.emailInvalid')); + return; + } + setEmailError(null); + void handleOpenHostedFlow(trimmed); + }; + const applyStatus = async (res: KYCStatusResponse) => { setStatus(res.status); setReason(res.reason ?? null); @@ -237,27 +267,64 @@ export default function KYCScreen({ onApproved, token, provider = 'etherfuse' }:

- + {needsEmail ? ( +
+
+

{t('kyc.emailRequiredTitle')}

+

{t('kyc.emailRequiredDesc', { provider: providerName })}

+
+ { setEmail(e.target.value); setEmailError(null); }} + placeholder={t('kyc.emailPlaceholder')} + className="w-full rounded-xl border border-outline-variant/30 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + {emailError &&

{emailError}

} + +
+ ) : ( + + )} {status === 'rejected' && (
- {/* Chat Preview Section */} + {/* Chat / location quick actions — no fake message preview (see + docs/AUDIT_MOBILE_MAINNET.md, "mensaje de chat falso en QRReveal"; + a static placeholder attributed to the real counterparty was + misleading). Real messages live in the chat screen itself. */}
-
-
- Pharmacist -
-
-

- {counterpartyName ?? '—'}: Estamos en Av. Juárez 34, a un costado del banco. -

+
+
+
+

{counterpartyName ?? '—'}