diff --git a/API_Mobile_App.md b/API_Mobile_App.md index 95255e0..d410e8f 100644 --- a/API_Mobile_App.md +++ b/API_Mobile_App.md @@ -127,47 +127,123 @@ Used when interacting with a specific store (Catalog, Checkout). **Campos internos não retornados para o app:** `keyHash`, `strongHash`, `source`, `isShadow`, `profile_metrics`, `behavioral_stats`, `otpHash`, `otpHashParams`, `otpExpiresAt`. ### 4. Update Profile -**POST** `/api/customer/:id` +**PATCH** `/api/customer/profile` + +**Headers:** + +- `Authorization: Bearer ` +- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` +- `Content-Type: application/json` + +**Notes:** + +- Todos os campos são opcionais — envie apenas o que mudou. +- `profilePicture` aceita base64 data URL (máx 2 MB decodificado). +- A resposta inclui `profilePictureUrl` apenas quando imagem foi enviada. + +**Body:** + +```json +{ + "name": "Daniel A. Loverde", + "cpf": "12345678901", + "phoneNumber": "5511988888888", + "profilePicture": "data:image/jpeg;base64,/9j/4AAQSkZJRgAB..." +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +|---|---|---|---| +| `name` | string | não | Nome de exibição | +| `cpf` | string | não | 11 dígitos, formatado ou raw | +| `phoneNumber` | string | não | Com DDI, sem `+`: `"5519991670000"` | +| `profilePicture` | string | não | base64 data URL — jpeg, png ou webp — máx 2 MB decoded | + +**Formato `profilePicture`:** + +``` +data:image/jpeg;base64, +data:image/png;base64, +data:image/webp;base64, +``` + +**Response (sucesso):** + +```json +{ + "error": false, + "code": "PROFILE_UPDATED", + "profilePictureUrl": "/uploads/profiles/e30188cf_1780615191653.jpg" +} +``` + +> `profilePictureUrl` é URL relativa. Prefixar com `https://atomenta.com.br` para exibir a imagem. + +**Erros possíveis:** + +| Código | HTTP | Descrição | +|---|---|---| +| `INVALID_CPF` | 400 | CPF não tem 11 dígitos | +| `INVALID_PROFILE_IMAGE` | 400 | Não é data URL válido ou excede 2 MB | +| `NO_FIELDS` | 400 | Nenhum campo reconhecido no body | +| `CUSTOMER_NOT_FOUND` | 404 | — | + +### 5. Set Default Address +**PATCH** `/api/customer/addresses/:addressId/default` **Headers:** - `Authorization: Bearer ` - `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` -**Body:** +**Notes:** + +- Define um endereço como padrão pelo seu `id`. +- Define `isDefault: true` no endereço alvo e `isDefault: false` em todos os outros. +- O `id` de cada endereço é retornado no campo `address_book` do perfil (`GET /api/customer/profile`). +- Ao salvar um endereço via `PATCH /api/customer/profile`, o backend gera automaticamente um `id` UUID caso o endereço não possua um. +- Se nenhum endereço tiver `isDefault: true`, o primeiro do array é tratado como padrão. + +**Response 200:** ```json { - "name": "Daniel A. Loverde", // Fields to update - "phoneNumber": "+5511988888888", - "biometricsEnabled": true, - "address_book": [ - { - "label": "Casa", - "type": "residential", - "address": "Avenida das Andorinhas", - "number": "477", - "neighborhood": "Jardim Andorinhas", - "city": "Campinas", - "state": "SP", - "zipCode": "13101-400", - "country": "Brasil", - "complement": "Apto 78", - "lat_long": [-22.9064, -47.0616] - } - ] + "error": false, + "code": "ADDRESS_DEFAULT_SET" } ``` -**Regra de coordenadas (`address_book.lat_long`):** +**Erros:** -- Se existir `zipCode` válido e houver cache do CEP no Atomenta (`/api/public/cep/:cep`) para o mesmo usuário, a API valida o ponto enviado. -- Se não houver cache local, o Atomenta consulta a AwesomeAPI e atualiza o cache antes de validar. -- Se `lat_long` estiver ausente/inválido, a API preenche com as coordenadas 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. +| Código | HTTP | Descrição | +|---|---|---| +| `ADDRESS_NOT_FOUND` | 404 | Nenhum endereço com esse `id` no `address_book` | +| `CUSTOMER_NOT_FOUND` | 404 | — | -### 5. List Favorite Stores +**Exemplo de `address_book` no perfil (com `id` e `isDefault`):** + +```json +"address_book": [ + { + "id": "a1b2c3d4-...", + "label": "Casa", + "type": "residential", + "address": "Estrada Vicinal Nene Moro", + "number": "SN", + "neighborhood": "Curtume", + "city": "Aguaí", + "state": "SP", + "zipCode": "13868070", + "country": "Brasil", + "lat_long": [-22.06743, -46.98018], + "isDefault": true + } +] +``` + +--- + +### 6. List Favorite Stores **GET** `/api/customer/favorites` **Headers:** @@ -580,6 +656,44 @@ Use este endpoint ao trocar endereço na tela de pagamento para validar cobertur } ``` +**Exemplo com pizza (tamanho + sabores + borda):** + +```json +{ + "items": [ + { + "productId": "prod_pizza_calabresa", + "name": "Pizza Calabresa", + "qty": 1, + "price": 65.00, + "choices": [ + "Tamanho: Grande (+R$ 55,00)", + "Sabor: Calabresa", + "Massa: Tradicional", + "Borda: Catupiry (+R$ 10,00)" + ] + } + ] +} +``` + +**Como montar `choices[]` para pizza:** + +| Linha | Formato | Obrigatório | +|---|---|---| +| Tamanho | `"Tamanho: (+R$ X,XX)"` | Sim | +| Sabor (único) | `"Sabor: "` | Sim | +| Sabor N (múltiplos) | `"Sabor 1: "`, `"Sabor 2: "` | Sim quando maxFlavors > 1 | +| Massa | `"Massa: "` | Não | +| Borda sem custo | `"Borda: "` | Não | +| Borda com custo | `"Borda: (+R$ X,XX)"` | Não | + +**Regras:** +- `choices[]` é um array de strings. O backend converte para `options[]` na normalização do pedido. +- O `price` do item deve refletir o preço total da pizza (tamanho + modificador de borda). +- Os nomes de tamanho, massa e borda devem corresponder aos `id` ou `name` retornados pelo catalog endpoint. +- Máximo de sabores definido por `pizzaConfig.sizes[n].maxFlavors`. + **Regras para `items[].addons`:** - `qty` do adicional é aceito no backend (ex.: `2 ovos`). @@ -929,4 +1043,246 @@ Para dashboard da loja e análise de produto, usar: - O painel `/store/reviews` segue funcional com os campos legados (`rate`, `message`, `itemFeedback`, `improvementFeedback`, `deliveryFeedback`). - Campos novos coexistem para evolução gradual sem quebrar o fluxo atual. +--- + +## Feature Control (Feature Flags) + +O Feature Control permite ativar/desativar funcionalidades do app remotamente sem publicar uma nova versão. O app nunca chama o Atomenta diretamente — usa um **BFF (Backend for Frontend)** que protege o `Atomenta-Token` do cliente. + +### Arquitetura + +``` +App iOS → POST /feature-control/bootstrap → BFF → Atomenta /api/feature-control/evaluate +``` + +O BFF mantém cache de 60 segundos. Em caso de timeout ou erro upstream, retorna flags de fallback estáticas definidas nas variáveis de ambiente do BFF. + +--- + +### 1. Bootstrap — Avaliar flags na inicialização + +**`POST /feature-control/bootstrap`** + +Deve ser chamado uma vez por sessão do app (ao abrir ou ao autenticar). Retorna os valores de todos os flags solicitados para o contexto do usuário. + +**Headers:** +``` +Content-Type: application/json +Authorization: Bearer ← opcional, mas recomendado +``` + +> Se o `Authorization` for enviado, o BFF extrai `subjectId` automaticamente do JWT (`sub` / `customerId` / `id`). Se não for enviado, `context.subjectId` é obrigatório. + +**Request body:** +```json +{ + "environment": "production", + "keys": [ + "at.ios.only", + "at.android.only", + "at.promo", + "at.city.aguai" + ], + "context": { + "subjectType": "customer", + "subjectId": "e30188cf-cf69-471e-bc59-4c92e0f9ad7b", + "platform": "ios", + "appVersion": "2.3.1", + "attributes": { + "city": "Aguaí" + } + } +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +|---|---|---|---| +| `environment` | string | não | `"production"` (default) ou `"sandbox"` | +| `keys` | string[] | sim | Lista de flags a avaliar (máx 100) | +| `context.subjectType` | string | sim | `"customer"` \| `"user"` \| `"store"` \| `"anonymous"` | +| `context.subjectId` | string | sim* | ID do usuário. *Dispensável se JWT enviado no header | +| `context.platform` | string | não | `"ios"` ou `"android"` | +| `context.appVersion` | string | não | Versão semântica, ex: `"2.3.1"` | +| `context.storeId` | string | não | Para flags segmentadas por loja | +| `context.attributes` | object | não | Atributos extras (ex: `city`, `tier`) | + +**Response 200 — fonte live:** +```json +{ + "ok": true, + "source": "live", + "configVersion": 7, + "evaluatedAt": "2026-06-05T12:00:00.000Z", + "flags": { + "atiosonly": true, + "atandroidonly": false, + "atpromo": true, + "atcityaguai": true + }, + "raw": { + "at.ios.only": { + "enabled": true, + "variant": "on", + "payload": null, + "reason": "rollout" + }, + "at.promo": { + "enabled": true, + "variant": "on", + "payload": null, + "reason": "rollout" + }, + "at.city.aguai": { + "enabled": true, + "variant": "on", + "payload": null, + "reason": "segment" + }, + "at.android.only": { + "enabled": false, + "variant": "off", + "payload": null, + "reason": "default" + } + } +} +``` + +**Response 200 — fonte fallback** (quando BFF não consegue atingir Atomenta): +```json +{ + "ok": true, + "source": "fallback", + "configVersion": 0, + "evaluatedAt": "2026-06-05T12:00:00.000Z", + "flags": { "ios": true, "promo": true }, + "raw": { ... }, + "upstreamError": { + "status": 504, + "code": "FEATURE_CONTROL_UPSTREAM_TIMEOUT", + "message": "Timeout no upstream" + } +} +``` + +#### Como usar `raw` no app (Swift) + +O app usa o campo **`raw`**, indexado pela chave original completa. O campo `flags` contém uma versão simplificada (pontos removidos), mas o app Swift usa `raw` para manter compatibilidade direta com os nomes de flag. + +| Chave original | Acesso via `raw` | `flags` simplificado | +|---|---|---| +| `at.ios.only` | `raw["at.ios.only"]` | `flags["atiosonly"]` | +| `at.android.only` | `raw["at.android.only"]` | `flags["atandroidonly"]` | +| `at.promo` | `raw["at.promo"]` | `flags["atpromo"]` | +| `at.city.aguai` | `raw["at.city.aguai"]` | `flags["atcityaguai"]` | + +> **Use sempre `raw`** para acessar flags no app — as chaves simplificadas em `flags` são geradas mecanicamente e menos legíveis. + +**Regra de leitura em `raw`:** +- `raw["at.promo"].enabled == true` → flag habilitado +- `raw["at.promo"].variant == "on"` → variante ativa +- `raw["at.promo"].payload` → dados extras opcionais (JSON livre) + +**Exemplo Swift (padrão atual do app):** +```swift +// FeatureFlagsState.isEnabled() já lê raw automaticamente +if appState.featureFlags.isEnabled("at.promo") { + // mostrar módulo de promoções +} + +if appState.featureFlags.isEnabled("at.ios.only") { + // funcionalidade exclusiva iOS +} +``` + +#### `source` e estratégia de fallback + +| `source` | Significado | Ação recomendada | +|---|---|---| +| `"live"` | Dados frescos do Atomenta | Usar normalmente | +| `"fallback"` | BFF usou defaults estáticos (timeout/erro) | Usar com cautela; re-tentar na próxima sessão | + +--- + +### 2. Telemetria de Exposição + +**`POST /feature-control/telemetry/exposure`** + +Registra quais flags o usuário visualizou (para análise de rollout). Envio em batch, **fire-and-forget** — não bloquear UX aguardando resposta. + +**Headers:** +``` +Content-Type: application/json +Authorization: Bearer +``` + +**Request body:** +```json +{ + "events": [ + { + "featureKey": "at.promo", + "variant": "on", + "subjectType": "customer", + "storeId": "store_1777215327582_akum6" + }, + { + "featureKey": "at.ios.only", + "variant": "on", + "subjectType": "customer" + } + ] +} +``` + +| Campo | Tipo | Obrigatório | Descrição | +|---|---|---|---| +| `featureKey` | string | sim | Chave original com prefixo `fc.` | +| `variant` | string | não | Variante exposta (default `"off"`) | +| `subjectType` | string | não | Default `"customer"` | +| `storeId` | string | não | Quando relevante | + +Máximo de **100 eventos por request**. + +**Response 200:** +```json +{ + "ok": true, + "code": "FEATURE_CONTROL_EXPOSURE_ACCEPTED", + "result": { "count": 2 } +} +``` + +--- + +### 3. Flags disponíveis (referência) + +Padrão de nomenclatura: `at..` (pontos como separador, sem hífens). + +| Flag | Descrição | Default | +|---|---|---| +| `at.ios.only` | Funcionalidades exclusivas iOS | `true` | +| `at.android.only` | Funcionalidades exclusivas Android | `true` | +| `at.promo` | Módulo de promoções ativo | `true` | +| `at.city.aguai` | Expansão para cidade de Aguaí/SP | `true` | + +> Novos flags são criados pelo time Pedi Foods no painel `/feature-control`. Consulte o time antes de codificar um flag inexistente. A lista completa de flags ativos está disponível no painel admin → Feature Control. + +--- + +### 4. Fluxo recomendado no app + +``` +1. App abre / usuário autentica +2. POST /feature-control/bootstrap (com JWT no header) +3. Salvar flags em memória/UserDefaults para a sessão +4. Renderizar UI baseada nos flags +5. POST /feature-control/telemetry/exposure (fire-and-forget, por flag visualizado) +6. Ao fechar sessão ou após 15 min, repetir passo 2 na próxima abertura +``` + +> **Não** chamar bootstrap a cada tela — apenas na inicialização da sessão. O BFF tem cache de 60s no servidor; o app deve ter cache local mínimo de 15 minutos para evitar latência desnecessária. + +--- + Autor: Daniel Arantes Loverde diff --git a/README.md b/README.md new file mode 100644 index 0000000..89707cb --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# ⚠️ INFORMAÇÕES CONFIDENCIAIS E PROPRIEDADE INTELECTUAL ⚠️ + +## 🚨 AVISO LEGAL IMPORTANTE + +**ATENÇÃO: Este repositório contém informações CONFIDENCIAIS e PROPRIETÁRIAS.** + +### ⛔ PROIBIÇÕES ABSOLUTAS + +- **NÃO** compartilhe este código com terceiros +- **NÃO** faça fork, clone ou cópia sem autorização expressa +- **NÃO** utilize qualquer parte deste código em outros projetos +- **NÃO** distribua, venda ou comercialize este software +- **NÃO** reverse engineer ou descompile o código +- **NÃO** acesse sem permissão explícita do proprietário + +### 🔒 DIREITOS DE PROPRIEDADE INTELECTUAL + +Este software é propriedade exclusiva de **Daniel Arantes Loverde** e está protegido por: + +- **Lei de Propriedade Intelectual (Lei 9.610/98)** +- **Lei de Software (Lei 9.609/98)** +- **Lei Geral de Proteção de Dados (LGPD - Lei 13.709/18)** +- **Direitos Autorais e Patentes** + +### ⚖️ CONSEQUÊNCIAS LEGAIS + +O acesso não autorizado, uso indevido, cópia ou distribuição deste código constitui: + +- **CRIME** de violação de propriedade intelectual +- **CRIME** de roubo de propriedade intelectual +- **CRIME** de violação de direitos autorais +- **CRIME** de acesso não autorizado a sistema informatizado + +**Penas previstas: 2 a 5 anos de reclusão + multa** + +### 🛡️ MEDIDAS DE SEGURANÇA + +- **Monitoramento**: Todo acesso é registrado e monitorado +- **Rastreamento**: IPs e atividades são rastreados +- **Logs**: Sistema completo de auditoria +- **Criptografia**: Dados protegidos com AES-256 +- **Autenticação**: Acesso restrito com JWT + OTP + +### 📧 CONTATO AUTORIZADO + +**ÚNICO CONTATO VÁLIDO:** +- **Email**: daniel@loverde.com.br +- **Autorização**: Apenas via email oficial + +### 🔐 CLASSIFICAÇÃO + +**NÍVEL DE CONFIDENCIALIDADE: MÁXIMO** +- **Restrito**: Apenas para o proprietário +- **Não Divulgar**: Informações proprietárias +- **Não Compartilhar**: Código fonte protegido + +--- + +## ⚠️ DISCLAIMER FINAL + +**Ao acessar este repositório, você concorda que:** + +1. **Reconhece** que este é um software proprietário +2. **Compromete-se** a não violar os direitos de propriedade intelectual +3. **Aceita** as consequências legais por qualquer violação +4. **Entende** que o acesso é monitorado e rastreado +5. **Concorda** em respeitar todas as leis aplicáveis + +--- + +## Sobre este repositório + +App **PediFoods iOS** — projeto [Skip](https://skip.tools) dual-platform em +Swift/SwiftUI (`pedi-foods/`), transpilado para Android via skipstone. É o app +de produção e a **fonte da verdade** para requests, responses e comportamento; +o port nativo Android é construído em paridade com ele. + +Build e execução: ver `pedi-foods/README.md` (Skip, `skip checkup`/`skip verify`, +Xcode + emulador Android). Testes via `swift test` / `skip test`. + +--- + +## Fluxo Git (desenvolvimento) + +- Ao terminar cada task ou fatia lógica (ex.: fatia de checkout, ajuste de tela, correção de testes): fazer **commit imediato** com mensagem clara (`feat:`, `fix:`, `chore:`). +- Não commitar artefactos de build (`.build/`, saída do skipstone, `Darwin/` gerado) nem ficheiros de estado local irrelevantes para o projeto rodar a partir do código-fonte, salvo pedido explícito. +- O agente faz commit local; o push é sempre feito pelo Daniel após revisão. + +--- + +**© 2024 Daniel Arantes Loverde. Todos os direitos reservados.** + +**Última atualização: Junho 2026** + +--- + +*Este documento é parte integrante do software e deve ser mantido confidencial.* + +Autor: Daniel Arantes Loverde diff --git a/feature-control-bff/README.md b/feature-control-bff/README.md index db11f92..7b57000 100644 --- a/feature-control-bff/README.md +++ b/feature-control-bff/README.md @@ -27,11 +27,10 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`: ```json { - "fc.promo": { "enabled": true, "variant": "on" }, - "fc.promo-codes": { "enabled": true, "variant": "on" }, - "fc.android": { "enabled": true, "variant": "on" }, - "fc.city-aguai": { "enabled": true, "variant": "on" }, - "fc.ios": { "enabled": true, "variant": "on" } + "at.ios.only": { "enabled": true, "variant": "on" }, + "at.android.only": { "enabled": true, "variant": "on" }, + "at.promo": { "enabled": true, "variant": "on" }, + "at.city.aguai": { "enabled": true, "variant": "on" } } ``` @@ -40,7 +39,7 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`: ```json { "environment": "production", - "keys": ["fc.promo", "fc.promo-codes", "fc.android", "fc.city-aguai", "fc.ios"], + "keys": ["at.ios.only", "at.android.only", "at.promo", "at.city.aguai"], "context": { "subjectType": "customer", "subjectId": "cust_123", @@ -48,8 +47,7 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`: "platform": "ios", "appVersion": "2.3.1", "attributes": { - "city": "Belo Horizonte", - "tier": "gold" + "city": "Aguaí" } } } @@ -66,14 +64,19 @@ Se o app enviar `Authorization: Bearer `, o BFF tenta extrair `subjectId` d "configVersion": 7, "evaluatedAt": "2026-04-16T12:00:00.000Z", "flags": { - "promo": true, - "promoCodes": true, - "android": true, - "cityAguai": true, - "ios": true + "atiosonly": true, + "atandroidonly": false, + "atpromo": true, + "atcityaguai": true }, "raw": { - "fc.promo": { + "at.ios.only": { + "enabled": true, + "variant": "on", + "payload": null, + "reason": "rollout" + }, + "at.promo": { "enabled": true, "variant": "on", "payload": null, @@ -83,6 +86,9 @@ Se o app enviar `Authorization: Bearer `, o BFF tenta extrair `subjectId` d } ``` +> O app Swift usa o campo **`raw`** indexado pela chave original (ex: `raw["at.promo"]`). O campo `flags` é uma versão simplificada gerada mecanicamente (pontos removidos). +``` + Quando o upstream falha (ex.: `429`, `500`, timeout), o BFF devolve `fallback` com defaults estáticos. ## Execução local diff --git a/logo/alelocard_logo.png b/logo/alelocard_logo.png new file mode 100644 index 0000000..9201194 Binary files /dev/null and b/logo/alelocard_logo.png differ diff --git a/logo/amexcard_logo.png b/logo/amexcard_logo.png new file mode 100644 index 0000000..42edc89 Binary files /dev/null and b/logo/amexcard_logo.png differ diff --git a/logo/hipercard_logo.png b/logo/hipercard_logo.png new file mode 100644 index 0000000..a184c60 Binary files /dev/null and b/logo/hipercard_logo.png differ diff --git a/logo/mastercard_logo.png b/logo/mastercard_logo.png new file mode 100644 index 0000000..fefd01b Binary files /dev/null and b/logo/mastercard_logo.png differ diff --git a/logo/sodexo_logo.png b/logo/sodexo_logo.png new file mode 100644 index 0000000..1aefb69 Binary files /dev/null and b/logo/sodexo_logo.png differ diff --git a/logo/visacard_logo.png b/logo/visacard_logo.png new file mode 100644 index 0000000..279c267 Binary files /dev/null and b/logo/visacard_logo.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json index b7320fe..35fe5b3 100644 --- a/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json +++ b/pedi-foods/Darwin/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -1,16 +1,34 @@ { "colors" : [ { - "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "red" : "0.9529411764705882", - "green" : "0.9607843137254902", - "blue" : "0.9686274509803922", - "alpha" : "1.0000000000000000" + "alpha" : "1.000", + "blue" : "0.969", + "green" : "0.961", + "red" : "0.953" } - } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.102", + "green" : "0.102", + "red" : "0.102" + } + }, + "idiom" : "universal" } ], "info" : { diff --git a/pedi-foods/Darwin/Assets.xcassets/alelocard_logo.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/alelocard_logo.imageset/Contents.json new file mode 100644 index 0000000..c43b70e --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/alelocard_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "alelocard_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/alelocard_logo.imageset/alelocard_logo.png b/pedi-foods/Darwin/Assets.xcassets/alelocard_logo.imageset/alelocard_logo.png new file mode 100644 index 0000000..9201194 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/alelocard_logo.imageset/alelocard_logo.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/amexcard_logo.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/amexcard_logo.imageset/Contents.json new file mode 100644 index 0000000..e25b625 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/amexcard_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "amexcard_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/amexcard_logo.imageset/amexcard_logo.png b/pedi-foods/Darwin/Assets.xcassets/amexcard_logo.imageset/amexcard_logo.png new file mode 100644 index 0000000..42edc89 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/amexcard_logo.imageset/amexcard_logo.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/hipercard_logo.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/hipercard_logo.imageset/Contents.json new file mode 100644 index 0000000..9893028 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/hipercard_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "hipercard_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/hipercard_logo.imageset/hipercard_logo.png b/pedi-foods/Darwin/Assets.xcassets/hipercard_logo.imageset/hipercard_logo.png new file mode 100644 index 0000000..a184c60 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/hipercard_logo.imageset/hipercard_logo.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/icon_pix.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/icon_pix.imageset/Contents.json new file mode 100644 index 0000000..22f4230 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/icon_pix.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icon_pix.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/icon_pix.imageset/icon_pix.png b/pedi-foods/Darwin/Assets.xcassets/icon_pix.imageset/icon_pix.png new file mode 100644 index 0000000..d3d4824 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/icon_pix.imageset/icon_pix.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/Contents.json deleted file mode 100644 index 6df8882..0000000 --- a/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/Contents.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "images": [ - { - "idiom": "universal", - "filename": "login_light.png", - "scale": "1x" - }, - { - "idiom": "universal", - "scale": "2x" - }, - { - "idiom": "universal", - "scale": "3x" - }, - { - "idiom": "universal", - "filename": "login_dark.png", - "scale": "1x", - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ] - }, - { - "idiom": "universal", - "scale": "2x", - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ] - }, - { - "idiom": "universal", - "scale": "3x", - "appearances": [ - { - "appearance": "luminosity", - "value": "dark" - } - ] - } - ], - "info": { - "version": 1, - "author": "xcode" - } -} \ No newline at end of file diff --git a/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_dark.png b/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_dark.png deleted file mode 100644 index bf637d4..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_dark.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_light.png b/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_light.png deleted file mode 100644 index e7c5b09..0000000 Binary files a/pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_light.png and /dev/null differ diff --git a/pedi-foods/Darwin/Assets.xcassets/mastercard_logo.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/mastercard_logo.imageset/Contents.json new file mode 100644 index 0000000..a37b708 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/mastercard_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mastercard_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/mastercard_logo.imageset/mastercard_logo.png b/pedi-foods/Darwin/Assets.xcassets/mastercard_logo.imageset/mastercard_logo.png new file mode 100644 index 0000000..fefd01b Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/mastercard_logo.imageset/mastercard_logo.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/Contents.json new file mode 100644 index 0000000..162c776 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "placeholder-pizza.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png b/pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png new file mode 100644 index 0000000..e4072c0 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/sodexo_logo.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/sodexo_logo.imageset/Contents.json new file mode 100644 index 0000000..450d47f --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/sodexo_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "sodexo_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/sodexo_logo.imageset/sodexo_logo.png b/pedi-foods/Darwin/Assets.xcassets/sodexo_logo.imageset/sodexo_logo.png new file mode 100644 index 0000000..1aefb69 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/sodexo_logo.imageset/sodexo_logo.png differ diff --git a/pedi-foods/Darwin/Assets.xcassets/visacard_logo.imageset/Contents.json b/pedi-foods/Darwin/Assets.xcassets/visacard_logo.imageset/Contents.json new file mode 100644 index 0000000..6cbca03 --- /dev/null +++ b/pedi-foods/Darwin/Assets.xcassets/visacard_logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "visacard_logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pedi-foods/Darwin/Assets.xcassets/visacard_logo.imageset/visacard_logo.png b/pedi-foods/Darwin/Assets.xcassets/visacard_logo.imageset/visacard_logo.png new file mode 100644 index 0000000..279c267 Binary files /dev/null and b/pedi-foods/Darwin/Assets.xcassets/visacard_logo.imageset/visacard_logo.png differ diff --git a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj index 416a597..fd9fbd3 100644 --- a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj +++ b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj @@ -162,8 +162,6 @@ }; /* End PBXResourcesBuildPhase section */ -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 499CD43A2AC5B799001AE8D8 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -269,7 +267,7 @@ repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.0.7; + minimumVersion = 1.0.10; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/pedi-foods/Darwin/Sources/LaunchScreen.storyboard b/pedi-foods/Darwin/Sources/LaunchScreen.storyboard index bd2b472..6383af8 100644 --- a/pedi-foods/Darwin/Sources/LaunchScreen.storyboard +++ b/pedi-foods/Darwin/Sources/LaunchScreen.storyboard @@ -23,7 +23,7 @@ - + diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 22f37e4..a3deaf9 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -79,32 +79,13 @@ "comment" : "A small, secondary text label displaying the price of an item.", "isCommentAutoGenerated" : true }, - "1. Tamanho" : { - "comment" : "A heading for the size selection step in the pizza product detail sheet.", - "isCommentAutoGenerated" : true + "0000 0000 0000 0000" : { + }, "1km" : { "comment" : "A label for the minimum distance filter option.", "isCommentAutoGenerated" : true }, - "2. Massa" : { - - }, - "3. Borda" : { - - }, - "4. Sabores (%lld/%lld)" : { - "comment" : "A heading that describes the step where the user selects pizza flavors. The number in the parentheses indicates the maximum number of flavors that can be selected.", - "isCommentAutoGenerated" : true, - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "4. Sabores (%1$lld/%2$lld)" - } - } - } - }, "10km" : { "comment" : "A label displayed next to the far end of the distance slider in the filters modal.", "isCommentAutoGenerated" : true @@ -156,6 +137,9 @@ }, "Adicionar loja aos favoritos" : { + }, + "Adicionar novo cartão" : { + }, "Adicionar novo endereço" : { "comment" : "A button label that translates to \"Add new address\" in English.", @@ -176,15 +160,17 @@ "Alterar" : { "comment" : "A button label that translates to \"Change\" in English.", "isCommentAutoGenerated" : true + }, + "Ao menos um cartão deve permanecer" : { + + }, + "Ao menos um endereço deve permanecer" : { + }, "Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)" : { "comment" : "A text that informs the user that registering implies acceptance of the app's terms of use and privacy policy. The text is clickable and navigates to the respective views when tapped.", "isCommentAutoGenerated" : true }, - "Apenas Pagar" : { - "comment" : "A button that allows the user to pay without saving the payment information.", - "isCommentAutoGenerated" : true - }, "Aplicar" : { "comment" : "A button that applies a coupon code to a cart.", "isCommentAutoGenerated" : true @@ -229,10 +215,6 @@ }, "Atualizando status do pedido..." : { - }, - "Atualize os dados do endereço abaixo." : { - "comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.", - "isCommentAutoGenerated" : true }, "Avaliar Pedido" : { @@ -300,6 +282,9 @@ }, "Carregando cardápio..." : { + }, + "Carregando endereços..." : { + }, "Carregando pedido..." : { @@ -311,6 +296,9 @@ "Carrinho" : { "comment" : "A label for the cart section of the app.", "isCommentAutoGenerated" : true + }, + "Cartão de Crédito" : { + }, "Categorias" : { "comment" : "A heading for the list of available categories in the filters modal.", @@ -332,10 +320,6 @@ "comment" : "A label for the comment section in the order review view.", "isCommentAutoGenerated" : true }, - "Concluir" : { - "comment" : "The text for a button that confirms and closes a sheet.", - "isCommentAutoGenerated" : true - }, "Conexão segura e criptografada" : { "comment" : "A description below the \"Verificar e Entrar\" button, emphasizing the security of the login process.", "isCommentAutoGenerated" : true @@ -376,9 +360,8 @@ "comment" : "A section header for entering and applying a discount coupon.", "isCommentAutoGenerated" : true }, - "Dados do Cartão" : { - "comment" : "The header text for the section that collects payment card information.", - "isCommentAutoGenerated" : true + "Cupons de Desconto" : { + }, "Dark" : { "comment" : "Menu item indicating that the appearance should be in dark mode", @@ -439,6 +422,9 @@ } } } + }, + "Definir como principal" : { + }, "Desconto" : { "comment" : "A label for a discount applied to an order.", @@ -464,6 +450,9 @@ "Em uma escala de 0 a 10, qual é a chance de você indicar o Pedi Foods para um amigo?" : { "comment" : "A description under the NPS scale question in the order review view.", "isCommentAutoGenerated" : true + }, + "Endereço de cobrança" : { + }, "ENDEREÇO DE ENTREGA" : { "comment" : "A label displayed above the address section when delivery is selected.", @@ -498,6 +487,9 @@ "Escolha o tamanho da sua fome" : { "comment" : "A label displayed below the pizza size selection.", "isCommentAutoGenerated" : true + }, + "Escolha seu sabor" : { + }, "Este sabor não possui adicionais." : { "comment" : "A message displayed when a pizza flavor does not have any add-ons.", @@ -630,6 +622,12 @@ "Já tem uma conta?" : { "comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.", "isCommentAutoGenerated" : true + }, + "Ligar para %@" : { + + }, + "Ligar para a loja?" : { + }, "Light" : { "comment" : "Menu item indicating that the appearance should be in light mode", @@ -668,9 +666,15 @@ "Limpar carrinho e adicionar" : { "comment" : "A button that clears the current cart and adds the selected product.", "isCommentAutoGenerated" : true + }, + "Limpar e adicionar" : { + }, "Loja não informou métodos presenciais." : { + }, + "Mais rápido e seguro" : { + }, "MÉTODO DE PAGAMENTO" : { "comment" : "A label displayed above the payment methods section in the checkout view.", @@ -683,6 +687,9 @@ "Meu Perfil" : { "comment" : "The title of the user profile view.", "isCommentAutoGenerated" : true + }, + "Meus Cartões" : { + }, "Meus Endereços" : { "comment" : "A label displayed at the top of the view.", @@ -701,6 +708,9 @@ }, "Motivo do cancelamento" : { + }, + "Na entrega/retirada com a loja" : { + }, "Name" : { "comment" : "Placeholder title for the Name field in a form", @@ -742,6 +752,9 @@ }, "Não tem conta ainda?" : { + }, + "Nenhum cartão cadastrado" : { + }, "Nenhum endereço cadastrado" : { "comment" : "A message displayed when a user has no addresses saved.", @@ -783,6 +796,12 @@ } } } + }, + "Novo cartão" : { + + }, + "Novo Cartão" : { + }, "Novo endereço" : { "comment" : "A label for a form to add a new address.", @@ -791,6 +810,9 @@ "Novo por aqui?" : { "comment" : "A text that appears at the bottom of the screen, inviting users to create an account.", "isCommentAutoGenerated" : true + }, + "Número do cartão" : { + }, "O que pode melhorar?" : { "comment" : "A label for the section where users can select tags they think could improve an order.", @@ -823,13 +845,21 @@ "comment" : "A label describing the sorting options available in the filters modal.", "isCommentAutoGenerated" : true }, - "Pagamento" : { - "comment" : "The title of the screen where a user can enter and save payment information.", - "isCommentAutoGenerated" : true + "ou" : { + }, "Pagamento via PIX" : { "comment" : "The navigation title for the PaymentPixView.", "isCommentAutoGenerated" : true + }, + "Pagar Na Maquininha" : { + + }, + "Pagar Pelo App" : { + + }, + "Pague mais rápido nas próximas compras" : { + }, "Pedido #%@" : { @@ -861,7 +891,16 @@ }, "Pizza de varios sabores" : { "comment" : "A title displayed above the main content of the view.", - "isCommentAutoGenerated" : true + "extractionState" : "stale", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha seu sabor" + } + } + } }, "Política de Privacidade" : { "comment" : "The title of the privacy policy section.", @@ -904,13 +943,12 @@ "comment" : "A label describing the price filter section in the filters modal.", "isCommentAutoGenerated" : true }, - "Preencha os dados abaixo para adicionar um endereço." : { - "comment" : "A description below the form to add a new address, instructing the user to fill in the required information.", - "isCommentAutoGenerated" : true - }, "Preencha os dados abaixo para começar." : { "comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.", "isCommentAutoGenerated" : true + }, + "Principal" : { + }, "PRINCIPAL" : { "comment" : "A label indicating that a particular address is the user's primary address.", @@ -978,6 +1016,12 @@ "Salvar Alterações" : { "comment" : "The text on a button that saves changes to a user's profile.", "isCommentAutoGenerated" : true + }, + "Salvar cartão" : { + + }, + "Salvar Cartão" : { + }, "Save" : { "comment" : "Button title indicating that the current contents should be saved", @@ -1008,6 +1052,12 @@ } } } + }, + "Selecionar Cartão" : { + + }, + "Selecionar endereço" : { + }, "Sem adicionais" : { @@ -1046,6 +1096,16 @@ "comment" : "A message indicating that the user's cart is empty.", "isCommentAutoGenerated" : true }, + "Seu carrinho tem itens de %@. Deseja limpar e adicionar itens de %@?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Seu carrinho tem itens de %1$@. Deseja limpar e adicionar itens de %2$@?" + } + } + } + }, "Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?" : { "comment" : "A message displayed when the user attempts to add a product to their cart from a different store. Asks if the user wants to clear their current cart before adding the new product.", "isCommentAutoGenerated" : true @@ -1064,6 +1124,9 @@ "Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque." : { "comment" : "A tagline displayed in the login view.", "isCommentAutoGenerated" : true + }, + "Substituir carrinho?" : { + }, "Subtotal" : { @@ -1165,6 +1228,9 @@ "Total do Pedido" : { "comment" : "The label above the total price of the order in the payment card view.", "isCommentAutoGenerated" : true + }, + "Trocar" : { + }, "Trocar de loja?" : { "comment" : "A title for an alert that prompts the user to switch stores.", @@ -1173,10 +1239,16 @@ "Trocar Foto" : { "comment" : "A button label that allows a user to change their profile picture.", "isCommentAutoGenerated" : true + }, + "Trocar Pagamento" : { + }, "UPLOAD" : { "comment" : "A button label that says \"UPLOAD\".", "isCommentAutoGenerated" : true + }, + "Vence %@" : { + }, "Ver Detalhes" : { "comment" : "A button that shows order details when pressed.", @@ -1276,6 +1348,9 @@ } } } + }, + "WhatsApp não encontrado. Deseja ligar para %@?" : { + } }, "version" : "1.1" diff --git a/pedi-foods/Sources/PediFoods/Resources/alelocard_logo.png b/pedi-foods/Sources/PediFoods/Resources/alelocard_logo.png new file mode 100644 index 0000000..9201194 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/alelocard_logo.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/amexcard_logo.png b/pedi-foods/Sources/PediFoods/Resources/amexcard_logo.png new file mode 100644 index 0000000..42edc89 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/amexcard_logo.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/hipercard_logo.png b/pedi-foods/Sources/PediFoods/Resources/hipercard_logo.png new file mode 100644 index 0000000..a184c60 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/hipercard_logo.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/icon_pix.png b/pedi-foods/Sources/PediFoods/Resources/icon_pix.png new file mode 100644 index 0000000..d3d4824 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/icon_pix.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/mastercard_logo.png b/pedi-foods/Sources/PediFoods/Resources/mastercard_logo.png new file mode 100644 index 0000000..fefd01b Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/mastercard_logo.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/placeholder-pizza.png b/pedi-foods/Sources/PediFoods/Resources/placeholder-pizza.png new file mode 100644 index 0000000..e4072c0 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/placeholder-pizza.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/sodexo_logo.png b/pedi-foods/Sources/PediFoods/Resources/sodexo_logo.png new file mode 100644 index 0000000..1aefb69 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/sodexo_logo.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/visacard_logo.png b/pedi-foods/Sources/PediFoods/Resources/visacard_logo.png new file mode 100644 index 0000000..279c267 Binary files /dev/null and b/pedi-foods/Sources/PediFoods/Resources/visacard_logo.png differ diff --git a/pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift new file mode 100644 index 0000000..c4c7b94 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift @@ -0,0 +1,83 @@ +import Foundation + +struct SavedCard: Decodable, Identifiable, Hashable { + let id: String + let nickname: String? + let holderName: String + let last4: String + let brand: String? + let expiryMonth: String + let expiryYear: String + let isDefault: Bool + + var displayLabel: String { + if let nickname, nickname.isEmpty == false { return nickname } + let brandLabel = (brand ?? "Cartão").capitalized + return "\(brandLabel) •••• \(last4)" + } + + var expiryLabel: String { "\(expiryMonth)/\(expiryYear)" } +} + +struct SaveCardCreditCardPayload: Encodable { + let holderName: String + let number: String + let expiryMonth: String + let expiryYear: String + let ccv: String +} + +struct SaveCardHolderInfoPayload: Encodable { + let name: String + let email: String + let cpfCnpj: String + let postalCode: String + let addressNumber: String + let phone: String +} + +struct SaveCardPayload: Encodable { + let creditCard: SaveCardCreditCardPayload + let creditCardHolderInfo: SaveCardHolderInfoPayload + let nickname: String? + let isDefault: Bool +} + +struct UpdateCardPayload: Encodable { + let nickname: String? + let isDefault: Bool? +} + +struct SavedCardResult: Decodable { + let id: String + let holderName: String + let last4: String + let brand: String? + let expiryMonth: String + let expiryYear: String + let isDefault: Bool +} + +struct CreditCardOrderPayload: Encodable { + let holderName: String + let number: String + let expiryMonth: String + let expiryYear: String + let ccv: String +} + +struct ChangePaymentMethodPayload: Encodable { + let paymentMethod: String + let clientCpfCnpj: String? + let creditCard: CreditCardOrderPayload? + let creditCardHolderInfo: SaveCardHolderInfoPayload? + let savedCardId: String? +} + +struct ChangePaymentMethodResult: Decodable { + let paymentMethod: String? + let paymentLocation: String? + let paymentId: String? + let paymentPayload: CreateOrderPaymentPayload? + let payment: CreateOrderPaymentInfo? +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift index a74256c..f5cab1d 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift @@ -82,7 +82,6 @@ final class ApiClient { } func send(_ request: ApiRequest) async throws -> T { - #if canImport(LCEssentials) && os(iOS) return try await sendWithLCEssentials(request) #else @@ -111,10 +110,15 @@ private extension ApiClient { guard let data = responseString.data(using: .utf8) else { throw NetworkError.decodeError("Resposta nao UTF-8") } + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorFlag = object["error"] as? Bool, errorFlag { + let message = object["message"] as? String ?? object["msg"] as? String + throw NetworkError.httpError(200, message ?? "Erro no servidor") + } do { return try JSONDecoder().decode(T.self, from: data) } catch { - throw NetworkError.decodeError(String(data: data, encoding: .utf8)) + throw NetworkError.decodeError(sanitizedBody(data)) } } catch { let mapped = mapError(error) @@ -151,6 +155,7 @@ private extension ApiClient { case "POST": return .post case "PUT": return .put case "DELETE": return .delete + case "PATCH": return .patch default: return .get } } @@ -247,6 +252,12 @@ private extension ApiClient { throw NetworkError.unauthorized(payload?.message) } + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorFlag = object["error"] as? Bool, errorFlag { + let message = object["message"] as? String ?? object["msg"] as? String + throw NetworkError.httpError(http.statusCode, message ?? "Erro no servidor") + } + if http.statusCode == 429 { let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "") throw NetworkError.rateLimited(retryAfter) @@ -263,7 +274,7 @@ private extension ApiClient { do { return try JSONDecoder().decode(type, from: data) } catch { - throw NetworkError.decodeError(String(data: data, encoding: .utf8)) + throw NetworkError.decodeError(sanitizedBody(data)) } } @@ -309,7 +320,15 @@ private extension ApiClient { if let code = payload?.code, code.isEmpty == false { return "Erro: \(code)" } - return String(data: data, encoding: .utf8) + + // fallback if error true is present but without a classic structure + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorFlag = object["error"] as? Bool, errorFlag { + if let msg = object["message"] as? String ?? object["msg"] as? String { + return msg + } + } + return sanitizedBody(data) } func serverPayload(from data: Data) -> ApiErrorDescriptor? { @@ -318,9 +337,10 @@ private extension ApiClient { } if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + let errorFlag = object["error"] as? Bool ?? false let code = object["code"] as? String - let message = object["message"] as? String - if code != nil || message != nil { + let message = object["message"] as? String ?? object["msg"] as? String ?? object["error_description"] as? String + if errorFlag || code != nil || message != nil { return ApiErrorDescriptor(code: code, message: message) } } @@ -344,35 +364,89 @@ private extension ApiClient { #if canImport(LCEssentials) && os(iOS) func serverMessage(from error: NSError) -> String? { - if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String, - !reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - if let data = reason.data(using: .utf8), - let parsed = serverMessage(from: data), - !parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return parsed + for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] { + if let value = error.userInfo[key] as? String, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let data = value.data(using: .utf8), + let parsed = serverMessage(from: data), + !parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines) + if t.hasPrefix("{") { continue } + if let safe = sanitizedMessage(t) { return safe } + } + let t = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe } + } else if let valueData = error.userInfo[key] as? Data { + if let parsed = serverMessage(from: valueData) { + let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines) + if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe } + } + } + } + + for (_, value) in error.userInfo { + if let str = value as? String, + str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"), + let data = str.data(using: .utf8), + let parsed = serverMessage(from: data) { + let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines) + if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe } } - return reason } if let description = error.userInfo[NSLocalizedDescriptionKey] as? String, !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !description.lowercased().contains("nsurlerrordomain") { - return description + !description.lowercased().contains("nsurlerrordomain"), + let safe = sanitizedMessage(description) { + return safe } return nil } func serverPayload(from error: NSError) -> ApiErrorDescriptor? { - if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String, - let data = reason.data(using: .utf8), - let payload = serverPayload(from: data) { - return payload + for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data"] { + if let value = error.userInfo[key] as? String, + let data = value.data(using: .utf8), + let payload = serverPayload(from: data) { + return payload + } else if let valueData = error.userInfo[key] as? Data, + let payload = serverPayload(from: valueData) { + return payload + } + } + + for (_, value) in error.userInfo { + if let str = value as? String, + str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"), + let data = str.data(using: .utf8), + let payload = serverPayload(from: data) { + return payload + } } return nil } #endif + func sanitizedBody(_ data: Data) -> String? { + guard let raw = String(data: data, encoding: .utf8) else { return nil } + return sanitizedMessage(raw) + } + + func sanitizedMessage(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false else { return nil } + // Drop data URLs (base64 images) + if trimmed.lowercased().hasPrefix("data:image") { return "Erro ao processar imagem." } + // Drop fields containing base64, + if trimmed.contains("base64,") { return "Resposta do servidor inválida." } + // Truncate long strings (raw JSON bodies, etc.) + if trimmed.count > 300 { + return String(trimmed.prefix(300)) + "…" + } + return trimmed + } + func buildURL(path: String, query: [URLQueryItem]) throws -> URL { guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else { throw NetworkError.invalidURL diff --git a/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift index a8ff063..fc119c8 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift @@ -15,32 +15,25 @@ struct CustomerProfileUpdatePayload: Encodable { } struct CustomerIdentityUpdatePayload: Encodable { - let name: String - let email: String - let phoneNumber: String + let name: String? + let email: String? + let phoneNumber: String? let profilePicture: String? - let addressBook: [CustomerAddressPayload] enum CodingKeys: String, CodingKey { case name case email case phoneNumber - case phone case profilePicture - case profile_picture - case addressBook = "address_book" } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(email, forKey: .email) - try container.encode(phoneNumber, forKey: .phoneNumber) - try container.encode(phoneNumber, forKey: .phone) - try container.encode(addressBook, forKey: .addressBook) + try container.encodeIfPresent(name, forKey: .name) + try container.encodeIfPresent(email, forKey: .email) + try container.encodeIfPresent(phoneNumber, forKey: .phoneNumber) if let profilePicture, profilePicture.isEmpty == false { try container.encode(profilePicture, forKey: .profilePicture) - try container.encode(profilePicture, forKey: .profile_picture) } } } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift index 4e207c3..dc73a2e 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift @@ -4,6 +4,13 @@ import Foundation struct EmptyResult: Decodable {} +struct ProfilePatchEnvelope: Decodable { + let error: Bool + let code: String? + let message: String? + let profilePictureUrl: String? +} + struct RegistrationResult: Decodable { let id: String? let name: String? @@ -46,18 +53,23 @@ struct CustomerAddress: Decodable { let state: String? let zipCode: String? let latLong: [Double]? + let isDefault: Bool? + + init(id: String?, label: String?, address: String?, number: String?, + complement: String?, neighborhood: String?, city: String?, + state: String?, zipCode: String?, latLong: [Double]?, isDefault: Bool?) { + self.id = id; self.label = label; self.address = address + self.number = number; self.complement = complement + self.neighborhood = neighborhood; self.city = city + self.state = state; self.zipCode = zipCode + self.latLong = latLong; self.isDefault = isDefault + } enum CodingKeys: String, CodingKey { - case id - case label - case address - case number - case complement - case neighborhood - case city - case state - case zipCode + case id, label, address, number, complement + case neighborhood, city, state, zipCode case latLong = "lat_long" + case isDefault } } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift index 275c804..b4627b5 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift @@ -7,6 +7,12 @@ struct CreateOrderPayload: Encodable { let paymentMethod: String let deliveryType: String let address: CreateOrderAddressPayload? + // Cartão salvo + let savedCardId: String? + // Novo cartão (checkout transparente) + let clientCpfCnpj: String? + let creditCard: CreditCardOrderPayload? + let creditCardHolderInfo: SaveCardHolderInfoPayload? } struct CreateOrderCustomerPayload: Encodable { @@ -16,15 +22,16 @@ struct CreateOrderCustomerPayload: Encodable { let asaasId: String? } -struct CreateOrderItemPayload: Encodable { +struct CreateOrderItemPayload: Codable { let productId: String let name: String let qty: Int let price: Double let addons: [CreateOrderAddonPayload] + let choices: [String]? } -struct CreateOrderAddonPayload: Encodable { +struct CreateOrderAddonPayload: Codable { let addonId: String let name: String let qty: Int diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift index d60ad5c..bba14fc 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift @@ -40,6 +40,10 @@ struct AppOrderSummary: Decodable, Identifiable { case storeLogo case store_logo case logo + case storeImage + case store_image + case storeImageUrl + case logoUrl case date case createdAt case updatedAt @@ -62,7 +66,7 @@ struct AppOrderSummary: Decodable, Identifiable { deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType]) storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone]) - storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo]) + storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl]) let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date]) createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate @@ -97,6 +101,7 @@ struct PublicOrderResult: Codable, Identifiable { let customerOtp: String? let confirmOtp: String? let cancellationReason: String? + let fullAddress: String? let deliveryAddress: PublicOrderDeliveryAddress? let review: PublicOrderReview? let items: [PublicOrderItem] @@ -133,12 +138,17 @@ struct PublicOrderResult: Codable, Identifiable { case storeLogo case store_logo case logo + case storeImage + case store_image + case storeImageUrl + case logoUrl case createdAt case updatedAt case otp case customerOtp case confirmOtp case cancellationReason + case fullAddress case address case deliveryAddress case delivery_address @@ -148,6 +158,8 @@ struct PublicOrderResult: Codable, Identifiable { case orderReview case items case timeline + case history + case orderedAt } init( @@ -178,6 +190,7 @@ struct PublicOrderResult: Codable, Identifiable { customerOtp: String? = nil, confirmOtp: String? = nil, cancellationReason: String? = nil, + fullAddress: String? = nil, deliveryAddress: PublicOrderDeliveryAddress? = nil, review: PublicOrderReview? = nil, items: [PublicOrderItem] = [], @@ -210,6 +223,7 @@ struct PublicOrderResult: Codable, Identifiable { self.customerOtp = customerOtp self.confirmOtp = confirmOtp self.cancellationReason = cancellationReason + self.fullAddress = fullAddress self.deliveryAddress = deliveryAddress self.review = review self.items = items @@ -248,13 +262,14 @@ struct PublicOrderResult: Codable, Identifiable { total = ApiService.decodeFlexibleDouble(from: container, keys: [.total]) storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone]) - storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo]) - createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) + storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl]) + createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt, .orderedAt]) updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) otp = ApiService.decodeFlexibleString(from: container, keys: [.otp]) customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp]) confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp]) cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason]) + fullAddress = ApiService.decodeFlexibleString(from: container, keys: [.fullAddress]) deliveryAddress = (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .address)) ?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .deliveryAddress)) ?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .delivery_address)) @@ -263,7 +278,9 @@ struct PublicOrderResult: Codable, Identifiable { review = (try? container.decode(PublicOrderReview.self, forKey: .review)) ?? (try? container.decode(PublicOrderReview.self, forKey: .orderReview)) items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? [] - timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) ?? [] + timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) + ?? (try? container.decode([PublicOrderTimelineEvent].self, forKey: .history)) + ?? [] } func encode(to encoder: Encoder) throws { @@ -294,6 +311,7 @@ struct PublicOrderResult: Codable, Identifiable { try container.encodeIfPresent(customerOtp, forKey: .customerOtp) try container.encodeIfPresent(confirmOtp, forKey: .confirmOtp) try container.encodeIfPresent(cancellationReason, forKey: .cancellationReason) + try container.encodeIfPresent(fullAddress, forKey: .fullAddress) try container.encodeIfPresent(deliveryAddress, forKey: .address) try container.encodeIfPresent(review, forKey: .review) try container.encode(items, forKey: .items) @@ -537,20 +555,23 @@ struct PublicOrderReview: Codable { struct PublicOrderItem: Codable, Identifiable { let id: String + let productId: String? let name: String? let qty: Int? let price: Double? enum CodingKeys: String, CodingKey { case id + case productId case name case qty case quantity case price } - init(id: String = UUID().uuidString, name: String?, qty: Int?, price: Double?) { + init(id: String = UUID().uuidString, productId: String? = nil, name: String?, qty: Int?, price: Double?) { self.id = id + self.productId = productId self.name = name self.qty = qty self.price = price @@ -559,6 +580,7 @@ struct PublicOrderItem: Codable, Identifiable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + productId = try? container.decode(String.self, forKey: .productId) name = try? container.decode(String.self, forKey: .name) qty = ApiService.decodeFlexibleInt(from: container, keys: [.qty, .quantity]) price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) @@ -567,6 +589,7 @@ struct PublicOrderItem: Codable, Identifiable { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) + try container.encodeIfPresent(productId, forKey: .productId) try container.encodeIfPresent(name, forKey: .name) try container.encodeIfPresent(qty, forKey: .qty) try container.encodeIfPresent(price, forKey: .price) @@ -581,6 +604,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { let completed: Bool? let message: String? let time: String? + let date: String? enum CodingKeys: String, CodingKey { case id @@ -589,7 +613,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { case active case completed case message + case event case time + case date case createdAt case updatedAt } @@ -601,7 +627,8 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { active: Bool? = nil, completed: Bool? = nil, message: String?, - time: String? + time: String?, + date: String? = nil ) { self.id = id self.status = status @@ -610,6 +637,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { self.completed = completed self.message = message self.time = time + self.date = date } init(from decoder: Decoder) throws { @@ -619,10 +647,12 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { label = try? container.decode(String.self, forKey: .label) active = try? container.decode(Bool.self, forKey: .active) completed = try? container.decode(Bool.self, forKey: .completed) - message = try? container.decode(String.self, forKey: .message) + message = (try? container.decode(String.self, forKey: .message)) + ?? (try? container.decode(String.self, forKey: .event)) time = (try? container.decode(String.self, forKey: .time)) ?? (try? container.decode(String.self, forKey: .createdAt)) ?? (try? container.decode(String.self, forKey: .updatedAt)) + date = try? container.decode(String.self, forKey: .date) } func encode(to encoder: Encoder) throws { @@ -634,6 +664,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { try container.encodeIfPresent(completed, forKey: .completed) try container.encodeIfPresent(message, forKey: .message) try container.encodeIfPresent(time, forKey: .time) + try container.encodeIfPresent(date, forKey: .date) } } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index ee295f4..4eb13a7 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -155,28 +155,17 @@ final class ApiService { return envelope } - func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ApiEnvelope { - let currentProfile = try await profile(forceRefresh: true) - guard currentProfile.error == false, let customer = currentProfile.result else { - throw NetworkError.invalidResponse - } - + func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ProfilePatchEnvelope { let payload = CustomerIdentityUpdatePayload( - name: name, - email: email, - phoneNumber: phoneNumber, - profilePicture: profilePicture, - addressBook: (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:)) + name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name, + email: email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : email, + phoneNumber: phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : phoneNumber, + profilePicture: profilePicture ) let body = try JSONEncoder().encode(payload) - let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) - let envelope: ApiEnvelope = try await sendEnvelope(req) - let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())" - if envelope.error == false, envelope.result != nil { - AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) - } else { - invalidateFavoritesCache() - } + let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body) + let envelope: ProfilePatchEnvelope = try await send(req) + AppContentCache.shared.invalidate(prefix: profileCachePrefix) return envelope } @@ -223,6 +212,13 @@ final class ApiService { return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook) } + func setDefaultAddress(addressId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true) + let envelope: ApiEnvelope = try await sendEnvelope(req) + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + return envelope + } + func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope { let digits = zipCode.filter(\.isNumber) let normalized = String(digits.prefix(8)) @@ -426,6 +422,49 @@ final class ApiService { ) return try await sendEnvelope(req) } + + // MARK: - Cards + + func listCards() async throws -> ApiEnvelope<[SavedCard]> { + let req = ApiRequest(path: "/api/customer/cards", method: "GET", module: .customer, requiresAuth: true) + return try await sendEnvelope(req) + } + + func saveCard(payload: SaveCardPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/cards", method: "POST", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + func updateCard(cardId: String, payload: UpdateCardPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "PATCH", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + func deleteCard(cardId: String) async throws -> ApiEnvelope { + let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "DELETE", module: .customer, requiresAuth: true) + return try await sendEnvelope(req) + } + + func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + // MARK: - Profile CPF + + func updateProfileCpf(cpf: String) async throws -> ApiEnvelope { + let payload = ["cpf": cpf] + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body) + let result: ApiEnvelope = try await sendEnvelope(req) + if result.error == false { + AppContentCache.shared.invalidate(prefix: profileCachePrefix) + } + return result + } } extension ApiService { static func decodeFlexibleString(from container: KeyedDecodingContainer, keys: [K]) -> String? { diff --git a/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift b/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift index 0c77e5b..09f12bf 100644 --- a/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift +++ b/pedi-foods/Sources/PediFoods/Services/FeatureControlService.swift @@ -258,7 +258,7 @@ final class FeatureControlService { } private func featureKeys() -> [String] { - let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only" + let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only,at.cupons" let items = raw .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index 9d3b368..3c117bf 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -9,6 +9,7 @@ struct AppState { var featureFlags = FeatureFlagsState() var homeFilters = HomeFiltersState() var activeModal: AppModal? = nil + var shouldNavigateToOrders: Bool = false } enum FeatureFlagValue: Codable, Equatable { @@ -92,6 +93,7 @@ struct ProfileState { var email: String = "" var phone: String = "" var profilePicture: String = "" + var cpf: String = "" } struct AddressState { @@ -171,6 +173,7 @@ struct CartItemState: Identifiable { var name: String var imageURL: String? = nil var details: String? = nil + var choices: [String]? = nil var addons: [CartItemAddonState] = [] var quantity: Int var unitPrice: Double @@ -274,7 +277,8 @@ extension CartState { qty: $0.quantity, price: $0.unitPrice ) - } + }, + choices: item.choices?.isEmpty == false ? item.choices : nil ) } } diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift index ed4d739..9c40302 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift @@ -2,10 +2,12 @@ import SwiftUI struct TermsOfUseView: View { @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { + screenHeader Text("Termos de Uso") .font(AppTypography.heading1) .foregroundStyle(AppColors.textPrimary) @@ -16,17 +18,40 @@ struct TermsOfUseView: View { .padding(24) } .background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()) - .navigationTitle("Termos de Uso") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + } + + private var screenHeader: some View { + ZStack { + Text("Termos de Uso") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } } } struct PrivacyPolicyView: View { @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { + screenHeader Text("Política de Privacidade") .font(AppTypography.heading1) .foregroundStyle(AppColors.textPrimary) @@ -37,7 +62,28 @@ struct PrivacyPolicyView: View { .padding(24) } .background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()) - .navigationTitle("Privacidade") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + } + + private var screenHeader: some View { + ZStack { + Text("Privacidade") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift index c391380..505b510 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift @@ -2,7 +2,6 @@ import SwiftUI struct AddAddressFormView: View { @Environment(\.dismiss) var dismiss - @Environment(\.colorScheme) var colorScheme @State var label = "" @State var zipCode = "" @State var address = "" @@ -31,38 +30,18 @@ struct AddAddressFormView: View { !state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - @ViewBuilder private var logoImage: some View { - SwiftUI.Image("pedifoods") - .resizable() - } - var body: some View { ZStack { - (colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea() + AppColors.backgroundLight.ignoresSafeArea() - ScrollView { + ScrollView(showsIndicators: false) { VStack(spacing: 0) { screenHeader( title: existingAddress == nil ? "Novo endereço" : "Editar endereço", onBack: { dismiss() } ) - .padding(.horizontal, 24) - .padding(.bottom, 12) - - logoImage - .scaledToFit() - .frame(width: 120, height: 120) - - Text(existingAddress == nil ? "Novo endereço" : "Editar endereço") - .font(AppTypography.heading1) - .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) - - Text(existingAddress == nil ? "Preencha os dados abaixo para adicionar um endereço." : "Atualize os dados do endereço abaixo.") - .font(AppTypography.body) - .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) - .multilineTextAlignment(.center) - .padding(.top, 8) - .padding(.bottom, 20) + .padding(.horizontal, 20) + .padding(.bottom, 20) VStack(alignment: .leading, spacing: 16) { LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", keyboardType: .default, text: $label) @@ -127,7 +106,7 @@ struct AddAddressFormView: View { Spacer().frame(height: 120) } } - .padding(.top, 0) + .padding(.top, 18) } .navigationBarBackButtonHidden(true) .appHiddenNavigationBar() @@ -140,8 +119,7 @@ struct AddAddressFormView: View { ZStack { Text(title) .font(AppTypography.heading2) - .foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) - + .foregroundStyle(AppColors.textPrimary) HStack { Button(action: onBack) { Image(systemName: "chevron.left") @@ -177,7 +155,8 @@ struct AddAddressFormView: View { city: clean(city), state: clean(state), zipCode: optional(normalizeZipCodeForAPI(zipCode)), - latLong: latLong + latLong: latLong, + isDefault: existingAddress?.isDefault ) isLoading = true zipLookupMessage = nil diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddCardFormView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddCardFormView.swift new file mode 100644 index 0000000..f2ba942 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddCardFormView.swift @@ -0,0 +1,461 @@ +import SwiftUI + +struct AddCardFormView: View { + let appState: AppState + let isFirstCard: Bool + let onCardAdded: (SavedCard) -> Void + + @Environment(\.dismiss) var dismiss + + @State private var cardNumber = "" + @State private var holderName = "" + @State private var expiry = "" + @State private var cvv = "" + @State private var cpf = "" + @State private var nickname = "" + @State private var isDefault = false + @State private var isSaving = false + + @State private var addresses: [CustomerAddress] = [] + @State private var selectedAddress: CustomerAddress? = nil + @State private var isLoadingAddresses = false + @State private var showAddressPicker = false + + private var detectedBrandLogo: String? { + let clean = cardNumber.filter(\.isNumber) + guard clean.isEmpty == false else { return nil } + if clean.hasPrefix("506766") || clean.hasPrefix("603389") { return "sodexo_logo" } + if clean.hasPrefix("5067") || clean.hasPrefix("5078") || clean.hasPrefix("6278") || clean.hasPrefix("6367") { return "alelocard_logo" } + if clean.hasPrefix("3841") || clean.hasPrefix("606282") || clean.hasPrefix("637095") || clean.hasPrefix("637568") { return "hipercard_logo" } + if clean.hasPrefix("34") || clean.hasPrefix("37") { return "amexcard_logo" } + if clean.hasPrefix("4") { return "visacard_logo" } + let prefix2 = Int(clean.prefix(2)) ?? 0 + if (51...59).contains(prefix2) { return "mastercard_logo" } + if let p4 = Int(clean.prefix(4)), (2221...2720).contains(p4) { return "mastercard_logo" } + return nil + } + + private var selectedAddressZip: String { + (selectedAddress?.zipCode ?? "").filter(\.isNumber) + } + + private var canSave: Bool { + let digits = cardNumber.filter(\.isNumber) + let cpfDigits = cpf.filter(\.isNumber) + let parts = expiry.split(separator: "/") + return digits.count >= 13 + && holderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + && parts.count == 2 + && cvv.count >= 3 + && cpfDigits.count == 11 + && selectedAddress != nil + && selectedAddressZip.count >= 7 + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + screenHeader + + formSection("Dados do Cartão") { + cardNumberField + labeledField("Nome no cartão", placeholder: "Como impresso no cartão", text: $holderName, autocap: true) + HStack(spacing: 12) { + labeledField("Validade", placeholder: "MM/AA", text: $expiry, keyboard: .numberPad) + .onChange(of: expiry) { _, v in expiry = formatExpiry(v) } + labeledField("CVV", placeholder: "•••", text: $cvv, keyboard: .numberPad) + .onChange(of: cvv) { _, v in cvv = String(v.filter(\.isNumber).prefix(4)) } + } + } + + formSection("Identificação do Titular") { + labeledField("CPF", placeholder: "000.000.000-00", text: $cpf, keyboard: .numberPad) + .onChange(of: cpf) { _, v in cpf = formatCPF(v.filter(\.isNumber)) } + addressPickerRow + } + + formSection("Opções") { + labeledField("Apelido (opcional)", placeholder: "Ex: Cartão do Nubank", text: $nickname) + if isFirstCard == false { + Toggle(isOn: $isDefault) { + Text("Definir como principal") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + .tint(AppColors.primary) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + Button(action: { Task { await saveCard() } }) { + Group { + if isSaving { + ProgressView().tint(Color(hex: "#0E1A06")) + } else { + Text("Salvar Cartão").font(AppTypography.heading2) + } + } + .foregroundStyle(Color(hex: "#0E1A06")) + .frame(maxWidth: .infinity, minHeight: 56) + .background(canSave && !isSaving ? Color(hex: "#C8F06E") : Color(hex: "#C8F06E").opacity(0.45)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(!canSave || isSaving) + .padding(.top, 4) + } + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, 40) + } + .background(AppColors.backgroundLight) + .navigationBarBackButtonHidden(true) + .appHiddenNavigationBar() + .sheet(isPresented: $showAddressPicker) { + addressPickerSheet + } + .task { await loadData() } + } + + // MARK: - Address picker row + + private var addressPickerRow: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Endereço de cobrança") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + + Button { + if addresses.isEmpty == false { showAddressPicker = true } + } label: { + HStack(spacing: 10) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(selectedAddress != nil ? AppColors.primary : AppColors.textMuted) + + VStack(alignment: .leading, spacing: 2) { + if isLoadingAddresses { + Text("Carregando endereços...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } else if let addr = selectedAddress { + Text(addressDisplayTitle(addr)) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + if let sub = addressDisplaySubtitle(addr) { + Text(sub) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + } + } else if addresses.isEmpty { + Text("Nenhum endereço cadastrado") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } else { + Text("Selecionar endereço") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + } + + Spacer() + + if addresses.isEmpty == false { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppColors.textMuted) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(isLoadingAddresses || addresses.isEmpty) + } + } + + // MARK: - Address picker sheet + + private var addressPickerSheet: some View { + NavigationStack { + ScrollView(showsIndicators: false) { + VStack(spacing: 10) { + ForEach(Array(addresses.enumerated()), id: \.offset) { _, addr in + Button { + selectedAddress = addr + showAddressPicker = false + } label: { + HStack(spacing: 12) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(isSelected(addr) ? AppColors.primary : AppColors.textMuted) + + VStack(alignment: .leading, spacing: 3) { + Text(addressDisplayTitle(addr)) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + if let sub = addressDisplaySubtitle(addr) { + Text(sub) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + } + } + + Spacer() + + if isSelected(addr) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(AppColors.primary) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, 30) + } + .background(AppColors.backgroundLight) + .navigationTitle("Endereço de cobrança") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Fechar") { showAddressPicker = false } + .foregroundStyle(AppColors.textPrimary) + } + } + } + } + + // MARK: - Sub-views + + private var screenHeader: some View { + ZStack { + Text("Novo Cartão") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + + private var cardNumberField: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Número do cartão") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + HStack(spacing: 8) { + TextField("0000 0000 0000 0000", text: $cardNumber) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .keyboardType(.numberPad) + .onChange(of: cardNumber) { _, v in cardNumber = formatCardNumber(v.filter(\.isNumber)) } + let digits = cardNumber.filter(\.isNumber) + if let logo = detectedBrandLogo { + Image(logo) + .resizable() + .scaledToFit() + .frame(width: 40, height: 26) + } else if digits.count >= 4 { + Image(systemName: "creditcard") + .font(.system(size: 22)) + .foregroundStyle(AppColors.textMuted) + .frame(width: 40, height: 26) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private func labeledField( + _ label: String, + placeholder: String, + text: Binding, + keyboard: UIKeyboardType = .default, + autocap: Bool = false + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + TextField(placeholder, text: text) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .keyboardType(keyboard) + .autocorrectionDisabled() + .textInputAutocapitalization(autocap ? .characters : .never) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .padding(.leading, 2) + content() + } + } + + // MARK: - Helpers + + private func isSelected(_ addr: CustomerAddress) -> Bool { + guard let sel = selectedAddress else { return false } + if let id = addr.id, let selId = sel.id { return id == selId } + return addr.address == sel.address && addr.number == sel.number + } + + private func addressDisplayTitle(_ addr: CustomerAddress) -> String { + let label = addr.label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if label.isEmpty == false { return label } + let street = addr.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let number = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let base = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ") + return base.isEmpty ? "Endereço" : base + } + + private func addressDisplaySubtitle(_ addr: CustomerAddress) -> String? { + let parts = [ + addr.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines), + addr.city?.trimmingCharacters(in: .whitespacesAndNewlines), + addr.state?.trimmingCharacters(in: .whitespacesAndNewlines) + ].compactMap { v -> String? in + guard let v, v.isEmpty == false else { return nil } + return v + } + return parts.isEmpty ? nil : parts.joined(separator: ", ") + } + + // MARK: - Load & Save + + @MainActor + private func loadData() async { + holderName = appState.profile.name + cpf = formatCPF(appState.profile.cpf.filter(\.isNumber)) + isDefault = isFirstCard + + isLoadingAddresses = true + defer { isLoadingAddresses = false } + if let result = try? await ApiService().profile(forceRefresh: false).result { + let book = result.addressBook ?? [] + addresses = book + selectedAddress = book.first + } + } + + @MainActor + private func saveCard() async { + guard canSave, let addr = selectedAddress else { return } + isSaving = true + defer { isSaving = false } + + let parts = expiry.split(separator: "/") + let month = String(parts[0]) + let year: String = { + let y = String(parts[1]) + return y.count == 2 ? "20\(y)" : y + }() + let cleanNumber = cardNumber.filter(\.isNumber) + let cleanCpf = cpf.filter(\.isNumber) + let zip = selectedAddressZip + let addrNumber = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "0" + + let creditCard = SaveCardCreditCardPayload( + holderName: holderName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased(), + number: cleanNumber, + expiryMonth: month, + expiryYear: year, + ccv: cvv + ) + let holderInfo = SaveCardHolderInfoPayload( + name: holderName.trimmingCharacters(in: .whitespacesAndNewlines), + email: appState.profile.email, + cpfCnpj: cleanCpf, + postalCode: zip, + addressNumber: addrNumber.isEmpty ? "0" : addrNumber, + phone: appState.profile.phone + ) + let payload = SaveCardPayload( + creditCard: creditCard, + creditCardHolderInfo: holderInfo, + nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : nickname, + isDefault: isDefault || isFirstCard + ) + + do { + let response = try await ApiService().saveCard(payload: payload) + if response.error == false, let result = response.result { + let newCard = SavedCard( + id: result.id, + nickname: payload.nickname, + holderName: result.holderName, + last4: result.last4, + brand: result.brand, + expiryMonth: result.expiryMonth, + expiryYear: result.expiryYear, + isDefault: result.isDefault + ) + onCardAdded(newCard) + dismiss() + SnackbarCenter.shared.show(title: "Cartão salvo com sucesso.", style: .success, icon: "creditcard.fill", duration: 2.5) + } else { + SnackbarCenter.shared.show(title: response.message ?? "Não foi possível salvar o cartão.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + } catch { + SnackbarCenter.shared.show(title: "Erro ao salvar cartão. Verifique os dados e tente novamente.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + } + + // MARK: - Formatters + + private func formatCardNumber(_ digits: String) -> String { + let d = String(digits.prefix(16)) + var result = "" + for (i, c) in d.enumerated() { + if i > 0 && i % 4 == 0 { result += " " } + result.append(c) + } + return result + } + + private func formatExpiry(_ value: String) -> String { + let digits = String(value.filter(\.isNumber).prefix(4)) + if digits.count > 2 { return "\(digits.prefix(2))/\(digits.dropFirst(2))" } + return digits + } + + private func formatCPF(_ digits: String) -> String { + let d = String(digits.prefix(11)) + if d.count <= 3 { return d } + if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" } + if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" } + return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))" + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift index d195279..04d2817 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift @@ -118,6 +118,9 @@ struct AddressCard: View { let item: AddressListItem var onEdit: (() -> Void)? = nil var onDelete: (() -> Void)? = nil + var onSetDefault: (() -> Void)? = nil + + private var hasActions: Bool { onEdit != nil || onSetDefault != nil } var body: some View { HStack(spacing: 14) { @@ -150,30 +153,30 @@ struct AddressCard: View { Spacer(minLength: 8) - if onEdit != nil || onDelete != nil { + if hasActions { Rectangle() .fill(AppColors.backgroundLight) .frame(width: 1, height: 96) } - VStack(spacing: 24) { + VStack(spacing: 20) { if let onEdit { Button(action: onEdit) { Image(systemName: "pencil") - .font(.system(size: 22)) + .font(.system(size: 20)) .foregroundStyle(AppColors.textMuted) } } - if let onDelete { - Button(action: onDelete) { - Image(systemName: "trash") - .font(.system(size: 22)) + if let onSetDefault, item.isPrimary == false { + Button(action: onSetDefault) { + Image(systemName: "star") + .font(.system(size: 20)) .foregroundStyle(AppColors.textMuted) } } } - .frame(width: onEdit != nil || onDelete != nil ? 40 : 0) + .frame(width: hasActions ? 40 : 0) } .padding(.horizontal, 16) .padding(.vertical, 20) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift index b15b176..9817781 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift @@ -12,6 +12,7 @@ struct AddressesView: View { @State var editingAddress: CustomerAddress? = nil @State var openSwipeRowId: String? = nil @State var deletingRowId: String? = nil + @State var settingDefaultRowId: String? = nil let tabBarClearance: CGFloat = 96 @@ -54,7 +55,9 @@ struct AddressesView: View { } else { ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in let rowId = addressRowId(for: address, index: index) - let isSelected: Bool = { + let isPrimary: Bool = { + if address.isDefault == true { return true } + if addresses.contains(where: { $0.isDefault == true }) { return false } if let selectedId = appState.address.selectedId { return address.id == selectedId } @@ -65,10 +68,10 @@ struct AddressesView: View { Button { selectAddress(address) } label: { - AddressCard(item: addressToListItem(address, isPrimary: isSelected)) + AddressCard(item: addressToListItem(address, isPrimary: isPrimary)) } .buttonStyle(.plain) - } else { + } else if addresses.count > 1 { SwipeToDeleteAddressRow( rowId: rowId, openRowId: $openSwipeRowId, @@ -76,19 +79,30 @@ struct AddressesView: View { onDelete: { deleteAddress(address, rowId: rowId) } ) { AddressCard( - item: addressToListItem(address, isPrimary: isSelected), - onEdit: { beginEditing(address) } + item: addressToListItem(address, isPrimary: isPrimary), + onEdit: { beginEditing(address) }, + onSetDefault: { setDefaultAddress(address, rowId: rowId) } ) .appContentShape(Rectangle()) - .onTapGesture { - if openSwipeRowId == rowId { - openSwipeRowId = nil - } - } + .simultaneousGesture(TapGesture().onEnded { + if openSwipeRowId == rowId { openSwipeRowId = nil } + }) + .opacity(settingDefaultRowId == rowId ? 0.6 : 1.0) } .id(rowId) .opacity(deletingRowId == rowId ? 0.6 : 1.0) - .disabled(deletingRowId != nil) + .disabled(deletingRowId != nil || settingDefaultRowId != nil) + } else { + AddressCard( + item: addressToListItem(address, isPrimary: isPrimary), + onEdit: { beginEditing(address) } + ) + .overlay(alignment: .bottom) { + Text("Ao menos um endereço deve permanecer") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted.opacity(0.7)) + .padding(.bottom, 8) + } } } } @@ -230,8 +244,65 @@ struct AddressesView: View { return "idx:\(index):\(address.label ?? ""):\(address.address ?? ""):\(address.number ?? "")" } + private func setDefaultAddress(_ address: CustomerAddress, rowId: String) { + guard settingDefaultRowId == nil else { return } + settingDefaultRowId = rowId + openSwipeRowId = nil + + Task { + var resolvedId = address.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if resolvedId.isEmpty { + if let book = try? await ApiService().profile(forceRefresh: true).result?.addressBook { + await MainActor.run { addresses = book } + resolvedId = book.first { + $0.address == address.address && + $0.number == address.number && + $0.zipCode == address.zipCode + }?.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + } + + guard resolvedId.isEmpty == false else { + await MainActor.run { + settingDefaultRowId = nil + SnackbarCenter.shared.show(title: "Não foi possível identificar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + return + } + + do { + let response = try await ApiService().setDefaultAddress(addressId: resolvedId) + await MainActor.run { + settingDefaultRowId = nil + if response.error == false { + addresses = addresses.map { addr in + let isTarget = (addr.id ?? "") == resolvedId + return CustomerAddress( + id: addr.id, label: addr.label, address: addr.address, + number: addr.number, complement: addr.complement, + neighborhood: addr.neighborhood, city: addr.city, + state: addr.state, zipCode: addr.zipCode, + latLong: addr.latLong, isDefault: isTarget + ) + } + selectAddress(address) + SnackbarCenter.shared.show(title: "Endereço principal atualizado.", style: .success, icon: "star.fill", duration: 2.5) + } else { + SnackbarCenter.shared.show(title: response.message ?? "Não foi possível definir endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + } + } catch { + await MainActor.run { + settingDefaultRowId = nil + SnackbarCenter.shared.show(title: "Erro ao atualizar endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + } + } + } + private func deleteAddress(_ address: CustomerAddress, rowId: String) { - guard deletingRowId == nil else { return } + guard addresses.count > 1, deletingRowId == nil else { return } deletingRowId = rowId openSwipeRowId = nil @@ -302,7 +373,7 @@ struct AddressesView: View { do { let service = ApiService() - let response = try await service.profile() + let response = try await service.profile(forceRefresh: true) guard response.error == false else { errorMessage = response.message ?? "Não foi possível carregar os endereços." isLoading = false diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift index 701c1c9..5ccb722 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift @@ -3,6 +3,7 @@ import SwiftUI struct CartView: View { @Binding var appState: AppState + @Binding var selectedTab: MainTab @State var openCheckout = false @State var couponCode = "" @State var appliedCouponCode: String? = nil @@ -49,7 +50,7 @@ struct CartView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppColors.backgroundLight) .navigationDestination(isPresented: $openCheckout) { - CheckoutView(appState: $appState) + CheckoutView(appState: $appState, selectedTab: $selectedTab) } .task(id: deliveryFeeWatchKey) { await refreshDeliveryFee() diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift index 250dcb3..438bd90 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -234,7 +234,13 @@ extension CheckoutView { return } - let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod) + // Crédito pelo app → abre seleção de cartão antes de criar pedido + if useInAppPayment && effectivePaymentMethod == .creditCard { + showCardSelectionSheet = true + return + } + + let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod, savedCardId: nil) guard case .success(let payload) = payloadBuildResult else { let message: String if case .failure(let reason) = payloadBuildResult { @@ -251,83 +257,31 @@ extension CheckoutView { do { let response = try await ApiService().createOrder(storeId: storeId, payload: payload) - if response.error { - SnackbarCenter.shared.show( - title: response.message ?? "Não foi possível criar o pedido.", - style: .error, - icon: "xmark.octagon.fill", - duration: 3.0 - ) - return - } - - guard let result = response.result else { - SnackbarCenter.shared.show(title: "Resposta de pagamento inválida.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) - return - } - - let orderSnapshot = result.asPublicOrderResult() - SessionStateStore.saveTrackedOrder(orderSnapshot) - - let orderId = result.id ?? UUID().uuidString let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod) if useInAppPayment == false || isInAppMethod == false { - appState.cart.clear() - SessionStateStore.clearPendingCartOrder() - orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + if response.error == false, let result = response.result { + let orderId = result.id ?? UUID().uuidString + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + } else { + SnackbarCenter.shared.show(title: response.message ?? "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } return } - - if orderSnapshot.isPaymentConfirmed { - appState.cart.clear() - SessionStateStore.clearPendingCartOrder() - orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) - return - } - - SessionStateStore.savePendingCartOrderId(orderId) - - if effectivePaymentMethod == .creditCard { - cardPaymentContext = CardPaymentContext( - orderId: orderId, - shortId: result.shortId, - total: totalValue - ) - return - } - - let pixFromPayment = result.payment?.pix - let pixFromPayload = result.paymentPayload - - let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? pixFromPayment?.copyPaste - : pixFromPayload?.copyPaste - let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? pixFromPayment?.qrCodeImage - : pixFromPayload?.qrCodeImage - let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? pixFromPayment?.expirationDate - : pixFromPayload?.expirationDate - - guard let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { - SnackbarCenter.shared.show(title: "Código PIX não retornado pela API.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) - return - } - - pixPaymentContext = PixPaymentContext( - id: orderId, - orderId: orderId, - shortId: result.shortId, - copyPaste: copyPaste, - qrCodeImageBase64: qrCodeImage, - expirationDate: expirationDate - ) + handleOrderResponse(response, effectivePaymentMethod: effectivePaymentMethod) } catch { SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } } - func buildCreateOrderPayload(paymentMethod: CheckoutPaymentMethod) -> Result { + func buildCreateOrderPayload( + paymentMethod: CheckoutPaymentMethod, + savedCardId: String? = nil, + creditCard: CreditCardOrderPayload? = nil, + creditCardHolderInfo: SaveCardHolderInfoPayload? = nil, + clientCpfCnpj: String? = nil + ) -> Result { guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) } let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -360,18 +314,101 @@ extension CheckoutView { return .success( CreateOrderPayload( - customer: CreateOrderCustomerPayload( - name: profileName, - phone: profilePhone, - email: profileEmail, - asaasId: nil - ), - items: appState.cart.toOrderItemsPayload(), - total: totalValue, - paymentMethod: paymentMethod.rawValue, - deliveryType: deliveryType.rawValue, - address: addressPayload + customer: CreateOrderCustomerPayload( + name: profileName, + phone: profilePhone, + email: profileEmail, + asaasId: nil + ), + items: appState.cart.toOrderItemsPayload(), + total: totalValue, + paymentMethod: paymentMethod.rawValue, + deliveryType: deliveryType.rawValue, + address: addressPayload, + savedCardId: savedCardId, + clientCpfCnpj: clientCpfCnpj, + creditCard: creditCard, + creditCardHolderInfo: creditCardHolderInfo ) ) } + + @MainActor + func confirmOrderWithSavedCard(cardId: String, storeId: String) async { + let payloadResult = buildCreateOrderPayload(paymentMethod: .creditCard, savedCardId: cardId) + guard case .success(let payload) = payloadResult else { return } + + isSubmittingOrder = true + defer { isSubmittingOrder = false } + + do { + let response = try await ApiService().createOrder(storeId: storeId, payload: payload) + handleOrderResponse(response, effectivePaymentMethod: .creditCard) + } catch { + SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + } + + func handleOrderResponse(_ response: ApiEnvelope, effectivePaymentMethod: CheckoutPaymentMethod) { + if response.error { + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível criar o pedido.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + return + } + guard let result = response.result else { + SnackbarCenter.shared.show(title: "Resposta de pagamento inválida.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + return + } + + let orderSnapshot = result.asPublicOrderResult() + SessionStateStore.saveTrackedOrder(orderSnapshot) + let orderId = result.id ?? UUID().uuidString + + if orderSnapshot.isPaymentConfirmed { + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + return + } + + SessionStateStore.savePendingCartOrderId(orderId) + + let pixFromPayment = result.payment?.pix + let pixFromPayload = result.paymentPayload + let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? pixFromPayment?.copyPaste : pixFromPayload?.copyPaste + let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? pixFromPayment?.qrCodeImage : pixFromPayload?.qrCodeImage + let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? pixFromPayment?.expirationDate : pixFromPayload?.expirationDate + + if let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + let itemsData = (try? JSONEncoder().encode(appState.cart.toOrderItemsPayload())).flatMap { String(data: $0, encoding: .utf8) } ?? "[]" + let storeId = appState.cart.storeId ?? "" + pixPaymentContext = PixPaymentContext( + id: orderId, + orderId: orderId, + shortId: result.shortId, + storeId: storeId, + copyPaste: copyPaste, + qrCodeImageBase64: qrCodeImage, + expirationDate: expirationDate, + total: totalValue, + profileName: appState.profile.name, + profileEmail: appState.profile.email, + profilePhone: appState.profile.phone, + addressZip: selectedCustomerAddress?.zipCode, + addressNumber: selectedCustomerAddress?.number, + deliveryType: deliveryType.rawValue, + itemsJSON: itemsData + ) + return + } + + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) + } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift index 2399d09..9180b4d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -7,6 +7,8 @@ import AppKit struct CheckoutView: View { @Binding var appState: AppState + @Binding var selectedTab: MainTab + @Environment(\.dismiss) var dismiss @State var storeInfo: StoreInfoResult? = nil @State var errorMessage: String? = nil @@ -26,6 +28,7 @@ struct CheckoutView: View { @State var pixPaymentContext: PixPaymentContext? = nil @State var cardPaymentContext: CardPaymentContext? = nil @State var orderTrackingContext: OrderTrackingContext? = nil + @State var showCardSelectionSheet = false var paymentConfig: StorePaymentMethodsInfo? { storeInfo?.paymentMethods } @@ -96,6 +99,7 @@ struct CheckoutView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 18) { + screenHeader deliveryTypeSection addressSection orderSummarySection @@ -118,8 +122,8 @@ struct CheckoutView: View { .padding(.bottom, 10) } .background(AppColors.backgroundLight) - .navigationTitle("Finalizar Pedido") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .appBottomSafeAreaInset { bottomBar } @@ -154,6 +158,7 @@ struct CheckoutView: View { .navigationDestination(item: $pixPaymentContext) { context in PaymentPixView( context: context, + appState: $appState, onPaymentConfirmed: { appState.cart.clear() SessionStateStore.clearPendingCartOrder() @@ -167,18 +172,67 @@ struct CheckoutView: View { .navigationDestination(item: $cardPaymentContext) { context in PaymentCardView( context: context, - onPaymentConfirmed: { + appState: $appState, + onOrderCreated: { orderId, shortId in appState.cart.clear() SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: shortId) } - ) { - appState.cart.clear() - SessionStateStore.clearPendingCartOrder() - orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId) - } + ) + } + .sheet(isPresented: $showCardSelectionSheet) { + let cardContext = CardPaymentContext.build( + storeId: appState.cart.storeId ?? "", + total: totalValue, + deliveryType: deliveryType.rawValue, + profile: appState.profile, + address: selectedCustomerAddress, + items: appState.cart.toOrderItemsPayload() + ) + CardSelectionSheet( + cardContext: cardContext, + appState: $appState, + onSavedCardConfirmed: { cardId in + showCardSelectionSheet = false + Task { + guard let storeId = appState.cart.storeId else { return } + await confirmOrderWithSavedCard(cardId: cardId, storeId: storeId) + } + }, + onOrderCreated: { orderId, shortId in + showCardSelectionSheet = false + appState.cart.clear() + SessionStateStore.clearPendingCartOrder() + orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: shortId) + } + ) } .navigationDestination(item: $orderTrackingContext) { context in - OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId) + OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId, postOrderBack: { + appState.shouldNavigateToOrders = true + selectedTab = .profile + }) + } + } + + private var screenHeader: some View { + ZStack { + Text("Finalizar Pedido") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } } } @@ -301,57 +355,74 @@ struct CheckoutView: View { .font(AppTypography.overline) .foregroundStyle(sectionTitleColor) - VStack(alignment: .leading, spacing: 12) { - paymentGroupCard( - title: "Pagar Pelo App", - subtitle: "Mais rápido e seguro", - isSelected: useInAppPayment - ) { - VStack(spacing: 0) { - ForEach(availableInAppPaymentMethods, id: \.rawValue) { method in - paymentRow(method, isInAppGroup: true) - if method != availableInAppPaymentMethods.last { - Divider() + VStack(alignment: .leading, spacing: 0) { + // Toggle row + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(useInAppPayment ? "Pagar Pelo App" : "Pagar Na Maquininha") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Text(useInAppPayment ? "Mais rápido e seguro" : "Na entrega/retirada com a loja") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + Spacer() + Toggle("", isOn: Binding( + get: { useInAppPayment }, + set: { newValue in + withAnimation(.easeInOut(duration: 0.2)) { + useInAppPayment = newValue + if newValue { + if availableInAppPaymentMethods.contains(paymentMethod) == false { + paymentMethod = .pix + } + } else { + guard availableStoreMachineMethods.isEmpty == false else { + useInAppPayment = true + return + } + if availableStoreMachineMethods.contains(paymentMethod) == false, + let first = availableStoreMachineMethods.first { + paymentMethod = first + } + } } } - } - } onTap: { - useInAppPayment = true - if availableInAppPaymentMethods.contains(paymentMethod) == false { - paymentMethod = .pix - } + )) + .labelsHidden() + .tint(AppColors.primary) } + .padding(14) - paymentGroupCard( - title: "Pagar Na Maquininha Da Loja", - subtitle: "Pague na entrega/retirada com os métodos aceitos pela loja", - isSelected: useInAppPayment == false - ) { + Divider() + .padding(.horizontal, 14) + + // Methods list + if useInAppPayment { + ForEach(availableInAppPaymentMethods, id: \.rawValue) { method in + paymentRow(method, isInAppGroup: true) + if method != availableInAppPaymentMethods.last { + Divider().padding(.horizontal, 14) + } + } + } else { if availableStoreMachineMethods.isEmpty { Text("Loja não informou métodos presenciais.") .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) - .padding(.horizontal, 14) - .padding(.bottom, 14) + .padding(14) } else { - VStack(spacing: 0) { - ForEach(availableStoreMachineMethods, id: \.rawValue) { method in - paymentRow(method, isInAppGroup: false) - if method != availableStoreMachineMethods.last { - Divider() - } + ForEach(availableStoreMachineMethods, id: \.rawValue) { method in + paymentRow(method, isInAppGroup: false) + if method != availableStoreMachineMethods.last { + Divider().padding(.horizontal, 14) } } } - } onTap: { - guard availableStoreMachineMethods.isEmpty == false else { return } - useInAppPayment = false - if availableStoreMachineMethods.contains(paymentMethod) == false, - let first = availableStoreMachineMethods.first { - paymentMethod = first - } } } + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } } @@ -365,9 +436,18 @@ struct CheckoutView: View { .fill(AppColors.backgroundLight) .frame(width: 54, height: 54) .overlay( - Image(systemName: method.iconName) - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(method == .pix ? Color.green : AppColors.textPrimary) + Group { + if method == .pix { + Image("icon_pix") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } else { + Image(systemName: method.iconName) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + } + } ) VStack(alignment: .leading, spacing: 3) { @@ -405,44 +485,6 @@ struct CheckoutView: View { return method.subtitle } - private func paymentGroupCard( - title: String, - subtitle: String, - isSelected: Bool, - @ViewBuilder content: () -> Content, - onTap: @escaping () -> Void - ) -> some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textPrimary) - Text(subtitle) - .font(.caption) - .foregroundStyle(AppColors.textMuted) - } - Spacer() - Circle() - .stroke(isSelected ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2) - .frame(width: 22, height: 22) - .background( - Circle() - .fill(isSelected ? AppColors.tertiary : Color.clear) - ) - } - .padding(.horizontal, 14) - .padding(.top, 14) - .appContentShape(Rectangle()) - .onTapGesture(perform: onTap) - - content() - .allowsHitTesting(isSelected) - .opacity(isSelected ? 1 : 0.82) - } - .background(AppColors.surface) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) - } private var bottomBar: some View { VStack(spacing: 12) { @@ -548,9 +590,19 @@ struct PixPaymentContext: Identifiable, Hashable { let id: String let orderId: String let shortId: String? + let storeId: String let copyPaste: String let qrCodeImageBase64: String? let expirationDate: String? + // Para troca de pagamento + let total: Double + let profileName: String + let profileEmail: String + let profilePhone: String + let addressZip: String? + let addressNumber: String? + let deliveryType: String + let itemsJSON: String } struct OrderTrackingContext: Identifiable, Hashable { @@ -560,14 +612,60 @@ struct OrderTrackingContext: Identifiable, Hashable { } struct CardPaymentContext: Identifiable, Hashable { - var id: String { orderId } - let orderId: String - let shortId: String? + let id: String + let storeId: String let total: Double + let deliveryType: String + let profileName: String + let profileEmail: String + let profilePhone: String + let addressStreet: String? + let addressNumber: String? + let addressNeighborhood: String? + let addressCity: String? + let addressState: String? + let addressZip: String? + let addressComplement: String? + let itemsJSON: String + + static func build(storeId: String, total: Double, deliveryType: String, profile: ProfileState, address: CustomerAddress?, items: [CreateOrderItemPayload]) -> CardPaymentContext { + let itemsData = (try? JSONEncoder().encode(items)).flatMap { String(data: $0, encoding: .utf8) } ?? "[]" + return CardPaymentContext( + id: UUID().uuidString, + storeId: storeId, + total: total, + deliveryType: deliveryType, + profileName: profile.name, + profileEmail: profile.email, + profilePhone: profile.phone, + addressStreet: address?.address, + addressNumber: address?.number, + addressNeighborhood: address?.neighborhood, + addressCity: address?.city, + addressState: address?.state, + addressZip: address?.zipCode, + addressComplement: address?.complement, + itemsJSON: itemsData + ) + } + + var addressPayload: CreateOrderAddressPayload? { + guard let street = addressStreet, street.isEmpty == false, + let number = addressNumber, number.isEmpty == false, + let neighborhood = addressNeighborhood, neighborhood.isEmpty == false else { return nil } + return CreateOrderAddressPayload(street: street, number: number, neighborhood: neighborhood, city: addressCity, state: addressState, zip: addressZip, complement: addressComplement) + } + + var orderItems: [CreateOrderItemPayload] { + guard let data = itemsJSON.data(using: .utf8), + let decoded = try? JSONDecoder().decode([CreateOrderItemPayload].self, from: data) else { return [] } + return decoded + } } struct PaymentPixView: View { let context: PixPaymentContext + @Binding var appState: AppState var onPaymentConfirmed: (() -> Void)? = nil var onOpenTracking: (() -> Void)? = nil @Environment(\.dismiss) var dismiss @@ -576,9 +674,20 @@ struct PaymentPixView: View { @State var hasOpenedTracking = false @State var hasShownPixExpiredSnackbar = false @State var currentTime = Date() + @State var showChangePaymentSheet = false + @State var savedCards: [SavedCard] = [] + @State var currentContext: PixPaymentContext + + init(context: PixPaymentContext, appState: Binding, onPaymentConfirmed: (() -> Void)? = nil, onOpenTracking: (() -> Void)? = nil) { + self.context = context + self._appState = appState + self.onPaymentConfirmed = onPaymentConfirmed + self.onOpenTracking = onOpenTracking + _currentContext = State(initialValue: context) + } private var qrImageSource: String? { - guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines), + guard let raw = currentContext.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines), raw.isEmpty == false else { return nil } if raw.lowercased().hasPrefix("data:image") { return raw } return "data:image/png;base64,\(raw)" @@ -587,6 +696,7 @@ struct PaymentPixView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 14) { + screenHeader RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) .fill(AppColors.surface) .overlay( @@ -626,40 +736,80 @@ struct PaymentPixView: View { .frame(height: 20) .overlay( VStack(spacing: 8) { - Text(context.copyPaste) + Text(currentContext.copyPaste) .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(AppColors.textMuted) .lineLimit(0) .multilineTextAlignment(.center) .padding(.horizontal, 10) - + } .padding(.vertical, 14) ) - - if let expirationDate = context.expirationDate, expirationDate.isEmpty == false { + + if let expirationDate = currentContext.expirationDate, expirationDate.isEmpty == false { Text(expirationLabel) .font(.caption) .foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted) } - - PrimaryButton(title: "Copiar Código PIX") { - if isPixExpired { - showPixExpiredSnackbar() - return + + HStack(spacing: 10) { + PrimaryButton(title: "Copiar Código PIX") { + if isPixExpired { + showPixExpiredSnackbar() + return + } + copyToClipboard(currentContext.copyPaste) + SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0) } - copyToClipboard(context.copyPaste) - SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0) + .disabled(isPixExpired) + .opacity(isPixExpired ? 0.5 : 1.0) + + Button { + Task { await loadSavedCards() } + showChangePaymentSheet = true + } label: { + HStack(spacing: 6) { + Text("Trocar") + .font(AppTypography.heading3) + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(AppColors.textPrimary) + .frame(height: 54) + .padding(.horizontal, 16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) } - .disabled(isPixExpired) - .opacity(isPixExpired ? 0.5 : 1.0) .padding(.top, 10) } .padding(20) } .background(AppColors.backgroundLight) - .navigationTitle("Pagamento via PIX") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + .sheet(isPresented: $showChangePaymentSheet) { + ChangePaymentSheet( + pixContext: currentContext, + savedCards: savedCards, + appState: $appState, + onChanged: { newContext in + showChangePaymentSheet = false + if let newContext { + currentContext = newContext + tracker.stop() + tracker.start(orderId: newContext.orderId, jwt: DefaultTokenStore().jwt) + } + }, + onConfirmed: { + showChangePaymentSheet = false + onPaymentConfirmed?() + openTrackingOnce() + } + ) + } .task { tracker.onOrderUpdated = { updated in latestOrder = updated @@ -668,7 +818,7 @@ struct PaymentPixView: View { openTrackingOnce() } } - tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt) + tracker.start(orderId: currentContext.orderId, jwt: DefaultTokenStore().jwt) } .task { while Task.isCancelled == false { @@ -685,6 +835,27 @@ struct PaymentPixView: View { } } + private var screenHeader: some View { + ZStack { + Text("Pagamento via PIX") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private func openTrackingOnce() { guard hasOpenedTracking == false else { return } hasOpenedTracking = true @@ -695,8 +866,16 @@ struct PaymentPixView: View { appWriteClipboardText(value) } + @MainActor + private func loadSavedCards() async { + guard savedCards.isEmpty else { return } + if let cards = try? await ApiService().listCards().result { + savedCards = cards + } + } + private var parsedExpirationDate: Date? { - let raw = (context.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let raw = (currentContext.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard raw.isEmpty == false else { return nil } let iso = ISO8601DateFormatter() @@ -768,30 +947,284 @@ struct PaymentPixView: View { } } +// MARK: - CardSelectionSheet + +struct CardSelectionSheet: View { + let cardContext: CardPaymentContext + @Binding var appState: AppState + let onSavedCardConfirmed: (String) -> Void + let onOrderCreated: (String, String?) -> Void + + @Environment(\.dismiss) var dismiss + @State private var cards: [SavedCard] = [] + @State private var isLoading = false + @State var selectedCardId: String? + @State var isSubmitting = false + @State var showNewCardSheet = false + + init(cardContext: CardPaymentContext, appState: Binding, onSavedCardConfirmed: @escaping (String) -> Void, onOrderCreated: @escaping (String, String?) -> Void) { + self.cardContext = cardContext + self._appState = appState + self.onSavedCardConfirmed = onSavedCardConfirmed + self.onOrderCreated = onOrderCreated + } + + var body: some View { + ZStack(alignment: .top) { + VStack(spacing: 0) { + // Header + HStack { + Text("Selecionar Cartão") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + Spacer() + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textMuted) + .frame(width: 30, height: 30) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 32) + } else if cards.isEmpty == false { + VStack(alignment: .leading, spacing: 0) { + ForEach(cards) { card in + savedCardRow(card) + if card.id != cards.last?.id { + Divider().padding(.horizontal, 14) + } + } + } + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + + if let selectedCardId { + PrimaryButton(title: isSubmitting ? "Processando..." : "Pagar \(formatCurrency(cardContext.total))") { + guard isSubmitting == false else { return } + onSavedCardConfirmed(selectedCardId) + } + .disabled(isSubmitting) + } + + Text("ou") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .frame(maxWidth: .infinity, alignment: .center) + } + + Button { + showNewCardSheet = true + } label: { + HStack(spacing: 10) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(AppColors.primary) + Text("Adicionar novo cartão") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + Spacer() + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.bottom, 32) + } + } + .background(AppColors.backgroundLight) + .sheet(isPresented: $showNewCardSheet) { + PaymentCardView( + context: cardContext, + appState: $appState, + onOrderCreated: { orderId, shortId in + showNewCardSheet = false + onOrderCreated(orderId, shortId) + } + ) + } + + SnackbarOverlay(center: SnackbarCenter.shared) + } + .task { await fetchCards() } + } + + @MainActor + private func fetchCards() async { + isLoading = true + defer { isLoading = false } + if let result = try? await ApiService().listCards(), result.error == false, let fetched = result.result { + cards = fetched + selectedCardId = fetched.first(where: { $0.isDefault })?.id ?? fetched.first?.id + } + } + + private func savedCardRow(_ card: SavedCard) -> some View { + Button { + selectedCardId = card.id + } label: { + HStack(spacing: 12) { + Image(systemName: "creditcard.fill") + .font(.system(size: 20)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 44, height: 44) + .background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + VStack(alignment: .leading, spacing: 3) { + Text(card.displayLabel) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Text("Vence \(card.expiryLabel)") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + Spacer() + Circle() + .stroke(selectedCardId == card.id ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2) + .frame(width: 22, height: 22) + .background(Circle().fill(selectedCardId == card.id ? AppColors.tertiary : Color.clear)) + } + .padding(14) + } + .buttonStyle(.plain) + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } +} + +// MARK: - PaymentCardView + struct PaymentCardView: View { let context: CardPaymentContext - var onPaymentConfirmed: (() -> Void)? = nil - var onOpenTracking: (() -> Void)? = nil + @Binding var appState: AppState + let onOrderCreated: (String, String?) -> Void + @Environment(\.dismiss) var dismiss - @State var cardHolderName = "" + @State var holderName: String @State var cardNumber = "" @State var expiry = "" @State var cvv = "" - @State var tracker = OrderRealtimeTracker() - @State var latestOrder: PublicOrderResult? = nil - @State var hasOpenedTracking = false + @State var cpf: String + @State var saveCard = false + @State var nickname = "" + @State var isSubmitting = false + + private var detectedBrandLogo: String? { + let clean = cardNumber.filter(\.isNumber) + guard !clean.isEmpty else { return nil } + + // Sodexo + if clean.hasPrefix("506766") || clean.hasPrefix("603389") { + return "sodexo_logo" + } + + // Alelo + if clean.hasPrefix("5067") || clean.hasPrefix("5078") || clean.hasPrefix("6278") || clean.hasPrefix("6367") { + return "alelocard_logo" + } + + // Hipercard + if clean.hasPrefix("3841") || clean.hasPrefix("606282") || clean.hasPrefix("637095") || clean.hasPrefix("637568") { + return "hipercard_logo" + } + + // Visa + if clean.hasPrefix("4") { + return "visacard_logo" + } + + // Mastercard + if (51...55).contains(Int(clean.prefix(2)) ?? 0) { + return "mastercard_logo" + } + if let prefix4 = Int(clean.prefix(4)), (2221...2720).contains(prefix4) { + return "mastercard_logo" + } + + // Amex + if clean.hasPrefix("34") || clean.hasPrefix("37") { + return "amexcard_logo" + } + + return nil + } + + init(context: CardPaymentContext, appState: Binding, onOrderCreated: @escaping (String, String?) -> Void) { + self.context = context + self._appState = appState + self.onOrderCreated = onOrderCreated + _holderName = State(initialValue: context.profileName) + _cpf = State(initialValue: appState.wrappedValue.profile.cpf) + } + + private func validateExpiryDate() -> Bool { + let clean = expiry.filter(\.isNumber) + guard clean.count == 4 || clean.count == 6 else { return false } + let monthStr = String(clean.prefix(2)) + guard let month = Int(monthStr), (1...12).contains(month) else { return false } + return true + } + + var canSubmit: Bool { + holderName.trimmingCharacters(in: .whitespaces).isEmpty == false && + cardNumber.filter(\.isNumber).count >= 13 && + validateExpiryDate() && + cvv.filter(\.isNumber).count >= 3 && + cpf.filter(\.isNumber).count == 11 + } var body: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 14) { - RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) - .fill(AppColors.surface) - .overlay( + ZStack(alignment: .top) { + VStack(spacing: 0) { + HStack { + Text("Novo Cartão") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + Spacer() + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textMuted) + .frame(width: 30, height: 30) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + // Total HStack { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 2) { Text("Total do Pedido") .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) + .textCase(nil) Text(formatCurrency(context.total)) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) @@ -799,86 +1232,223 @@ struct PaymentCardView: View { Spacer() } .padding(14) - ) - .frame(height: 88) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) - Text("Dados do Cartão") - .font(AppTypography.heading2) - .foregroundStyle(AppColors.textPrimary) + // Dados do cartão + VStack(alignment: .leading, spacing: 0) { + sectionHeader("DADOS DO CARTÃO") + VStack(spacing: 10) { + labeledField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber, brandLogo: detectedBrandLogo) + .keyboardType(.numberPad) + .onChange(of: cardNumber) { _, v in + let d = String(v.filter(\.isNumber).prefix(16)) + let masked = stride(from: 0, to: d.count, by: 4) + .map { i -> String in + let start = d.index(d.startIndex, offsetBy: i) + let end = d.index(start, offsetBy: min(4, d.count - i)) + return String(d[start.. { + do { _ = try await ApiService().updateProfileCpf(cpf: cleanCpf) } catch {} + } + } + + // Optionally save card + if saveCard { + let savePayload = SaveCardPayload( + creditCard: SaveCardCreditCardPayload(holderName: holderName, number: cleanCardNumber, expiryMonth: expiryMonth, expiryYear: expiryYear, ccv: cvv), + creditCardHolderInfo: holderInfo, + nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : nickname, + isDefault: false + ) + _ = try await ApiService().saveCard(payload: savePayload) + } + + let response = try await ApiService().createOrder(storeId: context.storeId, payload: payload) + if response.error { + SnackbarCenter.shared.show(title: response.message ?? "Pagamento recusado.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) + return + } + guard let result = response.result else { return } + let orderId = result.id ?? UUID().uuidString + onOrderCreated(orderId, result.shortId) + } catch let error as NetworkError { + let message: String + if case .httpError(_, let serverMessage) = error, let serverMessage, serverMessage.isEmpty == false { + message = serverMessage + } else { + message = error.errorDescription ?? "Não foi possível processar o pagamento." + } + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 5.0) + } catch { + SnackbarCenter.shared.show(title: "Não foi possível processar o pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } } - private func labeledField(_ label: String, placeholder: String, text: Binding) -> some View { + private func sectionHeader(_ text: String) -> some View { + Text(text) + .font(AppTypography.overline) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 4) + } + + private func labeledField(_ label: String, placeholder: String, text: Binding, brandLogo: String? = nil) -> some View { VStack(alignment: .leading, spacing: 6) { Text(label) .font(AppTypography.caption) .foregroundStyle(AppColors.textMuted) - TextField(placeholder, text: text) - .appNoAutoCap() - .padding(.horizontal, 12) - .frame(height: 46) - .background(AppColors.backgroundLight) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + HStack(spacing: 8) { + TextField(placeholder, text: text) + .appNoAutoCap() + .foregroundStyle(AppColors.brandDark) + if let brandLogo, brandLogo.isEmpty == false { + Image(brandLogo) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + } + .padding(.horizontal, 12) + .frame(height: 46) + .background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } } @@ -886,3 +1456,411 @@ struct PaymentCardView: View { String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } } + +// MARK: - ChangePaymentSheet + +struct ChangePaymentSheet: View { + let pixContext: PixPaymentContext + let savedCards: [SavedCard] + @Binding var appState: AppState + let onChanged: (PixPaymentContext?) -> Void + let onConfirmed: () -> Void + + @Environment(\.dismiss) var dismiss + @State var isSubmitting = false + @State var showCardSheet = false + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Trocar Pagamento") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textMuted) + .frame(width: 30, height: 30) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(spacing: 12) { + optionRow(icon: "qrcode", title: "Novo QR Code PIX", subtitle: "Gera um novo código PIX") { + Task { await changeToMethod("PIX") } + } + optionRow(icon: "creditcard.fill", title: "Cartão de Crédito", subtitle: "Débito imediato no cartão") { + showCardSheet = true + } + optionRow(icon: "banknote.fill", title: "Pagar na Entrega", subtitle: "Pague ao receber o pedido") { + Task { await changeToMethod("CASH") } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 32) + } + } + .background(AppColors.backgroundLight) + .sheet(isPresented: $showCardSheet) { + let items = (try? JSONDecoder().decode([CreateOrderItemPayload].self, from: pixContext.itemsJSON.data(using: .utf8) ?? Data())) ?? [] + let cardCtx = CardPaymentContext.build(storeId: pixContext.storeId, total: pixContext.total, deliveryType: pixContext.deliveryType, profile: appState.profile, address: nil, items: items) + ChangePaymentCardSheet(pixContext: pixContext, cardContext: cardCtx, savedCards: savedCards, appState: $appState) { _, _ in + showCardSheet = false + onConfirmed() + } + } + } + + private func optionRow(icon: String, title: String, subtitle: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 14) { + Image(systemName: icon).font(.system(size: 20)).foregroundStyle(AppColors.primary) + .frame(width: 44, height: 44).background(AppColors.brandSoft) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + VStack(alignment: .leading, spacing: 3) { + Text(title).font(AppTypography.heading3).foregroundStyle(AppColors.textPrimary) + Text(subtitle).font(.caption).foregroundStyle(AppColors.textMuted) + } + Spacer() + if isSubmitting { ProgressView().scaleEffect(0.8) } + else { Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).foregroundStyle(AppColors.textMuted) } + } + .padding(14).background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain).disabled(isSubmitting) + } + + @MainActor + private func changeToMethod(_ method: String) async { + isSubmitting = true + defer { isSubmitting = false } + let payload = ChangePaymentMethodPayload(paymentMethod: method, clientCpfCnpj: nil, creditCard: nil, creditCardHolderInfo: nil, savedCardId: nil) + do { + let response = try await ApiService().changePaymentMethod(storeId: pixContext.storeId, orderId: pixContext.orderId, payload: payload) + if response.error { + SnackbarCenter.shared.show(title: response.message ?? "Não foi possível trocar o pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) + return + } + guard let result = response.result else { return } + if method == "CASH" { + SnackbarCenter.shared.show(title: "Pagamento alterado. Pague ao receber.", style: .success, icon: "checkmark.circle.fill", duration: 3.0) + onConfirmed(); return + } + let pix = result.payment?.pix ?? result.paymentPayload + if let copyPaste = pix?.copyPaste, copyPaste.isEmpty == false { + let newCtx = PixPaymentContext(id: UUID().uuidString, orderId: pixContext.orderId, shortId: pixContext.shortId, storeId: pixContext.storeId, copyPaste: copyPaste, qrCodeImageBase64: pix?.qrCodeImage, expirationDate: pix?.expirationDate, total: pixContext.total, profileName: pixContext.profileName, profileEmail: pixContext.profileEmail, profilePhone: pixContext.profilePhone, addressZip: pixContext.addressZip, addressNumber: pixContext.addressNumber, deliveryType: pixContext.deliveryType, itemsJSON: pixContext.itemsJSON) + SnackbarCenter.shared.show(title: "Novo QR Code gerado.", style: .success, icon: "qrcode", duration: 2.5) + onChanged(newCtx) + } + } catch let error as NetworkError { + let msg: String + if case .httpError(_, let m) = error, let m, m.isEmpty == false { msg = m } else { msg = error.errorDescription ?? "Erro ao trocar pagamento." } + SnackbarCenter.shared.show(title: msg, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } catch { + SnackbarCenter.shared.show(title: "Erro ao trocar pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + } +} + +// MARK: - ChangePaymentCardSheet + +struct ChangePaymentCardSheet: View { + let pixContext: PixPaymentContext + let cardContext: CardPaymentContext + let savedCards: [SavedCard] + @Binding var appState: AppState + let onConfirmed: (String, String?) -> Void + + @Environment(\.dismiss) var dismiss + @State var selectedCardId: String? + @State var showNewCardForm = false + @State var isSubmitting = false + + init(pixContext: PixPaymentContext, cardContext: CardPaymentContext, savedCards: [SavedCard], appState: Binding, onConfirmed: @escaping (String, String?) -> Void) { + self.pixContext = pixContext; self.cardContext = cardContext; self.savedCards = savedCards + self._appState = appState; self.onConfirmed = onConfirmed + _selectedCardId = State(initialValue: savedCards.first(where: { $0.isDefault })?.id ?? savedCards.first?.id) + } + + var body: some View { + ZStack(alignment: .top) { + VStack(spacing: 0) { + HStack { + Text("Cartão de Crédito").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark").font(.system(size: 12, weight: .bold)).foregroundStyle(AppColors.textMuted) + .frame(width: 30, height: 30).background(AppColors.surface).clipShape(Circle()) + }.buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + if savedCards.isEmpty == false { + VStack(spacing: 0) { + ForEach(savedCards) { card in + Button { selectedCardId = card.id } label: { + HStack(spacing: 12) { + Image(systemName: "creditcard.fill").font(.system(size: 18)).foregroundStyle(AppColors.textPrimary) + .frame(width: 44, height: 44).background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + VStack(alignment: .leading, spacing: 3) { + Text(card.displayLabel).font(AppTypography.heading3).foregroundStyle(AppColors.textPrimary) + Text("Vence \(card.expiryLabel)").font(.caption).foregroundStyle(AppColors.textMuted) + } + Spacer() + Circle().stroke(selectedCardId == card.id ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2) + .frame(width: 22, height: 22).background(Circle().fill(selectedCardId == card.id ? AppColors.tertiary : Color.clear)) + }.padding(14) + }.buttonStyle(.plain) + if card.id != savedCards.last?.id { Divider().padding(.horizontal, 14) } + } + } + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + + if let selectedCardId { + PrimaryButton(title: isSubmitting ? "Processando..." : "Pagar com este Cartão") { + guard isSubmitting == false else { return } + Task { await changeToSavedCard(cardId: selectedCardId) } + }.disabled(isSubmitting) + } + Text("ou").font(AppTypography.caption).foregroundStyle(AppColors.textMuted).frame(maxWidth: .infinity, alignment: .center) + } + Button { showNewCardForm = true } label: { + HStack(spacing: 10) { + Image(systemName: "plus.circle.fill").font(.system(size: 18)).foregroundStyle(AppColors.primary) + Text("Novo cartão").font(AppTypography.heading3).foregroundStyle(AppColors.primary) + Spacer() + } + .padding(14).background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + }.buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.bottom, 32) + } + } + .background(AppColors.backgroundLight) + .sheet(isPresented: $showNewCardForm) { + ChangePaymentNewCardView(pixContext: pixContext, appState: $appState) { orderId, shortId in + showNewCardForm = false; onConfirmed(orderId, shortId) + } + } + + SnackbarOverlay(center: SnackbarCenter.shared) + } + } + + @MainActor + private func changeToSavedCard(cardId: String) async { + isSubmitting = true; defer { isSubmitting = false } + let payload = ChangePaymentMethodPayload(paymentMethod: "CREDIT_CARD", clientCpfCnpj: nil, creditCard: nil, creditCardHolderInfo: nil, savedCardId: cardId) + do { + let response = try await ApiService().changePaymentMethod(storeId: pixContext.storeId, orderId: pixContext.orderId, payload: payload) + if response.error { SnackbarCenter.shared.show(title: response.message ?? "Pagamento recusado.", style: .error, icon: "xmark.octagon.fill", duration: 3.5); return } + onConfirmed(pixContext.orderId, pixContext.shortId) + } catch let error as NetworkError { + let msg: String + if case .httpError(_, let m) = error, let m, m.isEmpty == false { msg = m } else { msg = "Pagamento recusado." } + SnackbarCenter.shared.show(title: msg, style: .error, icon: "xmark.octagon.fill", duration: 4.0) + } catch { SnackbarCenter.shared.show(title: "Erro ao processar.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } + } +} + +// MARK: - ChangePaymentNewCardView + +struct ChangePaymentNewCardView: View { + let pixContext: PixPaymentContext + @Binding var appState: AppState + let onConfirmed: (String, String?) -> Void + + @Environment(\.dismiss) var dismiss + @State var holderName: String + @State var cardNumber = "" + @State var expiry = "" + @State var cvv = "" + @State var cpf: String + @State var saveCard = false + @State var nickname = "" + @State var isSubmitting = false + + private var detectedBrandLogo: String? { + let clean = cardNumber.filter(\.isNumber) + guard !clean.isEmpty else { return nil } + + // Sodexo + if clean.hasPrefix("506766") || clean.hasPrefix("603389") { + return "sodexo_logo" + } + + // Alelo + if clean.hasPrefix("5067") || clean.hasPrefix("5078") || clean.hasPrefix("6278") || clean.hasPrefix("6367") { + return "alelocard_logo" + } + + // Hipercard + if clean.hasPrefix("3841") || clean.hasPrefix("606282") || clean.hasPrefix("637095") || clean.hasPrefix("637568") { + return "hipercard_logo" + } + + // Visa + if clean.hasPrefix("4") { + return "visacard_logo" + } + + // Mastercard + if (51...55).contains(Int(clean.prefix(2)) ?? 0) { + return "mastercard_logo" + } + if let prefix4 = Int(clean.prefix(4)), (2221...2720).contains(prefix4) { + return "mastercard_logo" + } + + // Amex + if clean.hasPrefix("34") || clean.hasPrefix("37") { + return "amexcard_logo" + } + + return nil + } + + init(pixContext: PixPaymentContext, appState: Binding, onConfirmed: @escaping (String, String?) -> Void) { + self.pixContext = pixContext; self._appState = appState; self.onConfirmed = onConfirmed + _holderName = State(initialValue: appState.wrappedValue.profile.name) + _cpf = State(initialValue: appState.wrappedValue.profile.cpf) + } + + private func validateExpiryDate() -> Bool { + let clean = expiry.filter(\.isNumber) + guard clean.count == 4 || clean.count == 6 else { return false } + let monthStr = String(clean.prefix(2)) + guard let month = Int(monthStr), (1...12).contains(month) else { return false } + return true + } + + var canSubmit: Bool { + holderName.trimmingCharacters(in: .whitespaces).isEmpty == false && + cardNumber.filter(\.isNumber).count >= 13 && + validateExpiryDate() && + cvv.filter(\.isNumber).count >= 3 && + cpf.filter(\.isNumber).count == 11 + } + + var body: some View { + ZStack(alignment: .top) { + VStack(spacing: 0) { + HStack { + Text("Novo Cartão").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark").font(.system(size: 12, weight: .bold)).foregroundStyle(AppColors.textMuted) + .frame(width: 30, height: 30).background(AppColors.surface).clipShape(Circle()) + }.buttonStyle(.plain) + } + .padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + VStack(spacing: 10) { + cardField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber, brandLogo: detectedBrandLogo).keyboardType(.numberPad) + .onChange(of: cardNumber) { _, v in + let d = String(v.filter(\.isNumber).prefix(16)) + let m = stride(from: 0, to: d.count, by: 4).map { i -> String in let s = d.index(d.startIndex, offsetBy: i); let e = d.index(s, offsetBy: min(4, d.count - i)); return String(d[s.. { do { _ = try await ApiService().updateProfileCpf(cpf: cleanCpf) } catch {} } + } + do { + if saveCard { + let sp = SaveCardPayload(creditCard: SaveCardCreditCardPayload(holderName: holderName, number: cleanNumber, expiryMonth: expiryMonth, expiryYear: expiryYear, ccv: cvv), creditCardHolderInfo: holderInfo, nickname: nickname.trimmingCharacters(in: .whitespaces).isEmpty ? nil : nickname, isDefault: false) + _ = try await ApiService().saveCard(payload: sp) + } + let response = try await ApiService().changePaymentMethod(storeId: pixContext.storeId, orderId: pixContext.orderId, payload: payload) + if response.error { SnackbarCenter.shared.show(title: response.message ?? "Pagamento recusado.", style: .error, icon: "xmark.octagon.fill", duration: 4.0); return } + onConfirmed(pixContext.orderId, pixContext.shortId) + } catch let error as NetworkError { + let msg: String + if case .httpError(_, let m) = error, let m, m.isEmpty == false { msg = m } else { msg = error.errorDescription ?? "Erro ao processar." } + SnackbarCenter.shared.show(title: msg, style: .error, icon: "xmark.octagon.fill", duration: 5.0) + } catch { SnackbarCenter.shared.show(title: "Erro ao processar pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) } + } + + private func cardField(_ label: String, placeholder: String, text: Binding, brandLogo: String? = nil) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label).font(AppTypography.caption).foregroundStyle(AppColors.textMuted) + HStack(spacing: 8) { + TextField(placeholder, text: text).appNoAutoCap() + .foregroundStyle(AppColors.brandDark) + if let brandLogo, brandLogo.isEmpty == false { + Image(brandLogo) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + } + .padding(.horizontal, 12).frame(height: 46).background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift index 36073e2..3fffbf8 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift @@ -16,7 +16,7 @@ struct MainTabView: View { } case .cart: NavigationStack { - CartView(appState: $appState) + CartView(appState: $appState, selectedTab: $selectedTab) } case .profile: NavigationStack { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift index 10cd7b4..15e4413 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift @@ -4,10 +4,20 @@ struct OrderDetailsView: View { let order: PublicOrderResult let orderId: String let initialShortId: String? + @Binding var appState: AppState + + @Environment(\.dismiss) var dismiss + @Environment(\.openURL) var openURL + @State private var storeContactPhone: String? = nil + @State private var resolvedStoreLogoURL: String? = nil + @State private var showCallAlert = false + @State private var navigateToStore = false + @State private var showClearCartAlert = false var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 14) { + screenHeader statusCard storeCard itemsCard @@ -22,8 +32,44 @@ struct OrderDetailsView: View { .padding(.bottom, UIDevice.bottomNotch) } .background(AppColors.backgroundLight) - .navigationTitle("Detalhes do Pedido") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + .navigationDestination(isPresented: $navigateToStore) { + if let storeId = order.storeId, storeId.isEmpty == false { + StoreDetailView( + storeId: storeId, + storeName: order.storeName ?? "Loja", + storeCoverURL: nil, + storeLogoURL: order.storeLogoURL, + storeCategory: nil, + storeRating: nil, + storeDistance: nil, + storeDeliveryFee: nil, + appState: $appState + ) + } + } + .alert("Ligar para a loja?", isPresented: $showCallAlert) { + Button("Ligar para \(order.storeName ?? "a loja")") { + if let phone = storeContactPhone { + openTel(phone) + } + } + Button("Cancelar", role: .cancel) {} + } message: { + Text("WhatsApp não encontrado. Deseja ligar para \(order.storeName ?? "a loja")?") + } + .task { + await loadStoreContactPhone() + } + .alert("Substituir carrinho?", isPresented: $showClearCartAlert) { + Button("Limpar e adicionar", role: .destructive) { + applyReorder(clearFirst: true) + } + Button("Cancelar", role: .cancel) {} + } message: { + Text("Seu carrinho tem itens de \(appState.cart.storeName ?? appState.cart.storeId ?? "outra loja"). Deseja limpar e adicionar itens de \(order.storeName ?? "esta loja")?") + } .appBottomSafeAreaInset { VStack { reorderButton @@ -35,6 +81,27 @@ struct OrderDetailsView: View { } } + private var screenHeader: some View { + ZStack { + Text("Detalhes do Pedido") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private var statusCard: some View { HStack(spacing: 14) { Circle() @@ -54,6 +121,26 @@ struct OrderDetailsView: View { Text(statusDateText) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) + + if let reason = cancellationReasonText { + Text(reason) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 2) + } + + if let addr = deliveryAddressSummary { + HStack(spacing: 4) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 11)) + .foregroundStyle(AppColors.textMuted) + Text(addr) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(2) + } + .padding(.top, 2) + } } Spacer(minLength: 0) @@ -64,30 +151,39 @@ struct OrderDetailsView: View { } private var storeCard: some View { - HStack(spacing: 12) { - AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL)) - .frame(width: 54, height: 54) - .clipShape(Circle()) - .background(AppColors.brandSoft, in: Circle()) - - VStack(alignment: .leading, spacing: 4) { - Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja") - .font(AppTypography.heading2) - .foregroundStyle(AppColors.textPrimary) - Text(storeSubtitle) - .font(AppTypography.body) - .foregroundStyle(AppColors.textMuted) + Button { + if order.storeId?.isEmpty == false { + navigateToStore = true } + } label: { + HStack(spacing: 12) { + AsyncStoreImage(imageURL: resolvedMediaURL(resolvedStoreLogoURL ?? order.storeLogoURL)) + .frame(width: 54, height: 54) + .clipShape(Circle()) + .background(AppColors.brandSoft, in: Circle()) - Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 4) { + Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + Text(storeSubtitle) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } - Text("Ver loja") - .font(AppTypography.body) - .foregroundStyle(Color(hex: "#A5D645")) + Spacer(minLength: 0) + + if order.storeId?.isEmpty == false { + Text("Ver loja") + .font(AppTypography.body) + .foregroundStyle(Color(hex: "#A5D645")) + } + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } - .padding(16) - .background(AppColors.surface) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .buttonStyle(.plain) } private var itemsCard: some View { @@ -216,27 +312,28 @@ struct OrderDetailsView: View { private var reorderButton: some View { Button("Pedir Novamente") { - SnackbarCenter.shared.show( - title: "Recompra será integrada com o catálogo em breve.", - style: .info, - icon: "cart.badge.plus", - duration: 2.0 - ) + reorder() } .font(AppTypography.heading2) .foregroundStyle(Color(hex: "#0E1A06")) .frame(maxWidth: .infinity, minHeight: 56) - .background(Color(hex: "#C8F06E")) + .background(order.items.isEmpty ? Color(hex: "#C8F06E").opacity(0.45) : Color(hex: "#C8F06E")) .clipShape(Capsule()) .buttonStyle(.plain) + .disabled(order.items.isEmpty) } private var helpFooter: some View { - Text("Precisa de ajuda com esse pedido?") - .font(AppTypography.body) - .foregroundStyle(Color(hex: "#A5D645")) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 8) + Button { + handleHelpTap() + } label: { + Text("Precisa de ajuda com esse pedido?") + .font(AppTypography.body) + .foregroundStyle(Color(hex: "#A5D645")) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 8) + } + .buttonStyle(.plain) } private var subtotal: Double { @@ -296,6 +393,24 @@ struct OrderDetailsView: View { return "Pedido #\(displayOrderTitle)" } + private var deliveryAddressSummary: String? { + if let full = order.fullAddress, full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + return full.trimmingCharacters(in: .whitespacesAndNewlines) + } + let line1 = deliveryAddressLine + let line2 = deliveryAddressLine2 + let combined = [line1, line2].filter { $0.isEmpty == false }.joined(separator: ", ") + return combined.isEmpty ? nil : combined + } + + private var cancellationReasonText: String? { + guard statusTitle.contains("cancelado"), + let reason = order.cancellationReason, + reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + else { return nil } + return "Motivo: \(reason.trimmingCharacters(in: .whitespacesAndNewlines))" + } + private var statusTitle: String { let status = normalized(order.status) if status.contains("CANCEL") { return "Pedido cancelado" } @@ -309,6 +424,12 @@ struct OrderDetailsView: View { } private var statusDateText: String { + if let event = order.timeline.first, + let date = event.date, date.isEmpty == false { + let time = event.time.flatMap { $0.isEmpty ? nil : $0 } + let combined = time.map { "\(date) às \($0)" } ?? date + return "\(statusDatePrefix) \(combined)" + } if let formatted = formatDate(order.updatedAt ?? order.createdAt) { return "\(statusDatePrefix) \(formatted)" } @@ -342,14 +463,81 @@ struct OrderDetailsView: View { String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } + private func reorder() { + guard order.items.isEmpty == false else { return } + let cartStoreId = appState.cart.storeId ?? appState.cart.items.first?.storeId ?? "" + let orderStoreId = order.storeId ?? "" + let cartHasDifferentStore = cartStoreId.isEmpty == false + && orderStoreId.isEmpty == false + && cartStoreId != orderStoreId + && appState.cart.items.isEmpty == false + if cartHasDifferentStore { + showClearCartAlert = true + } else { + applyReorder(clearFirst: false) + } + } + + private func applyReorder(clearFirst: Bool) { + if clearFirst { + appState.cart.clear() + } + let storeId = order.storeId ?? "" + if appState.cart.storeId == nil || appState.cart.storeId?.isEmpty == true { + appState.cart.storeId = storeId + appState.cart.storeName = order.storeName + } + var addedCount = 0 + for item in order.items { + let name = item.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard name.isEmpty == false else { continue } + let qty = max(1, item.qty ?? 1) + let price = item.price ?? 0 + let cartItem = CartItemState( + id: UUID().uuidString, + productId: item.productId ?? item.id, + storeId: storeId, + name: name, + imageURL: nil, + details: nil, + addons: [], + quantity: qty, + unitPrice: price + ) + appState.cart.add(item: cartItem) + addedCount += qty + } + let label = addedCount == 1 ? "1 item adicionado ao carrinho." : "\(addedCount) itens adicionados ao carrinho." + SnackbarCenter.shared.show(title: label, style: .success, icon: "cart.badge.plus", duration: 2.5) + } + private func formatDate(_ isoValue: String?) -> String? { guard let isoValue, isoValue.isEmpty == false else { return nil } + let iso = ISO8601DateFormatter() - iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - var date = iso.date(from: isoValue) + let optionSets: [ISO8601DateFormatter.Options] = [ + [.withInternetDateTime, .withFractionalSeconds], + [.withInternetDateTime], + [.withFullDate, .withTime, .withColonSeparatorInTime], + [.withFullDate, .withTime, .withColonSeparatorInTime, .withTimeZone], + [.withFullDate] + ] + var date: Date? = nil + for options in optionSets { + iso.formatOptions = options + if let d = iso.date(from: isoValue) { + date = d + break + } + } if date == nil { - iso.formatOptions = [.withInternetDateTime] - date = iso.date(from: isoValue) + let fallback = DateFormatter() + fallback.locale = Locale(identifier: "en_US_POSIX") + for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSZ", "yyyy-MM-dd'T'HH:mm:ssZ", + "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd"] { + fallback.dateFormat = fmt + if let d = fallback.date(from: isoValue) { date = d; break } + } } guard let date else { return nil } @@ -359,6 +547,64 @@ struct OrderDetailsView: View { return formatter.string(from: date) } + @MainActor + private func loadStoreContactPhone() async { + if let inline = order.storePhone, inline.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + storeContactPhone = inline + } + guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), + storeId.isEmpty == false else { return } + do { + let response = try await ApiService().storeInfo(storeId: storeId) + if response.error == false, let result = response.result { + let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if phone.isEmpty == false { + storeContactPhone = phone + } + if let logo = result.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + resolvedStoreLogoURL = logo + } + } + } catch {} + } + + private func handleHelpTap() { + guard let phoneRaw = storeContactPhone, + phoneRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + SnackbarCenter.shared.show( + title: "Telefone da loja indisponível.", + style: .warning, + icon: "exclamationmark.triangle.fill", + duration: 2.8 + ) + return + } + if let waURL = makeWhatsAppURL(from: phoneRaw) { + openURL(waURL) + } else { + showCallAlert = true + } + } + + private func openTel(_ phoneRaw: String) { + let digits = phoneRaw.filter(\.isNumber) + if digits.isEmpty { return } + if let url = URL(string: "tel://\(digits)") { + openURL(url) + } + } + + private func makeWhatsAppURL(from phoneRaw: String) -> URL? { + var digits = phoneRaw.filter(\.isNumber) + if digits.isEmpty { return nil } + if digits.hasPrefix("0") { digits = String(digits.drop(while: { $0 == "0" })) } + if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) { + digits = "55" + digits + } + guard digits.count >= 12 else { return nil } + return URL(string: "https://wa.me/\(digits)") + } + private func normalized(_ value: String?) -> String { (value ?? "") .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift index 70006c6..5a4fcfc 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift @@ -12,6 +12,8 @@ private struct TrackingStep: Identifiable { struct OrderTrackingView: View { let orderId: String let initialShortId: String? + var postOrderBack: (() -> Void)? = nil + @Environment(\.dismiss) var dismiss @Environment(\.openURL) var openURL @State var isLoading = true @@ -27,6 +29,7 @@ struct OrderTrackingView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 16) { + screenHeader topHeader orderTitleSection statusBanner @@ -43,8 +46,8 @@ struct OrderTrackingView: View { .padding(.bottom, UIDevice.bottomNotch + 45) } .background(AppColors.backgroundLight) - .navigationTitle("Pedido \(displayOrderTitle)") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .alert("Motivo do cancelamento", isPresented: $showCancellationReason) { Button("Fechar", role: .cancel) {} } message: { @@ -97,6 +100,33 @@ struct OrderTrackingView: View { self.reviewSavedObserver = nil } + private var screenHeader: some View { + ZStack { + Text("Pedido \(displayOrderTitle)") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { + if let postOrderBack { + postOrderBack() + } else { + dismiss() + } + }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private var topHeader: some View { HStack(spacing: 10) { Circle() diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift index 5be7f66..32bd5c6 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift @@ -1,6 +1,7 @@ import SwiftUI struct OrdersView: View { + @Binding var appState: AppState @Environment(\.dismiss) var dismiss @State var isLoading = false @State var errorMessage: String? = nil @@ -8,6 +9,8 @@ struct OrdersView: View { @State var hasLoadedOnce = false @State var storeRatingByStoreId: [String: Double] = [:] @State var storeRatingByStoreName: [String: Double] = [:] + @State var storeLogoByStoreId: [String: String] = [:] + @State var storeLogoByStoreName: [String: String] = [:] @State var selectedOrderRoute: OrderRouteContext? = nil var body: some View { @@ -58,7 +61,8 @@ struct OrdersView: View { initialShortId: context.shortId, fallbackPaymentMethod: context.paymentMethod, fallbackTotal: context.total, - routeIntent: context.intent + routeIntent: context.intent, + appState: $appState ) } } @@ -104,7 +108,7 @@ struct OrdersView: View { return VStack(alignment: .leading, spacing: 14) { HStack(spacing: 12) { - AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL)) + AsyncStoreImage(imageURL: resolvedMediaURL(storeLogoURL(for: order))) .frame(width: 80, height: 80) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) @@ -464,20 +468,25 @@ struct OrdersView: View { var byId: [String: Double] = [:] var byName: [String: Double] = [:] + var logoById: [String: String] = [:] + var logoByName: [String: String] = [:] for store in storeList { - guard let rating = store.rating, rating > 0 else { continue } let storeId = normalizedOrderId(store.id) - if storeId.isEmpty == false { - byId[storeId] = rating - } let nameKey = normalizedStoreName(store.name) - if nameKey.isEmpty == false { - byName[nameKey] = rating + if let rating = store.rating, rating > 0 { + if storeId.isEmpty == false { byId[storeId] = rating } + if nameKey.isEmpty == false { byName[nameKey] = rating } + } + if let logo = store.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + if storeId.isEmpty == false { logoById[storeId] = logo } + if nameKey.isEmpty == false { logoByName[nameKey] = logo } } } storeRatingByStoreId = byId storeRatingByStoreName = byName + storeLogoByStoreId = logoById + storeLogoByStoreName = logoByName } private func storeRating(for order: AppOrderSummary) -> Double? { @@ -492,6 +501,17 @@ struct OrdersView: View { return nil } + private func storeLogoURL(for order: AppOrderSummary) -> String? { + if let url = order.storeLogoURL, url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + return url + } + let storeId = normalizedOrderId(order.storeId) + if storeId.isEmpty == false, let logo = storeLogoByStoreId[storeId] { return logo } + let nameKey = normalizedStoreName(order.storeName) + if nameKey.isEmpty == false, let logo = storeLogoByStoreName[nameKey] { return logo } + return nil + } + private func resolvedMediaURL(_ raw: String?) -> String? { ImageSourceResolver.resolve(raw) } @@ -629,11 +649,11 @@ struct OrderEntryDestinationView: View { let fallbackPaymentMethod: String? let fallbackTotal: Double? let routeIntent: OrderRouteIntent + @Binding var appState: AppState @State var isResolvingRoute = true @State var didResolve = false @State var pixContext: PixPaymentContext? = nil - @State var cardContext: CardPaymentContext? = nil @State var orderTrackingContext: OrderTrackingContext? = nil @State var orderDetails: PublicOrderResult? = nil @@ -651,6 +671,7 @@ struct OrderEntryDestinationView: View { } else if let pixContext { PaymentPixView( context: pixContext, + appState: $appState, onPaymentConfirmed: { orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) }, @@ -658,18 +679,8 @@ struct OrderEntryDestinationView: View { orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) } ) - } else if let cardContext { - PaymentCardView( - context: cardContext, - onPaymentConfirmed: { - orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId) - }, - onOpenTracking: { - orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId) - } - ) } else if let orderDetails { - OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId) + OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId, appState: $appState) } else { OrderTrackingView(orderId: orderId, initialShortId: initialShortId) } @@ -712,24 +723,30 @@ struct OrderEntryDestinationView: View { let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? pixFromPayment?.expirationDate : pixFromPayload?.expirationDate + let storeId = order.storeId ?? "" pixContext = PixPaymentContext( id: order.id, orderId: order.id, shortId: order.shortId ?? initialShortId, + storeId: storeId, copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (copyPaste ?? "") : "Código PIX indisponível no momento. Aguarde e tente novamente.", qrCodeImageBase64: qrCodeImage, - expirationDate: expirationDate + expirationDate: expirationDate, + total: order.total ?? 0, + profileName: "", + profileEmail: "", + profilePhone: "", + addressZip: nil, + addressNumber: nil, + deliveryType: order.deliveryType ?? "DELIVERY", + itemsJSON: "[]" ) return } if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" { - cardContext = CardPaymentContext( - orderId: order.id, - shortId: order.shortId ?? initialShortId, - total: order.total ?? fallbackTotal ?? 0 - ) + orderTrackingContext = OrderTrackingContext(orderId: order.id, shortId: order.shortId ?? initialShortId) return } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift index bc27f6b..377cfef 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift @@ -10,6 +10,7 @@ struct PizzaFlavorAddonsSheet: View { var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 12) { + screenHeader Text(flavor.name) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) @@ -77,12 +78,28 @@ struct PizzaFlavorAddonsSheet: View { .padding(20) } .background(AppColors.backgroundLight.ignoresSafeArea()) - .navigationTitle("Adicionais") - .appInlineNavigationTitle() - .appTopBarTrailingToolbar { - Button("Concluir") { dismiss() } + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + } + + private var screenHeader: some View { + ZStack { + Text("Adicionais") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } .buttonStyle(.plain) - .foregroundStyle(AppColors.primary) + Spacer() + } } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift index f96b628..30cfd6b 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift @@ -2,28 +2,39 @@ import Foundation import SwiftUI extension PizzaProductDetailSheet { - var stepSizes: some View { - let sizeItems = sizes - return VStack(alignment: .leading, spacing: 10) { - Text("1. Tamanho") - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textPrimary) - ForEach(0.. 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil, + subtitle: (crust.priceModifier ?? 0) > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil, isSelected: selectedCrustId == crust.id ) { selectedCrustId = crust.id + withAnimation(.easeInOut(duration: 0.2)) { + expandedStep = 3 + } } } } } - .padding(12) - .background(AppColors.surface) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } var stepFlavors: some View { - let flavorItems = flavors - return VStack(alignment: .leading, spacing: 10) { - Text("4. Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))") - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textPrimary) - + accordionSection( + step: 3, + label: "Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))", + summary: selectedFlavorIds.isEmpty ? nil + : selectedFlavorProducts.map(\.name).joined(separator: ", ") + ) { Text("Toque no sabor para escolher adicionais.") .font(.caption) .foregroundStyle(AppColors.textMuted) - ForEach(0..= maxFlavorsAllowed - let disableSwitch = isSelected == false && maxReached HStack(spacing: 10) { + AsyncStoreImage(imageURL: resolveImageURL(flavor.image)) + .frame(width: 52, height: 52) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + VStack(alignment: .leading, spacing: 4) { Text(flavor.name) .font(AppTypography.body) @@ -101,21 +117,15 @@ extension PizzaProductDetailSheet { .foregroundStyle(AppColors.textMuted) } } - Spacer() - Toggle("", isOn: Binding( get: { isSelected }, set: { value in - if value { - addFlavor(flavor.id) - } else { - removeFlavor(flavor.id) - } + if value { addFlavor(flavor.id) } else { removeFlavor(flavor.id) } } )) .labelsHidden() - .disabled(disableSwitch) + .disabled(!isSelected && maxReached) } .padding(.vertical, 2) .appContentShape(Rectangle()) @@ -126,23 +136,90 @@ extension PizzaProductDetailSheet { } } } - .padding(12) + } + + // MARK: - Accordion container + + func accordionSection( + step: Int, + label: String, + summary: String?, + @ViewBuilder content: () -> some View + ) -> some View { + let isExpanded = expandedStep == step + let isDone = summary != nil + + return VStack(spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + expandedStep = isExpanded ? -1 : step + } + } label: { + HStack(spacing: 10) { + ZStack { + Circle() + .fill(isDone || isExpanded ? AppColors.primary : AppColors.textMuted.opacity(0.25)) + .frame(width: 26, height: 26) + if isDone && !isExpanded { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } else { + Text("\(step + 1)") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + } + } + + Text(label) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + Spacer() + + if let summary, !isExpanded { + Text(summary) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 140, alignment: .trailing) + } + + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppColors.textMuted) + } + .padding(14) + } + .buttonStyle(.plain) + + if isExpanded { + Divider().padding(.horizontal, 14) + VStack(alignment: .leading, spacing: 10) { + content() + } + .padding(14) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } - func sizeRow(_ size: StorePizzaSize) -> some View { - let title = size.name ?? "Tamanho" - let subtitle = "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)" - let selected = selectedSizeId == size.id + // MARK: - Helpers - return radioRow( - title: title, - subtitle: subtitle, - isSelected: selected - ) { - selectedSizeId = size.id + func nextStep(after step: Int) -> Int { + if step == 0 { + if doughs.count > 1 { return 1 } + if crusts.count > 1 { return 2 } + return 3 } + if step == 1 { + if crusts.count > 1 { return 2 } + return 3 + } + return 3 } func radioRow(title: String, subtitle: String?, isSelected: Bool, action: @escaping () -> Void) -> some View { @@ -158,7 +235,6 @@ extension PizzaProductDetailSheet { .frame(width: 10, height: 10) } } - VStack(alignment: .leading, spacing: 2) { Text(title) .font(AppTypography.body) @@ -169,7 +245,6 @@ extension PizzaProductDetailSheet { .foregroundStyle(AppColors.textMuted) } } - Spacer() } .appContentShape(Rectangle()) @@ -181,40 +256,29 @@ extension PizzaProductDetailSheet { guard let crust else { return "Sem borda especial" } let name = crust.name ?? "Borda" let modifier = crust.priceModifier ?? 0 - if modifier > 0 { - return "\(name) (+ \(formatCurrency(modifier)))" - } - return name + return modifier > 0 ? "\(name) (+ \(formatCurrency(modifier)))" : name } func applyAutoSelections() { if selectedSizeId != nil { - if doughs.count == 1 { - selectedDoughId = doughs.first?.id - } else if doughs.isEmpty { - selectedDoughId = "__none__" - } + if doughs.count == 1 { selectedDoughId = doughs.first?.id } + else if doughs.isEmpty { selectedDoughId = "__none__" } } - if isDoughReady { - if crusts.count == 1 { - selectedCrustId = crusts.first?.id - } else if crusts.isEmpty { - selectedCrustId = "__none__" - } + if crusts.count == 1 { selectedCrustId = crusts.first?.id } + else if crusts.isEmpty { selectedCrustId = "__none__" } } } func trimFlavorSelectionByLimit() { let limit = maxFlavorsAllowed guard selectedFlavorIds.count > limit else { return } - let sorted = selectedFlavorIds.sorted() - selectedFlavorIds = Set(sorted.prefix(limit)) + selectedFlavorIds = Set(selectedFlavorIds.sorted().prefix(limit)) } func addFlavor(_ flavorId: String) { - if selectedFlavorIds.contains(flavorId) { return } - if selectedFlavorIds.count >= maxFlavorsAllowed { return } + guard !selectedFlavorIds.contains(flavorId), + selectedFlavorIds.count < maxFlavorsAllowed else { return } selectedFlavorIds.insert(flavorId) } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift index c6852bf..d523a2c 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift @@ -17,6 +17,7 @@ struct PizzaProductDetailSheet: View { @State var flavorAddonQuantities: [String: [String: Int]] = [:] @State var selectedFlavorForAddons: StoreCatalogProduct? = nil @State var quantity: Int = 1 + @State var expandedStep: Int = 0 var flavors: [StoreCatalogProduct] { category.products @@ -45,7 +46,7 @@ struct PizzaProductDetailSheet: View { return resolveImageURL(firstImage) } - private var selectedSize: StorePizzaSize? { + var selectedSize: StorePizzaSize? { guard let selectedSizeId else { return nil } return sizes.first(where: { $0.id == selectedSizeId }) } @@ -76,7 +77,7 @@ struct PizzaProductDetailSheet: View { isCrustReady } - private var selectedFlavorProducts: [StoreCatalogProduct] { + var selectedFlavorProducts: [StoreCatalogProduct] { flavors .filter { selectedFlavorIds.contains($0.id) } .sorted { $0.name < $1.name } @@ -102,12 +103,12 @@ struct PizzaProductDetailSheet: View { } private var basePizzaPrice: Double { - selectedFlavorProducts - .map { flavor in - guard let selectedSizeId else { return flavor.price ?? 0 } - return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0 - } - .max() ?? 0 + let prices = selectedFlavorProducts.map { flavor in + guard let selectedSizeId else { return flavor.price ?? 0 } + return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0 + } + guard prices.isEmpty == false else { return 0 } + return prices.reduce(0, +) / Double(prices.count) } private var unitPrice: Double { @@ -179,6 +180,34 @@ struct PizzaProductDetailSheet: View { return chunks.isEmpty ? nil : chunks.joined(separator: " • ") } + private var pizzaChoices: [String] { + var choices: [String] = [] + if let sizeName = selectedSize?.name { + let sizePrice = basePizzaPrice + if sizePrice > 0 { + choices.append("Tamanho: \(sizeName) (+\(formatCurrency(sizePrice)))") + } else { + choices.append("Tamanho: \(sizeName)") + } + } + if let crustName = selectedCrust?.name, crustName.isEmpty == false { + let mod = crustPriceModifier + if mod > 0 { + choices.append("Borda: \(crustName) (+\(formatCurrency(mod)))") + } else { + choices.append("Borda: \(crustName)") + } + } + if let doughName = selectedDoughName, doughName.isEmpty == false { + choices.append("Massa: \(doughName)") + } + let flavorCount = selectedFlavorProducts.count + for flavor in selectedFlavorProducts { + choices.append(flavorCount > 1 ? "Meio \(flavor.name)" : flavor.name) + } + return choices + } + private var addButtonTitle: String { if canConfirm == false { return "Selecione as opções" @@ -189,11 +218,20 @@ struct PizzaProductDetailSheet: View { var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { - AsyncStoreImage(imageURL: representativeImage) + screenHeader + Rectangle() + .fill(AppColors.brandSoft) + .frame(maxWidth: .infinity) .frame(height: 220) + .overlay( + Image("placeholder-pizza") + .resizable() + .scaledToFill() + .clipped() + ) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) - Text("Pizza de varios sabores") + Text("Escolha seu sabor") .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) @@ -206,18 +244,9 @@ struct PizzaProductDetailSheet: View { .foregroundStyle(AppColors.primary) stepSizes - - if selectedSizeId != nil { - stepDoughs - } - - if isDoughReady { - stepCrusts - } - - if canShowFlavors { - stepFlavors - } + if selectedSizeId != nil { stepDoughs } + if isDoughReady { stepCrusts } + if canShowFlavors { stepFlavors } } .padding(20) .padding(.bottom, 90) @@ -262,9 +291,10 @@ struct PizzaProductDetailSheet: View { id: cartItemId, productId: selectedFlavorProducts.first?.id ?? category.id, storeId: storeId, - name: "Pizza de varios sabores", + name: "Escolha seu sabor", imageURL: representativeImage, details: selectedDetailsText, + choices: pizzaChoices.isEmpty ? nil : pizzaChoices, addons: selectedAddonsPayload, quantity: quantity, unitPrice: unitPrice @@ -291,12 +321,8 @@ struct PizzaProductDetailSheet: View { } } .background(AppColors.backgroundLight.ignoresSafeArea()) - .navigationTitle("Monte sua pizza") - .appInlineNavigationTitle() - .appTopBarTrailingToolbar { - Button("Fechar") { dismiss() } - .foregroundStyle(AppColors.primary) - } + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .onAppear { applyAutoSelections() let existing = currentQuantityForItemId(cartItemId) @@ -314,4 +340,25 @@ struct PizzaProductDetailSheet: View { } } + private var screenHeader: some View { + ZStack { + Text("Monte sua pizza") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift index 8af117d..08c6e22 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift @@ -72,6 +72,7 @@ struct ProductDetailSheet: View { var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { + screenHeader AsyncStoreImage(imageURL: imageURL) .frame(height: 220) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) @@ -221,12 +222,8 @@ struct ProductDetailSheet: View { .background(.ultraThinMaterial) } .background(AppColors.backgroundLight.ignoresSafeArea()) - .navigationTitle("Detalhes") - .appInlineNavigationTitle() - .appTopBarTrailingToolbar { - Button("Fechar") { dismiss() } - .foregroundStyle(AppColors.primary) - } + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .onAppear { let existing = currentQuantityForItemId(cartItemId) quantity = existing > 0 ? existing : 1 @@ -246,6 +243,27 @@ struct ProductDetailSheet: View { } } + private var screenHeader: some View { + ZStack { + Text("Detalhes") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private func formatCurrency(_ value: Double) -> String { String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index 495ddc8..148fbc3 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -14,6 +14,7 @@ struct ProfileView: View { @State var openAddressesOnboarding = false @State var onboardingMessage: String? = nil @State var showLogoutAlert = false + @State private var openOrders = false let tabBarClearance: CGFloat = 120 var body: some View { @@ -28,7 +29,7 @@ struct ProfileView: View { VStack(spacing: 14) { NavigationLink { - OrdersView() + OrdersView(appState: $appState) } label: { ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos") } @@ -41,12 +42,12 @@ struct ProfileView: View { } .buttonStyle(.plain) -// NavigationLink { -// Text("Meus Cartões") -// } label: { -// ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões") -// } -// .buttonStyle(.plain) + NavigationLink { + SavedCardsView(appState: $appState) + } label: { + ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões") + } + .buttonStyle(.plain) NavigationLink { MyReviewsView() @@ -55,12 +56,14 @@ struct ProfileView: View { } .buttonStyle(.plain) -// NavigationLink { -// Text("Cupons de Desconto") -// } label: { -// ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO") -// } -// .buttonStyle(.plain) + if appState.featureFlags.isEnabled("at.cupons") { + NavigationLink { + Text("Cupons de Desconto") + } label: { + ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO") + } + .buttonStyle(.plain) + } // NavigationLink { // Text("Ajuda") @@ -120,6 +123,15 @@ struct ProfileView: View { AddressesView(message: onboardingMessage, appState: $appState) } } + .navigationDestination(isPresented: $openOrders) { + OrdersView(appState: $appState) + } + .onChange(of: appState.shouldNavigateToOrders) { _, val in + if val { + appState.shouldNavigateToOrders = false + openOrders = true + } + } } private var header: some View { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift index 54d9725..f14094b 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift @@ -24,6 +24,8 @@ struct MyReviewsView: View { @State var isLoading = false @State var loadError: String? = nil + @Environment(\.dismiss) var dismiss + init(initialOrder: ReviewDraft? = nil) { self.initialOrder = initialOrder } @@ -31,6 +33,7 @@ struct MyReviewsView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 12) { + screenHeader if isLoading && reviews.isEmpty && pendingReviews.isEmpty { ProgressView() .frame(maxWidth: .infinity) @@ -66,8 +69,8 @@ struct MyReviewsView: View { .padding(.bottom, UIDevice.bottomNotch + 30) } .background(AppColors.backgroundLight) - .navigationTitle("Minhas Avaliações") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .navigationDestination(item: $selectedDraft) { draft in OrderReviewView(draft: draft) { Task { await loadReviewsFromBackend(forceRefresh: true) } @@ -86,6 +89,27 @@ struct MyReviewsView: View { } } + private var screenHeader: some View { + ZStack { + Text("Minhas Avaliações") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private var emptyState: some View { VStack(spacing: 8) { Text("Você não tem avaliações nem pendências no momento.") @@ -434,6 +458,7 @@ struct OrderReviewView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 24) { + screenHeader topSection orderItemsSection if existingReview != nil { @@ -450,8 +475,8 @@ struct OrderReviewView: View { .padding(.bottom, UIDevice.bottomNotch + 140) } .background(AppColors.backgroundLight) - .navigationTitle("Avaliar Pedido") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .onAppear { if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) { existingReview = cachedReview @@ -488,6 +513,27 @@ struct OrderReviewView: View { } } + private var screenHeader: some View { + ZStack { + Text("Avaliar Pedido") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private var newReviewContent: some View { VStack(alignment: .leading, spacing: 24) { starsSection @@ -802,6 +848,7 @@ struct OrderReviewView: View { .frame(height: 110) .padding(.horizontal, 8) .padding(.vertical, 8) + .foregroundStyle(AppColors.textPrimary) .scrollContentBackground(.hidden) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/SavedCardsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/SavedCardsView.swift new file mode 100644 index 0000000..f5c6c64 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/SavedCardsView.swift @@ -0,0 +1,244 @@ +import SwiftUI + +struct SavedCardsView: View { + @Binding var appState: AppState + @Environment(\.dismiss) var dismiss + + @State private var cards: [SavedCard] = [] + @State private var isLoading = false + @State private var errorMessage: String? = nil + @State private var openSwipeRowId: String? = nil + @State private var deletingCardId: String? = nil + @State private var showAddCard = false + + private var canDelete: Bool { cards.count > 1 } + private let tabBarClearance: CGFloat = 96 + + var body: some View { + ZStack { + AppColors.backgroundLight.ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + screenHeader + + VStack(spacing: 12) { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 32) + } else if let errorMessage { + Text(errorMessage) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + .padding(.top, 32) + } else if cards.isEmpty { + Text("Nenhum cartão cadastrado") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 32) + } else { + ForEach(cards) { card in + if canDelete { + SwipeToDeleteAddressRow( + rowId: card.id, + openRowId: $openSwipeRowId, + isDeleting: deletingCardId == card.id, + onDelete: { deleteCard(card) } + ) { + cardRow(card) + .appContentShape(Rectangle()) + .onTapGesture { + if openSwipeRowId == card.id { openSwipeRowId = nil } + } + } + .id(card.id) + .opacity(deletingCardId == card.id ? 0.6 : 1.0) + .disabled(deletingCardId != nil) + } else { + cardRow(card) + } + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, tabBarClearance + 20) + } + + VStack { + Spacer() + addCardButton + .padding(.bottom, tabBarClearance) + } + } + .navigationBarBackButtonHidden(true) + .appHiddenNavigationBar() + .sheet(isPresented: $showAddCard) { + NavigationStack { + AddCardFormView(appState: appState, isFirstCard: cards.isEmpty) { newCard in + cards.append(newCard) + } + } + } + .task { await loadCards() } + } + + private var screenHeader: some View { + ZStack { + Text("Meus Cartões") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + + private var addCardButton: some View { + ZStack(alignment: .bottom) { + Rectangle() + .fill(AppColors.backgroundLight) + .frame(height: 136) + Button(action: { showAddCard = true }) { + HStack(spacing: 12) { + Image(systemName: "creditcard.fill") + .font(.system(size: 20)) + Text("Adicionar novo cartão") + .font(AppTypography.heading3) + } + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + .padding(.bottom, 14) + } + } + + private func cardRow(_ card: SavedCard) -> some View { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AppColors.backgroundLight) + .frame(width: 52, height: 52) + if let logo = brandLogoName(for: card.brand) { + Image(logo) + .resizable() + .scaledToFit() + .frame(width: 36, height: 24) + } else { + Image(systemName: "creditcard.fill") + .font(.system(size: 22)) + .foregroundStyle(AppColors.textMuted) + } + } + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(card.displayLabel) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + if card.isDefault { + Text("Principal") + .font(AppTypography.caption) + .foregroundStyle(AppColors.primary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(AppColors.brandSoft) + .clipShape(Capsule()) + } + } + Text("Vence \(card.expiryLabel)") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + if !canDelete { + Text("Ao menos um cartão deve permanecer") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted.opacity(0.7)) + } + } + + Spacer(minLength: 0) + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private func brandLogoName(for brand: String?) -> String? { + switch brand?.lowercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: .diacriticInsensitive, locale: .current) { + case "visa": return "visacard_logo" + case "mastercard", "master": return "mastercard_logo" + case "amex", "american express", "americanexpress": return "amexcard_logo" + case "hipercard": return "hipercard_logo" + case "alelo": return "alelocard_logo" + case "sodexo": return "sodexo_logo" + default: return nil + } + } + + @MainActor + private func loadCards() async { + guard isLoading == false else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let response = try await ApiService().listCards() + if response.error == false { + cards = response.result ?? [] + } else { + errorMessage = response.message ?? "Erro ao carregar cartões." + } + } catch { + errorMessage = "Não foi possível carregar seus cartões." + } + } + + private func deleteCard(_ card: SavedCard) { + guard canDelete, deletingCardId == nil else { return } + deletingCardId = card.id + openSwipeRowId = nil + Task { + do { + let response = try await ApiService().deleteCard(cardId: card.id) + if response.error == false { + cards.removeAll { $0.id == card.id } + } else { + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível excluir o cartão.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + } catch { + SnackbarCenter.shared.show( + title: "Erro ao excluir cartão.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + deletingCardId = nil + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift index c07efd1..c303c68 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift @@ -37,7 +37,7 @@ struct ScrollOffsetReader: View { ) .onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in if baseline == nil { baseline = minY } - let offset = max(0, (baseline ?? 0) - minY) + let offset = (baseline ?? 0) - minY if abs(offsetY - offset) > 0.5 { offsetY = offset } @@ -47,6 +47,7 @@ struct ScrollOffsetReader: View { struct AsyncStoreImage: View { let imageURL: String? + var fallbackImageName: String = "placeholder-product" var body: some View { CachedRemoteImage(imageURL: imageURL) { @@ -58,7 +59,7 @@ struct AsyncStoreImage: View { } private var fallback: some View { - Image("placeholder-product") + Image(fallbackImageName) .resizable() .scaledToFill() } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Components.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Components.swift index 41b03ee..5924dbc 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Components.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Components.swift @@ -26,16 +26,19 @@ extension StoreDetailView { var heroSection: some View { ZStack(alignment: .top) { AsyncStoreImage(imageURL: resolvedURL(storeCoverURL)) - .frame(height: topSectionHeight) - .overlay( - LinearGradient( - colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)], - startPoint: .top, - endPoint: .bottom - ) - ) + .frame(height: topSectionHeight + stretchAmount) + .offset(y: -stretchAmount) .ignoresSafeArea(.container, edges: .top) + LinearGradient( + colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: topSectionHeight) + .ignoresSafeArea(.container, edges: .top) + .allowsHitTesting(false) + VStack(spacing: 0) { HStack { heroIconButton(icon: "chevron.left") { @@ -266,9 +269,17 @@ extension StoreDetailView { Spacer() ZStack(alignment: .bottomTrailing) { - AsyncStoreImage(imageURL: resolvedURL(item.imageURL)) - .frame(width: 92, height: 92) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + Group { + if item.isPizzaSummary { + Image("placeholder-pizza") + .resizable() + .scaledToFill() + } else { + AsyncStoreImage(imageURL: resolvedURL(item.imageURL)) + } + } + .frame(width: 92, height: 92) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) Button { guard isStoreOpen else { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift index 1cd504e..3de4194 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -286,7 +286,7 @@ extension StoreDetailView { StoreCatalogListItem( id: "\(category.id)::pizza-summary", product: first, - title: "Pizza de varios sabores", + title: "Escolha seu sabor", description: "Escolha o tamanho da sua fome", imageURL: representativeImage ?? first.image, isPizzaSummary: true, diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift index 5b96dd9..5e6761a 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift @@ -38,6 +38,7 @@ struct StoreDetailView: View { @State var scrollOffset: CGFloat = 0 var isCategoryTabsPinned: Bool { scrollOffset >= topSectionHeight } + var stretchAmount: CGFloat { max(0, -scrollOffset) } let cardTopInset: CGFloat = 180 let summaryCardBaseHeight: CGFloat = 212 @@ -45,25 +46,39 @@ struct StoreDetailView: View { let coverVisibleUntilY: CGFloat = 253 let storeLogoSize: CGFloat = 84 + var safeAreaTop: CGFloat { + #if canImport(UIKit) + return UIDevice.appSafeAreaTop + #else + return 0 + #endif + } + var body: some View { ScrollViewReader { proxy in - ScrollView(showsIndicators: false) { - ScrollOffsetReader(offsetY: $scrollOffset) - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - topSection - Section { + ZStack(alignment: .top) { + ScrollView(showsIndicators: false) { + ScrollOffsetReader(offsetY: $scrollOffset) + LazyVStack(spacing: 0) { + topSection + categoryTabs(proxy: proxy, isPinned: false) + .opacity(isCategoryTabsPinned ? 0 : 1) sectionedProducts - } header: { - categoryTabs(proxy: proxy, isPinned: isCategoryTabsPinned) } } - } - .coordinateSpace(name: StoreDetailScrollCoordinateSpace.name) - .refreshable { - await loadStoreData(forceRefresh: true) + .coordinateSpace(name: StoreDetailScrollCoordinateSpace.name) + .refreshable { + await loadStoreData(forceRefresh: true) + } + .ignoresSafeArea(edges: .top) + .background(AppColors.backgroundLight) + + categoryTabs(proxy: proxy, isPinned: true) + .opacity(isCategoryTabsPinned ? 1 : 0) + .allowsHitTesting(isCategoryTabsPinned) + .zIndex(10) } .ignoresSafeArea(edges: .top) - .background(AppColors.backgroundLight) .saturation(isStoreOpen ? 1 : 0) } .navigationBarBackButtonHidden(true) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift index b04cb9e..59853ee 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift @@ -11,6 +11,7 @@ struct UserProfileView: View { @State var name: String = "" @State var email: String = "" @State var phone: String = "" + @State var cpf: String = "" @State var profilePicture: String = "" @State var isSaving = false @@ -21,6 +22,7 @@ struct UserProfileView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 22) { + screenHeader avatarSection formSection saveButton @@ -30,8 +32,8 @@ struct UserProfileView: View { .padding(.bottom, UIDevice.bottomNotch + 24) } .background(AppColors.backgroundLight) - .navigationTitle("Meu Perfil") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .onAppear { hydrateFromAppState() } @@ -42,6 +44,27 @@ struct UserProfileView: View { #endif } + private var screenHeader: some View { + ZStack { + Text("Meu Perfil") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private var avatarSection: some View { VStack(spacing: 12) { Circle() @@ -97,6 +120,14 @@ struct UserProfileView: View { phone = masked } } + + textFieldSection(title: "CPF", placeholder: "000.000.000-00", text: $cpf) + .keyboardType(.numberPad) + .onChange(of: cpf) { _, newValue in + let digits = newValue.filter(\.isNumber) + let masked = formatCPF(digits) + if masked != newValue { cpf = masked } + } .appNoAutoCap() } .padding(16) @@ -148,6 +179,15 @@ struct UserProfileView: View { email = appState.profile.email phone = formatPhoneForDisplay(appState.profile.phone) profilePicture = appState.profile.profilePicture + cpf = formatCPF(appState.profile.cpf.filter(\.isNumber)) + } + + private func formatCPF(_ digits: String) -> String { + let d = String(digits.prefix(11)) + if d.count <= 3 { return d } + if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" } + if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" } + return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))" } @MainActor @@ -158,6 +198,7 @@ struct UserProfileView: View { let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedPhone = normalizePhoneNumberForAPI(phone) let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines) + let newPhoto = cleanPhoto.hasPrefix("data:") ? cleanPhoto : nil isSaving = true defer { isSaving = false } @@ -167,7 +208,7 @@ struct UserProfileView: View { name: cleanName, email: cleanEmail, phoneNumber: normalizedPhone, - profilePicture: cleanPhoto.isEmpty ? nil : cleanPhoto + profilePicture: newPhoto ) if response.error { @@ -180,12 +221,38 @@ struct UserProfileView: View { return } - let customer = response.result - appState.profile.id = customer?.id ?? appState.profile.id - appState.profile.name = customer?.name ?? cleanName - appState.profile.email = customer?.email ?? cleanEmail - appState.profile.phone = customer?.phoneNumber ?? normalizedPhone - appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto + appState.profile.name = cleanName + appState.profile.email = cleanEmail + appState.profile.phone = normalizedPhone + if let pictureUrl = response.profilePictureUrl, pictureUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + appState.profile.profilePicture = ImageSourceResolver.resolve(pictureUrl) ?? pictureUrl + } else if cleanPhoto.isEmpty == false { + appState.profile.profilePicture = cleanPhoto + } + + let cleanCpf = cpf.filter(\.isNumber) + if cleanCpf.count == 11 { + guard isValidCPF(cleanCpf) else { + SnackbarCenter.shared.show( + title: "CPF inválido. Verifique e tente novamente.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + return + } + let cpfResponse = try await ApiService().updateProfileCpf(cpf: cleanCpf) + if cpfResponse.error { + SnackbarCenter.shared.show( + title: cpfResponse.message ?? "Não foi possível atualizar o CPF.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + return + } + appState.profile.cpf = cleanCpf + } SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email) ) @@ -241,14 +308,29 @@ struct UserProfileView: View { } } + private func isValidCPF(_ digits: String) -> Bool { + guard digits.count == 11, digits.unicodeScalars.allSatisfy({ CharacterSet.decimalDigits.contains($0) }) else { return false } + guard Set(digits).count > 1 else { return false } + func checkDigit(_ d: String, _ length: Int) -> Bool { + let sum = d.prefix(length).enumerated().reduce(0) { acc, pair in + acc + (Int(String(pair.element)) ?? 0) * (length + 1 - pair.offset) + } + let rem = (sum * 10) % 11 + let expected = rem == 10 ? 0 : rem + return Int(String(d[d.index(d.startIndex, offsetBy: length)])) == expected + } + return checkDigit(digits, 9) && checkDigit(digits, 10) + } + #if os(iOS) @MainActor private func applySelectedPhoto(_ item: PhotosPickerItem?) async { guard let item else { return } do { guard let data = try await item.loadTransferable(type: Data.self) else { return } - guard let image = UIImage(data: data), - let jpegData = image.jpegData(compressionQuality: 0.82) else { return } + guard let image = UIImage(data: data) else { return } + let resized = resizedIfNeeded(image, maxSide: 600) + guard let jpegData = resized.jpegData(compressionQuality: 0.8) else { return } profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())" } catch { SnackbarCenter.shared.show( @@ -259,5 +341,17 @@ struct UserProfileView: View { ) } } + + private func resizedIfNeeded(_ image: UIImage, maxSide: CGFloat) -> UIImage { + let w = image.size.width + let h = image.size.height + guard w > maxSide || h > maxSide else { return image } + let scale = maxSide / max(w, h) + let newSize = CGSize(width: w * scale, height: h * scale) + let renderer = UIGraphicsImageRenderer(size: newSize) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + } #endif } diff --git a/sim-home.png b/sim-home.png deleted file mode 100644 index 742d33f..0000000 Binary files a/sim-home.png and /dev/null differ