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:
@@ -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 <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
|
||||
@@ -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`
|
||||
|
||||
89
feature-control-bff/README.md
Normal file
89
feature-control-bff/README.md
Normal 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
|
||||
```
|
||||
333
feature-control-bff/core.mjs
Normal file
333
feature-control-bff/core.mjs
Normal 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"
|
||||
});
|
||||
}
|
||||
}
|
||||
10
feature-control-bff/package.json
Normal file
10
feature-control-bff/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
34
feature-control-bff/server.mjs
Normal file
34
feature-control-bff/server.mjs
Normal 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}`);
|
||||
});
|
||||
275
feature-control-bff/tests/bff.test.mjs
Normal file
275
feature-control-bff/tests/bff.test.mjs
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -41,10 +41,6 @@ android {
|
||||
// versionName = MARKETING_VERSION
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
lint {
|
||||
disable.add("Instantiatable")
|
||||
disable.add("MissingPermission")
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct CustomerFavoritesMutationResult: Decodable {
|
||||
let favorites: [String]
|
||||
let store: StoreSummary?
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
lhs.label == rhs.label &&
|
||||
lhs.address == rhs.address &&
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ struct HomeView: View {
|
||||
@State var stores: [StoreSummary] = []
|
||||
@State var productSearchIndexByStoreId: [String: [String]] = [:]
|
||||
@State var searchDebounceToken = 0
|
||||
@State var favoriteRequestStoreIds: Set<String> = []
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
49
pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift
Normal file
49
pedi-foods/Tests/PediFoodsTests/FavoritesDecodingTests.swift
Normal 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")
|
||||
}
|
||||
@@ -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
BIN
sim-home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Reference in New Issue
Block a user