Merge pull request 'feature/step-001' (#3) from feature/step-001 into main

Reviewed-on: Loverde-Company-LTDA/Pedi-Foods-Skip#3
This commit is contained in:
2026-04-16 14:53:53 -03:00
31 changed files with 2045 additions and 217 deletions

View File

@@ -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. - 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. - Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil.
### 5. List Favorite Stores
**GET** `/api/customer/favorites`
**Headers:**
- `Authorization: Bearer <JWT_Token>`
- `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 <JWT_Token>`
- `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 <JWT_Token>`
- `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 ## 🏠 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:** **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 ```json
{ {
"id": "ord_987...", "event": "order_cancelled",
"shortId": "1234", "emittedAt": "2026-03-05T18:00:00.000Z",
"storeId": "store_123...", "orderId": "ffdc47b4-6a55-4713-aea9-45169e4cf0d7",
"userId": "cust_uuid...", "shortId": "3778",
"status": "CONFIRMED", "storeId": "store_1772117366848_wmqw4",
"paymentStatus": "CONFIRMED", "userId": "cust_uuid_001",
"updatedAt": "2026-02-15T20:10:00.000Z" "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` - depois: a cada `10s`
- parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela. - 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):** **Público/App (Customer):**
- `GET /api/public/reviews/tags` → catálogo oficial de tags e regras de validação. - `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. - Não hardcodear tags no app; usar o catálogo do backend.
- Enviar no POST de review apenas os `id` retornados em cada bloco. - 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. Permite o customer enviar review da loja a partir de um pedido finalizado.
**Endpoint:** **Endpoint:**
@@ -749,7 +895,7 @@ Exemplo inválido (entrega positiva com tags negativas):
Retorno esperado: `400 INVALID_DELIVERY_SENTIMENT_TAGS`. 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: Para dashboard da loja e análise de produto, usar:
- `GET /api/store/:storeId/reviews` - `GET /api/store/:storeId/reviews`

View File

@@ -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 <JWT>`, 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
```

View File

@@ -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"
});
}
}

View File

@@ -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"
}
}

View File

@@ -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}`);
});

View File

@@ -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);
}
});

View File

@@ -41,10 +41,6 @@ android {
// versionName = MARKETING_VERSION // versionName = MARKETING_VERSION
} }
buildFeatures {
buildConfig = true
}
lint { lint {
disable.add("Instantiatable") disable.add("Instantiatable")
disable.add("MissingPermission") disable.add("MissingPermission")

View File

@@ -300,7 +300,7 @@
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git"; repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 1.0.5; minimumVersion = 1.0.7;
}; };
}; };
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */

View File

@@ -10,8 +10,8 @@ let package = Package(
.library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]), .library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]),
], ],
dependencies: [ dependencies: [
.package(url: "https://source.skip.tools/skip.git", from: "1.7.0"), .package(url: "https://source.skip.tools/skip.git", from: "1.8.4"),
.package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0"), .package(path: "Vendor/skip-fuse-ui"),
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5") .package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5")
], ],
targets: [ targets: [

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct FeaturedStoreCard: View { struct FeaturedStoreCard: View {
let store: FeaturedStoreCardModel let store: FeaturedStoreCardModel
var onFavoriteToggle: (() -> Void)? = nil
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -21,7 +22,9 @@ struct FeaturedStoreCard: View {
HStack { HStack {
Spacer() Spacer()
Button(action: {}) { Button {
onFavoriteToggle?()
} label: {
Image(systemName: store.isFavorite ? "heart.fill" : "heart") Image(systemName: store.isFavorite ? "heart.fill" : "heart")
.foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted) .foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted)
.padding(8) .padding(8)
@@ -29,6 +32,8 @@ struct FeaturedStoreCard: View {
.clipShape(Circle()) .clipShape(Circle())
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y) .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) .padding(10)
} }
} }

View File

@@ -1,3 +1,4 @@
import Foundation
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@@ -15,6 +16,7 @@ struct ContentView: View {
@State private var sessionExpiredObserver: NSObjectProtocol? @State private var sessionExpiredObserver: NSObjectProtocol?
#endif #endif
@State var cartResetObserver: Any? @State var cartResetObserver: Any?
@State var appResumeObserver: Any?
#if os(Android) #if os(Android)
@State var snackbarCenter = SnackbarCenter.shared @State var snackbarCenter = SnackbarCenter.shared
#else #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 { .onAppear {
attachCartResetObserverIfNeeded() attachCartResetObserverIfNeeded()
attachAppResumeObserverIfNeeded()
#if os(iOS) #if os(iOS)
attachSessionExpiredObserverIfNeeded() attachSessionExpiredObserverIfNeeded()
#endif #endif
} }
.onDisappear { .onDisappear {
detachCartResetObserver() detachCartResetObserver()
detachAppResumeObserver()
#if os(iOS) #if os(iOS)
detachSessionExpiredObserver() detachSessionExpiredObserver()
#endif #endif
@@ -179,6 +193,7 @@ struct ContentView: View {
// Keep local state when backend refresh fails transiently. // Keep local state when backend refresh fails transiently.
} }
await refreshFeatureFlags(forceRefresh: false)
isBootstrappingSession = false isBootstrappingSession = false
} }
@@ -189,7 +204,9 @@ struct ContentView: View {
object: nil, object: nil,
queue: nil queue: nil
) { _ in ) { _ in
appState.cart = CartState() Task { @MainActor in
appState.cart = CartState()
}
} }
} }
@@ -199,6 +216,26 @@ struct ContentView: View {
self.cartResetObserver = nil 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 @MainActor
private func hydrateAppState(with customer: CustomerProfile) { private func hydrateAppState(with customer: CustomerProfile) {
appState.profile.id = customer.id appState.profile.id = customer.id
@@ -206,6 +243,7 @@ struct ContentView: View {
appState.profile.email = customer.email appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? "" appState.profile.phone = customer.phoneNumber ?? ""
appState.profile.profilePicture = customer.profilePicture ?? "" appState.profile.profilePicture = customer.profilePicture ?? ""
appState.favorites.storeIds = Set(customer.favorites ?? [])
SessionStateStore.setActiveUserKey( SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
) )
@@ -299,6 +337,46 @@ struct ContentView: View {
scheduleDisableAuthEntryPreparation() 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() { private func scheduleDisableAuthEntryPreparation() {
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(nanoseconds: 900_000_000) try? await Task.sleep(nanoseconds: 900_000_000)

View File

@@ -39,6 +39,7 @@ let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFo
/* SKIP @bridge */public func onResume() { /* SKIP @bridge */public func onResume() {
logger.debug("onResume") logger.debug("onResume")
NotificationCenter.default.post(name: .appDidResume, object: nil)
} }
/* SKIP @bridge */public func onPause() { /* SKIP @bridge */public func onPause() {

View File

@@ -153,6 +153,9 @@
"Adicionar fotos" : { "Adicionar fotos" : {
"comment" : "A label for adding photos in the order review section.", "comment" : "A label for adding photos in the order review section.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Adicionar loja aos favoritos" : {
}, },
"Adicionar novo endereço" : { "Adicionar novo endereço" : {
"comment" : "A button label that translates to \"Add new address\" in English.", "comment" : "A button label that translates to \"Add new address\" in English.",
@@ -932,6 +935,9 @@
"Remover" : { "Remover" : {
"comment" : "A button that deletes the user's profile picture.", "comment" : "A button that deletes the user's profile picture.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Remover loja dos favoritos" : {
}, },
"Resposta da loja" : { "Resposta da loja" : {
"comment" : "A heading for the reply from the store in an order review.", "comment" : "A heading for the reply from the store in an order review.",

View File

@@ -14,6 +14,17 @@ enum ApiConfig {
return URL(string: raw) ?? URL(string: "https://atomenta.com.br")! 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 // Tokens provided by backend modules
static let storeToken = "550e8400-e29b-41d4-a716-446655440008" static let storeToken = "550e8400-e29b-41d4-a716-446655440008"
static let customerToken = "550e8400-e29b-41d4-a716-44665544000a" static let customerToken = "550e8400-e29b-41d4-a716-44665544000a"

View File

@@ -0,0 +1,6 @@
import Foundation
struct CustomerFavoritesMutationResult: Decodable {
let favorites: [String]
let store: StoreSummary?
}

View File

@@ -21,6 +21,7 @@ struct CustomerProfile: Decodable {
let email: String let email: String
let phoneNumber: String? let phoneNumber: String?
let profilePicture: String? let profilePicture: String?
let favorites: [String]?
let addressBook: [CustomerAddress]? let addressBook: [CustomerAddress]?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
@@ -29,6 +30,7 @@ struct CustomerProfile: Decodable {
case email case email
case phoneNumber case phoneNumber
case profilePicture case profilePicture
case favorites
case addressBook = "address_book" case addressBook = "address_book"
} }
} }

View File

@@ -23,6 +23,7 @@ final class ApiService {
private var tokenStore: TokenStore private var tokenStore: TokenStore
private let profileCachePrefix = "api:profile:" private let profileCachePrefix = "api:profile:"
private let ordersCachePrefix = "api:orders:" private let ordersCachePrefix = "api:orders:"
private let favoritesCachePrefix = "api:favorites:"
private let publicCategoriesCacheKey = "api:public-categories" private let publicCategoriesCacheKey = "api:public-categories"
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) { init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
@@ -68,10 +69,16 @@ final class ApiService {
tokenStore.clear() tokenStore.clear()
AppContentCache.shared.invalidate(prefix: profileCachePrefix) AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: ordersCachePrefix) AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey) AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
NotificationCenter.default.post(name: .sessionExpired, object: message) NotificationCenter.default.post(name: .sessionExpired, object: message)
} }
private func invalidateFavoritesCache() {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
}
private func scopedCacheSuffix() -> String { private func scopedCacheSuffix() -> String {
let jwt = tokenStore.jwt ?? "anonymous" let jwt = tokenStore.jwt ?? "anonymous"
if jwt.count <= 16 { return jwt } if jwt.count <= 16 { return jwt }
@@ -168,7 +175,7 @@ final class ApiService {
if envelope.error == false, envelope.result != nil { if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
} else { } else {
AppContentCache.shared.invalidate(prefix: profileCachePrefix) invalidateFavoritesCache()
} }
return envelope return envelope
} }
@@ -241,11 +248,51 @@ final class ApiService {
if envelope.error == false, envelope.result != nil { if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
} else { } else {
AppContentCache.shared.invalidate(prefix: profileCachePrefix) invalidateFavoritesCache()
} }
return envelope 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<CustomerFavoritesMutationResult> {
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
if envelope.error == false {
invalidateFavoritesCache()
}
return envelope
}
func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
if envelope.error == false {
invalidateFavoritesCache()
}
return envelope
}
func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
if isFavorite {
return try await addStoreToFavorites(storeId: storeId)
}
return try await removeStoreFromFavorites(storeId: storeId)
}
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool { private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
lhs.label == rhs.label && lhs.label == rhs.label &&
lhs.address == rhs.address && lhs.address == rhs.address &&

View File

@@ -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"
}
}

View File

@@ -4,4 +4,5 @@ extension Notification.Name {
static let sessionExpired = Notification.Name("SessionExpiredNotification") static let sessionExpired = Notification.Name("SessionExpiredNotification")
static let cartDidReset = Notification.Name("CartDidResetNotification") static let cartDidReset = Notification.Name("CartDidResetNotification")
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification") static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
static let appDidResume = Notification.Name("AppDidResumeNotification")
} }

View File

@@ -0,0 +1,280 @@
import Foundation
enum StoreCatalogNormalizer {
static func sanitize(categories: [StoreCatalogCategory], storeId: String) -> [StoreCatalogCategory] {
var seenCategoryIds: Set<String> = []
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<String> = []
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<String> = []
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<String> = []
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<String> = []
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<String>
) -> StoreCatalogProduct {
let productId = makeUniqueId(
rawValue: product.id,
fallback: "\(categoryId)-product-\(productIndex)",
seenIds: &seenProductIds
)
var seenGroupIds: Set<String> = []
var seenAddonItemIds: Set<String> = []
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>
) -> 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
}
}

View File

@@ -6,10 +6,64 @@ struct AppState {
var cart = CartState() var cart = CartState()
var address = AddressState() var address = AddressState()
var favorites = FavoritesState() var favorites = FavoritesState()
var featureFlags = FeatureFlagsState()
var homeFilters = HomeFiltersState() var homeFilters = HomeFiltersState()
var activeModal: AppModal? = nil 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 { enum AppModal: String, Identifiable {
case addressPicker case addressPicker
case filters case filters

View File

@@ -285,6 +285,7 @@ struct OtpView: View {
appState.profile.email = customer.email appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? "" appState.profile.phone = customer.phoneNumber ?? ""
appState.profile.profilePicture = customer.profilePicture ?? "" appState.profile.profilePicture = customer.profilePicture ?? ""
appState.favorites.storeIds = Set(customer.favorites ?? [])
SessionStateStore.setActiveUserKey( SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
) )
@@ -315,6 +316,7 @@ struct OtpView: View {
} }
appState.profile.email = email appState.profile.email = email
appState.favorites = FavoritesState()
SessionStateStore.setActiveUserKey( SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: nil, email: email) SessionStateStore.makeUserKey(profileId: nil, email: email)
) )

View File

@@ -1,76 +1,35 @@
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit enum HomeScrollCoordinateSpace {
#endif static let name = "home-scroll"
}
#if os(iOS) #if os(iOS)
@MainActor struct HomeScrollOffsetPreferenceKey: PreferenceKey {
struct ScrollOffsetObserver: UIViewRepresentable { static let defaultValue: CGFloat = 0
let onOffsetChange: (CGFloat) -> Void
func makeUIView(context: Context) -> ScrollOffsetProbeView { static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
let view = ScrollOffsetProbeView() value = nextValue()
view.onOffsetChange = onOffsetChange
return view
}
func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) {
uiView.onOffsetChange = onOffsetChange
uiView.attachIfNeeded()
} }
} }
@MainActor struct ScrollOffsetObserver: View {
final class ScrollOffsetProbeView: UIView { let onOffsetChange: (CGFloat) -> Void
var onOffsetChange: (CGFloat) -> Void = { _ in }
private weak var observedScrollView: UIScrollView? var body: some View {
private var observation: NSKeyValueObservation? Color.clear
.frame(height: 0)
override init(frame: CGRect) { .background(
super.init(frame: frame) GeometryReader { geometry in
backgroundColor = .clear Color.clear.preference(
isUserInteractionEnabled = false key: HomeScrollOffsetPreferenceKey.self,
} value: geometry.frame(in: .named(HomeScrollCoordinateSpace.name)).minY
)
required init?(coder: NSCoder) { }
super.init(coder: coder) )
} .onPreferenceChange(HomeScrollOffsetPreferenceKey.self) { minY in
onOffsetChange(-minY)
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
} }
view = current.superview
}
return nil
} }
} }
#endif #endif

View File

@@ -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)
}
}
}

View File

@@ -19,6 +19,7 @@ struct HomeView: View {
@State var stores: [StoreSummary] = [] @State var stores: [StoreSummary] = []
@State var productSearchIndexByStoreId: [String: [String]] = [:] @State var productSearchIndexByStoreId: [String: [String]] = [:]
@State var searchDebounceToken = 0 @State var searchDebounceToken = 0
@State var favoriteRequestStoreIds: Set<String> = []
private let specials: [SpecialOfferCardModel] = [ private let specials: [SpecialOfferCardModel] = [
// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]), // .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 refreshCategories: true
) )
} }
.background(scrollOffsetObserver) .coordinateSpace(name: HomeScrollCoordinateSpace.name)
header(collapseProgress: collapseProgress, height: headerHeight) header(collapseProgress: collapseProgress, height: headerHeight)
.frame(maxWidth: .infinity, alignment: .top) .frame(maxWidth: .infinity, alignment: .top)
@@ -84,6 +85,7 @@ struct HomeView: View {
private var contentStack: some View { private var contentStack: some View {
VStack(spacing: 24) { VStack(spacing: 24) {
scrollOffsetObserver
categoriesSection categoriesSection
section(title: "Featured") { section(title: "Featured") {
@@ -102,7 +104,14 @@ struct HomeView: View {
appState: $appState appState: $appState
) )
} label: { } label: {
FeaturedStoreCard(store: store) FeaturedStoreCard(
store: store,
onFavoriteToggle: {
Task {
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
}
}
)
.frame(width: 190) .frame(width: 190)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -171,7 +180,14 @@ struct HomeView: View {
appState: $appState appState: $appState
) )
} label: { } label: {
FeaturedStoreCard(store: store) FeaturedStoreCard(
store: store,
onFavoriteToggle: {
Task {
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
}
}
)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }

View File

@@ -1,7 +1,4 @@
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct CategoryHeaderOffsetPreferenceKey: PreferenceKey { struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
static let defaultValue: [String: CGFloat] = [:] 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 { struct ScrollOffsetReader: View {
@Binding var offsetY: CGFloat @Binding var offsetY: CGFloat
var body: some View { var body: some View {
#if canImport(UIKit)
ScrollOffsetReaderRepresentable(offsetY: $offsetY)
#else
Color.clear 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 { struct AsyncStoreImage: View {
let imageURL: String? let imageURL: String?

View File

@@ -1,11 +1,15 @@
import SwiftUI import SwiftUI
extension StoreDetailView { 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) { Button(action: action) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.white) .foregroundStyle(foregroundStyle)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
.background(Color.white.opacity(0.24)) .background(Color.white.opacity(0.24))
.clipShape(Circle()) .clipShape(Circle())
@@ -65,6 +69,49 @@ extension StoreDetailView {
info?.isOpen ?? true 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 { var summaryCardHeight: CGFloat {
summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight) summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight)
} }
@@ -97,9 +144,13 @@ extension StoreDetailView {
if forceRefresh == false, if forceRefresh == false,
let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self), let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self),
let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) { let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) {
let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: cachedCatalog, storeId: storeId)
info = cachedInfo info = cachedInfo
categories = cachedCatalog categories = normalizedCatalog
selectedCategoryId = cachedCatalog.first?.id selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
from: normalizedCatalog,
preferredId: selectedCategoryId
)
isLoading = false isLoading = false
return return
} }
@@ -110,9 +161,9 @@ extension StoreDetailView {
} }
do { do {
async let infoRequest = ApiService().storeInfo(storeId: storeId) let apiService = ApiService()
async let catalogRequest = ApiService().storeCatalog(storeId: storeId) let infoResponse = try await apiService.storeInfo(storeId: storeId)
let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest) let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
if infoResponse.error { if infoResponse.error {
errorMessage = infoResponse.message ?? "Não foi possível carregar a loja." errorMessage = infoResponse.message ?? "Não foi possível carregar a loja."
@@ -125,13 +176,21 @@ extension StoreDetailView {
return return
} }
let normalizedCatalog = StoreCatalogNormalizer.sanitize(
categories: catalogResponse.result ?? [],
storeId: storeId
)
info = infoResponse.result info = infoResponse.result
categories = catalogResponse.result ?? [] categories = normalizedCatalog
selectedCategoryId = categories.first?.id selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
from: normalizedCatalog,
preferredId: selectedCategoryId
)
if let info = infoResponse.result { if let info = infoResponse.result {
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours) 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 isLoading = false
} catch { } catch {
isLoading = false isLoading = false

View File

@@ -34,6 +34,7 @@ struct StoreDetailView: View {
@State var categoryHeaderOffsets: [String: CGFloat] = [:] @State var categoryHeaderOffsets: [String: CGFloat] = [:]
@State var isProgrammaticCategoryScroll = false @State var isProgrammaticCategoryScroll = false
@State var scrollOffsetY: CGFloat = 0 @State var scrollOffsetY: CGFloat = 0
@State var isFavoriteRequestInFlight = false
let cardTopInset: CGFloat = 168 let cardTopInset: CGFloat = 168
let summaryCardBaseHeight: CGFloat = 170 let summaryCardBaseHeight: CGFloat = 170
@@ -52,6 +53,7 @@ struct StoreDetailView: View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
#if os(Android) #if os(Android)
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ScrollOffsetReader(offsetY: $scrollOffsetY)
topSection topSection
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
@@ -60,6 +62,7 @@ struct StoreDetailView: View {
} }
#else #else
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
ScrollOffsetReader(offsetY: $scrollOffsetY)
topSection topSection
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false) categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
@@ -71,8 +74,8 @@ struct StoreDetailView: View {
.refreshable { .refreshable {
await loadStoreData(forceRefresh: true) await loadStoreData(forceRefresh: true)
} }
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
.background(ScrollOffsetReader(offsetY: $scrollOffsetY))
} }
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
.overlay(alignment: .top) { .overlay(alignment: .top) {
@@ -218,7 +221,14 @@ struct StoreDetailView: View {
} }
Spacer() Spacer()
heroIconButton(icon: "magnifyingglass") {} 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(.horizontal, 14)
.padding(.top, UIDevice.topNotch) .padding(.top, UIDevice.topNotch)

View File

@@ -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<CustomerProfile>.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<CustomerFavoritesMutationResult>.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")
}

View File

@@ -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)
}

BIN
sim-home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB