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.
|
- Se a distância entre `lat_long` enviado e o ponto do CEP for maior que `1.5 km`, a API substitui `lat_long` pelo ponto do CEP cacheado.
|
||||||
- Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil.
|
- Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil.
|
||||||
|
|
||||||
|
### 5. List Favorite Stores
|
||||||
|
**GET** `/api/customer/favorites`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
- `Authorization: Bearer <JWT_Token>`
|
||||||
|
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- Retorna as lojas favoritas do customer autenticado.
|
||||||
|
- Apenas lojas públicas/visíveis são retornadas.
|
||||||
|
- A ordem segue a ordem salva em `favorites`.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": false,
|
||||||
|
"code": "CUSTOMER_FAVORITES_RETRIEVED",
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": "store_1775510226293_yn5ys",
|
||||||
|
"name": "CPS Drinks",
|
||||||
|
"logo": null,
|
||||||
|
"cover": null,
|
||||||
|
"category": "Doces & Bolos",
|
||||||
|
"rating": 4.8,
|
||||||
|
"totalReviews": 12,
|
||||||
|
"isOpen": true,
|
||||||
|
"statusLabel": "Aberto",
|
||||||
|
"nextOpenLabel": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Add Store to Favorites
|
||||||
|
**POST** `/api/customer/favorites/:storeId`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
- `Authorization: Bearer <JWT_Token>`
|
||||||
|
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- Salva a loja nos favoritos do customer autenticado.
|
||||||
|
- Se a loja já estiver favoritada, a operação continua idempotente.
|
||||||
|
- A loja precisa existir e estar publicamente visível.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": false,
|
||||||
|
"code": "CUSTOMER_FAVORITE_SAVED",
|
||||||
|
"result": {
|
||||||
|
"favorites": ["store_1775510226293_yn5ys"],
|
||||||
|
"store": {
|
||||||
|
"id": "store_1775510226293_yn5ys",
|
||||||
|
"name": "CPS Drinks",
|
||||||
|
"logo": null,
|
||||||
|
"cover": null,
|
||||||
|
"category": "Doces & Bolos",
|
||||||
|
"rating": 4.8,
|
||||||
|
"totalReviews": 12,
|
||||||
|
"isOpen": true,
|
||||||
|
"statusLabel": "Aberto",
|
||||||
|
"nextOpenLabel": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Remove Store from Favorites
|
||||||
|
**DELETE** `/api/customer/favorites/:storeId`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
- `Authorization: Bearer <JWT_Token>`
|
||||||
|
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- Remove a loja dos favoritos do customer autenticado.
|
||||||
|
- A operação é idempotente: se a loja não estiver favoritada, o array volta sem ela.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": false,
|
||||||
|
"code": "CUSTOMER_FAVORITE_REMOVED",
|
||||||
|
"result": {
|
||||||
|
"favorites": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏠 App Home Screen
|
## 🏠 App Home Screen
|
||||||
@@ -538,19 +638,29 @@ Use este canal para acompanhar mudança de status do pedido em tempo real (sem d
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Eventos recebidos pelo app:**
|
**Eventos recebidos pelo app:**
|
||||||
- `order_update`: enviado quando o pedido muda (pagamento aprovado, aceito pela loja, saiu para entrega, concluído, cancelado).
|
- `order_update`: alteração geral de status do pedido.
|
||||||
|
- `order_cancelled`: cancelamento confirmado, com motivo e metadados de estorno.
|
||||||
|
- `delivery_order_cancelled`: evento dedicado para telas de OTP/entrega interromperem o fluxo imediatamente.
|
||||||
|
|
||||||
**Payload típico de `order_update`:**
|
**Payload típico de `order_cancelled`:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "ord_987...",
|
"event": "order_cancelled",
|
||||||
"shortId": "1234",
|
"emittedAt": "2026-03-05T18:00:00.000Z",
|
||||||
"storeId": "store_123...",
|
"orderId": "ffdc47b4-6a55-4713-aea9-45169e4cf0d7",
|
||||||
"userId": "cust_uuid...",
|
"shortId": "3778",
|
||||||
"status": "CONFIRMED",
|
"storeId": "store_1772117366848_wmqw4",
|
||||||
"paymentStatus": "CONFIRMED",
|
"userId": "cust_uuid_001",
|
||||||
"updatedAt": "2026-02-15T20:10:00.000Z"
|
"status": "CANCELED",
|
||||||
|
"cancelledBy": "store",
|
||||||
|
"cancelledAt": "2026-03-05T18:00:00.000Z",
|
||||||
|
"cancelReasonCode": "STORE_UNAVAILABLE",
|
||||||
|
"cancelReasonDetail": "Falta de insumo crítico para finalizar o pedido",
|
||||||
|
"refundStatus": "failed",
|
||||||
|
"refundIdempotencyKey": "refund:ffdc47b4-...",
|
||||||
|
"refundProviderRef": null,
|
||||||
|
"refundError": "Falha ao solicitar estorno automático"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -566,9 +676,45 @@ Se o socket cair, o app deve continuar consultando o status até estado final.
|
|||||||
- depois: a cada `10s`
|
- depois: a cada `10s`
|
||||||
- parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela.
|
- parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela.
|
||||||
|
|
||||||
### 8. Catálogo Oficial de Tags de Review
|
### 8. Cancelamento de Pedido (Loja/Customer)
|
||||||
|
|
||||||
### 8.1 Matriz Completa de Endpoints (Reviews + Tags)
|
**Cancelamento pela loja (Store API):**
|
||||||
|
- `POST /api/store/:storeId/orders/:orderId/cancel`
|
||||||
|
|
||||||
|
**Cancelamento pelo customer:**
|
||||||
|
- `POST /api/customer/orders/:orderId/cancel`
|
||||||
|
|
||||||
|
**Request body (ambos):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reasonCode": "CUSTOMER_REQUEST",
|
||||||
|
"reasonDetail": "Cliente solicitou cancelamento antes da entrega"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Motivos aceitos (`reasonCode`):**
|
||||||
|
- `STORE_UNAVAILABLE`
|
||||||
|
- `CUSTOMER_REQUEST`
|
||||||
|
- `DELIVERY_ISSUE`
|
||||||
|
|
||||||
|
**Regras atuais de cancelamento:**
|
||||||
|
- Permitido em: `PAYMENT_PENDING`, `PENDING`, `ACCEPTED`, `PREPARING`, `READY`, `DELIVERING`.
|
||||||
|
- Bloqueado em: `COMPLETED` e `CANCELED`.
|
||||||
|
- Motoboy não cancela pedido.
|
||||||
|
- Ao cancelar, o backend invalida `otp`, `confirmOtp` e `customerOtp`.
|
||||||
|
|
||||||
|
**Concorrência e idempotência (ponta a ponta):**
|
||||||
|
- Cancelamento é serializado por `orderId` (lock por pedido).
|
||||||
|
- Estorno automático usa lock + chave idempotente de estorno para evitar duplicidade.
|
||||||
|
- Em segunda tentativa após cancelamento efetivado, a API retorna `409 ORDER_ALREADY_CANCELED`.
|
||||||
|
|
||||||
|
**Campos relevantes no retorno:**
|
||||||
|
- `cancelledBy`, `cancelledAt`, `cancelReasonCode`, `cancelReasonDetail`
|
||||||
|
- `refundStatus`, `refundIdempotencyKey`, `refundProviderRef`
|
||||||
|
|
||||||
|
### 9. Catálogo Oficial de Tags de Review
|
||||||
|
|
||||||
|
### 9.1 Matriz Completa de Endpoints (Reviews + Tags)
|
||||||
|
|
||||||
**Público/App (Customer):**
|
**Público/App (Customer):**
|
||||||
- `GET /api/public/reviews/tags` → catálogo oficial de tags e regras de validação.
|
- `GET /api/public/reviews/tags` → catálogo oficial de tags e regras de validação.
|
||||||
@@ -630,7 +776,7 @@ O app deve buscar este endpoint para renderizar as tags válidas e enviar soment
|
|||||||
- Não hardcodear tags no app; usar o catálogo do backend.
|
- Não hardcodear tags no app; usar o catálogo do backend.
|
||||||
- Enviar no POST de review apenas os `id` retornados em cada bloco.
|
- Enviar no POST de review apenas os `id` retornados em cada bloco.
|
||||||
|
|
||||||
### 9. Enviar Avaliação do Pedido
|
### 10. Enviar Avaliação do Pedido
|
||||||
Permite o customer enviar review da loja a partir de um pedido finalizado.
|
Permite o customer enviar review da loja a partir de um pedido finalizado.
|
||||||
|
|
||||||
**Endpoint:**
|
**Endpoint:**
|
||||||
@@ -749,7 +895,7 @@ Exemplo inválido (entrega positiva com tags negativas):
|
|||||||
|
|
||||||
Retorno esperado: `400 INVALID_DELIVERY_SENTIMENT_TAGS`.
|
Retorno esperado: `400 INVALID_DELIVERY_SENTIMENT_TAGS`.
|
||||||
|
|
||||||
### 10. Analytics de Reviews (Store/API)
|
### 11. Analytics de Reviews (Store/API)
|
||||||
Para dashboard da loja e análise de produto, usar:
|
Para dashboard da loja e análise de produto, usar:
|
||||||
|
|
||||||
- `GET /api/store/:storeId/reviews`
|
- `GET /api/store/:storeId/reviews`
|
||||||
|
|||||||
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
|
// versionName = MARKETING_VERSION
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
buildConfig = true
|
|
||||||
}
|
|
||||||
|
|
||||||
lint {
|
lint {
|
||||||
disable.add("Instantiatable")
|
disable.add("Instantiatable")
|
||||||
disable.add("MissingPermission")
|
disable.add("MissingPermission")
|
||||||
|
|||||||
@@ -300,7 +300,7 @@
|
|||||||
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
|
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = upToNextMajorVersion;
|
kind = upToNextMajorVersion;
|
||||||
minimumVersion = 1.0.5;
|
minimumVersion = 1.0.7;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ let package = Package(
|
|||||||
.library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]),
|
.library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://source.skip.tools/skip.git", from: "1.7.0"),
|
.package(url: "https://source.skip.tools/skip.git", from: "1.8.4"),
|
||||||
.package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0"),
|
.package(path: "Vendor/skip-fuse-ui"),
|
||||||
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5")
|
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct FeaturedStoreCard: View {
|
struct FeaturedStoreCard: View {
|
||||||
let store: FeaturedStoreCardModel
|
let store: FeaturedStoreCardModel
|
||||||
|
var onFavoriteToggle: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -21,7 +22,9 @@ struct FeaturedStoreCard: View {
|
|||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {}) {
|
Button {
|
||||||
|
onFavoriteToggle?()
|
||||||
|
} label: {
|
||||||
Image(systemName: store.isFavorite ? "heart.fill" : "heart")
|
Image(systemName: store.isFavorite ? "heart.fill" : "heart")
|
||||||
.foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted)
|
.foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
@@ -29,6 +32,8 @@ struct FeaturedStoreCard: View {
|
|||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
|
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.accessibilityLabel(store.isFavorite ? "Remover loja dos favoritos" : "Adicionar loja aos favoritos")
|
||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@@ -15,6 +16,7 @@ struct ContentView: View {
|
|||||||
@State private var sessionExpiredObserver: NSObjectProtocol?
|
@State private var sessionExpiredObserver: NSObjectProtocol?
|
||||||
#endif
|
#endif
|
||||||
@State var cartResetObserver: Any?
|
@State var cartResetObserver: Any?
|
||||||
|
@State var appResumeObserver: Any?
|
||||||
#if os(Android)
|
#if os(Android)
|
||||||
@State var snackbarCenter = SnackbarCenter.shared
|
@State var snackbarCenter = SnackbarCenter.shared
|
||||||
#else
|
#else
|
||||||
@@ -80,14 +82,26 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: appState.profile.id) { _, _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
await refreshFeatureFlags(forceRefresh: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: appState.cart.storeId) { _, _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
await refreshFeatureFlags(forceRefresh: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
attachCartResetObserverIfNeeded()
|
attachCartResetObserverIfNeeded()
|
||||||
|
attachAppResumeObserverIfNeeded()
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
attachSessionExpiredObserverIfNeeded()
|
attachSessionExpiredObserverIfNeeded()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
detachCartResetObserver()
|
detachCartResetObserver()
|
||||||
|
detachAppResumeObserver()
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
detachSessionExpiredObserver()
|
detachSessionExpiredObserver()
|
||||||
#endif
|
#endif
|
||||||
@@ -179,6 +193,7 @@ struct ContentView: View {
|
|||||||
// Keep local state when backend refresh fails transiently.
|
// Keep local state when backend refresh fails transiently.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await refreshFeatureFlags(forceRefresh: false)
|
||||||
isBootstrappingSession = false
|
isBootstrappingSession = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +204,9 @@ struct ContentView: View {
|
|||||||
object: nil,
|
object: nil,
|
||||||
queue: nil
|
queue: nil
|
||||||
) { _ in
|
) { _ in
|
||||||
appState.cart = CartState()
|
Task { @MainActor in
|
||||||
|
appState.cart = CartState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +216,26 @@ struct ContentView: View {
|
|||||||
self.cartResetObserver = nil
|
self.cartResetObserver = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func attachAppResumeObserverIfNeeded() {
|
||||||
|
guard appResumeObserver == nil else { return }
|
||||||
|
appResumeObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .appDidResume,
|
||||||
|
object: nil,
|
||||||
|
queue: nil
|
||||||
|
) { _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard root == .main else { return }
|
||||||
|
await refreshFeatureFlags(forceRefresh: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detachAppResumeObserver() {
|
||||||
|
guard let appResumeObserver else { return }
|
||||||
|
NotificationCenter.default.removeObserver(appResumeObserver)
|
||||||
|
self.appResumeObserver = nil
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func hydrateAppState(with customer: CustomerProfile) {
|
private func hydrateAppState(with customer: CustomerProfile) {
|
||||||
appState.profile.id = customer.id
|
appState.profile.id = customer.id
|
||||||
@@ -206,6 +243,7 @@ struct ContentView: View {
|
|||||||
appState.profile.email = customer.email
|
appState.profile.email = customer.email
|
||||||
appState.profile.phone = customer.phoneNumber ?? ""
|
appState.profile.phone = customer.phoneNumber ?? ""
|
||||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||||
|
appState.favorites.storeIds = Set(customer.favorites ?? [])
|
||||||
SessionStateStore.setActiveUserKey(
|
SessionStateStore.setActiveUserKey(
|
||||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||||
)
|
)
|
||||||
@@ -299,6 +337,46 @@ struct ContentView: View {
|
|||||||
scheduleDisableAuthEntryPreparation()
|
scheduleDisableAuthEntryPreparation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func refreshFeatureFlags(forceRefresh: Bool) async {
|
||||||
|
guard root == .main else { return }
|
||||||
|
|
||||||
|
let subjectType: String
|
||||||
|
let subjectId: String
|
||||||
|
if let profileId = appState.profile.id?.trimmingCharacters(in: .whitespacesAndNewlines), profileId.isEmpty == false {
|
||||||
|
subjectType = "customer"
|
||||||
|
subjectId = profileId
|
||||||
|
} else {
|
||||||
|
subjectType = "anonymous"
|
||||||
|
subjectId = "anonymous-device"
|
||||||
|
}
|
||||||
|
|
||||||
|
var attrs: [String: String] = [:]
|
||||||
|
let addressLabel = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if addressLabel.isEmpty == false, addressLabel.lowercased() != "defina seu endereco" {
|
||||||
|
attrs["addressLabel"] = addressLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
let context = FeatureControlEvaluationContext(
|
||||||
|
subjectType: subjectType,
|
||||||
|
subjectId: subjectId,
|
||||||
|
storeId: appState.cart.storeId,
|
||||||
|
attributes: attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
let snapshot = await FeatureControlService.shared.evaluate(
|
||||||
|
context: context,
|
||||||
|
jwt: appState.session.jwt,
|
||||||
|
forceRefresh: forceRefresh
|
||||||
|
)
|
||||||
|
appState.featureFlags = snapshot
|
||||||
|
await FeatureControlService.shared.sendExposureEvents(
|
||||||
|
snapshot: snapshot,
|
||||||
|
context: context,
|
||||||
|
jwt: appState.session.jwt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func scheduleDisableAuthEntryPreparation() {
|
private func scheduleDisableAuthEntryPreparation() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFo
|
|||||||
|
|
||||||
/* SKIP @bridge */public func onResume() {
|
/* SKIP @bridge */public func onResume() {
|
||||||
logger.debug("onResume")
|
logger.debug("onResume")
|
||||||
|
NotificationCenter.default.post(name: .appDidResume, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SKIP @bridge */public func onPause() {
|
/* SKIP @bridge */public func onPause() {
|
||||||
|
|||||||
@@ -153,6 +153,9 @@
|
|||||||
"Adicionar fotos" : {
|
"Adicionar fotos" : {
|
||||||
"comment" : "A label for adding photos in the order review section.",
|
"comment" : "A label for adding photos in the order review section.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Adicionar loja aos favoritos" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Adicionar novo endereço" : {
|
"Adicionar novo endereço" : {
|
||||||
"comment" : "A button label that translates to \"Add new address\" in English.",
|
"comment" : "A button label that translates to \"Add new address\" in English.",
|
||||||
@@ -932,6 +935,9 @@
|
|||||||
"Remover" : {
|
"Remover" : {
|
||||||
"comment" : "A button that deletes the user's profile picture.",
|
"comment" : "A button that deletes the user's profile picture.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Remover loja dos favoritos" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Resposta da loja" : {
|
"Resposta da loja" : {
|
||||||
"comment" : "A heading for the reply from the store in an order review.",
|
"comment" : "A heading for the reply from the store in an order review.",
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ enum ApiConfig {
|
|||||||
return URL(string: raw) ?? URL(string: "https://atomenta.com.br")!
|
return URL(string: raw) ?? URL(string: "https://atomenta.com.br")!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var featureControlBffURL: URL {
|
||||||
|
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_BFF_URL"] ?? "http://localhost:8787"
|
||||||
|
return URL(string: raw) ?? URL(string: "http://localhost:8787")!
|
||||||
|
}
|
||||||
|
|
||||||
|
static var featureControlEnvironment: String {
|
||||||
|
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_ENVIRONMENT"] ?? "production"
|
||||||
|
let clean = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return clean.isEmpty ? "production" : clean
|
||||||
|
}
|
||||||
|
|
||||||
// Tokens provided by backend modules
|
// Tokens provided by backend modules
|
||||||
static let storeToken = "550e8400-e29b-41d4-a716-446655440008"
|
static let storeToken = "550e8400-e29b-41d4-a716-446655440008"
|
||||||
static let customerToken = "550e8400-e29b-41d4-a716-44665544000a"
|
static let customerToken = "550e8400-e29b-41d4-a716-44665544000a"
|
||||||
|
|||||||
@@ -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 email: String
|
||||||
let phoneNumber: String?
|
let phoneNumber: String?
|
||||||
let profilePicture: String?
|
let profilePicture: String?
|
||||||
|
let favorites: [String]?
|
||||||
let addressBook: [CustomerAddress]?
|
let addressBook: [CustomerAddress]?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
@@ -29,6 +30,7 @@ struct CustomerProfile: Decodable {
|
|||||||
case email
|
case email
|
||||||
case phoneNumber
|
case phoneNumber
|
||||||
case profilePicture
|
case profilePicture
|
||||||
|
case favorites
|
||||||
case addressBook = "address_book"
|
case addressBook = "address_book"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ final class ApiService {
|
|||||||
private var tokenStore: TokenStore
|
private var tokenStore: TokenStore
|
||||||
private let profileCachePrefix = "api:profile:"
|
private let profileCachePrefix = "api:profile:"
|
||||||
private let ordersCachePrefix = "api:orders:"
|
private let ordersCachePrefix = "api:orders:"
|
||||||
|
private let favoritesCachePrefix = "api:favorites:"
|
||||||
private let publicCategoriesCacheKey = "api:public-categories"
|
private let publicCategoriesCacheKey = "api:public-categories"
|
||||||
|
|
||||||
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
|
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
|
||||||
@@ -68,10 +69,16 @@ final class ApiService {
|
|||||||
tokenStore.clear()
|
tokenStore.clear()
|
||||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||||
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
|
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
|
||||||
|
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
|
||||||
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
|
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
|
||||||
NotificationCenter.default.post(name: .sessionExpired, object: message)
|
NotificationCenter.default.post(name: .sessionExpired, object: message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func invalidateFavoritesCache() {
|
||||||
|
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||||
|
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
|
||||||
|
}
|
||||||
|
|
||||||
private func scopedCacheSuffix() -> String {
|
private func scopedCacheSuffix() -> String {
|
||||||
let jwt = tokenStore.jwt ?? "anonymous"
|
let jwt = tokenStore.jwt ?? "anonymous"
|
||||||
if jwt.count <= 16 { return jwt }
|
if jwt.count <= 16 { return jwt }
|
||||||
@@ -168,7 +175,7 @@ final class ApiService {
|
|||||||
if envelope.error == false, envelope.result != nil {
|
if envelope.error == false, envelope.result != nil {
|
||||||
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
||||||
} else {
|
} else {
|
||||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
invalidateFavoritesCache()
|
||||||
}
|
}
|
||||||
return envelope
|
return envelope
|
||||||
}
|
}
|
||||||
@@ -241,11 +248,51 @@ final class ApiService {
|
|||||||
if envelope.error == false, envelope.result != nil {
|
if envelope.error == false, envelope.result != nil {
|
||||||
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
||||||
} else {
|
} else {
|
||||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
invalidateFavoritesCache()
|
||||||
}
|
}
|
||||||
return envelope
|
return envelope
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listFavoriteStores(forceRefresh: Bool = false) async throws -> ApiEnvelope<[StoreSummary]> {
|
||||||
|
let cacheKey = "\(favoritesCachePrefix)\(scopedCacheSuffix())"
|
||||||
|
if forceRefresh == false,
|
||||||
|
let cached: ApiEnvelope<[StoreSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[StoreSummary]>.self) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
let req = ApiRequest(path: "/api/customer/favorites", method: "GET", module: .customer, requiresAuth: true)
|
||||||
|
let envelope: ApiEnvelope<[StoreSummary]> = try await sendEnvelope(req)
|
||||||
|
if envelope.error == false, envelope.result != nil {
|
||||||
|
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
func addStoreToFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
|
||||||
|
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "POST", module: .customer, requiresAuth: true)
|
||||||
|
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
|
||||||
|
if envelope.error == false {
|
||||||
|
invalidateFavoritesCache()
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeStoreFromFavorites(storeId: String) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
|
||||||
|
let req = ApiRequest(path: "/api/customer/favorites/\(storeId)", method: "DELETE", module: .customer, requiresAuth: true)
|
||||||
|
let envelope: ApiEnvelope<CustomerFavoritesMutationResult> = try await sendEnvelope(req)
|
||||||
|
if envelope.error == false {
|
||||||
|
invalidateFavoritesCache()
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStoreFavorite(storeId: String, isFavorite: Bool) async throws -> ApiEnvelope<CustomerFavoritesMutationResult> {
|
||||||
|
if isFavorite {
|
||||||
|
return try await addStoreToFavorites(storeId: storeId)
|
||||||
|
}
|
||||||
|
return try await removeStoreFromFavorites(storeId: storeId)
|
||||||
|
}
|
||||||
|
|
||||||
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
|
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
|
||||||
lhs.label == rhs.label &&
|
lhs.label == rhs.label &&
|
||||||
lhs.address == rhs.address &&
|
lhs.address == rhs.address &&
|
||||||
|
|||||||
@@ -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 sessionExpired = Notification.Name("SessionExpiredNotification")
|
||||||
static let cartDidReset = Notification.Name("CartDidResetNotification")
|
static let cartDidReset = Notification.Name("CartDidResetNotification")
|
||||||
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
|
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
|
||||||
|
static let appDidResume = Notification.Name("AppDidResumeNotification")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 cart = CartState()
|
||||||
var address = AddressState()
|
var address = AddressState()
|
||||||
var favorites = FavoritesState()
|
var favorites = FavoritesState()
|
||||||
|
var featureFlags = FeatureFlagsState()
|
||||||
var homeFilters = HomeFiltersState()
|
var homeFilters = HomeFiltersState()
|
||||||
var activeModal: AppModal? = nil
|
var activeModal: AppModal? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum FeatureFlagValue: Codable, Equatable {
|
||||||
|
case boolean(Bool)
|
||||||
|
case text(String)
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let boolValue = try? container.decode(Bool.self) {
|
||||||
|
self = .boolean(boolValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let stringValue = try? container.decode(String.self) {
|
||||||
|
self = .text(stringValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let intValue = try? container.decode(Int.self) {
|
||||||
|
self = .text(String(intValue))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let doubleValue = try? container.decode(Double.self) {
|
||||||
|
self = .text(String(doubleValue))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self = .text("off")
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case .boolean(let value):
|
||||||
|
try container.encode(value)
|
||||||
|
case .text(let value):
|
||||||
|
try container.encode(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var boolValue: Bool {
|
||||||
|
switch self {
|
||||||
|
case .boolean(let value):
|
||||||
|
return value
|
||||||
|
case .text(let value):
|
||||||
|
return value.lowercased() == "on" || value.lowercased() == "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FeatureFlagsState: Codable, Equatable {
|
||||||
|
var configVersion: Int = 0
|
||||||
|
var evaluatedAt: String? = nil
|
||||||
|
var source: String = "default"
|
||||||
|
var values: [String: FeatureFlagValue] = [:]
|
||||||
|
var raw: [String: FeatureControlRawFlag] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
enum AppModal: String, Identifiable {
|
enum AppModal: String, Identifiable {
|
||||||
case addressPicker
|
case addressPicker
|
||||||
case filters
|
case filters
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ struct OtpView: View {
|
|||||||
appState.profile.email = customer.email
|
appState.profile.email = customer.email
|
||||||
appState.profile.phone = customer.phoneNumber ?? ""
|
appState.profile.phone = customer.phoneNumber ?? ""
|
||||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||||
|
appState.favorites.storeIds = Set(customer.favorites ?? [])
|
||||||
SessionStateStore.setActiveUserKey(
|
SessionStateStore.setActiveUserKey(
|
||||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||||
)
|
)
|
||||||
@@ -315,6 +316,7 @@ struct OtpView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
appState.profile.email = email
|
appState.profile.email = email
|
||||||
|
appState.favorites = FavoritesState()
|
||||||
SessionStateStore.setActiveUserKey(
|
SessionStateStore.setActiveUserKey(
|
||||||
SessionStateStore.makeUserKey(profileId: nil, email: email)
|
SessionStateStore.makeUserKey(profileId: nil, email: email)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,76 +1,35 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
enum HomeScrollCoordinateSpace {
|
||||||
#endif
|
static let name = "home-scroll"
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@MainActor
|
struct HomeScrollOffsetPreferenceKey: PreferenceKey {
|
||||||
struct ScrollOffsetObserver: UIViewRepresentable {
|
static let defaultValue: CGFloat = 0
|
||||||
let onOffsetChange: (CGFloat) -> Void
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> ScrollOffsetProbeView {
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
let view = ScrollOffsetProbeView()
|
value = nextValue()
|
||||||
view.onOffsetChange = onOffsetChange
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) {
|
|
||||||
uiView.onOffsetChange = onOffsetChange
|
|
||||||
uiView.attachIfNeeded()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
struct ScrollOffsetObserver: View {
|
||||||
final class ScrollOffsetProbeView: UIView {
|
let onOffsetChange: (CGFloat) -> Void
|
||||||
var onOffsetChange: (CGFloat) -> Void = { _ in }
|
|
||||||
|
|
||||||
private weak var observedScrollView: UIScrollView?
|
var body: some View {
|
||||||
private var observation: NSKeyValueObservation?
|
Color.clear
|
||||||
|
.frame(height: 0)
|
||||||
override init(frame: CGRect) {
|
.background(
|
||||||
super.init(frame: frame)
|
GeometryReader { geometry in
|
||||||
backgroundColor = .clear
|
Color.clear.preference(
|
||||||
isUserInteractionEnabled = false
|
key: HomeScrollOffsetPreferenceKey.self,
|
||||||
}
|
value: geometry.frame(in: .named(HomeScrollCoordinateSpace.name)).minY
|
||||||
|
)
|
||||||
required init?(coder: NSCoder) {
|
}
|
||||||
super.init(coder: coder)
|
)
|
||||||
}
|
.onPreferenceChange(HomeScrollOffsetPreferenceKey.self) { minY in
|
||||||
|
onOffsetChange(-minY)
|
||||||
deinit {
|
|
||||||
observation?.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func didMoveToSuperview() {
|
|
||||||
super.didMoveToSuperview()
|
|
||||||
attachIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func didMoveToWindow() {
|
|
||||||
super.didMoveToWindow()
|
|
||||||
attachIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
func attachIfNeeded() {
|
|
||||||
guard let scrollView = findEnclosingScrollView() else { return }
|
|
||||||
guard scrollView !== observedScrollView else { return }
|
|
||||||
|
|
||||||
observation?.invalidate()
|
|
||||||
observedScrollView = scrollView
|
|
||||||
observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in
|
|
||||||
self?.onOffsetChange(sv.contentOffset.y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findEnclosingScrollView() -> UIScrollView? {
|
|
||||||
var view: UIView? = self
|
|
||||||
while let current = view {
|
|
||||||
if let scrollView = current as? UIScrollView {
|
|
||||||
return scrollView
|
|
||||||
}
|
}
|
||||||
view = current.superview
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -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 stores: [StoreSummary] = []
|
||||||
@State var productSearchIndexByStoreId: [String: [String]] = [:]
|
@State var productSearchIndexByStoreId: [String: [String]] = [:]
|
||||||
@State var searchDebounceToken = 0
|
@State var searchDebounceToken = 0
|
||||||
|
@State var favoriteRequestStoreIds: Set<String> = []
|
||||||
|
|
||||||
private let specials: [SpecialOfferCardModel] = [
|
private let specials: [SpecialOfferCardModel] = [
|
||||||
// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
|
// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
|
||||||
@@ -47,7 +48,7 @@ struct HomeView: View {
|
|||||||
refreshCategories: true
|
refreshCategories: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.background(scrollOffsetObserver)
|
.coordinateSpace(name: HomeScrollCoordinateSpace.name)
|
||||||
|
|
||||||
header(collapseProgress: collapseProgress, height: headerHeight)
|
header(collapseProgress: collapseProgress, height: headerHeight)
|
||||||
.frame(maxWidth: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, alignment: .top)
|
||||||
@@ -84,6 +85,7 @@ struct HomeView: View {
|
|||||||
|
|
||||||
private var contentStack: some View {
|
private var contentStack: some View {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
|
scrollOffsetObserver
|
||||||
categoriesSection
|
categoriesSection
|
||||||
|
|
||||||
section(title: "Featured") {
|
section(title: "Featured") {
|
||||||
@@ -102,7 +104,14 @@ struct HomeView: View {
|
|||||||
appState: $appState
|
appState: $appState
|
||||||
)
|
)
|
||||||
} label: {
|
} label: {
|
||||||
FeaturedStoreCard(store: store)
|
FeaturedStoreCard(
|
||||||
|
store: store,
|
||||||
|
onFavoriteToggle: {
|
||||||
|
Task {
|
||||||
|
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
.frame(width: 190)
|
.frame(width: 190)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -171,7 +180,14 @@ struct HomeView: View {
|
|||||||
appState: $appState
|
appState: $appState
|
||||||
)
|
)
|
||||||
} label: {
|
} label: {
|
||||||
FeaturedStoreCard(store: store)
|
FeaturedStoreCard(
|
||||||
|
store: store,
|
||||||
|
onFavoriteToggle: {
|
||||||
|
Task {
|
||||||
|
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
|
struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
|
||||||
static let defaultValue: [String: CGFloat] = [:]
|
static let defaultValue: [String: CGFloat] = [:]
|
||||||
@@ -11,125 +8,41 @@ struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum StoreDetailScrollCoordinateSpace {
|
||||||
|
static let name = "store-detail-scroll"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||||
|
static let defaultValue: CGFloat = 0
|
||||||
|
|
||||||
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
|
value = nextValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ScrollOffsetReader: View {
|
struct ScrollOffsetReader: View {
|
||||||
@Binding var offsetY: CGFloat
|
@Binding var offsetY: CGFloat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if canImport(UIKit)
|
|
||||||
ScrollOffsetReaderRepresentable(offsetY: $offsetY)
|
|
||||||
#else
|
|
||||||
Color.clear
|
Color.clear
|
||||||
#endif
|
.frame(height: 0)
|
||||||
|
.background(
|
||||||
|
GeometryReader { geometry in
|
||||||
|
Color.clear.preference(
|
||||||
|
key: ScrollOffsetPreferenceKey.self,
|
||||||
|
value: geometry.frame(in: .named(StoreDetailScrollCoordinateSpace.name)).minY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in
|
||||||
|
let normalizedOffset = max(0, -minY)
|
||||||
|
if abs(offsetY - normalizedOffset) > 0.5 {
|
||||||
|
offsetY = normalizedOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
struct ScrollOffsetReaderRepresentable: UIViewRepresentable {
|
|
||||||
@Binding var offsetY: CGFloat
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> OffsetProbeView {
|
|
||||||
let view = OffsetProbeView()
|
|
||||||
view.onOffsetChanged = { value in
|
|
||||||
if offsetY != value {
|
|
||||||
offsetY = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: OffsetProbeView, context: Context) {
|
|
||||||
uiView.onOffsetChanged = { value in
|
|
||||||
if offsetY != value {
|
|
||||||
offsetY = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class OffsetProbeView: UIView {
|
|
||||||
var onOffsetChanged: ((CGFloat) -> Void)?
|
|
||||||
private var observation: NSKeyValueObservation?
|
|
||||||
private weak var observedScrollView: UIScrollView?
|
|
||||||
|
|
||||||
override func didMoveToWindow() {
|
|
||||||
super.didMoveToWindow()
|
|
||||||
attachIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func didMoveToSuperview() {
|
|
||||||
super.didMoveToSuperview()
|
|
||||||
attachIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func attachIfNeeded() {
|
|
||||||
guard observation == nil else { return }
|
|
||||||
guard observedScrollView == nil else { return }
|
|
||||||
|
|
||||||
if let scrollView = enclosingScrollView() ?? findScrollViewInWindow() {
|
|
||||||
observe(scrollView)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
retryAttach()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func observe(_ scrollView: UIScrollView) {
|
|
||||||
observedScrollView = scrollView
|
|
||||||
observation = scrollView.observe(\.contentOffset, options: [.new, .initial]) { [weak self, weak scrollView] _, change in
|
|
||||||
guard let self, let scrollView, let y = change.newValue?.y else { return }
|
|
||||||
let adjusted = max(0, y + scrollView.adjustedContentInset.top)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.onOffsetChanged?(adjusted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func retryAttach() {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
|
||||||
self?.attachIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func enclosingScrollView() -> UIScrollView? {
|
|
||||||
var current: UIView? = self
|
|
||||||
while let view = current {
|
|
||||||
if let scrollView = view as? UIScrollView {
|
|
||||||
return scrollView
|
|
||||||
}
|
|
||||||
current = view.superview
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findScrollViewInWindow() -> UIScrollView? {
|
|
||||||
guard let window else { return nil }
|
|
||||||
let targetPoint = convert(CGPoint(x: bounds.midX, y: bounds.midY), to: window)
|
|
||||||
return findScrollView(in: window, containing: targetPoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findScrollView(in root: UIView, containing point: CGPoint) -> UIScrollView? {
|
|
||||||
for subview in root.subviews.reversed() {
|
|
||||||
if let match = findScrollView(in: subview, containing: point) {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let scrollView = root as? UIScrollView {
|
|
||||||
let rectInWindow = scrollView.convert(scrollView.bounds, to: window)
|
|
||||||
if rectInWindow.contains(point) {
|
|
||||||
return scrollView
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
observation?.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct AsyncStoreImage: View {
|
struct AsyncStoreImage: View {
|
||||||
let imageURL: String?
|
let imageURL: String?
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension StoreDetailView {
|
extension StoreDetailView {
|
||||||
func heroIconButton(icon: String, action: @escaping () -> Void) -> some View {
|
func heroIconButton(
|
||||||
|
icon: String,
|
||||||
|
foregroundStyle: Color = .white,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.foregroundStyle(Color.white)
|
.foregroundStyle(foregroundStyle)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.background(Color.white.opacity(0.24))
|
.background(Color.white.opacity(0.24))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
@@ -65,6 +69,49 @@ extension StoreDetailView {
|
|||||||
info?.isOpen ?? true
|
info?.isOpen ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isFavoriteStore: Bool {
|
||||||
|
appState.favorites.storeIds.contains(storeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func toggleFavoriteStore() async {
|
||||||
|
guard isFavoriteRequestInFlight == false else { return }
|
||||||
|
guard appState.session.isAuthenticated else {
|
||||||
|
SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isFavorite = isFavoriteStore
|
||||||
|
isFavoriteRequestInFlight = true
|
||||||
|
defer { isFavoriteRequestInFlight = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false)
|
||||||
|
guard response.error == false, let result = response.result else {
|
||||||
|
let message = response.message ?? "Não foi possível atualizar seus favoritos."
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appState.favorites.storeIds = Set(result.favorites)
|
||||||
|
let successTitle = isFavorite
|
||||||
|
? "\(storeName) removida dos favoritos."
|
||||||
|
: "\(storeName) adicionada aos favoritos."
|
||||||
|
let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill"
|
||||||
|
SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2)
|
||||||
|
} catch {
|
||||||
|
let message: String
|
||||||
|
if let networkError = error as? NetworkError {
|
||||||
|
message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||||
|
} else if let serviceError = error as? ApiServiceError {
|
||||||
|
message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||||
|
} else {
|
||||||
|
message = "Não foi possível atualizar seus favoritos."
|
||||||
|
}
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var summaryCardHeight: CGFloat {
|
var summaryCardHeight: CGFloat {
|
||||||
summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight)
|
summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight)
|
||||||
}
|
}
|
||||||
@@ -97,9 +144,13 @@ extension StoreDetailView {
|
|||||||
if forceRefresh == false,
|
if forceRefresh == false,
|
||||||
let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self),
|
let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self),
|
||||||
let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) {
|
let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) {
|
||||||
|
let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: cachedCatalog, storeId: storeId)
|
||||||
info = cachedInfo
|
info = cachedInfo
|
||||||
categories = cachedCatalog
|
categories = normalizedCatalog
|
||||||
selectedCategoryId = cachedCatalog.first?.id
|
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||||
|
from: normalizedCatalog,
|
||||||
|
preferredId: selectedCategoryId
|
||||||
|
)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -110,9 +161,9 @@ extension StoreDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
async let infoRequest = ApiService().storeInfo(storeId: storeId)
|
let apiService = ApiService()
|
||||||
async let catalogRequest = ApiService().storeCatalog(storeId: storeId)
|
let infoResponse = try await apiService.storeInfo(storeId: storeId)
|
||||||
let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest)
|
let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
|
||||||
|
|
||||||
if infoResponse.error {
|
if infoResponse.error {
|
||||||
errorMessage = infoResponse.message ?? "Não foi possível carregar a loja."
|
errorMessage = infoResponse.message ?? "Não foi possível carregar a loja."
|
||||||
@@ -125,13 +176,21 @@ extension StoreDetailView {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let normalizedCatalog = StoreCatalogNormalizer.sanitize(
|
||||||
|
categories: catalogResponse.result ?? [],
|
||||||
|
storeId: storeId
|
||||||
|
)
|
||||||
|
|
||||||
info = infoResponse.result
|
info = infoResponse.result
|
||||||
categories = catalogResponse.result ?? []
|
categories = normalizedCatalog
|
||||||
selectedCategoryId = categories.first?.id
|
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||||
|
from: normalizedCatalog,
|
||||||
|
preferredId: selectedCategoryId
|
||||||
|
)
|
||||||
if let info = infoResponse.result {
|
if let info = infoResponse.result {
|
||||||
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
|
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
|
||||||
}
|
}
|
||||||
AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
} catch {
|
} catch {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct StoreDetailView: View {
|
|||||||
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
||||||
@State var isProgrammaticCategoryScroll = false
|
@State var isProgrammaticCategoryScroll = false
|
||||||
@State var scrollOffsetY: CGFloat = 0
|
@State var scrollOffsetY: CGFloat = 0
|
||||||
|
@State var isFavoriteRequestInFlight = false
|
||||||
|
|
||||||
let cardTopInset: CGFloat = 168
|
let cardTopInset: CGFloat = 168
|
||||||
let summaryCardBaseHeight: CGFloat = 170
|
let summaryCardBaseHeight: CGFloat = 170
|
||||||
@@ -52,6 +53,7 @@ struct StoreDetailView: View {
|
|||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
#if os(Android)
|
#if os(Android)
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
|
ScrollOffsetReader(offsetY: $scrollOffsetY)
|
||||||
topSection
|
topSection
|
||||||
|
|
||||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
||||||
@@ -60,6 +62,7 @@ struct StoreDetailView: View {
|
|||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||||
|
ScrollOffsetReader(offsetY: $scrollOffsetY)
|
||||||
topSection
|
topSection
|
||||||
|
|
||||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
||||||
@@ -71,8 +74,8 @@ struct StoreDetailView: View {
|
|||||||
.refreshable {
|
.refreshable {
|
||||||
await loadStoreData(forceRefresh: true)
|
await loadStoreData(forceRefresh: true)
|
||||||
}
|
}
|
||||||
|
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
|
||||||
.ignoresSafeArea(edges: .top)
|
.ignoresSafeArea(edges: .top)
|
||||||
.background(ScrollOffsetReader(offsetY: $scrollOffsetY))
|
|
||||||
}
|
}
|
||||||
.ignoresSafeArea(edges: .top)
|
.ignoresSafeArea(edges: .top)
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
@@ -218,7 +221,14 @@ struct StoreDetailView: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
heroIconButton(icon: "magnifyingglass") {}
|
heroIconButton(icon: "magnifyingglass") {}
|
||||||
heroIconButton(icon: "heart") {}
|
heroIconButton(
|
||||||
|
icon: isFavoriteStore ? "heart.fill" : "heart",
|
||||||
|
foregroundStyle: isFavoriteStore ? Color.red : Color.white
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
await toggleFavoriteStore()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, UIDevice.topNotch)
|
.padding(.top, UIDevice.topNotch)
|
||||||
|
|||||||
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