diff --git a/API_Mobile_App.md b/API_Mobile_App.md index 3150af2..007a3e5 100644 --- a/API_Mobile_App.md +++ b/API_Mobile_App.md @@ -167,6 +167,106 @@ Used when interacting with a specific store (Catalog, Checkout). - Se a distância entre `lat_long` enviado e o ponto do CEP for maior que `1.5 km`, a API substitui `lat_long` pelo ponto do CEP cacheado. - Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil. +### 5. List Favorite Stores +**GET** `/api/customer/favorites` + +**Headers:** + +- `Authorization: Bearer ` +- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` + +**Notes:** + +- Retorna as lojas favoritas do customer autenticado. +- Apenas lojas públicas/visíveis são retornadas. +- A ordem segue a ordem salva em `favorites`. + +**Response:** + +```json +{ + "error": false, + "code": "CUSTOMER_FAVORITES_RETRIEVED", + "result": [ + { + "id": "store_1775510226293_yn5ys", + "name": "CPS Drinks", + "logo": null, + "cover": null, + "category": "Doces & Bolos", + "rating": 4.8, + "totalReviews": 12, + "isOpen": true, + "statusLabel": "Aberto", + "nextOpenLabel": null + } + ] +} +``` + +### 6. Add Store to Favorites +**POST** `/api/customer/favorites/:storeId` + +**Headers:** + +- `Authorization: Bearer ` +- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` + +**Notes:** + +- Salva a loja nos favoritos do customer autenticado. +- Se a loja já estiver favoritada, a operação continua idempotente. +- A loja precisa existir e estar publicamente visível. + +**Response:** + +```json +{ + "error": false, + "code": "CUSTOMER_FAVORITE_SAVED", + "result": { + "favorites": ["store_1775510226293_yn5ys"], + "store": { + "id": "store_1775510226293_yn5ys", + "name": "CPS Drinks", + "logo": null, + "cover": null, + "category": "Doces & Bolos", + "rating": 4.8, + "totalReviews": 12, + "isOpen": true, + "statusLabel": "Aberto", + "nextOpenLabel": null + } + } +} +``` + +### 7. Remove Store from Favorites +**DELETE** `/api/customer/favorites/:storeId` + +**Headers:** + +- `Authorization: Bearer ` +- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` + +**Notes:** + +- Remove a loja dos favoritos do customer autenticado. +- A operação é idempotente: se a loja não estiver favoritada, o array volta sem ela. + +**Response:** + +```json +{ + "error": false, + "code": "CUSTOMER_FAVORITE_REMOVED", + "result": { + "favorites": [] + } +} +``` + --- ## 🏠 App Home Screen @@ -538,19 +638,29 @@ Use este canal para acompanhar mudança de status do pedido em tempo real (sem d ``` **Eventos recebidos pelo app:** -- `order_update`: enviado quando o pedido muda (pagamento aprovado, aceito pela loja, saiu para entrega, concluído, cancelado). +- `order_update`: alteração geral de status do pedido. +- `order_cancelled`: cancelamento confirmado, com motivo e metadados de estorno. +- `delivery_order_cancelled`: evento dedicado para telas de OTP/entrega interromperem o fluxo imediatamente. -**Payload típico de `order_update`:** +**Payload típico de `order_cancelled`:** ```json { - "id": "ord_987...", - "shortId": "1234", - "storeId": "store_123...", - "userId": "cust_uuid...", - "status": "CONFIRMED", - "paymentStatus": "CONFIRMED", - "updatedAt": "2026-02-15T20:10:00.000Z" + "event": "order_cancelled", + "emittedAt": "2026-03-05T18:00:00.000Z", + "orderId": "ffdc47b4-6a55-4713-aea9-45169e4cf0d7", + "shortId": "3778", + "storeId": "store_1772117366848_wmqw4", + "userId": "cust_uuid_001", + "status": "CANCELED", + "cancelledBy": "store", + "cancelledAt": "2026-03-05T18:00:00.000Z", + "cancelReasonCode": "STORE_UNAVAILABLE", + "cancelReasonDetail": "Falta de insumo crítico para finalizar o pedido", + "refundStatus": "failed", + "refundIdempotencyKey": "refund:ffdc47b4-...", + "refundProviderRef": null, + "refundError": "Falha ao solicitar estorno automático" } ``` @@ -566,9 +676,45 @@ Se o socket cair, o app deve continuar consultando o status até estado final. - depois: a cada `10s` - parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela. -### 8. Catálogo Oficial de Tags de Review +### 8. Cancelamento de Pedido (Loja/Customer) -### 8.1 Matriz Completa de Endpoints (Reviews + Tags) +**Cancelamento pela loja (Store API):** +- `POST /api/store/:storeId/orders/:orderId/cancel` + +**Cancelamento pelo customer:** +- `POST /api/customer/orders/:orderId/cancel` + +**Request body (ambos):** +```json +{ + "reasonCode": "CUSTOMER_REQUEST", + "reasonDetail": "Cliente solicitou cancelamento antes da entrega" +} +``` + +**Motivos aceitos (`reasonCode`):** +- `STORE_UNAVAILABLE` +- `CUSTOMER_REQUEST` +- `DELIVERY_ISSUE` + +**Regras atuais de cancelamento:** +- Permitido em: `PAYMENT_PENDING`, `PENDING`, `ACCEPTED`, `PREPARING`, `READY`, `DELIVERING`. +- Bloqueado em: `COMPLETED` e `CANCELED`. +- Motoboy não cancela pedido. +- Ao cancelar, o backend invalida `otp`, `confirmOtp` e `customerOtp`. + +**Concorrência e idempotência (ponta a ponta):** +- Cancelamento é serializado por `orderId` (lock por pedido). +- Estorno automático usa lock + chave idempotente de estorno para evitar duplicidade. +- Em segunda tentativa após cancelamento efetivado, a API retorna `409 ORDER_ALREADY_CANCELED`. + +**Campos relevantes no retorno:** +- `cancelledBy`, `cancelledAt`, `cancelReasonCode`, `cancelReasonDetail` +- `refundStatus`, `refundIdempotencyKey`, `refundProviderRef` + +### 9. Catálogo Oficial de Tags de Review + +### 9.1 Matriz Completa de Endpoints (Reviews + Tags) **Público/App (Customer):** - `GET /api/public/reviews/tags` → catálogo oficial de tags e regras de validação. @@ -630,7 +776,7 @@ O app deve buscar este endpoint para renderizar as tags válidas e enviar soment - Não hardcodear tags no app; usar o catálogo do backend. - Enviar no POST de review apenas os `id` retornados em cada bloco. -### 9. Enviar Avaliação do Pedido +### 10. Enviar Avaliação do Pedido Permite o customer enviar review da loja a partir de um pedido finalizado. **Endpoint:** @@ -749,7 +895,7 @@ Exemplo inválido (entrega positiva com tags negativas): Retorno esperado: `400 INVALID_DELIVERY_SENTIMENT_TAGS`. -### 10. Analytics de Reviews (Store/API) +### 11. Analytics de Reviews (Store/API) Para dashboard da loja e análise de produto, usar: - `GET /api/store/:storeId/reviews` diff --git a/feature-control-bff/README.md b/feature-control-bff/README.md new file mode 100644 index 0000000..ac6f863 --- /dev/null +++ b/feature-control-bff/README.md @@ -0,0 +1,89 @@ +# Feature Control BFF + +BFF mínimo para expor flags ao app sem expor `Atomenta-Token` no cliente. + +## Endpoint + +- `POST /feature-control/bootstrap` +- `POST /feature-control/telemetry/exposure` +- `GET /health` + +## Variáveis de ambiente + +- `PORT` (default: `8787`) +- `ATOMENTA_ORIGIN` (default: `https://atomenta.com.br`) +- `FEATURE_CONTROL_MODULE_TOKEN` (ou `ATOMENTA_FEATURE_CONTROL_MODULE_ID`) **obrigatório** +- `FEATURE_CONTROL_ENVIRONMENT` (default: `production`) +- `FEATURE_CONTROL_CACHE_TTL_MS` (default: `60000`) +- `FEATURE_CONTROL_TIMEOUT_MS` (default: `2000`) +- `FEATURE_CONTROL_DEFAULTS_JSON` (opcional, JSON com defaults de fallback) + +Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`: + +```json +{ + "fc.checkout_v2": { "enabled": false, "variant": "off" }, + "fc.search_ranking_v3": { "enabled": true, "variant": "on", "payload": { "model": "v3" } } +} +``` + +## Request (App -> BFF) + +```json +{ + "environment": "production", + "keys": ["fc.checkout_v2", "fc.search_ranking_v3"], + "context": { + "subjectType": "customer", + "subjectId": "cust_123", + "storeId": "store_001", + "platform": "ios", + "appVersion": "2.3.1", + "attributes": { + "city": "Belo Horizonte", + "tier": "gold" + } + } +} +``` + +Se o app enviar `Authorization: Bearer `, o BFF tenta extrair `subjectId` do JWT (`sub`/`customerId`/`id`) quando `context.subjectId` não vier. + +## Response (BFF -> App) + +```json +{ + "ok": true, + "source": "live", + "configVersion": 7, + "evaluatedAt": "2026-04-16T12:00:00.000Z", + "flags": { + "checkoutV2": true, + "searchRankingV3": "off" + }, + "raw": { + "fc.checkout_v2": { + "enabled": true, + "variant": "on", + "payload": null, + "reason": "rollout" + } + } +} +``` + +Quando o upstream falha (ex.: `429`, `500`, timeout), o BFF devolve `fallback` com defaults estáticos. + +## Execução local + +```bash +cd feature-control-bff +FEATURE_CONTROL_MODULE_TOKEN="550e8400-e29b-41d4-a716-44665544000b" npm start +``` + +## Testes + +```bash +cd feature-control-bff +npm test +``` diff --git a/feature-control-bff/core.mjs b/feature-control-bff/core.mjs new file mode 100644 index 0000000..d5e3a22 --- /dev/null +++ b/feature-control-bff/core.mjs @@ -0,0 +1,333 @@ +import crypto from "node:crypto"; + +export function loadConfig(env = process.env) { + return { + atomentaOrigin: (env.ATOMENTA_ORIGIN || "https://atomenta.com.br").replace(/\/+$/, ""), + moduleToken: env.FEATURE_CONTROL_MODULE_TOKEN || env.ATOMENTA_FEATURE_CONTROL_MODULE_ID || "", + defaultEnvironment: env.FEATURE_CONTROL_ENVIRONMENT || "production", + cacheTtlMs: Number(env.FEATURE_CONTROL_CACHE_TTL_MS || 60_000), + requestTimeoutMs: Number(env.FEATURE_CONTROL_TIMEOUT_MS || 2_000), + staticDefaults: parseDefaults(env.FEATURE_CONTROL_DEFAULTS_JSON) + }; +} + +export function createRuntime(config) { + return { cache: new Map(), inFlight: new Map(), config }; +} + +export function nowIso() { + return new Date().toISOString(); +} + +export function writeJson(res, status, payload) { + const body = JSON.stringify(payload); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + "Cache-Control": "no-store" + }); + res.end(body); +} + +const MAX_BODY_BYTES = 128 * 1024; +const MAX_KEYS = 100; +const MAX_ATTRIBUTES_BYTES = 16 * 1024; +const MAX_EXPOSURE_EVENTS = 100; + +function parseDefaults(raw) { + if (!raw) return {}; + const parsed = safeParseJson(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return parsed; +} + +function safeParseJson(input) { + try { + return JSON.parse(input); + } catch { + return null; + } +} + +function normalizeString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function getSubjectFromAuthHeader(authHeader) { + if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) return null; + const token = authHeader.slice(7).trim(); + const parts = token.split("."); + if (parts.length < 2) return null; + + try { + const payloadBase64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = payloadBase64 + "=".repeat((4 - (payloadBase64.length % 4)) % 4); + const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf-8")); + const subjectId = normalizeString(payload.sub) || normalizeString(payload.customerId) || normalizeString(payload.id); + if (!subjectId) return null; + return { subjectType: "customer", subjectId }; + } catch { + return null; + } +} + +function buildCacheKey(input) { + const stable = JSON.stringify({ + env: input.environment, + keys: input.keys, + context: { + subjectType: input.context.subjectType, + subjectId: input.context.subjectId, + storeId: input.context.storeId || "", + platform: input.context.platform || "", + appVersion: input.context.appVersion || "", + attributes: input.context.attributes || {} + } + }); + return crypto.createHash("sha256").update(stable).digest("hex"); +} + +async function readBody(req) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + throw Object.assign(new Error("Payload muito grande"), { status: 413, code: "PAYLOAD_TOO_LARGE" }); + } + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf-8"); +} + +function validateBootstrapRequest(body, authHeader, config) { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw Object.assign(new Error("Body inválido"), { status: 400, code: "INVALID_BODY" }); + } + + const keys = Array.isArray(body.keys) ? body.keys.map(normalizeString).filter(Boolean) : []; + if (!keys.length) throw Object.assign(new Error("`keys` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_KEYS_REQUIRED" }); + if (keys.length > MAX_KEYS) { + throw Object.assign(new Error(`Máximo de ${MAX_KEYS} chaves por request`), { status: 400, code: "FEATURE_CONTROL_TOO_MANY_KEYS" }); + } + + const environment = normalizeString(body.environment) || config.defaultEnvironment; + const contextInput = body.context && typeof body.context === "object" ? body.context : {}; + const fromJwt = getSubjectFromAuthHeader(authHeader); + const subjectType = normalizeString(contextInput.subjectType) || fromJwt?.subjectType || "customer"; + const subjectId = normalizeString(contextInput.subjectId) || fromJwt?.subjectId || ""; + if (!subjectId) { + throw Object.assign(new Error("`context.subjectId` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_SUBJECT_ID_REQUIRED" }); + } + + const allowed = new Set(["user", "customer", "store", "anonymous"]); + if (!allowed.has(subjectType)) { + throw Object.assign(new Error("`context.subjectType` inválido"), { status: 400, code: "FEATURE_CONTROL_SUBJECT_TYPE_INVALID" }); + } + + const attributes = contextInput.attributes && typeof contextInput.attributes === "object" && !Array.isArray(contextInput.attributes) + ? contextInput.attributes + : {}; + + if (Buffer.byteLength(JSON.stringify(attributes), "utf-8") > MAX_ATTRIBUTES_BYTES) { + throw Object.assign(new Error("`context.attributes` excede limite"), { status: 400, code: "FEATURE_CONTROL_ATTRIBUTES_TOO_LARGE" }); + } + + return { + environment, + keys: [...new Set(keys)].sort(), + context: { + subjectType, + subjectId, + storeId: normalizeString(contextInput.storeId) || undefined, + platform: normalizeString(contextInput.platform) || undefined, + appVersion: normalizeString(contextInput.appVersion) || undefined, + attributes + } + }; +} + +function validateExposureRequest(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw Object.assign(new Error("Body inválido"), { status: 400, code: "INVALID_BODY" }); + } + if (!Array.isArray(body.events) || body.events.length === 0) { + throw Object.assign(new Error("`events` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_EVENTS_REQUIRED" }); + } + if (body.events.length > MAX_EXPOSURE_EVENTS) { + throw Object.assign(new Error(`Máximo de ${MAX_EXPOSURE_EVENTS} eventos por request`), { + status: 400, + code: "FEATURE_CONTROL_EXPOSURE_TOO_MANY_EVENTS" + }); + } + + const events = body.events.map((event) => ({ + featureKey: normalizeString(event?.featureKey), + variant: normalizeString(event?.variant) || "off", + subjectType: normalizeString(event?.subjectType) || "customer", + storeId: normalizeString(event?.storeId) || undefined + })); + + for (const event of events) { + if (!event.featureKey) { + throw Object.assign(new Error("`featureKey` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_EXPOSURE_FEATURE_KEY_REQUIRED" }); + } + } + return { events }; +} + +function buildFallbackFlags(keys, config) { + const raw = {}; + const simplified = {}; + + for (const key of keys) { + const fromEnv = config.staticDefaults[key]; + const enabled = Boolean(fromEnv?.enabled); + const variant = typeof fromEnv?.variant === "string" ? fromEnv.variant : enabled ? "on" : "off"; + raw[key] = { enabled, variant, payload: fromEnv?.payload ?? null, reason: "fallback_default" }; + } + + for (const [key, value] of Object.entries(raw)) { + const simplifiedKey = key.replace(/^fc\./, "").replace(/[_-]([a-z])/g, (_, c) => c.toUpperCase()).replace(/\./g, ""); + if (!simplified[simplifiedKey]) { + simplified[simplifiedKey] = value.variant === "on" || value.enabled === true ? true : value.variant; + } + } + + return { raw, simplified }; +} + +function mapUpstreamError(error) { + return { + status: Number(error?.status || 500), + code: normalizeString(error?.code) || "FEATURE_CONTROL_BFF_ERROR", + message: normalizeString(error?.message) || "Erro ao avaliar flags" + }; +} + +async function callAtomenta(path, requestPayload, config) { + if (!config.moduleToken) { + throw Object.assign(new Error("Token de módulo não configurado"), { status: 500, code: "FEATURE_CONTROL_MODULE_TOKEN_MISSING" }); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs); + + try { + const response = await fetch(`${config.atomentaOrigin}${path}`, { + method: "POST", + signal: controller.signal, + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "Atomenta-Token": config.moduleToken + }, + body: JSON.stringify(requestPayload) + }); + + const text = await response.text(); + const parsed = safeParseJson(text) || {}; + if (!response.ok) { + const upstreamCode = parsed.code || "FEATURE_CONTROL_UPSTREAM_ERROR"; + const upstreamMessage = parsed.message || `Erro upstream (${response.status})`; + throw Object.assign(new Error(upstreamMessage), { status: response.status, code: upstreamCode }); + } + return parsed; + } catch (error) { + if (error.name === "AbortError") { + throw Object.assign(new Error("Timeout no upstream"), { status: 504, code: "FEATURE_CONTROL_UPSTREAM_TIMEOUT" }); + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +function formatBffResponse(evaluated, requestPayload, source = "live") { + const result = evaluated?.result || {}; + const flags = result.flags && typeof result.flags === "object" ? result.flags : {}; + const simplified = {}; + for (const key of requestPayload.keys) { + const value = flags[key]; + if (!value || typeof value !== "object") continue; + const simplifiedKey = key.replace(/^fc\./, "").replace(/[_-]([a-z])/g, (_, c) => c.toUpperCase()).replace(/\./g, ""); + if (!simplified[simplifiedKey]) simplified[simplifiedKey] = value.variant === "on" || value.enabled === true ? true : value.variant || "off"; + } + return { + ok: true, + source, + configVersion: Number(result.configVersion || 0), + evaluatedAt: result.evaluatedAt || nowIso(), + flags: simplified, + raw: flags + }; +} + +async function evaluateWithCache(requestPayload, runtime) { + const cacheKey = buildCacheKey(requestPayload); + const current = runtime.cache.get(cacheKey); + if (current && current.expiresAt > Date.now()) return current.payload; + + const inflight = runtime.inFlight.get(cacheKey); + if (inflight) return inflight; + + const work = (async () => { + const evaluated = await callAtomenta("/api/feature-control/evaluate", requestPayload, runtime.config); + const payload = formatBffResponse(evaluated, requestPayload, "live"); + runtime.cache.set(cacheKey, { payload, expiresAt: Date.now() + runtime.config.cacheTtlMs }); + return payload; + })(); + runtime.inFlight.set(cacheKey, work); + + try { + return await work; + } finally { + runtime.inFlight.delete(cacheKey); + } +} + +export async function handleBootstrap(req, res, runtime) { + try { + const body = safeParseJson(await readBody(req)); + if (!body) return writeJson(res, 400, { ok: false, code: "INVALID_JSON", message: "JSON inválido" }); + const requestPayload = validateBootstrapRequest(body, req.headers.authorization, runtime.config); + try { + const payload = await evaluateWithCache(requestPayload, runtime); + return writeJson(res, 200, payload); + } catch (error) { + const mapped = mapUpstreamError(error); + const fallback = buildFallbackFlags(requestPayload.keys, runtime.config); + return writeJson(res, mapped.status === 400 ? 400 : 200, { + ok: mapped.status === 400 ? false : true, + source: "fallback", + configVersion: 0, + evaluatedAt: nowIso(), + flags: fallback.simplified, + raw: fallback.raw, + upstreamError: mapped + }); + } + } catch (error) { + return writeJson(res, Number(error?.status || 500), { + ok: false, + code: normalizeString(error?.code) || "FEATURE_CONTROL_BFF_ERROR", + message: normalizeString(error?.message) || "Erro interno" + }); + } +} + +export async function handleExposure(req, res, runtime) { + try { + const body = safeParseJson(await readBody(req)); + if (!body) return writeJson(res, 400, { ok: false, code: "INVALID_JSON", message: "JSON inválido" }); + const requestPayload = validateExposureRequest(body); + const response = await callAtomenta("/api/feature-control/telemetry/exposure", requestPayload, runtime.config); + return writeJson(res, 200, { ok: true, code: "FEATURE_CONTROL_EXPOSURE_ACCEPTED", result: response?.result ?? null }); + } catch (error) { + return writeJson(res, Number(error?.status || 500), { + ok: false, + code: normalizeString(error?.code) || "FEATURE_CONTROL_EXPOSURE_ERROR", + message: normalizeString(error?.message) || "Falha ao enviar exposição" + }); + } +} diff --git a/feature-control-bff/package.json b/feature-control-bff/package.json new file mode 100644 index 0000000..6e8d9f9 --- /dev/null +++ b/feature-control-bff/package.json @@ -0,0 +1,10 @@ +{ + "name": "feature-control-bff", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs", + "test": "node --test tests/*.test.mjs" + } +} diff --git a/feature-control-bff/server.mjs b/feature-control-bff/server.mjs new file mode 100644 index 0000000..e8188cc --- /dev/null +++ b/feature-control-bff/server.mjs @@ -0,0 +1,34 @@ +import http from "node:http"; +import { createRuntime, handleBootstrap, handleExposure, loadConfig, nowIso, writeJson } from "./core.mjs"; + +const PORT = Number(process.env.PORT || 8787); +const runtime = createRuntime(loadConfig(process.env)); + +const server = http.createServer(async (req, res) => { + const method = req.method || "GET"; + const url = req.url || "/"; + + if (method === "GET" && url === "/health") { + return writeJson(res, 200, { + ok: true, + service: "feature-control-bff", + time: nowIso(), + cacheEntries: runtime.cache.size, + inFlight: runtime.inFlight.size + }); + } + + if (method === "POST" && url === "/feature-control/bootstrap") { + return handleBootstrap(req, res, runtime); + } + + if (method === "POST" && url === "/feature-control/telemetry/exposure") { + return handleExposure(req, res, runtime); + } + + return writeJson(res, 404, { ok: false, code: "NOT_FOUND", message: "Rota não encontrada" }); +}); + +server.listen(PORT, () => { + console.log(`[feature-control-bff] listening on :${PORT}`); +}); diff --git a/feature-control-bff/tests/bff.test.mjs b/feature-control-bff/tests/bff.test.mjs new file mode 100644 index 0000000..98ba28d --- /dev/null +++ b/feature-control-bff/tests/bff.test.mjs @@ -0,0 +1,275 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { spawn } from "node:child_process"; + +function listen(server) { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolve(address.port); + }); + }); +} + +function closeServer(server) { + return new Promise((resolve) => server.close(() => resolve())); +} + +async function waitForHealth(url, timeoutMs = 5000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const response = await fetch(`${url}/health`); + if (response.ok) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("BFF health timeout"); +} + +async function startBff(env) { + const child = spawn("node", ["server.mjs"], { + cwd: new URL("..", import.meta.url), + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"] + }); + + const logs = []; + child.stdout.on("data", (chunk) => logs.push(chunk.toString("utf-8"))); + child.stderr.on("data", (chunk) => logs.push(chunk.toString("utf-8"))); + + const port = Number(env.PORT); + const baseUrl = `http://127.0.0.1:${port}`; + await waitForHealth(baseUrl); + + return { + baseUrl, + logs, + stop: async () => { + child.kill("SIGTERM"); + await new Promise((resolve) => child.on("exit", () => resolve())); + } + }; +} + +function createUpstreamStub(options = {}) { + const state = { + evaluateMode: options.evaluateMode || "success", + evaluatePayload: null, + exposurePayload: null + }; + + const server = http.createServer(async (req, res) => { + if (req.method === "POST" && req.url === "/api/feature-control/evaluate") { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + state.evaluatePayload = JSON.parse(Buffer.concat(chunks).toString("utf-8")); + + if (state.evaluateMode === "429") { + res.writeHead(429, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ code: "FEATURE_CONTROL_RATE_LIMIT", message: "rate" })); + return; + } + + if (state.evaluateMode === "500") { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ code: "FEATURE_CONTROL_ERROR", message: "boom" })); + return; + } + + if (state.evaluateMode === "timeout") { + await new Promise((resolve) => setTimeout(resolve, 600)); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: false, + code: "FEATURE_CONTROL_EVALUATED", + result: { + evaluatedAt: "2026-04-16T12:00:00.000Z", + configVersion: 7, + flags: { + "fc.checkout_v2": { enabled: true, variant: "on", payload: null, reason: "rollout" }, + "fc.search_ranking_v3": { enabled: false, variant: "off", payload: null, reason: "default" } + } + } + }) + ); + return; + } + + if (req.method === "POST" && req.url === "/api/feature-control/telemetry/exposure") { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + state.exposurePayload = JSON.parse(Buffer.concat(chunks).toString("utf-8")); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: false, code: "FEATURE_CONTROL_EXPOSURE_TRACKED", result: { accepted: true } })); + return; + } + + res.writeHead(404).end(); + }); + + return { server, state }; +} + +function bootstrapBody() { + return { + environment: "production", + keys: ["fc.checkout_v2", "fc.search_ranking_v3"], + context: { + subjectType: "customer", + subjectId: "cust_123", + storeId: "store_001", + platform: "ios", + appVersion: "2.3.1", + attributes: { city: "Campinas" } + } + }; +} + +test("bootstrap sucesso retorna flags simplificadas e raw", async () => { + const upstream = createUpstreamStub({ evaluateMode: "success" }); + const upstreamPort = await listen(upstream.server); + const bffPort = upstreamPort + 1000; + const bff = await startBff({ + PORT: String(bffPort), + ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`, + FEATURE_CONTROL_MODULE_TOKEN: "token", + FEATURE_CONTROL_TIMEOUT_MS: "300" + }); + + try { + const response = await fetch(`${bff.baseUrl}/feature-control/bootstrap`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(bootstrapBody()) + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.ok, true); + assert.equal(json.configVersion, 7); + assert.equal(json.flags.checkoutV2, true); + assert.equal(json.flags.searchRankingV3, "off"); + assert.ok(json.raw["fc.checkout_v2"]); + assert.equal(upstream.state.evaluatePayload.context.subjectId, "cust_123"); + } finally { + await bff.stop(); + await closeServer(upstream.server); + } +}); + +test("bootstrap com 429 devolve fallback sem quebrar cliente", async () => { + const upstream = createUpstreamStub({ evaluateMode: "429" }); + const upstreamPort = await listen(upstream.server); + const bffPort = upstreamPort + 1000; + const bff = await startBff({ + PORT: String(bffPort), + ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`, + FEATURE_CONTROL_MODULE_TOKEN: "token", + FEATURE_CONTROL_DEFAULTS_JSON: JSON.stringify({ + "fc.checkout_v2": { enabled: false, variant: "off" } + }) + }); + + try { + const response = await fetch(`${bff.baseUrl}/feature-control/bootstrap`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(bootstrapBody()) + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.source, "fallback"); + assert.equal(json.upstreamError.status, 429); + assert.equal(json.flags.checkoutV2, "off"); + } finally { + await bff.stop(); + await closeServer(upstream.server); + } +}); + +test("bootstrap com timeout devolve fallback", async () => { + const upstream = createUpstreamStub({ evaluateMode: "timeout" }); + const upstreamPort = await listen(upstream.server); + const bffPort = upstreamPort + 1000; + const bff = await startBff({ + PORT: String(bffPort), + ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`, + FEATURE_CONTROL_MODULE_TOKEN: "token", + FEATURE_CONTROL_TIMEOUT_MS: "100" + }); + + try { + const response = await fetch(`${bff.baseUrl}/feature-control/bootstrap`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(bootstrapBody()) + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.source, "fallback"); + assert.equal(json.upstreamError.code, "FEATURE_CONTROL_UPSTREAM_TIMEOUT"); + } finally { + await bff.stop(); + await closeServer(upstream.server); + } +}); + +test("telemetria de exposure encaminha lote para upstream", async () => { + const upstream = createUpstreamStub({ evaluateMode: "success" }); + const upstreamPort = await listen(upstream.server); + const bffPort = upstreamPort + 1000; + const bff = await startBff({ + PORT: String(bffPort), + ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`, + FEATURE_CONTROL_MODULE_TOKEN: "token" + }); + + try { + const response = await fetch(`${bff.baseUrl}/feature-control/telemetry/exposure`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + events: [ + { featureKey: "fc.checkout_v2", variant: "on", subjectType: "customer", storeId: "store_1" } + ] + }) + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.ok, true); + assert.equal(upstream.state.exposurePayload.events[0].featureKey, "fc.checkout_v2"); + } finally { + await bff.stop(); + await closeServer(upstream.server); + } +}); + +test("telemetria rejeita payload sem eventos", async () => { + const upstream = createUpstreamStub({ evaluateMode: "success" }); + const upstreamPort = await listen(upstream.server); + const bffPort = upstreamPort + 1000; + const bff = await startBff({ + PORT: String(bffPort), + ATOMENTA_ORIGIN: `http://127.0.0.1:${upstreamPort}`, + FEATURE_CONTROL_MODULE_TOKEN: "token" + }); + + try { + const response = await fetch(`${bff.baseUrl}/feature-control/telemetry/exposure`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ events: [] }) + }); + assert.equal(response.status, 400); + const json = await response.json(); + assert.equal(json.ok, false); + assert.equal(json.code, "FEATURE_CONTROL_EVENTS_REQUIRED"); + } finally { + await bff.stop(); + await closeServer(upstream.server); + } +}); diff --git a/pedi-foods/Android/app/build.gradle.kts b/pedi-foods/Android/app/build.gradle.kts index 4752bf9..8a591a5 100644 --- a/pedi-foods/Android/app/build.gradle.kts +++ b/pedi-foods/Android/app/build.gradle.kts @@ -41,10 +41,6 @@ android { // versionName = MARKETING_VERSION } - buildFeatures { - buildConfig = true - } - lint { disable.add("Instantiatable") disable.add("MissingPermission") diff --git a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj index 3edd5e9..57006fe 100644 --- a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj +++ b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj @@ -300,7 +300,7 @@ repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.0.5; + minimumVersion = 1.0.7; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/pedi-foods/Package.swift b/pedi-foods/Package.swift index 3d1ebe1..888bcd1 100644 --- a/pedi-foods/Package.swift +++ b/pedi-foods/Package.swift @@ -10,8 +10,8 @@ let package = Package( .library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]), ], dependencies: [ - .package(url: "https://source.skip.tools/skip.git", from: "1.7.0"), - .package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0"), + .package(url: "https://source.skip.tools/skip.git", from: "1.8.4"), + .package(path: "Vendor/skip-fuse-ui"), .package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5") ], targets: [ diff --git a/pedi-foods/Sources/PediFoods/Components/StoreCard.swift b/pedi-foods/Sources/PediFoods/Components/StoreCard.swift index 9f7c7ad..26de91f 100644 --- a/pedi-foods/Sources/PediFoods/Components/StoreCard.swift +++ b/pedi-foods/Sources/PediFoods/Components/StoreCard.swift @@ -2,6 +2,7 @@ import SwiftUI struct FeaturedStoreCard: View { let store: FeaturedStoreCardModel + var onFavoriteToggle: (() -> Void)? = nil var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -21,7 +22,9 @@ struct FeaturedStoreCard: View { HStack { Spacer() - Button(action: {}) { + Button { + onFavoriteToggle?() + } label: { Image(systemName: store.isFavorite ? "heart.fill" : "heart") .foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted) .padding(8) @@ -29,6 +32,8 @@ struct FeaturedStoreCard: View { .clipShape(Circle()) .shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y) } + .buttonStyle(.borderless) + .accessibilityLabel(store.isFavorite ? "Remover loja dos favoritos" : "Adicionar loja aos favoritos") .padding(10) } } diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index fe2173c..5beb618 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI struct ContentView: View { @@ -15,6 +16,7 @@ struct ContentView: View { @State private var sessionExpiredObserver: NSObjectProtocol? #endif @State var cartResetObserver: Any? + @State var appResumeObserver: Any? #if os(Android) @State var snackbarCenter = SnackbarCenter.shared #else @@ -80,14 +82,26 @@ struct ContentView: View { } } } + .onChange(of: appState.profile.id) { _, _ in + Task { @MainActor in + await refreshFeatureFlags(forceRefresh: true) + } + } + .onChange(of: appState.cart.storeId) { _, _ in + Task { @MainActor in + await refreshFeatureFlags(forceRefresh: true) + } + } .onAppear { attachCartResetObserverIfNeeded() + attachAppResumeObserverIfNeeded() #if os(iOS) attachSessionExpiredObserverIfNeeded() #endif } .onDisappear { detachCartResetObserver() + detachAppResumeObserver() #if os(iOS) detachSessionExpiredObserver() #endif @@ -179,6 +193,7 @@ struct ContentView: View { // Keep local state when backend refresh fails transiently. } + await refreshFeatureFlags(forceRefresh: false) isBootstrappingSession = false } @@ -189,7 +204,9 @@ struct ContentView: View { object: nil, queue: nil ) { _ in - appState.cart = CartState() + Task { @MainActor in + appState.cart = CartState() + } } } @@ -199,6 +216,26 @@ struct ContentView: View { self.cartResetObserver = nil } + private func attachAppResumeObserverIfNeeded() { + guard appResumeObserver == nil else { return } + appResumeObserver = NotificationCenter.default.addObserver( + forName: .appDidResume, + object: nil, + queue: nil + ) { _ in + Task { @MainActor in + guard root == .main else { return } + await refreshFeatureFlags(forceRefresh: true) + } + } + } + + private func detachAppResumeObserver() { + guard let appResumeObserver else { return } + NotificationCenter.default.removeObserver(appResumeObserver) + self.appResumeObserver = nil + } + @MainActor private func hydrateAppState(with customer: CustomerProfile) { appState.profile.id = customer.id @@ -206,6 +243,7 @@ struct ContentView: View { appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? "" appState.profile.profilePicture = customer.profilePicture ?? "" + appState.favorites.storeIds = Set(customer.favorites ?? []) SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) @@ -299,6 +337,46 @@ struct ContentView: View { scheduleDisableAuthEntryPreparation() } + @MainActor + private func refreshFeatureFlags(forceRefresh: Bool) async { + guard root == .main else { return } + + let subjectType: String + let subjectId: String + if let profileId = appState.profile.id?.trimmingCharacters(in: .whitespacesAndNewlines), profileId.isEmpty == false { + subjectType = "customer" + subjectId = profileId + } else { + subjectType = "anonymous" + subjectId = "anonymous-device" + } + + var attrs: [String: String] = [:] + let addressLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines) + if addressLabel.isEmpty == false, addressLabel.lowercased() != "defina seu endereco" { + attrs["addressLabel"] = addressLabel + } + + let context = FeatureControlEvaluationContext( + subjectType: subjectType, + subjectId: subjectId, + storeId: appState.cart.storeId, + attributes: attrs + ) + + let snapshot = await FeatureControlService.shared.evaluate( + context: context, + jwt: appState.session.jwt, + forceRefresh: forceRefresh + ) + appState.featureFlags = snapshot + await FeatureControlService.shared.sendExposureEvents( + snapshot: snapshot, + context: context, + jwt: appState.session.jwt + ) + } + private func scheduleDisableAuthEntryPreparation() { Task { @MainActor in try? await Task.sleep(nanoseconds: 900_000_000) diff --git a/pedi-foods/Sources/PediFoods/PediFoodsApp.swift b/pedi-foods/Sources/PediFoods/PediFoodsApp.swift index 1d672a5..309fc70 100644 --- a/pedi-foods/Sources/PediFoods/PediFoodsApp.swift +++ b/pedi-foods/Sources/PediFoods/PediFoodsApp.swift @@ -39,6 +39,7 @@ let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFo /* SKIP @bridge */public func onResume() { logger.debug("onResume") + NotificationCenter.default.post(name: .appDidResume, object: nil) } /* SKIP @bridge */public func onPause() { diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 2a72535..b6d87f9 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -153,6 +153,9 @@ "Adicionar fotos" : { "comment" : "A label for adding photos in the order review section.", "isCommentAutoGenerated" : true + }, + "Adicionar loja aos favoritos" : { + }, "Adicionar novo endereço" : { "comment" : "A button label that translates to \"Add new address\" in English.", @@ -932,6 +935,9 @@ "Remover" : { "comment" : "A button that deletes the user's profile picture.", "isCommentAutoGenerated" : true + }, + "Remover loja dos favoritos" : { + }, "Resposta da loja" : { "comment" : "A heading for the reply from the store in an order review.", diff --git a/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift b/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift index 1fc39db..2c67a63 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift @@ -14,6 +14,17 @@ enum ApiConfig { return URL(string: raw) ?? URL(string: "https://atomenta.com.br")! } + static var featureControlBffURL: URL { + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_BFF_URL"] ?? "http://localhost:8787" + return URL(string: raw) ?? URL(string: "http://localhost:8787")! + } + + static var featureControlEnvironment: String { + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production" + let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return clean.isEmpty ? "production" : clean + } + // Tokens provided by backend modules static let storeToken = "550e8400-e29b-41d4-a716-446655440008" static let customerToken = "550e8400-e29b-41d4-a716-44665544000a" diff --git a/pedi-foods/Sources/PediFoods/Services/ApiFavoriteModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiFavoriteModels.swift new file mode 100644 index 0000000..317be1d --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiFavoriteModels.swift @@ -0,0 +1,6 @@ +import Foundation + +struct CustomerFavoritesMutationResult: Decodable { + let favorites: [String] + let store: StoreSummary? +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift index 8b54b96..4e207c3 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift @@ -21,6 +21,7 @@ struct CustomerProfile: Decodable { let email: String let phoneNumber: String? let profilePicture: String? + let favorites: [String]? let addressBook: [CustomerAddress]? enum CodingKeys: String, CodingKey { @@ -29,6 +30,7 @@ struct CustomerProfile: Decodable { case email case phoneNumber case profilePicture + case favorites case addressBook = "address_book" } } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index da0e965..ee295f4 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -23,6 +23,7 @@ final class ApiService { private var tokenStore: TokenStore private let profileCachePrefix = "api:profile:" private let ordersCachePrefix = "api:orders:" + private let favoritesCachePrefix = "api:favorites:" private let publicCategoriesCacheKey = "api:public-categories" init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) { @@ -68,10 +69,16 @@ final class ApiService { tokenStore.clear() AppContentCache.shared.invalidate(prefix: profileCachePrefix) AppContentCache.shared.invalidate(prefix: ordersCachePrefix) + AppContentCache.shared.invalidate(prefix: favoritesCachePrefix) AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey) NotificationCenter.default.post(name: .sessionExpired, object: message) } + private func invalidateFavoritesCache() { + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + AppContentCache.shared.invalidate(prefix: favoritesCachePrefix) + } + private func scopedCacheSuffix() -> String { let jwt = tokenStore.jwt ?? "anonymous" if jwt.count <= 16 { return jwt } @@ -168,7 +175,7 @@ final class ApiService { if envelope.error == false, envelope.result != nil { AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) } else { - AppContentCache.shared.invalidate(prefix: profileCachePrefix) + invalidateFavoritesCache() } return envelope } @@ -241,11 +248,51 @@ final class ApiService { if envelope.error == false, envelope.result != nil { AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) } else { - AppContentCache.shared.invalidate(prefix: profileCachePrefix) + invalidateFavoritesCache() } return envelope } + func listFavoriteStores(forceRefresh: Bool = false) async throws -> ApiEnvelope<[StoreSummary]> { + let cacheKey = "\(favoritesCachePrefix)\(scopedCacheSuffix())" + if forceRefresh == false, + let cached: ApiEnvelope<[StoreSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[StoreSummary]>.self) { + return cached + } + + let req = ApiRequest(path: "/api/customer/favorites", method: "GET", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope<[StoreSummary]> = try await sendEnvelope(req) + if envelope.error == false, envelope.result != nil { + AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours) + } + return envelope + } + + func addStoreToFavorites(storeId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + invalidateFavoritesCache() + } + return envelope + } + + func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + invalidateFavoritesCache() + } + return envelope + } + + func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope { + if isFavorite { + return try await addStoreToFavorites(storeId: storeId) + } + return try await removeStoreFromFavorites(storeId: storeId) + } + private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool { lhs.label == rhs.label && lhs.address == rhs.address && diff --git a/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift b/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift new file mode 100644 index 0000000..312a157 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift @@ -0,0 +1,287 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct FeatureControlRawFlag: Codable, Equatable { + let enabled: Bool + let variant: String + let payload: FeatureControlJSONValue? + let reason: String? +} + +enum FeatureControlJSONValue: Codable, Equatable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: FeatureControlJSONValue]) + case array([FeatureControlJSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: FeatureControlJSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([FeatureControlJSONValue].self) { + self = .array(value) + } else { + self = .null + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .object(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .null: try container.encodeNil() + } + } +} + +private struct FeatureControlBootstrapRequest: Codable { + struct Context: Codable { + let subjectType: String + let subjectId: String + let storeId: String? + let platform: String + let appVersion: String + let attributes: [String: String] + } + + let environment: String + let keys: [String] + let context: Context +} + +private struct FeatureControlBootstrapResponse: Codable { + let ok: Bool + let source: String? + let configVersion: Int + let evaluatedAt: String? + let flags: [String: FeatureFlagValue] + let raw: [String: FeatureControlRawFlag] +} + +private struct FeatureControlExposureRequest: Codable { + struct Event: Codable { + let featureKey: String + let variant: String + let subjectType: String + let storeId: String? + } + + let events: [Event] +} + +private struct FeatureControlCacheEntry: Codable { + let expiresAtUnixMs: Int64 + let snapshot: FeatureFlagsState +} + +struct FeatureControlEvaluationContext { + let subjectType: String + let subjectId: String + let storeId: String? + let attributes: [String: String] +} + +@MainActor +final class FeatureControlService { + static let shared = FeatureControlService() + + private let session: URLSession + private let cacheTTL: TimeInterval + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + private let userDefaults: UserDefaults + private let defaultsPrefix = "feature-control.cache.v1." + + init( + session: URLSession = .shared, + cacheTTL: TimeInterval = 60, + userDefaults: UserDefaults = .standard + ) { + self.session = session + self.cacheTTL = cacheTTL + self.userDefaults = userDefaults + } + + func evaluate( + context: FeatureControlEvaluationContext, + jwt: String?, + forceRefresh: Bool = false + ) async -> FeatureFlagsState { + let key = storageKey(for: context) + if forceRefresh == false, let cached = loadFromCache(storageKey: key) { + return cached + } + + let requestBody = FeatureControlBootstrapRequest( + environment: ApiConfig.featureControlEnvironment, + keys: featureKeys(), + context: .init( + subjectType: context.subjectType, + subjectId: context.subjectId, + storeId: context.storeId, + platform: platformName(), + appVersion: appVersion(), + attributes: context.attributes + ) + ) + + do { + let response = try await performBootstrapRequest(body: requestBody, jwt: jwt) + let snapshot = FeatureFlagsState( + configVersion: response.configVersion, + evaluatedAt: response.evaluatedAt, + source: response.source ?? "live", + values: response.flags, + raw: response.raw + ) + saveToCache(snapshot: snapshot, storageKey: key) + return snapshot + } catch { + if let cached = loadFromCache(storageKey: key) { + return FeatureFlagsState( + configVersion: cached.configVersion, + evaluatedAt: cached.evaluatedAt, + source: "cache_fallback", + values: cached.values, + raw: cached.raw + ) + } + return FeatureFlagsState(source: "defaults") + } + } + + func sendExposureEvents(snapshot: FeatureFlagsState, context: FeatureControlEvaluationContext, jwt: String?) async { + guard snapshot.raw.isEmpty == false else { return } + + let events = snapshot.raw.compactMap { entry -> FeatureControlExposureRequest.Event? in + let key = entry.key + let value = entry.value + guard value.enabled || value.variant.lowercased() != "off" else { return nil } + return .init( + featureKey: key, + variant: value.variant, + subjectType: context.subjectType, + storeId: context.storeId + ) + } + + guard events.isEmpty == false else { return } + + let batched = Array(events.prefix(100)) + let payload = FeatureControlExposureRequest(events: batched) + guard let body = try? encoder.encode(payload) else { return } + + var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/telemetry/exposure")) + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = 3 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let jwt, jwt.isEmpty == false { + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + } + + _ = try? await session.data(for: request) + } + + private func performBootstrapRequest(body: FeatureControlBootstrapRequest, jwt: String?) async throws -> FeatureControlBootstrapResponse { + var request = URLRequest(url: ApiConfig.featureControlBffURL.appendingPathComponent("/feature-control/bootstrap")) + request.httpMethod = "POST" + request.httpBody = try encoder.encode(body) + request.timeoutInterval = 3 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let jwt, jwt.isEmpty == false { + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + } + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + throw NetworkError.invalidResponse + } + + return try decoder.decode(FeatureControlBootstrapResponse.self, from: data) + } + + private func storageKey(for context: FeatureControlEvaluationContext) -> String { + let tokens = [ + ApiConfig.featureControlEnvironment, + context.subjectType, + context.subjectId, + context.storeId ?? "none", + platformName(), + appVersion(), + featureKeys().joined(separator: "|") + ] + let base = tokens.joined(separator: "::") + .lowercased() + .replacingOccurrences(of: " ", with: "_") + return defaultsPrefix + base + } + + private func saveToCache(snapshot: FeatureFlagsState, storageKey: String) { + let expiresAt = Int64((Date().timeIntervalSince1970 + cacheTTL) * 1000) + let entry = FeatureControlCacheEntry(expiresAtUnixMs: expiresAt, snapshot: snapshot) + guard let data = try? encoder.encode(entry) else { return } + userDefaults.set(data, forKey: storageKey) + } + + private func loadFromCache(storageKey: String) -> FeatureFlagsState? { + guard let data = userDefaults.data(forKey: storageKey), + let entry = try? decoder.decode(FeatureControlCacheEntry.self, from: data) else { + return nil + } + + let now = Int64(Date().timeIntervalSince1970 * 1000) + guard entry.expiresAtUnixMs > now else { + userDefaults.removeObject(forKey: storageKey) + return nil + } + + return entry.snapshot + } + + private func featureKeys() -> [String] { + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "fc.checkout_v2,fc.search_ranking_v3" + let items = raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0.isEmpty == false } + return items.isEmpty ? ["fc.checkout_v2"] : items + } + + private func platformName() -> String { + #if os(Android) + return "android" + #else + return "ios" + #endif + } + + private func appVersion() -> String { + #if canImport(UIKit) || canImport(AppKit) + let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let version, version.isEmpty == false { + return version + } + #endif + return "0.0.0" + } +} diff --git a/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift index 4746314..a25cc72 100644 --- a/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift +++ b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift @@ -4,4 +4,5 @@ extension Notification.Name { static let sessionExpired = Notification.Name("SessionExpiredNotification") static let cartDidReset = Notification.Name("CartDidResetNotification") static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification") + static let appDidResume = Notification.Name("AppDidResumeNotification") } diff --git a/pedi-foods/Sources/PediFoods/Services/StoreCatalogNormalizer.swift b/pedi-foods/Sources/PediFoods/Services/StoreCatalogNormalizer.swift new file mode 100644 index 0000000..563ee6e --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/StoreCatalogNormalizer.swift @@ -0,0 +1,280 @@ +import Foundation + +enum StoreCatalogNormalizer { + static func sanitize(categories: [StoreCatalogCategory], storeId: String) -> [StoreCatalogCategory] { + var seenCategoryIds: Set = [] + + return categories.enumerated().map { categoryIndex, category in + let categoryId = makeUniqueId( + rawValue: category.id, + fallback: "\(storeId)-category-\(categoryIndex)", + seenIds: &seenCategoryIds + ) + + let normalizedPizzaConfig = sanitize( + pizzaConfig: category.pizzaConfig, + categoryId: categoryId + ) + + var seenProductIds: Set = [] + let normalizedProducts = category.products.enumerated().map { productIndex, product in + sanitize( + product: product, + categoryId: categoryId, + productIndex: productIndex, + seenProductIds: &seenProductIds + ) + } + + return StoreCatalogCategory( + id: categoryId, + name: category.name, + isPizzaCategory: category.isPizzaCategory, + pizzaConfig: normalizedPizzaConfig, + products: normalizedProducts + ) + } + } + + static func preferredCategoryId( + from categories: [StoreCatalogCategory], + preferredId: String? + ) -> String? { + guard let preferredId, preferredId.isEmpty == false else { + return categories.first?.id + } + + if categories.contains(where: { $0.id == preferredId }) { + return preferredId + } + + return categories.first?.id + } + + private static func sanitize( + pizzaConfig: StorePizzaConfig?, + categoryId: String + ) -> StorePizzaConfig? { + guard let pizzaConfig else { return nil } + + var seenSizeIds: Set = [] + let normalizedSizes = pizzaConfig.sizes.enumerated().map { index, size in + StorePizzaSize( + id: makeUniqueId( + rawValue: size.id, + fallback: "\(categoryId)-size-\(index)", + seenIds: &seenSizeIds + ), + name: size.name, + slices: size.slices, + maxFlavors: size.maxFlavors + ) + } + + var seenDoughIds: Set = [] + let normalizedDoughs = pizzaConfig.doughs.enumerated().map { index, dough in + StorePizzaDough( + id: makeUniqueId( + rawValue: dough.id, + fallback: "\(categoryId)-dough-\(index)", + seenIds: &seenDoughIds + ), + name: dough.name, + active: dough.active + ) + } + + var seenCrustIds: Set = [] + let normalizedCrusts = pizzaConfig.crusts.enumerated().map { index, crust in + StorePizzaCrust( + id: makeUniqueId( + rawValue: crust.id, + fallback: "\(categoryId)-crust-\(index)", + seenIds: &seenCrustIds + ), + name: crust.name, + active: crust.active, + priceModifier: crust.priceModifier + ) + } + + return StorePizzaConfig( + sizes: normalizedSizes, + doughs: normalizedDoughs, + crusts: normalizedCrusts + ) + } + + private static func sanitize( + product: StoreCatalogProduct, + categoryId: String, + productIndex: Int, + seenProductIds: inout Set + ) -> StoreCatalogProduct { + let productId = makeUniqueId( + rawValue: product.id, + fallback: "\(categoryId)-product-\(productIndex)", + seenIds: &seenProductIds + ) + + var seenGroupIds: Set = [] + var seenAddonItemIds: Set = [] + let normalizedAddonGroups = product.addonGroups.enumerated().map { groupIndex, group in + let groupId = makeUniqueId( + rawValue: group.id, + fallback: "\(productId)-group-\(groupIndex)", + seenIds: &seenGroupIds + ) + + let normalizedItems = group.items.enumerated().map { itemIndex, item in + StoreAddonItem( + id: makeUniqueId( + rawValue: item.id, + fallback: "\(groupId)-item-\(itemIndex)", + seenIds: &seenAddonItemIds + ), + name: item.name, + price: item.price + ) + } + + return StoreAddonGroup( + id: groupId, + name: group.name, + minSelectors: group.minSelectors, + maxSelectors: group.maxSelectors, + items: normalizedItems + ) + } + + return StoreCatalogProduct( + id: productId, + type: product.type, + name: product.name, + description: product.description, + image: product.image, + price: product.price, + originalPrice: product.originalPrice, + pizzaPrices: product.pizzaPrices, + addonGroups: normalizedAddonGroups + ) + } + + private static func makeUniqueId( + rawValue: String, + fallback: String, + seenIds: inout Set + ) -> String { + let trimmedRawValue = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let baseId = trimmedRawValue.isEmpty ? fallback : trimmedRawValue + + if seenIds.contains(baseId) == false { + seenIds.insert(baseId) + return baseId + } + + var suffix = 1 + while true { + let candidate = "\(baseId)-\(suffix)" + if seenIds.contains(candidate) == false { + seenIds.insert(candidate) + return candidate + } + suffix += 1 + } + } +} + +extension StoreCatalogCategory { + init( + id: String, + name: String, + isPizzaCategory: Bool, + pizzaConfig: StorePizzaConfig?, + products: [StoreCatalogProduct] + ) { + self.id = id + self.name = name + self.isPizzaCategory = isPizzaCategory + self.pizzaConfig = pizzaConfig + self.products = products + } +} + +extension StoreCatalogProduct { + init( + id: String, + type: String?, + name: String, + description: String?, + image: String?, + price: Double?, + originalPrice: Double?, + pizzaPrices: [String: Double], + addonGroups: [StoreAddonGroup] + ) { + self.id = id + self.type = type + self.name = name + self.description = description + self.image = image + self.price = price + self.originalPrice = originalPrice + self.pizzaPrices = pizzaPrices + self.addonGroups = addonGroups + } +} + +extension StoreAddonGroup { + init( + id: String, + name: String, + minSelectors: Int?, + maxSelectors: Int?, + items: [StoreAddonItem] + ) { + self.id = id + self.name = name + self.minSelectors = minSelectors + self.maxSelectors = maxSelectors + self.items = items + } +} + +extension StoreAddonItem { + init( + id: String, + name: String, + price: Double? + ) { + self.id = id + self.name = name + self.price = price + } +} + +extension StorePizzaConfig { + init( + sizes: [StorePizzaSize], + doughs: [StorePizzaDough], + crusts: [StorePizzaCrust] + ) { + self.sizes = sizes + self.doughs = doughs + self.crusts = crusts + } +} + +extension StorePizzaCrust { + init( + id: String, + name: String?, + active: Bool?, + priceModifier: Double? + ) { + self.id = id + self.name = name + self.active = active + self.priceModifier = priceModifier + } +} diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index b48f28b..6788b70 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -6,10 +6,64 @@ struct AppState { var cart = CartState() var address = AddressState() var favorites = FavoritesState() + var featureFlags = FeatureFlagsState() var homeFilters = HomeFiltersState() var activeModal: AppModal? = nil } +enum FeatureFlagValue: Codable, Equatable { + case boolean(Bool) + case text(String) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let boolValue = try? container.decode(Bool.self) { + self = .boolean(boolValue) + return + } + if let stringValue = try? container.decode(String.self) { + self = .text(stringValue) + return + } + if let intValue = try? container.decode(Int.self) { + self = .text(String(intValue)) + return + } + if let doubleValue = try? container.decode(Double.self) { + self = .text(String(doubleValue)) + return + } + self = .text("off") + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .boolean(let value): + try container.encode(value) + case .text(let value): + try container.encode(value) + } + } + + var boolValue: Bool { + switch self { + case .boolean(let value): + return value + case .text(let value): + return value.lowercased() == "on" || value.lowercased() == "true" + } + } +} + +struct FeatureFlagsState: Codable, Equatable { + var configVersion: Int = 0 + var evaluatedAt: String? = nil + var source: String = "default" + var values: [String: FeatureFlagValue] = [:] + var raw: [String: FeatureControlRawFlag] = [:] +} + enum AppModal: String, Identifiable { case addressPicker case filters diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift index 93c4a93..25c8e6c 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift @@ -285,6 +285,7 @@ struct OtpView: View { appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? "" appState.profile.profilePicture = customer.profilePicture ?? "" + appState.favorites.storeIds = Set(customer.favorites ?? []) SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) @@ -315,6 +316,7 @@ struct OtpView: View { } appState.profile.email = email + appState.favorites = FavoritesState() SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: nil, email: email) ) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift index 4489f2b..c5eef3d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeScrollOffsetObserver.swift @@ -1,76 +1,35 @@ import SwiftUI -#if canImport(UIKit) -import UIKit -#endif + +enum HomeScrollCoordinateSpace { + static let name = "home-scroll" +} #if os(iOS) -@MainActor -struct ScrollOffsetObserver: UIViewRepresentable { - let onOffsetChange: (CGFloat) -> Void +struct HomeScrollOffsetPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 - func makeUIView(context: Context) -> ScrollOffsetProbeView { - let view = ScrollOffsetProbeView() - view.onOffsetChange = onOffsetChange - return view - } - - func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) { - uiView.onOffsetChange = onOffsetChange - uiView.attachIfNeeded() + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() } } -@MainActor -final class ScrollOffsetProbeView: UIView { - var onOffsetChange: (CGFloat) -> Void = { _ in } +struct ScrollOffsetObserver: View { + let onOffsetChange: (CGFloat) -> Void - private weak var observedScrollView: UIScrollView? - private var observation: NSKeyValueObservation? - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .clear - isUserInteractionEnabled = false - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - } - - deinit { - observation?.invalidate() - } - - override func didMoveToSuperview() { - super.didMoveToSuperview() - attachIfNeeded() - } - - override func didMoveToWindow() { - super.didMoveToWindow() - attachIfNeeded() - } - - func attachIfNeeded() { - guard let scrollView = findEnclosingScrollView() else { return } - guard scrollView !== observedScrollView else { return } - - observation?.invalidate() - observedScrollView = scrollView - observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in - self?.onOffsetChange(sv.contentOffset.y) - } - } - - private func findEnclosingScrollView() -> UIScrollView? { - var view: UIView? = self - while let current = view { - if let scrollView = current as? UIScrollView { - return scrollView + var body: some View { + Color.clear + .frame(height: 0) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: HomeScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named(HomeScrollCoordinateSpace.name)).minY + ) + } + ) + .onPreferenceChange(HomeScrollOffsetPreferenceKey.self) { minY in + onOffsetChange(-minY) } - view = current.superview - } - return nil } } #endif diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Favorites.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Favorites.swift new file mode 100644 index 0000000..b5fcc40 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Favorites.swift @@ -0,0 +1,42 @@ +import Foundation + +extension HomeView { + @MainActor + func toggleFavoriteStore(storeId: String, storeName: String) async { + guard favoriteRequestStoreIds.contains(storeId) == false else { return } + guard appState.session.isAuthenticated else { + SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5) + return + } + + let isFavorite = appState.favorites.storeIds.contains(storeId) + favoriteRequestStoreIds.insert(storeId) + defer { favoriteRequestStoreIds.remove(storeId) } + + do { + let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false) + guard response.error == false, let result = response.result else { + let message = response.message ?? "Não foi possível atualizar seus favoritos." + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5) + return + } + + appState.favorites.storeIds = Set(result.favorites) + let successTitle = isFavorite + ? "\(storeName) removida dos favoritos." + : "\(storeName) adicionada aos favoritos." + let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill" + SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2) + } catch { + let message: String + if let networkError = error as? NetworkError { + message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos." + } else if let serviceError = error as? ApiServiceError { + message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos." + } else { + message = "Não foi possível atualizar seus favoritos." + } + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index f2d28fd..2474a1d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -19,6 +19,7 @@ struct HomeView: View { @State var stores: [StoreSummary] = [] @State var productSearchIndexByStoreId: [String: [String]] = [:] @State var searchDebounceToken = 0 + @State var favoriteRequestStoreIds: Set = [] private let specials: [SpecialOfferCardModel] = [ // .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]), @@ -47,7 +48,7 @@ struct HomeView: View { refreshCategories: true ) } - .background(scrollOffsetObserver) + .coordinateSpace(name: HomeScrollCoordinateSpace.name) header(collapseProgress: collapseProgress, height: headerHeight) .frame(maxWidth: .infinity, alignment: .top) @@ -84,6 +85,7 @@ struct HomeView: View { private var contentStack: some View { VStack(spacing: 24) { + scrollOffsetObserver categoriesSection section(title: "Featured") { @@ -102,7 +104,14 @@ struct HomeView: View { appState: $appState ) } label: { - FeaturedStoreCard(store: store) + FeaturedStoreCard( + store: store, + onFavoriteToggle: { + Task { + await toggleFavoriteStore(storeId: store.id, storeName: store.name) + } + } + ) .frame(width: 190) } .buttonStyle(.plain) @@ -171,7 +180,14 @@ struct HomeView: View { appState: $appState ) } label: { - FeaturedStoreCard(store: store) + FeaturedStoreCard( + store: store, + onFavoriteToggle: { + Task { + await toggleFavoriteStore(storeId: store.id, storeName: store.name) + } + } + ) } .buttonStyle(.plain) } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift index ba3b30c..1ba5e28 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift @@ -1,7 +1,4 @@ import SwiftUI -#if canImport(UIKit) -import UIKit -#endif struct CategoryHeaderOffsetPreferenceKey: PreferenceKey { static let defaultValue: [String: CGFloat] = [:] @@ -11,125 +8,41 @@ struct CategoryHeaderOffsetPreferenceKey: PreferenceKey { } } +enum StoreDetailScrollCoordinateSpace { + static let name = "store-detail-scroll" +} + +struct ScrollOffsetPreferenceKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + struct ScrollOffsetReader: View { @Binding var offsetY: CGFloat var body: some View { -#if canImport(UIKit) - ScrollOffsetReaderRepresentable(offsetY: $offsetY) -#else Color.clear -#endif + .frame(height: 0) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: ScrollOffsetPreferenceKey.self, + value: geometry.frame(in: .named(StoreDetailScrollCoordinateSpace.name)).minY + ) + } + ) + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in + let normalizedOffset = max(0, -minY) + if abs(offsetY - normalizedOffset) > 0.5 { + offsetY = normalizedOffset + } + } } } -#if canImport(UIKit) -struct ScrollOffsetReaderRepresentable: UIViewRepresentable { - @Binding var offsetY: CGFloat - - func makeUIView(context: Context) -> OffsetProbeView { - let view = OffsetProbeView() - view.onOffsetChanged = { value in - if offsetY != value { - offsetY = value - } - } - return view - } - - func updateUIView(_ uiView: OffsetProbeView, context: Context) { - uiView.onOffsetChanged = { value in - if offsetY != value { - offsetY = value - } - } - } -} - -final class OffsetProbeView: UIView { - var onOffsetChanged: ((CGFloat) -> Void)? - private var observation: NSKeyValueObservation? - private weak var observedScrollView: UIScrollView? - - override func didMoveToWindow() { - super.didMoveToWindow() - attachIfNeeded() - } - - override func didMoveToSuperview() { - super.didMoveToSuperview() - attachIfNeeded() - } - - private func attachIfNeeded() { - guard observation == nil else { return } - guard observedScrollView == nil else { return } - - if let scrollView = enclosingScrollView() ?? findScrollViewInWindow() { - observe(scrollView) - return - } - - retryAttach() - } - - private func observe(_ scrollView: UIScrollView) { - observedScrollView = scrollView - observation = scrollView.observe(\.contentOffset, options: [.new, .initial]) { [weak self, weak scrollView] _, change in - guard let self, let scrollView, let y = change.newValue?.y else { return } - let adjusted = max(0, y + scrollView.adjustedContentInset.top) - DispatchQueue.main.async { - self.onOffsetChanged?(adjusted) - } - } - } - - private func retryAttach() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.attachIfNeeded() - } - } - - private func enclosingScrollView() -> UIScrollView? { - var current: UIView? = self - while let view = current { - if let scrollView = view as? UIScrollView { - return scrollView - } - current = view.superview - } - return nil - } - - private func findScrollViewInWindow() -> UIScrollView? { - guard let window else { return nil } - let targetPoint = convert(CGPoint(x: bounds.midX, y: bounds.midY), to: window) - return findScrollView(in: window, containing: targetPoint) - } - - private func findScrollView(in root: UIView, containing point: CGPoint) -> UIScrollView? { - for subview in root.subviews.reversed() { - if let match = findScrollView(in: subview, containing: point) { - return match - } - } - - if let scrollView = root as? UIScrollView { - let rectInWindow = scrollView.convert(scrollView.bounds, to: window) - if rectInWindow.contains(point) { - return scrollView - } - } - - return nil - } - - deinit { - observation?.invalidate() - } -} -#endif - struct AsyncStoreImage: View { let imageURL: String? diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift index f510296..b0dba94 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -1,11 +1,15 @@ import SwiftUI extension StoreDetailView { - func heroIconButton(icon: String, action: @escaping () -> Void) -> some View { + func heroIconButton( + icon: String, + foregroundStyle: Color = .white, + action: @escaping () -> Void + ) -> some View { Button(action: action) { Image(systemName: icon) .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.white) + .foregroundStyle(foregroundStyle) .frame(width: 32, height: 32) .background(Color.white.opacity(0.24)) .clipShape(Circle()) @@ -65,6 +69,49 @@ extension StoreDetailView { info?.isOpen ?? true } + var isFavoriteStore: Bool { + appState.favorites.storeIds.contains(storeId) + } + + @MainActor + func toggleFavoriteStore() async { + guard isFavoriteRequestInFlight == false else { return } + guard appState.session.isAuthenticated else { + SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5) + return + } + + let isFavorite = isFavoriteStore + isFavoriteRequestInFlight = true + defer { isFavoriteRequestInFlight = false } + + do { + let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false) + guard response.error == false, let result = response.result else { + let message = response.message ?? "Não foi possível atualizar seus favoritos." + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5) + return + } + + appState.favorites.storeIds = Set(result.favorites) + let successTitle = isFavorite + ? "\(storeName) removida dos favoritos." + : "\(storeName) adicionada aos favoritos." + let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill" + SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2) + } catch { + let message: String + if let networkError = error as? NetworkError { + message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos." + } else if let serviceError = error as? ApiServiceError { + message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos." + } else { + message = "Não foi possível atualizar seus favoritos." + } + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + } + var summaryCardHeight: CGFloat { summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight) } @@ -97,9 +144,13 @@ extension StoreDetailView { if forceRefresh == false, let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self), let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) { + let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: cachedCatalog, storeId: storeId) info = cachedInfo - categories = cachedCatalog - selectedCategoryId = cachedCatalog.first?.id + categories = normalizedCatalog + selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( + from: normalizedCatalog, + preferredId: selectedCategoryId + ) isLoading = false return } @@ -110,9 +161,9 @@ extension StoreDetailView { } do { - async let infoRequest = ApiService().storeInfo(storeId: storeId) - async let catalogRequest = ApiService().storeCatalog(storeId: storeId) - let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest) + let apiService = ApiService() + let infoResponse = try await apiService.storeInfo(storeId: storeId) + let catalogResponse = try await apiService.storeCatalog(storeId: storeId) if infoResponse.error { errorMessage = infoResponse.message ?? "Não foi possível carregar a loja." @@ -125,13 +176,21 @@ extension StoreDetailView { return } + let normalizedCatalog = StoreCatalogNormalizer.sanitize( + categories: catalogResponse.result ?? [], + storeId: storeId + ) + info = infoResponse.result - categories = catalogResponse.result ?? [] - selectedCategoryId = categories.first?.id + categories = normalizedCatalog + selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId( + from: normalizedCatalog, + preferredId: selectedCategoryId + ) if let info = infoResponse.result { AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours) } - AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) + AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours) isLoading = false } catch { isLoading = false diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift index 4834975..df42bbb 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift @@ -34,6 +34,7 @@ struct StoreDetailView: View { @State var categoryHeaderOffsets: [String: CGFloat] = [:] @State var isProgrammaticCategoryScroll = false @State var scrollOffsetY: CGFloat = 0 + @State var isFavoriteRequestInFlight = false let cardTopInset: CGFloat = 168 let summaryCardBaseHeight: CGFloat = 170 @@ -52,6 +53,7 @@ struct StoreDetailView: View { ScrollView(showsIndicators: false) { #if os(Android) LazyVStack(spacing: 0) { + ScrollOffsetReader(offsetY: $scrollOffsetY) topSection categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) @@ -60,6 +62,7 @@ struct StoreDetailView: View { } #else LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ScrollOffsetReader(offsetY: $scrollOffsetY) topSection categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) @@ -71,8 +74,8 @@ struct StoreDetailView: View { .refreshable { await loadStoreData(forceRefresh: true) } + .coordinateSpace(name: StoreDetailScrollCoordinateSpace.name) .ignoresSafeArea(edges: .top) - .background(ScrollOffsetReader(offsetY: $scrollOffsetY)) } .ignoresSafeArea(edges: .top) .overlay(alignment: .top) { @@ -218,7 +221,14 @@ struct StoreDetailView: View { } Spacer() heroIconButton(icon: "magnifyingglass") {} - heroIconButton(icon: "heart") {} + heroIconButton( + icon: isFavoriteStore ? "heart.fill" : "heart", + foregroundStyle: isFavoriteStore ? Color.red : Color.white + ) { + Task { + await toggleFavoriteStore() + } + } } .padding(.horizontal, 14) .padding(.top, UIDevice.topNotch) diff --git a/pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift b/pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift new file mode 100644 index 0000000..d91e743 --- /dev/null +++ b/pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import PediFoods + +@Test("Customer profile decodes persisted favorite store ids") +func customerProfileDecodesFavoriteStoreIds() throws { + let json = """ + { + "error": false, + "result": { + "id": "cust_1", + "name": "Daniel", + "email": "daniel@example.com", + "phoneNumber": "+5511999999999", + "favorites": ["store_a", "store_b"], + "address_book": [] + } + } + """ + + let envelope = try JSONDecoder().decode(ApiEnvelope.self, from: Data(json.utf8)) + #expect(envelope.result?.favorites == ["store_a", "store_b"]) +} + +@Test("Favorite mutation decodes updated favorites array") +func favoriteMutationDecodesUpdatedFavoritesArray() throws { + let json = """ + { + "error": false, + "result": { + "favorites": ["store_a"], + "store": { + "id": "store_a", + "name": "CPS Drinks", + "category": "Doces & Bolos", + "rating": 4.8, + "totalReviews": 12, + "isOpen": true, + "statusLabel": "Aberto" + } + } + } + """ + + let envelope = try JSONDecoder().decode(ApiEnvelope.self, from: Data(json.utf8)) + #expect(envelope.result?.favorites == ["store_a"]) + #expect(envelope.result?.store?.id == "store_a") + #expect(envelope.result?.store?.name == "CPS Drinks") +} diff --git a/pedi-foods/Tests/PediFoodsTests/StoreCatalogNormalizerTests.swift b/pedi-foods/Tests/PediFoodsTests/StoreCatalogNormalizerTests.swift new file mode 100644 index 0000000..cc85638 --- /dev/null +++ b/pedi-foods/Tests/PediFoodsTests/StoreCatalogNormalizerTests.swift @@ -0,0 +1,117 @@ +import Testing +@testable import PediFoods + +@Test("Store catalog normalizer makes IDs non-empty and unique") +func storeCatalogNormalizerMakesIdsUnique() { + let catalog = [ + StoreCatalogCategory( + id: "", + name: "Pizzas", + isPizzaCategory: true, + pizzaConfig: StorePizzaConfig( + sizes: [ + StorePizzaSize(id: "", name: "Grande", slices: 8, maxFlavors: 2), + StorePizzaSize(id: "", name: "Familia", slices: 12, maxFlavors: 3) + ], + doughs: [ + StorePizzaDough(id: "massa", name: "Tradicional", active: true), + StorePizzaDough(id: "massa", name: "Fina", active: true) + ], + crusts: [ + StorePizzaCrust(id: "", name: "Cheddar", active: true, priceModifier: 5), + StorePizzaCrust(id: "", name: "Catupiry", active: true, priceModifier: 6) + ] + ), + products: [ + StoreCatalogProduct( + id: "", + type: "pizza", + name: "Calabresa", + description: nil, + image: nil, + price: 10, + originalPrice: nil, + pizzaPrices: [:], + addonGroups: [ + StoreAddonGroup( + id: "", + name: "Extras", + minSelectors: nil, + maxSelectors: nil, + items: [ + StoreAddonItem(id: "", name: "Bacon", price: 2), + StoreAddonItem(id: "", name: "Bacon em dobro", price: 4) + ] + ), + StoreAddonGroup( + id: "", + name: "Molhos", + minSelectors: nil, + maxSelectors: nil, + items: [ + StoreAddonItem(id: "", name: "Alho", price: 1) + ] + ) + ] + ), + StoreCatalogProduct( + id: "", + type: "pizza", + name: "Mussarela", + description: nil, + image: nil, + price: 12, + originalPrice: nil, + pizzaPrices: [:], + addonGroups: [] + ) + ] + ), + StoreCatalogCategory( + id: "", + name: "Bebidas", + isPizzaCategory: false, + pizzaConfig: nil, + products: [ + StoreCatalogProduct( + id: "", + type: nil, + name: "Refrigerante", + description: nil, + image: nil, + price: 7, + originalPrice: nil, + pizzaPrices: [:], + addonGroups: [] + ) + ] + ) + ] + + let normalized = StoreCatalogNormalizer.sanitize(categories: catalog, storeId: "store-1") + + let categoryIds = normalized.map(\.id) + #expect(Set(categoryIds).count == categoryIds.count) + #expect(categoryIds.allSatisfy { $0.isEmpty == false }) + + let firstCategory = normalized[0] + let productIds = firstCategory.products.map(\.id) + #expect(Set(productIds).count == productIds.count) + #expect(productIds.allSatisfy { $0.isEmpty == false }) + + let addonGroupIds = firstCategory.products[0].addonGroups.map(\.id) + #expect(Set(addonGroupIds).count == addonGroupIds.count) + #expect(addonGroupIds.allSatisfy { $0.isEmpty == false }) + + let addonItemIds = firstCategory.products[0].addonGroups.flatMap(\.items).map(\.id) + #expect(Set(addonItemIds).count == addonItemIds.count) + #expect(addonItemIds.allSatisfy { $0.isEmpty == false }) + + let sizeIds = firstCategory.pizzaConfig?.sizes.map(\.id) ?? [] + let doughIds = firstCategory.pizzaConfig?.doughs.map(\.id) ?? [] + let crustIds = firstCategory.pizzaConfig?.crusts.map(\.id) ?? [] + + #expect(Set(sizeIds).count == sizeIds.count) + #expect(Set(doughIds).count == doughIds.count) + #expect(Set(crustIds).count == crustIds.count) +} diff --git a/sim-home.png b/sim-home.png new file mode 100644 index 0000000..742d33f Binary files /dev/null and b/sim-home.png differ