From 0f5ad4c268c5a2c82c82dc81145c24d470c87773 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sun, 15 Feb 2026 08:18:06 -0300 Subject: [PATCH] Payments --- API_Mobile_App.md | 282 ++++++- API_Store_External.md | 283 ++++++- .../Components/CachedRemoteImage.swift | 162 ++++ .../PediFoods/Components/StoreCard.swift | 58 +- .../Sources/PediFoods/ContentView.swift | 39 +- .../PediFoods/DesignSystem/DesignSystem.swift | 16 +- .../PediFoods/Resources/Localizable.xcstrings | 179 +++++ .../Services/ApiCustomerPayloadModels.swift | 51 ++ .../PediFoods/Services/ApiModels.swift | 207 +++-- .../PediFoods/Services/ApiOrderModels.swift | 145 ++++ .../PediFoods/Services/ApiPizzaModels.swift | 80 ++ .../PediFoods/Services/ApiService.swift | 33 + .../Sources/PediFoods/Services/AppCache.swift | 145 ++++ .../Services/SessionStateStore.swift | 104 +++ .../Sources/PediFoods/State/AppState.swift | 43 ++ .../PediFoods/Views/Auth/OtpView.swift | 10 + .../PediFoods/Views/Main/CartView.swift | 212 ++++-- .../PediFoods/Views/Main/CheckoutTypes.swift | 41 + .../Views/Main/CheckoutView+Logic.swift | 335 +++++++++ .../PediFoods/Views/Main/CheckoutView.swift | 711 ++++++++++++++++++ .../PediFoods/Views/Main/HomeView.swift | 98 ++- .../PediFoods/Views/Main/MainTabView.swift | 21 +- .../Views/Main/PizzaFlavorAddonsSheet.swift | 106 +++ .../Main/PizzaProductDetailSheet+Flow.swift | 229 ++++++ .../Views/Main/PizzaProductDetailSheet.swift | 319 ++++++++ .../Views/Main/ProductDetailSheet.swift | 135 +++- .../PediFoods/Views/Main/ProfileView.swift | 2 + .../Views/Main/StoreDetailPizzaSupport.swift | 32 + .../Views/Main/StoreDetailSupport.swift | 18 +- .../Views/Main/StoreDetailView+Logic.swift | 150 +++- .../Views/Main/StoreDetailView.swift | 153 +++- reference_img/IMG_0442.PNG | Bin 0 -> 465720 bytes reference_img/IMG_0443.PNG | Bin 0 -> 413919 bytes reference_img/payment_card.png | Bin 0 -> 142821 bytes reference_img/payment_pix.png | Bin 0 -> 197107 bytes 35 files changed, 4090 insertions(+), 309 deletions(-) create mode 100644 pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift create mode 100644 pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift create mode 100644 pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift create mode 100644 pedi-foods/Sources/PediFoods/Services/ApiPizzaModels.swift create mode 100644 pedi-foods/Sources/PediFoods/Services/AppCache.swift create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/CheckoutTypes.swift create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/StoreDetailPizzaSupport.swift create mode 100644 reference_img/IMG_0442.PNG create mode 100644 reference_img/IMG_0443.PNG create mode 100644 reference_img/payment_card.png create mode 100644 reference_img/payment_pix.png diff --git a/API_Mobile_App.md b/API_Mobile_App.md index 133ea5c..062e4f9 100644 --- a/API_Mobile_App.md +++ b/API_Mobile_App.md @@ -75,7 +75,8 @@ Used when interacting with a specific store (Catalog, Checkout). ``` **Fluxo:** -- Passo 1: enviar `email` + `phoneNumber` para receber código por email.`. +- Passo 1: enviar `email` + `phoneNumber` para receber código por email. +- Passo 2: enviar `email` + `phoneNumber` e o OTP no header `Authorization: Bearer `. **Response:** @@ -115,14 +116,16 @@ Used when interacting with a specific store (Catalog, Checkout). "phoneNumber": "+5511999999999", "favorites": ["store_abc...", "store_xyz..."], "address_book": [], - "behavioral_stats": { - "total_orders": 5, - "avg_ticket_size": 45.00 - } + "verified": true, + "biometricsEnabled": false, + "createdAt": "2026-01-21T22:37:56.472Z", + "updatedAt": "2026-02-13T19:00:00.000Z" } } ``` +**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` @@ -137,14 +140,56 @@ Used when interacting with a specific store (Catalog, Checkout). { "name": "Daniel A. Loverde", // Fields to update "phoneNumber": "+5511988888888", - "biometricsEnabled": true + "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] + } + ] } ``` +**Regra de coordenadas (`address_book.lat_long`):** + +- 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. + --- ## 🏠 App Home Screen +### List Categories +**GET** `/api/public/categories` + +**Headers:** +- `Accept: application/json` + +**Response:** + +```json +{ + "error": false, + "result": [ + { "id": "all", "name": "Todas", "icon": "🏪" }, + { "id": "lanches", "name": "Lanches", "icon": "🍔" }, + { "id": "pizza", "name": "Pizza", "icon": "🍕" } + ] +} +``` + ### List Stores (Smart Listing) **GET** `/api/app/stores` @@ -244,7 +289,7 @@ Lists stores based on user location, filtered by distance. ### 2. Full Store Info **GET** `/api/store/:storeId/info` -Returns detailed info including operating hours and accepted payment methods. +Retorna versão pública para app (sem dados administrativos/financeiros da loja). **Response:** @@ -254,30 +299,29 @@ Returns detailed info including operating hours and accepted payment methods. "code": "STORE_INFO_RETRIEVED", "result": { "isOpen": true, + "statusLabel": "Aberto", + "fantasyName": "Mc Donalds", + "specialty": "Lanches", + "minOrder": 15, + "deliveryTime": "30-45 min", + "logo": "https://...", + "cover": "https://...", "paymentMethods": { "paymentOnDelivery": true, "paymentOnPickup": true, "acceptCash": true, - "acceptPix": true, - "acceptCreditCard": true, // Resumo Geral - "acceptDebitCard": true, // Resumo Geral - "brands": { - "credit": { - "visa": true, - "master": true, - "elo": true, - "amex": false, - "hipercard": false - }, - "debit": { - "visa": true, - "master": true, - "elo": true - } - } + "acceptPix": true }, - "openingHours": { ... }, - "address": { ... } + "openingHours": { "...": "..." }, + "address": { + "street": "Rua X", + "neighborhood": "Centro", + "city": "Campinas", + "state": "SP", + "zipcode": "13000-000", + "latitude": -22.9, + "longitude": -47.0 + } } } ``` @@ -285,7 +329,7 @@ Returns detailed info including operating hours and accepted payment methods. ### 3. Product Catalog **GET** `/api/store/:storeId/catalog` -Returns categories with their respective products. +Retorna categorias com produtos, incluindo diferenciação explícita de pizza. **Response:** @@ -294,15 +338,45 @@ Returns categories with their respective products. "error": false, "result": [ { - "id": "cat_1...", - "name": "Burgers", + "id": "cat_lanches", + "name": "Lanches", + "isPizzaCategory": false, "products": [ { - "id": "prod_1...", - "name": "Big Mac", - "price": 25.90, - "image": "https://...", - "addonGroups": [...] + "id": "prod_xburger", + "type": "prepared", + "name": "X-Burger", + "description": "Pão, carne e queijo", + "image": "/uploads/products/xburger.jpg", + "price": "25.00", + "addons": "yes", + "addonGroups": [] + } + ] + }, + { + "id": "cat_pizzas", + "name": "Pizzas", + "isPizzaCategory": true, + "pizzaConfig": { + "sizes": [{ "id": "broto", "name": "Broto", "slices": 4, "maxFlavors": 1 }], + "doughs": [{ "id": "tradicional", "name": "Tradicional", "active": true }], + "crusts": [{ "id": "catupiry", "name": "Catupiry", "active": true, "priceModifier": "10.00" }] + }, + "products": [ + { + "id": "prod_pizza_calabresa", + "type": "pizza", + "name": "Pizza Calabresa", + "description": "Molho, mussarela e calabresa", + "image": "/uploads/products/pizza-calabresa.jpg", + "price": "0.00", + "pizzaPrices": { + "broto": "35.00", + "grande": "55.00" + }, + "addons": "yes", + "addonGroups": [] } ] } @@ -310,7 +384,59 @@ Returns categories with their respective products. } ``` -### 4. Create Order (Checkout) +**Como identificar pizza no app:** +- Produto pizza: `products[].type === "pizza"`. +- Configuração de montagem/preço por tamanho: `category.isPizzaCategory === true` + `category.pizzaConfig` + `product.pizzaPrices`. + +### 4. Validate Delivery Address (Checkout) +**POST** `/api/store/:storeId/delivery/validate-address` + +Use este endpoint ao trocar endereço na tela de pagamento para validar cobertura antes de finalizar. + +**Body:** + +```json +{ + "address": { + "street": "Avenida das Andorinhas", + "number": "477", + "neighborhood": "Jardim Andorinhas", + "city": "Campinas", + "state": "SP", + "zip": "13101-400", + "lat": -22.918, + "lng": -47.01813 + } +} +``` + +**Resposta Exemplo:** + +```json +{ + "error": false, + "code": "DELIVERY_ADDRESS_VALIDATED", + "result": { + "deliveryAllowed": true, + "reasonCode": "DELIVERY_ALLOWED", + "reasonMessage": "Endereço válido para entrega", + "deliveryMode": "KM", + "distance": 1.3, + "deliveryFee": 8, + "deliveryTime": "35 min", + "sameCity": false, + "matchedKmRange": { "from": 1, "to": 2, "price": 8, "time": 35 }, + "matchedNeighborhood": null + } +} +``` + +**Regras de validação:** +- `KM`: valida distância até o limite da última faixa + tolerância de `0.5 km`. +- `NEIGHBORHOOD`: reprova se cidade do endereço for diferente da cidade da loja. +- `NEIGHBORHOOD`: aprova se bairro configurado bater, ou se for mesma cidade (fallback). + +### 5. Create Order (Checkout) **POST** `/api/store/:storeId/orders` **Body:** @@ -330,9 +456,17 @@ Returns categories with their respective products. "items": [ { "productId": "prod_123", + "name": "X-Burger", "qty": 2, "price": 25.00, - "addons": [] + "addons": [ + { + "addonId": "addon_egg", + "name": "Ovo", + "price": 2.50, + "qty": 2 + } + ] } ], "total": 50.00, @@ -346,6 +480,12 @@ Returns categories with their respective products. } ``` +**Regras para `items[].addons`:** + +- `qty` do adicional é aceito no backend (ex.: `2 ovos`). +- Se `qty` não for enviado ou vier inválido, a API assume `1`. +- `qty` mínimo efetivo é `1`. + **Response:** ```json @@ -354,8 +494,74 @@ Returns categories with their respective products. "code": "ORDER_CREATED", "result": { "id": "ord_987...", - "status": "created", - "paymentPayload": "https://www.asaas.com/i/..." // Link para pagamento (Pix/Boleto) + "shortId": "1234", + "status": "PAYMENT_PENDING", + "paymentStatus": "PENDING", + "paymentMethod": "PIX", + "paymentPayload": { + "copyPaste": "000201010212...", + "qrCodeImage": "iVBORw0KGgoAAAANSUhEUgAA...", + "expirationDate": "2026-02-14T22:30:00.000Z" + }, + "payment": { + "method": "PIX", + "status": "PENDING", + "pix": { + "copyPaste": "000201010212...", + "qrCodeImage": "iVBORw0KGgoAAAANSUhEUgAA...", + "expirationDate": "2026-02-14T22:30:00.000Z" + } + } } } ``` + +**Observação de segurança:** o Atomenta não retorna mais links de checkout/fatura do Asaas no payload de criação do pedido. + +### 6. Realtime de Status do Pedido (Socket.IO) +Use este canal para acompanhar mudança de status do pedido em tempo real (sem depender de push notification). + +**Socket Endpoint:** +- `ws://:/socket.io/` (em produção, usar `wss://` se houver TLS no balanceador) + +**Auth no handshake:** +- Enviar o JWT do customer em `auth.token` (mesmo token do `Authorization: Bearer ...` das APIs HTTP). + +**Exemplo de conexão (conceitual):** + +```json +{ + "auth": { + "token": "Bearer " + } +} +``` + +**Eventos recebidos pelo app:** +- `order_update`: enviado quando o pedido muda (pagamento aprovado, aceito pela loja, saiu para entrega, concluído, cancelado). + +**Payload típico de `order_update`:** + +```json +{ + "id": "ord_987...", + "shortId": "1234", + "storeId": "store_123...", + "userId": "cust_uuid...", + "status": "CONFIRMED", + "paymentStatus": "CONFIRMED", + "updatedAt": "2026-02-15T20:10:00.000Z" +} +``` + +### 7. Fallback oficial (Polling) +Se o socket cair, o app deve continuar consultando o status até estado final. + +**Endpoint:** +- `GET /api/public/orders/:orderId` + +**Sugestão de estratégia:** +- primeiros 60s: a cada `3s` +- próximos 2min: a cada `5s` +- depois: a cada `10s` +- parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela. diff --git a/API_Store_External.md b/API_Store_External.md index db13983..a7c8862 100644 --- a/API_Store_External.md +++ b/API_Store_External.md @@ -22,7 +22,6 @@ Todas as chamadas devem usar autenticação híbrida. **GET** `/api/store/:storeId/info` - Retorna a configuração completa da loja, incluindo dados bancários, horários e flags de pagamento. **Resposta Exemplo:** - ```json { "error": false, @@ -73,7 +72,6 @@ Todas as chamadas devem usar autenticação híbrida. **GET** `/api/store/:storeId/dashboard` **Resposta Detalhada (100% Parity):** - ```json { "error": false, @@ -104,9 +102,84 @@ Todas as chamadas devem usar autenticação híbrida. ## 🍴 Catálogo e Menu (Complex Payloads) -### 4. Categorias de Pizza -**POST** `/api/store/:storeId/catalog/categories` +### 3.1 Catálogo Consolidado (App) +**GET** `/api/store/:storeId/catalog` +**Resposta:** +```json +{ + "error": false, + "code": "STORE_CATALOG_RETRIEVED", + "result": [ + { + "id": "cat_lanches", + "name": "Lanches", + "isPizzaCategory": false, + "products": [ + { + "id": "prod_xburger", + "type": "prepared", + "name": "X-Burger", + "description": "Pão, carne e queijo", + "image": "/uploads/products/xburger.jpg", + "price": "25.00", + "addons": "yes", + "addonGroups": [] + } + ] + }, + { + "id": "cat_pizzas", + "name": "Pizzas", + "isPizzaCategory": true, + "pizzaConfig": { + "sizes": [{ "id": "broto", "name": "Broto", "slices": 4, "maxFlavors": 1 }], + "doughs": [{ "id": "tradicional", "name": "Tradicional", "active": true }], + "crusts": [{ "id": "catupiry", "name": "Catupiry", "active": true, "priceModifier": "10.00" }] + }, + "products": [ + { + "id": "prod_pizza_calabresa", + "type": "pizza", + "name": "Pizza Calabresa", + "description": "Molho, mussarela e calabresa", + "image": "/uploads/products/pizza-calabresa.jpg", + "price": "0.00", + "pizzaPrices": { + "broto": "35.00", + "grande": "55.00" + }, + "addons": "yes", + "addonGroups": [] + } + ] + } + ] +} +``` + +**Regra de identificação de pizza:** +- Item pizza: `products[].type === "pizza"`. +- Configuração de montagem vem em `category.pizzaConfig`. +- Preço por tamanho vem em `product.pizzaPrices`. + +### 4. Listar Categorias do Catálogo +**GET** `/api/store/:storeId/catalog/categories` + +**Resposta:** +```json +{ + "error": false, + "code": "CATEGORIES_RETRIEVED", + "result": [ + { "id": "cat_1", "name": "Pizzas", "isActive": true } + ], + "message": "Categorias recuperadas com sucesso" +} +``` + +### 5. Criar Categoria (Ex.: Pizza) +**POST** `/api/store/:storeId/catalog/categories` ```json { "name": "Pizzas Salgadas", @@ -122,9 +195,14 @@ Todas as chamadas devem usar autenticação híbrida. } ``` -### 5. Produtos Tipo Combo ou Pizza -**POST** `/api/store/:storeId/catalog/products` +### 6. Atualizar Categoria +**PUT** `/api/store/:storeId/catalog/categories/:id` +### 7. Excluir Categoria +**DELETE** `/api/store/:storeId/catalog/categories/:id` + +### 8. Produtos Tipo Combo ou Pizza +**POST** `/api/store/:storeId/catalog/products` ```json { "type": "pizza", @@ -148,11 +226,10 @@ Todas as chamadas devem usar autenticação híbrida. ## 📦 Histórico e Detalhes do Pedido -### 6. Histórico com Filtros +### 9. Histórico com Filtros **GET** `/api/store/:storeId/orders/history?start=2024-01-01&end=2024-01-31&q=Ana` **Objeto de Pedido Completo (result.list[0]):** - ```json { "id": "order_guid", @@ -181,7 +258,7 @@ Todas as chamadas devem usar autenticação híbrida. > O campo `confirmOtp` (8 dígitos) é o código que o motoboy deve utilizar para iniciar a confirmação. O `customerOtp` (4 dígitos do cliente) **NÃO** é retornado via API por questões de segurança. ``` -### 7. Detalhes de um Pedido Individual +### 10. Detalhes de um Pedido Individual **GET** `/api/store/:storeId/orders/:orderId` Retorna o objeto completo do pedido (parity 100% com o painel). @@ -190,7 +267,7 @@ Retorna o objeto completo do pedido (parity 100% com o painel). ## 💰 Financeiro Detalhado -### 7. Resumo de Performance e Gateway +### 11. Resumo de Performance e Gateway **GET** `/api/store/:storeId/financial/summary` **Resposta:** @@ -216,7 +293,7 @@ Retorna o objeto completo do pedido (parity 100% com o painel). ## ⭐ Reviews e Avaliações -### 8. Métricas e Respostas +### 12. Métricas e Respostas **GET** `/api/store/:storeId/reviews` **Resposta:** @@ -330,13 +407,58 @@ Endpoints utilizados pela página pública (CORS habilitado para domínios parce --- ## 🛒 Checkout e Criação de Pedido +### 10.0 Validar Endereço de Entrega (Checkout) +**POST** `/api/store/:storeId/delivery/validate-address` + +Endpoint para validar cobertura de entrega no checkout, quando o cliente troca o endereço antes do pagamento. + +**Payload:** +```json +{ + "address": { + "street": "Avenida das Andorinhas", + "number": "477", + "neighborhood": "Jardim Andorinhas", + "city": "Campinas", + "state": "SP", + "zip": "13101-400", + "lat": -22.918, + "lng": -47.01813 + } +} +``` + +**Resposta Exemplo:** +```json +{ + "error": false, + "code": "DELIVERY_ADDRESS_VALIDATED", + "result": { + "deliveryAllowed": true, + "reasonCode": "DELIVERY_ALLOWED", + "reasonMessage": "Endereço válido para entrega", + "deliveryMode": "KM", + "distance": 1.3, + "deliveryFee": 8, + "deliveryTime": "35 min", + "sameCity": false, + "matchedKmRange": { "from": 1, "to": 2, "price": 8, "time": 35 }, + "matchedNeighborhood": null + } +} +``` + +**Regras:** +- `KM`: aprova somente até o limite da última faixa + `0.5 km` de tolerância. +- `NEIGHBORHOOD`: reprova se a cidade do endereço for diferente da cidade da loja. +- `NEIGHBORHOOD`: aprova com bairro configurado ou com fallback de mesma cidade. + ### 10. Criar Novo Pedido (Public) **POST** `/api/public/orders` Este endpoint é utilizado pelo front-end de checkout (Pedi Foods) para criar o pedido no sistema da loja. **Payload:** - ```json { "storeId": "store_123456", @@ -367,23 +489,152 @@ Este endpoint é utilizado pelo front-end de checkout (Pedi Foods) para criar o "name": "Pizza", "price": 50.00, "qty": 1, - "image": "..." + "image": "...", + "addons": [ + { + "addonId": "addon_bacon", + "name": "Bacon", + "price": 4.00, + "qty": 2 + } + ] } ] } ``` -**Resposta Sucesso:** +**Regras para `items[].addons`:** +- `qty` do adicional é suportado (ex.: 2x bacon/ovo). +- Se `qty` não for enviado ou vier inválido, a API assume `1`. +- `qty` mínimo efetivo é `1`. +**Resposta Sucesso:** ```json { "error": false, "result": { "id": "order_guid", "shortId": "1234", - "status": "PENDING", + "status": "PAYMENT_PENDING", "paymentStatus": "PENDING", - "paymentPayload": "..." // Se PIX (Big String Base64 ou Copia e Cola) + "paymentMethod": "PIX", + "paymentPayload": { + "copyPaste": "000201010212...", + "qrCodeImage": "iVBORw0KGgoAAAANSUhEUgAA...", + "expirationDate": "2026-02-14T22:30:00.000Z" + }, + "payment": { + "method": "PIX", + "status": "PENDING", + "pix": { + "copyPaste": "000201010212...", + "qrCodeImage": "iVBORw0KGgoAAAANSUhEUgAA...", + "expirationDate": "2026-02-14T22:30:00.000Z" + } + } } } ``` + +**Observação de segurança:** o Atomenta não retorna links de checkout/fatura do Asaas no response de criação de pedido. + +### 10.1 Realtime de Status de Pedido (Socket.IO) +Para atualização em tempo real do checkout/app (sem depender de push), usar Socket.IO. + +**Socket endpoint:** +- `ws://:/socket.io/` (usar `wss://` em produção com TLS) + +**Auth no handshake:** +- Enviar JWT no campo `auth.token`. +- Exemplo: `Bearer `. + +**Evento para o cliente:** +- `order_update` + +**Payload típico do evento:** +```json +{ + "id": "order_guid", + "shortId": "1234", + "storeId": "store_123456", + "userId": "user_guid", + "status": "CONFIRMED", + "paymentStatus": "CONFIRMED", + "updatedAt": "2026-02-15T20:10:00.000Z" +} +``` + +### 10.2 Fallback de Status (Polling) +Caso não haja conexão socket, consultar status por HTTP até estado final. + +**Endpoint:** +- `GET /api/public/orders/:orderId` + +**Recomendação de intervalo:** +- 0-60s: `3s` +- 60-180s: `5s` +- acima de 180s: `10s` +- parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`). + +### 11. Listar Lojas (Public App) +**GET** `/api/public/stores` + +Endpoint utilizado pelo aplicativo Pedi Foods para listar todas as lojas disponíveis para o cliente. + +**Resposta Sucesso:** +```json +{ + "error": false, + "result": [ + { + "id": "company_guid", + "storeId": "store_123456", + "name": "Pizzaria do Arantes", + "logo": "https://...", + "cover": "https://...", + "category": "Restaurante", + "isOpen": true, + "statusLabel": "Aberto Agora", + "rating": 4.8, + "deliveryTime": "30-45 min", + "deliveryFee": 5.00 + } + ] +} +``` + +--- + +## 📝 Gestão de Contratos (Store) + +### 12. Verificar Status do Contrato +**GET** `/api/store/:storeId/contract/status` +Retorna o ID e o status do contrato mais recente da loja. + +**Resposta Exemplo:** +```json +{ + "error": false, + "result": { + "id": "contract_guid", + "status": "draft" // ou "signed" + } +} +``` + +### 13. Assinar Contrato +**POST** `/api/store/:storeId/contract/sign` +Assina o contrato draft atual da loja. + +**Payload:** +```json +{ + "method": "typed", + "typedName": "João Silva", // Obrigatório se method === "typed" + "signatureDataUrl": "data:image/png;base64,..." // Obrigatório se method === "draw" +} +``` + +### 14. Visualizar PDF do Contrato +**GET** `/api/store/contract/:contractId/pdf` +Retorna o stream do arquivo PDF do contrato (draft ou assinado). diff --git a/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift b/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift new file mode 100644 index 0000000..9dd9159 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift @@ -0,0 +1,162 @@ +import Foundation +import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +struct CachedRemoteImage: View { + let imageURL: String? + let ttl: TimeInterval + let placeholder: Placeholder + + @StateObject var loader = CachedRemoteImageLoader() + + init( + imageURL: String?, + ttl: TimeInterval = 6 * 60 * 60, + @ViewBuilder placeholder: () -> Placeholder + ) { + self.imageURL = imageURL + self.ttl = ttl + self.placeholder = placeholder() + } + + var body: some View { + Group { +#if canImport(UIKit) + if let uiImage = loader.uiImage { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + } else { + placeholder + } +#elseif canImport(AppKit) + if let nsImage = loader.nsImage { + Image(nsImage: nsImage) + .resizable() + .scaledToFill() + } else { + placeholder + } +#else + if let imageURL, + let url = URL(string: imageURL) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + placeholder + } + } + } else { + placeholder + } +#endif + } + .onAppear { + loader.load(imageURL: imageURL, ttl: ttl) + } + .onChange(of: imageURL) { _, newValue in + loader.load(imageURL: newValue, ttl: ttl) + } + } +} + +@MainActor +final class CachedRemoteImageLoader: ObservableObject { +#if canImport(UIKit) + @Published var uiImage: UIImage? +#elseif canImport(AppKit) + @Published var nsImage: NSImage? +#endif + + private var currentKey: String? + private var task: Task? + + deinit { + task?.cancel() + } + + func load(imageURL: String?, ttl: TimeInterval) { + let normalized = Self.normalizeImageSource(imageURL) + let key = normalized ?? "" + guard currentKey != key else { return } + currentKey = key + + task?.cancel() + +#if canImport(UIKit) + uiImage = nil +#elseif canImport(AppKit) + nsImage = nil +#endif + + guard let normalized, normalized.isEmpty == false else { return } + + #if canImport(UIKit) || canImport(AppKit) + if let image = Self.imageFromDataURL(normalized) { +#if canImport(UIKit) + uiImage = image +#elseif canImport(AppKit) + nsImage = image +#endif + return + } + #endif + + guard let url = URL(string: normalized) else { return } + + task = Task { [weak self] in +#if canImport(UIKit) || canImport(AppKit) + let image = await AppImageCache.shared.image(for: url, ttl: ttl) + guard Task.isCancelled == false else { return } + await MainActor.run { +#if canImport(UIKit) + self?.uiImage = image +#elseif canImport(AppKit) + self?.nsImage = image +#endif + } +#endif + } + } + + private static func normalizeImageSource(_ value: String?) -> String? { + guard var normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines), + normalized.isEmpty == false else { return nil } + normalized = normalized.replacingOccurrences(of: "\\/", with: "/") + return normalized + } + + #if canImport(UIKit) || canImport(AppKit) + private static func imageFromDataURL(_ source: String) -> PlatformImage? { + let lower = source.lowercased() + guard lower.hasPrefix("data:image"), let commaIndex = source.firstIndex(of: ",") else { return nil } + + let header = String(source[..) -> [String: Double] { + if let direct = try? container.decode([String: Double].self, forKey: .pizzaPrices) { + return direct + } + if let asInt = try? container.decode([String: Int].self, forKey: .pizzaPrices) { + return asInt.mapValues { Double($0) } + } + if let asString = try? container.decode([String: String].self, forKey: .pizzaPrices) { + var parsed: [String: Double] = [:] + for (key, value) in asString { + let normalized = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ",", with: ".") + if let number = Double(normalized) { + parsed[key] = number + } + } + return parsed + } + return [:] + } } struct StoreAddonGroup: Decodable, Identifiable { @@ -242,56 +399,6 @@ struct StoreAddonItem: Decodable, Identifiable { } } -struct PublicCategory: Decodable { - let id: String - let name: String - let icon: String? -} - -struct CustomerProfileUpdatePayload: Encodable { - let addressBook: [CustomerAddressPayload] - - enum CodingKeys: String, CodingKey { - case addressBook = "address_book" - } -} - -struct CustomerAddressPayload: Encodable { - let label: String? - let address: String? - let number: String? - let complement: String? - let neighborhood: String? - let city: String? - let state: String? - let zipCode: String? - let latLong: [Double]? - - enum CodingKeys: String, CodingKey { - case label - case address - case number - case complement - case neighborhood - case city - case state - case zipCode - case latLong = "lat_long" - } - - init(from address: CustomerAddress) { - self.label = address.label - self.address = address.address - self.number = address.number - self.complement = address.complement - self.neighborhood = address.neighborhood - self.city = address.city - self.state = address.state - self.zipCode = address.zipCode - self.latLong = address.latLong - } -} - struct CepLookupResult: Decodable { let zipCode: String? let street: String? diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift new file mode 100644 index 0000000..19fdf66 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift @@ -0,0 +1,145 @@ +import Foundation + +struct CreateOrderPayload: Encodable { + let customer: CreateOrderCustomerPayload + let items: [CreateOrderItemPayload] + let total: Double + let paymentMethod: String + let deliveryType: String + let address: CreateOrderAddressPayload? +} + +struct CreateOrderCustomerPayload: Encodable { + let name: String + let phone: String + let email: String + let asaasId: String? +} + +struct CreateOrderItemPayload: Encodable { + let productId: String + let name: String + let qty: Int + let price: Double + let addons: [CreateOrderAddonPayload] +} + +struct CreateOrderAddonPayload: Encodable { + let addonId: String + let name: String + let qty: Int + let price: Double +} + +struct CreateOrderAddressPayload: Encodable { + let street: String + let number: String + let neighborhood: String + let city: String? + let state: String? + let zip: String? + let complement: String? +} + +struct CreateOrderResult: Decodable { + let id: String? + let shortId: String? + let status: String? + let paymentStatus: String? + let paymentMethod: String? + let paymentPayload: CreateOrderPaymentPayload? + let payment: CreateOrderPaymentInfo? + + enum CodingKeys: String, CodingKey { + case id + case shortId + case status + case paymentStatus + case paymentMethod + case paymentPayload + case payment + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try? container.decode(String.self, forKey: .id) + shortId = try? container.decode(String.self, forKey: .shortId) + status = try? container.decode(String.self, forKey: .status) + paymentStatus = try? container.decode(String.self, forKey: .paymentStatus) + paymentMethod = try? container.decode(String.self, forKey: .paymentMethod) + payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment) + + if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) { + paymentPayload = objectPayload + } else if let stringPayload = try? container.decode(String.self, forKey: .paymentPayload) { + paymentPayload = CreateOrderPaymentPayload( + copyPaste: stringPayload, + qrCodeImage: nil, + expirationDate: nil + ) + } else { + paymentPayload = nil + } + } +} + +struct CreateOrderPaymentInfo: Decodable { + let method: String? + let status: String? + let pix: CreateOrderPaymentPayload? +} + +struct CreateOrderPaymentPayload: Decodable { + let copyPaste: String? + let qrCodeImage: String? + let expirationDate: String? + + enum CodingKeys: String, CodingKey { + case copyPaste + case payload + case qrCodeImage + case encodedImage + case expirationDate + } + + init(copyPaste: String?, qrCodeImage: String?, expirationDate: String?) { + self.copyPaste = copyPaste + self.qrCodeImage = qrCodeImage + self.expirationDate = expirationDate + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + copyPaste = (try? container.decode(String.self, forKey: .copyPaste)) + ?? (try? container.decode(String.self, forKey: .payload)) + qrCodeImage = (try? container.decode(String.self, forKey: .qrCodeImage)) + ?? (try? container.decode(String.self, forKey: .encodedImage)) + expirationDate = try? container.decode(String.self, forKey: .expirationDate) + } +} + +struct ValidateDeliveryAddressPayload: Encodable { + let address: ValidateDeliveryAddressDataPayload +} + +struct ValidateDeliveryAddressDataPayload: Encodable { + let street: String? + let number: String? + let neighborhood: String? + let city: String? + let state: String? + let zip: String? + let lat: Double? + let lng: Double? +} + +struct ValidateDeliveryAddressResult: Decodable { + let deliveryAllowed: Bool? + let reasonCode: String? + let reasonMessage: String? + let deliveryMode: String? + let distance: Double? + let deliveryFee: Double? + let deliveryTime: String? + let sameCity: Bool? +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiPizzaModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiPizzaModels.swift new file mode 100644 index 0000000..56c0b75 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiPizzaModels.swift @@ -0,0 +1,80 @@ +import Foundation + +struct StorePizzaConfig: Decodable { + let sizes: [StorePizzaSize] + let doughs: [StorePizzaDough] + let crusts: [StorePizzaCrust] + + enum CodingKeys: String, CodingKey { + case sizes + case doughs + case crusts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + sizes = (try? container.decode([StorePizzaSize].self, forKey: .sizes)) ?? [] + doughs = (try? container.decode([StorePizzaDough].self, forKey: .doughs)) ?? [] + crusts = (try? container.decode([StorePizzaCrust].self, forKey: .crusts)) ?? [] + } +} + +struct StorePizzaSize: Decodable, Identifiable { + let id: String + let name: String? + let slices: Int? + let maxFlavors: Int? + + enum CodingKeys: String, CodingKey { + case id + case name + case slices + case maxFlavors + } +} + +struct StorePizzaDough: Decodable, Identifiable { + let id: String + let name: String? + let active: Bool? + + enum CodingKeys: String, CodingKey { + case id + case name + case active + } +} + +struct StorePizzaCrust: Decodable, Identifiable { + let id: String + let name: String? + let active: Bool? + let priceModifier: Double? + + enum CodingKeys: String, CodingKey { + case id + case name + case active + case priceModifier + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + name = try? container.decode(String.self, forKey: .name) + active = try? container.decode(Bool.self, forKey: .active) + + if let value = try? container.decode(Double.self, forKey: .priceModifier) { + priceModifier = value + } else if let value = try? container.decode(Int.self, forKey: .priceModifier) { + priceModifier = Double(value) + } else if let value = try? container.decode(String.self, forKey: .priceModifier) { + let normalized = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ",", with: ".") + priceModifier = Double(normalized) + } else { + priceModifier = nil + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index 4493af5..449e931 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -235,6 +235,18 @@ final class ApiService { let req = ApiRequest(path: "/api/store/\(storeId)/catalog", method: "GET", module: .store, requiresAuth: true) return try await sendEnvelope(req) } + + func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/store/\(storeId)/orders", method: "POST", module: .store, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope { + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/store/\(storeId)/delivery/validate-address", method: "POST", module: .store, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } } extension ApiService { static func decodeFlexibleDouble(from container: KeyedDecodingContainer, keys: [K]) -> Double? { @@ -256,4 +268,25 @@ extension ApiService { } return nil } + + static func decodeFlexibleInt(from container: KeyedDecodingContainer, keys: [K]) -> Int? { + for key in keys { + if let value = try? container.decode(Int.self, forKey: key) { + return value + } + if let asDouble = try? container.decode(Double.self, forKey: key) { + return Int(asDouble) + } + if let asString = try? container.decode(String.self, forKey: key) { + let normalized = asString + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ".", with: "") + .replacingOccurrences(of: ",", with: "") + if let parsed = Int(normalized) { + return parsed + } + } + } + return nil + } } diff --git a/pedi-foods/Sources/PediFoods/Services/AppCache.swift b/pedi-foods/Sources/PediFoods/Services/AppCache.swift new file mode 100644 index 0000000..a001cef --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/AppCache.swift @@ -0,0 +1,145 @@ +import Foundation +#if canImport(UIKit) +import UIKit +typealias PlatformImage = UIImage +#elseif canImport(AppKit) +import AppKit +typealias PlatformImage = NSImage +#endif + +final class AppContentCache: @unchecked Sendable { + static let shared = AppContentCache() + + private struct Entry { + let value: Any + let expiry: Date + } + + private var entries: [String: Entry] = [:] + private let queue = DispatchQueue(label: "com.pedifoods.content-cache", qos: .userInitiated) + + private init() {} + + func value(for key: String, as type: T.Type = T.self) -> T? { + queue.sync { + guard let entry = entries[key] else { return nil } + if entry.expiry <= Date() { + entries.removeValue(forKey: key) + return nil + } + return entry.value as? T + } + } + + func set(_ value: T, for key: String, ttl: TimeInterval) { + queue.sync { + entries[key] = Entry(value: value, expiry: Date().addingTimeInterval(ttl)) + } + } + + func invalidate(prefix: String? = nil) { + queue.sync { + guard let prefix, prefix.isEmpty == false else { + entries.removeAll() + return + } + + let keys = entries.keys.filter { $0.hasPrefix(prefix) } + for key in keys { + entries.removeValue(forKey: key) + } + } + } +} + +#if canImport(UIKit) || canImport(AppKit) +final class AppImageCache: @unchecked Sendable { + static let shared = AppImageCache() + + private struct Entry { + let image: PlatformImage + let expiry: Date + } + + private var entries: [String: Entry] = [:] + private let queue = DispatchQueue(label: "com.pedifoods.image-cache", qos: .userInitiated) + + private init() { + configureURLCacheIfNeeded() + } + + func image(for url: URL, ttl: TimeInterval, forceRefresh: Bool = false) async -> PlatformImage? { + let key = url.absoluteString + let now = Date() + + if forceRefresh == false { + let cached = queue.sync { entries[key] } + if let cached, cached.expiry > now { + return cached.image + } + } + + var request = URLRequest(url: url) + request.timeoutInterval = 20 + request.cachePolicy = forceRefresh ? .reloadIgnoringLocalCacheData : .returnCacheDataElseLoad + + if forceRefresh == false, + let diskCached = URLCache.shared.cachedResponse(for: request), + let image = platformImage(from: diskCached.data) { + queue.sync { + entries[key] = Entry(image: image, expiry: now.addingTimeInterval(ttl)) + } + return image + } + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let image = platformImage(from: data) else { return nil } + URLCache.shared.storeCachedResponse(CachedURLResponse(response: response, data: data), for: request) + queue.sync { + entries[key] = Entry(image: image, expiry: now.addingTimeInterval(ttl)) + } + return image + } catch { + return nil + } + } + + func invalidateAll() { + queue.sync { + entries.removeAll() + } + URLCache.shared.removeAllCachedResponses() + } + + private func configureURLCacheIfNeeded() { + let current = URLCache.shared + let minMemoryCapacity = 64 * 1024 * 1024 + let minDiskCapacity = 256 * 1024 * 1024 + + if current.memoryCapacity < minMemoryCapacity || current.diskCapacity < minDiskCapacity { + URLCache.shared = URLCache(memoryCapacity: minMemoryCapacity, diskCapacity: minDiskCapacity) + } + } + + private func platformImage(from data: Data) -> PlatformImage? { +#if canImport(UIKit) + return UIImage(data: data) +#elseif canImport(AppKit) + return NSImage(data: data) +#else + return nil +#endif + } +} +#endif + +#if !(canImport(UIKit) || canImport(AppKit)) +final class AppImageCache: @unchecked Sendable { + static let shared = AppImageCache() + + private init() {} + + func invalidateAll() {} +} +#endif diff --git a/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift b/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift index 8dcf9e1..0492958 100644 --- a/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift +++ b/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift @@ -7,10 +7,37 @@ private struct PersistedAddressState: Codable { let longitude: Double? } +private struct PersistedCartAddonState: Codable { + let id: String + let name: String + let quantity: Int + let unitPrice: Double +} + +private struct PersistedCartItemState: Codable { + let id: String + let productId: String + let storeId: String + let name: String + let imageURL: String? + let details: String? + let addons: [PersistedCartAddonState] + let quantity: Int + let unitPrice: Double +} + +private struct PersistedCartState: Codable { + let storeId: String? + let storeName: String? + let items: [PersistedCartItemState] + let total: Double +} + enum SessionStateStore { private static let legacyAddressKey = "session.address.state.v1" private static let addressKeyPrefix = "session.address.state.v2." private static let activeUserKey = "session.active.user.v1" + private static let cartKeyPrefix = "session.cart.state.v1." static func makeUserKey(profileId: String?, email: String?) -> String? { let id = (profileId ?? "") @@ -136,4 +163,81 @@ enum SessionStateStore { static func clearActiveUser() { UserDefaults.standard.removeObject(forKey: activeUserKey) } + + private static func cartStorageKey(for userKey: String?) -> String { + let key = (userKey ?? loadActiveUserKey() ?? "anonymous") + .trimmingCharacters(in: .whitespacesAndNewlines) + let safe = key.replacingOccurrences(of: " ", with: "_") + return cartKeyPrefix + safe + } + + static func loadCart() -> CartState? { + let defaults = UserDefaults.standard + let key = cartStorageKey(for: nil) + + if let data = defaults.data(forKey: key), + let decoded = try? JSONDecoder().decode(PersistedCartState.self, from: data) { + return CartState( + storeId: decoded.storeId, + storeName: decoded.storeName, + items: decoded.items.map { + CartItemState( + id: $0.id, + productId: $0.productId, + storeId: $0.storeId, + name: $0.name, + imageURL: $0.imageURL, + details: $0.details, + addons: $0.addons.map { + CartItemAddonState( + id: $0.id, + name: $0.name, + quantity: $0.quantity, + unitPrice: $0.unitPrice + ) + }, + quantity: $0.quantity, + unitPrice: $0.unitPrice + ) + }, + total: decoded.total + ) + } + + return nil + } + + static func saveCart(_ state: CartState) { + let payload = PersistedCartState( + storeId: state.storeId, + storeName: state.storeName, + items: state.items.map { + PersistedCartItemState( + id: $0.id, + productId: $0.productId, + storeId: $0.storeId, + name: $0.name, + imageURL: $0.imageURL, + details: $0.details, + addons: $0.addons.map { + PersistedCartAddonState( + id: $0.id, + name: $0.name, + quantity: $0.quantity, + unitPrice: $0.unitPrice + ) + }, + quantity: $0.quantity, + unitPrice: $0.unitPrice + ) + }, + total: state.total + ) + guard let data = try? JSONEncoder().encode(payload) else { return } + UserDefaults.standard.set(data, forKey: cartStorageKey(for: nil)) + } + + static func clearCart() { + UserDefaults.standard.removeObject(forKey: cartStorageKey(for: nil)) + } } diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index e734f94..cb0264a 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -52,7 +52,16 @@ struct CartItemState: Identifiable { var productId: String var storeId: String var name: String + var imageURL: String? = nil var details: String? = nil + var addons: [CartItemAddonState] = [] + var quantity: Int + var unitPrice: Double +} + +struct CartItemAddonState: Identifiable, Hashable { + let id: String + var name: String var quantity: Int var unitPrice: Double } @@ -71,6 +80,7 @@ extension CartState { storeName = nil items = [] total = 0 + SessionStateStore.clearCart() } mutating func add(item: CartItemState) { @@ -80,6 +90,7 @@ extension CartState { items.append(item) } recalculateTotal() + SessionStateStore.saveCart(self) } mutating func set(item: CartItemState) { @@ -98,12 +109,18 @@ extension CartState { storeName = nil } recalculateTotal() + if items.isEmpty { + SessionStateStore.clearCart() + } else { + SessionStateStore.saveCart(self) + } } mutating func increment(itemId: String) { guard let index = items.firstIndex(where: { $0.id == itemId }) else { return } items[index].quantity += 1 recalculateTotal() + SessionStateStore.saveCart(self) } mutating func decrement(itemId: String) { @@ -117,5 +134,31 @@ extension CartState { storeName = nil } recalculateTotal() + if items.isEmpty { + SessionStateStore.clearCart() + } else { + SessionStateStore.saveCart(self) + } + } + + func toOrderItemsPayload() -> [CreateOrderItemPayload] { + items.map { item in + CreateOrderItemPayload( + productId: item.productId, + name: item.name, + qty: item.quantity, + price: item.unitPrice, + addons: item.addons + .filter { $0.quantity > 0 } + .map { + CreateOrderAddonPayload( + addonId: $0.id, + name: $0.name, + qty: $0.quantity, + price: $0.unitPrice + ) + } + ) + } } } diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift index 896a78b..0b97e7d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift @@ -230,6 +230,11 @@ struct OtpView: View { SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) + if let cachedCart = SessionStateStore.loadCart() { + appState.cart = cachedCart + } else { + appState.cart = CartState() + } if let preferred = customer.addressBook?.first { appState.address.selectedId = preferred.id @@ -252,6 +257,11 @@ struct OtpView: View { SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: nil, email: email) ) + if let cachedCart = SessionStateStore.loadCart() { + appState.cart = cachedCart + } else { + appState.cart = CartState() + } if let cached = SessionStateStore.loadAddress() { appState.address = cached } else { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift index f18279f..9212929 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift @@ -3,73 +3,158 @@ import SwiftUI struct CartView: View { @Binding var appState: AppState + @State var openCheckout = false + @State var couponCode = "" + @State var appliedCouponCode: String? = nil + @State var discountValue: Double = 0 + @State var deliveryFee: Double = 5 var body: some View { - VStack(spacing: 14) { - Text("Carrinho") - .font(AppTypography.heading1) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - .padding(.top, 20) + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + Text("Meu Carrinho") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 20) - if appState.cart.items.isEmpty { - VStack(spacing: 10) { - Text("Seu carrinho está vazio") - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textPrimary) - Text("Adicione produtos para continuar.") - .font(AppTypography.body) - .foregroundStyle(AppColors.textMuted) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - } else { - ScrollView(showsIndicators: false) { + if appState.cart.items.isEmpty { + VStack(spacing: 10) { + Text("Seu carrinho está vazio") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Text("Adicione produtos para continuar.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, minHeight: 280, alignment: .center) + } else { VStack(spacing: 12) { - if let storeName = appState.cart.storeName, storeName.isEmpty == false { - Text(storeName) - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - } - ForEach(appState.cart.items) { item in cartItemRow(item) } } - .padding(.bottom, 120) + .padding(.horizontal, 20) + + couponSection + .padding(.horizontal, 20) + + summarySection + .padding(.horizontal, 20) } } + .padding(.bottom, 120) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppColors.backgroundLight) - .safeAreaInset(edge: .bottom) { - if appState.cart.items.isEmpty == false { - VStack(spacing: 10) { - HStack { - Text("Total") - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textPrimary) - Spacer() - Text(formatCurrency(appState.cart.total)) - .font(AppTypography.heading2) - .foregroundStyle(AppColors.primary) - } - .padding(.horizontal, 20) + .navigationDestination(isPresented: $openCheckout) { + CheckoutView(appState: $appState) + } + } - PrimaryButton(title: "Finalizar pedido", action: {}) - .padding(.horizontal, 20) + private var subtotalValue: Double { + appState.cart.items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) } + } + + private var totalValue: Double { + max(0, subtotalValue + deliveryFee - discountValue) + } + + private var couponSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Cupom de Desconto") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + HStack(spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "ticket") + .foregroundStyle(AppColors.textMuted) + TextField("Inserir cupom", text: $couponCode) + .appNoAutoCap() } - .padding(.top, 10) - .padding(.bottom, 12) - .background(.ultraThinMaterial) + .padding(.horizontal, 12) + .frame(height: 50) + .background(AppColors.surface) + .overlay( + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) + .stroke(AppColors.brandSoft, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + + Button("Aplicar") { + applyCoupon() + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textInverse) + .frame(width: 120, height: 50) + .background(AppColors.brandDark) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .buttonStyle(.plain) + } + + if let appliedCouponCode { + Text("Cupom aplicado: \(appliedCouponCode)") + .font(.caption) + .foregroundStyle(AppColors.primary) } } } + private var summarySection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Resumo de Valores") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue)) + summaryRow(title: "Taxa de Entrega", value: formatCurrency(deliveryFee)) + summaryRow(title: "Desconto", value: "-\(formatCurrency(discountValue))", valueColor: Color.red) + + Divider() + + summaryRow(title: "Total", value: formatCurrency(totalValue), highlighted: true) + + Button { + openCheckout = true + } label: { + HStack(spacing: 10) { + Text("Ir para o Pagamento") + .font(AppTypography.heading2) + Image(systemName: "arrow.right") + .font(.system(size: 18, weight: .bold)) + } + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity, minHeight: 54) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + } + + private func summaryRow(title: String, value: String, valueColor: Color? = nil, highlighted: Bool = false) -> some View { + HStack { + Text(title) + .font(highlighted ? AppTypography.heading2 : AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Spacer() + Text(value) + .font(highlighted ? AppTypography.heading1 : AppTypography.heading3) + .foregroundStyle(valueColor ?? AppColors.textPrimary) + } + } + private func cartItemRow(_ item: CartItemState) -> some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 14) { + AsyncStoreImage(imageURL: item.imageURL) + .frame(width: 78, height: 78) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + + VStack(alignment: .leading, spacing: 6) { Text(item.name) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) @@ -87,13 +172,13 @@ struct CartView: View { Spacer() - HStack(spacing: 8) { + HStack(spacing: 10) { Button(action: { appState.cart.decrement(itemId: item.id) }) { Image(systemName: "minus") .font(.system(size: 13, weight: .bold)) .foregroundStyle(AppColors.textPrimary) - .frame(width: 28, height: 28) - .background(AppColors.brandSoft) + .frame(width: 32, height: 32) + .background(AppColors.backgroundLight) .clipShape(Circle()) } .buttonStyle(.plain) @@ -107,18 +192,39 @@ struct CartView: View { Image(systemName: "plus") .font(.system(size: 13, weight: .bold)) .foregroundStyle(AppColors.textPrimary) - .frame(width: 28, height: 28) + .frame(width: 32, height: 32) .background(AppColors.tertiary) .clipShape(Circle()) } .buttonStyle(.plain) } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(AppColors.backgroundLight) + .clipShape(Capsule()) } - .padding(.horizontal, 14) + .padding(.horizontal, 12) .padding(.vertical, 12) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) - .padding(.horizontal, 20) + } + + private func applyCoupon() { + let normalized = couponCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard normalized.isEmpty == false else { + appliedCouponCode = nil + discountValue = 0 + return + } + + if normalized == "DESCONTO10" { + appliedCouponCode = normalized + discountValue = min(subtotalValue, subtotalValue * 0.1) + return + } + + appliedCouponCode = nil + discountValue = 0 } private func formatCurrency(_ value: Double) -> String { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutTypes.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutTypes.swift new file mode 100644 index 0000000..d86090b --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutTypes.swift @@ -0,0 +1,41 @@ +import Foundation + +enum CheckoutDeliveryType: String { + case delivery = "DELIVERY" + case pickup = "PICKUP" +} + +enum CheckoutPaymentMethod: String { + case pix = "PIX" + case creditCard = "CREDIT_CARD" + case debitCard = "DEBIT_CARD" + case money = "MONEY" + case voucher = "VOUCHER" + + var label: String { + switch self { + case .pix: return "PIX" + case .creditCard: return "Cartão de Crédito" + case .debitCard: return "Cartão de Débito" + case .money: return "Dinheiro" + case .voucher: return "Vale Refeição/Alimentação" + } + } + + var subtitle: String? { + switch self { + case .pix: return "Aprovação imediata" + case .creditCard: return "No app: rápido e seguro" + default: return nil + } + } + + var iconName: String { + switch self { + case .pix: return "bolt.fill" + case .creditCard, .debitCard: return "creditcard.fill" + case .money: return "banknote.fill" + case .voucher: return "ticket.fill" + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift new file mode 100644 index 0000000..a905a34 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -0,0 +1,335 @@ +import SwiftUI + +extension CheckoutView { + enum CheckoutPayloadValidationError: LocalizedError { + case emptyCart + case missingCustomerName + case missingCustomerEmail + case missingCustomerPhone + case missingAddressStreet + case missingAddressNumber + case missingAddressNeighborhood + + var errorDescription: String? { + switch self { + case .emptyCart: return "Carrinho vazio." + case .missingCustomerName: return "Nome do cliente não informado." + case .missingCustomerEmail: return "Email do cliente não informado." + case .missingCustomerPhone: return "Telefone do cliente não informado." + case .missingAddressStreet: return "Rua do endereço não informada." + case .missingAddressNumber: return "Número do endereço não informado." + case .missingAddressNeighborhood: return "Bairro do endereço não informado." + } + } + } + var checkoutAddressWatchKey: String { + let selectedId = appState.address.selectedId ?? "nil" + let display = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil" + let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil" + return "\(selectedId)|\(display)|\(lat)|\(lng)" + } + + func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } + + @MainActor + func loadStoreInfoIfNeeded() async { + guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { return } + do { + let response = try await ApiService().storeInfo(storeId: storeId) + if response.error { + errorMessage = response.message ?? "Não foi possível carregar opções de checkout." + return + } + storeInfo = response.result + errorMessage = nil + } catch { + errorMessage = "Não foi possível carregar opções de checkout." + } + } + + @MainActor + func refreshSelectedCustomerAddress() async { + do { + let response = try await ApiService().profile() + if let customer = response.result { + appState.profile.id = customer.id + appState.profile.name = customer.name + appState.profile.email = customer.email + if let phoneNumber = customer.phoneNumber, phoneNumber.isEmpty == false { + appState.profile.phone = phoneNumber + } + SessionStateStore.setActiveUserKey( + SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) + ) + } + + let addresses = response.result?.addressBook ?? [] + if let selectedId = appState.address.selectedId, selectedId.isEmpty == false { + selectedCustomerAddress = addresses.first(where: { $0.id == selectedId }) + } else { + selectedCustomerAddress = nil + } + + if selectedCustomerAddress == nil { + let display = appState.address.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if display.isEmpty == false, display != "defina seu endereco" { + selectedCustomerAddress = addresses.first { + ($0.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == display + } + } + } + + if selectedCustomerAddress == nil { + selectedCustomerAddress = addresses.first + } + + if let selected = selectedCustomerAddress { + appState.address.selectedId = selected.id + let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if label.isEmpty == false { + appState.address.display = label + } + if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { + appState.address.latitude = lat + appState.address.longitude = lng + } + SessionStateStore.saveAddress(appState.address) + } + } catch { + selectedCustomerAddress = nil + } + } + + @MainActor + func validateDeliveryAddressIfNeeded() async { + guard isDeliveryMode else { + addressValidationBlocked = false + addressValidationMessage = nil + baseDeliveryFee = nil + return + } + + guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { return } + + baseDeliveryFee = nil + + let payload = ValidateDeliveryAddressPayload( + address: ValidateDeliveryAddressDataPayload( + street: selectedCustomerAddress?.address, + number: selectedCustomerAddress?.number, + neighborhood: selectedCustomerAddress?.neighborhood, + city: selectedCustomerAddress?.city, + state: selectedCustomerAddress?.state, + zip: selectedCustomerAddress?.zipCode, + lat: appState.address.latitude, + lng: appState.address.longitude + ) + ) + + isValidatingAddress = true + defer { isValidatingAddress = false } + + do { + let response = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload) + if response.error { + addressValidationBlocked = true + addressValidationMessage = response.message ?? "Não foi possível validar o endereço de entrega." + baseDeliveryFee = nil + return + } + + let result = response.result + let allowed = result?.deliveryAllowed ?? false + addressValidationBlocked = allowed == false + addressValidationMessage = result?.reasonMessage + + if allowed { + lastAcceptedAddressState = appState.address + } else { + showAddressNotServedAlert = true + baseDeliveryFee = nil + } + + if allowed { + if let fee = result?.deliveryFee { + baseDeliveryFee = fee + } else { + addressValidationBlocked = true + addressValidationMessage = "Não foi possível calcular a taxa de entrega para este endereço." + baseDeliveryFee = nil + } + } + } catch { + addressValidationBlocked = true + addressValidationMessage = "Não foi possível validar o endereço de entrega." + baseDeliveryFee = nil + } + } + + func normalizeSelectedOptions() { + if availableDeliveryTypes.contains(deliveryType) == false, + let first = availableDeliveryTypes.first { + deliveryType = first + } + + if useInAppPayment { + if availableInAppPaymentMethods.contains(paymentMethod) == false, + let first = availableInAppPaymentMethods.first { + paymentMethod = first + } + } else { + if availableStoreMachineMethods.contains(paymentMethod) == false, + let first = availableStoreMachineMethods.first { + paymentMethod = first + } + } + + if lastAcceptedAddressState == nil { + lastAcceptedAddressState = appState.address + } + } + + func restoreLastAcceptedAddress() { + guard let snapshot = lastAcceptedAddressState else { return } + isRestoringAddress = true + appState.address = snapshot + SessionStateStore.saveAddress(snapshot) + Task { @MainActor in + await refreshSelectedCustomerAddress() + addressValidationBlocked = false + addressValidationMessage = nil + isRestoringAddress = false + } + } + + @MainActor + func handleConfirmPaymentTap() async { + guard canConfirmPayment else { return } + + if useInAppPayment == false { + SnackbarCenter.shared.show(title: "Pagamento presencial selecionado.", style: .info, icon: "creditcard.fill", duration: 2.0) + return + } + + if paymentMethod == .creditCard { + openCardPayment = true + return + } + + guard paymentMethod == .pix else { + SnackbarCenter.shared.show(title: "Método de pagamento não suportado no app.", style: .warning, icon: "exclamationmark.triangle.fill", duration: 2.0) + return + } + + guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { + SnackbarCenter.shared.show(title: "Loja do pedido não encontrada.", style: .error, icon: "xmark.octagon.fill", duration: 2.0) + return + } + + await refreshSelectedCustomerAddress() + + let payloadBuildResult = buildCreateOrderPayload(paymentMethod: .pix) + guard case .success(let payload) = payloadBuildResult else { + let message: String + if case .failure(let reason) = payloadBuildResult { + message = reason.localizedDescription + } else { + message = "Dados do pedido incompletos." + } + SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 2.0) + return + } + + isSubmittingOrder = true + defer { isSubmittingOrder = false } + + 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 gerar o pagamento PIX.", + 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 pixPayload = result.payment?.pix ?? result.paymentPayload + guard let copyPaste = pixPayload?.copyPaste, copyPaste.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 + } + + let orderId = result.id ?? UUID().uuidString + pixPaymentContext = PixPaymentContext( + id: orderId, + orderId: orderId, + shortId: result.shortId, + copyPaste: copyPaste, + qrCodeImageBase64: pixPayload?.qrCodeImage, + expirationDate: pixPayload?.expirationDate + ) + } catch { + SnackbarCenter.shared.show(title: "Não foi possível gerar o pagamento PIX.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) + } + } + + func buildCreateOrderPayload(paymentMethod: CheckoutPaymentMethod) -> Result { + guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) } + + let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines) + let profileEmail = appState.profile.email.trimmingCharacters(in: .whitespacesAndNewlines) + let profilePhone = appState.profile.phone.trimmingCharacters(in: .whitespacesAndNewlines) + guard profileName.isEmpty == false else { return .failure(.missingCustomerName) } + guard profileEmail.isEmpty == false else { return .failure(.missingCustomerEmail) } + guard profilePhone.isEmpty == false else { return .failure(.missingCustomerPhone) } + + let addressPayload: CreateOrderAddressPayload? + if isDeliveryMode { + let street = selectedCustomerAddress?.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let number = selectedCustomerAddress?.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let neighborhood = selectedCustomerAddress?.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard street.isEmpty == false else { return .failure(.missingAddressStreet) } + guard number.isEmpty == false else { return .failure(.missingAddressNumber) } + guard neighborhood.isEmpty == false else { return .failure(.missingAddressNeighborhood) } + addressPayload = CreateOrderAddressPayload( + street: street, + number: number, + neighborhood: neighborhood, + city: selectedCustomerAddress?.city, + state: selectedCustomerAddress?.state, + zip: selectedCustomerAddress?.zipCode, + complement: selectedCustomerAddress?.complement + ) + } else { + addressPayload = nil + } + + 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 + ) + ) + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift new file mode 100644 index 0000000..cdd6afe --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -0,0 +1,711 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +struct CheckoutView: View { + @Binding var appState: AppState + + @State var storeInfo: StoreInfoResult? = nil + @State var errorMessage: String? = nil + @State var deliveryType: CheckoutDeliveryType = .delivery + @State var paymentMethod: CheckoutPaymentMethod = .pix + @State var useInAppPayment = true + @State var discountValue: Double = 0 + @State var baseDeliveryFee: Double? = nil + @State var selectedCustomerAddress: CustomerAddress? = nil + @State var addressValidationMessage: String? = nil + @State var addressValidationBlocked = false + @State var isValidatingAddress = false + @State var showAddressNotServedAlert = false + @State var isRestoringAddress = false + @State var lastAcceptedAddressState: AddressState? = nil + @State var isSubmittingOrder = false + @State var pixPaymentContext: PixPaymentContext? = nil + @State var openCardPayment = false + + var paymentConfig: StorePaymentMethodsInfo? { storeInfo?.paymentMethods } + + var availableDeliveryTypes: [CheckoutDeliveryType] { + let deliveryEnabled = paymentConfig?.paymentOnDelivery ?? true + let pickupEnabled = paymentConfig?.paymentOnPickup ?? true + var values: [CheckoutDeliveryType] = [] + if deliveryEnabled { values.append(.delivery) } + if pickupEnabled { values.append(.pickup) } + return values.isEmpty ? [.delivery, .pickup] : values + } + + var availableInAppPaymentMethods: [CheckoutPaymentMethod] { + [.pix, .creditCard] + } + + var availableStoreMachineMethods: [CheckoutPaymentMethod] { + var methods: [CheckoutPaymentMethod] = [] + if paymentConfig?.acceptCash == true { methods.append(.money) } + if paymentConfig?.hasAnyCreditCard == true { methods.append(.creditCard) } + if paymentConfig?.hasAnyDebitCard == true { methods.append(.debitCard) } + if paymentConfig?.hasAnyVoucher == true { methods.append(.voucher) } + return methods + } + + var isDeliveryMode: Bool { + deliveryType == .delivery + } + + private var deliveryToggle: Binding { + Binding( + get: { isDeliveryMode }, + set: { isOn in + let next: CheckoutDeliveryType = isOn ? .delivery : .pickup + if availableDeliveryTypes.contains(next) { + deliveryType = next + } + } + ) + } + + private var subtotalValue: Double { + appState.cart.items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) } + } + + private var deliveryFeeValue: Double { + isDeliveryMode ? (baseDeliveryFee ?? 0) : 0 + } + + var totalValue: Double { + max(0, subtotalValue + deliveryFeeValue - discountValue) + } + + private var sectionTitleColor: Color { + AppColors.textMuted + } + + var canConfirmPayment: Bool { + if isDeliveryMode { + if addressValidationBlocked { return false } + if baseDeliveryFee == nil { return false } + if isValidatingAddress { return false } + } + if useInAppPayment == false && availableStoreMachineMethods.isEmpty { return false } + return true + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + deliveryTypeSection + addressSection + orderSummarySection + paymentSection + + if let errorMessage, errorMessage.isEmpty == false { + Text(errorMessage) + .font(.caption) + .foregroundStyle(Color.red) + } + + if let addressValidationMessage, addressValidationMessage.isEmpty == false { + Text(addressValidationMessage) + .font(.caption) + .foregroundStyle(addressValidationBlocked ? Color.red : AppColors.primary) + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 10) + } + .background(AppColors.backgroundLight) + .navigationTitle("Finalizar Pedido") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + bottomBar + } + .task { + await loadStoreInfoIfNeeded() + await refreshSelectedCustomerAddress() + normalizeSelectedOptions() + await validateDeliveryAddressIfNeeded() + } + .onChange(of: checkoutAddressWatchKey) { _, _ in + if isRestoringAddress { return } + Task { + await refreshSelectedCustomerAddress() + await validateDeliveryAddressIfNeeded() + } + } + .onChange(of: deliveryType) { _, _ in + Task { + await validateDeliveryAddressIfNeeded() + } + } + .alert("Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?", isPresented: $showAddressNotServedAlert) { + Button("Não", role: .cancel) { + restoreLastAcceptedAddress() + } + Button("Sim", role: .destructive) { + appState.cart.clear() + addressValidationBlocked = false + addressValidationMessage = nil + } + } + .navigationDestination(item: $pixPaymentContext) { context in + PaymentPixView(context: context) + } + .navigationDestination(isPresented: $openCardPayment) { + PaymentCardView(total: totalValue) + } + } + + private var deliveryTypeSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("TIPO DE ENTREGA") + .font(AppTypography.overline) + .foregroundStyle(sectionTitleColor) + + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(isDeliveryMode ? "Entrega" : "Retirada") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + Spacer() + Toggle("", isOn: deliveryToggle) + .labelsHidden() + .tint(AppColors.primary) + .disabled(availableDeliveryTypes.count <= 1) + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private var addressSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text(addressSectionTitle) + .font(AppTypography.overline) + .foregroundStyle(sectionTitleColor) + + HStack(spacing: 12) { + Circle() + .fill(AppColors.brandSoft) + .frame(width: 44, height: 44) + .overlay( + Image(systemName: "mappin.and.ellipse") + .foregroundStyle(AppColors.primary) + ) + + VStack(alignment: .leading, spacing: 4) { + Text(isDeliveryMode ? "Casa" : (appState.cart.storeName ?? "Loja")) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Text(isDeliveryMode ? customerAddressLabel : storeAddressLabel) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + + Spacer() + + if isDeliveryMode { + Button("Alterar") { + appState.activeModal = .addressPicker + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + .buttonStyle(.plain) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private var orderSummarySection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("RESUMO DO PEDIDO") + .font(AppTypography.overline) + .foregroundStyle(sectionTitleColor) + + VStack(alignment: .leading, spacing: 12) { + ForEach(appState.cart.items) { item in + HStack(alignment: .center, spacing: 10) { + Text("\(item.quantity)x") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + if let details = item.details, details.isEmpty == false { + Text(details) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + } + } + Spacer() + Text(formatCurrency(Double(item.quantity) * item.unitPrice)) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + } + + Divider() + .padding(.vertical, 4) + + summaryRow("Subtotal", formatCurrency(subtotalValue)) + summaryRow("Taxa de entrega", deliveryFeeLabel) + summaryRow("Desconto", "-\(formatCurrency(discountValue))", valueColor: Color.green) + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private var paymentSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("MÉTODO DE PAGAMENTO") + .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() + } + } + } + } onTap: { + useInAppPayment = true + if availableInAppPaymentMethods.contains(paymentMethod) == false { + paymentMethod = .pix + } + } + + paymentGroupCard( + title: "Pagar Na Maquininha Da Loja", + subtitle: "Pague na entrega/retirada com os métodos aceitos pela loja", + isSelected: useInAppPayment == false + ) { + if availableStoreMachineMethods.isEmpty { + Text("Loja não informou métodos presenciais.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 14) + .padding(.bottom, 14) + } else { + VStack(spacing: 0) { + ForEach(availableStoreMachineMethods, id: \.rawValue) { method in + paymentRow(method, isInAppGroup: false) + if method != availableStoreMachineMethods.last { + Divider() + } + } + } + } + } onTap: { + guard availableStoreMachineMethods.isEmpty == false else { return } + useInAppPayment = false + if availableStoreMachineMethods.contains(paymentMethod) == false, + let first = availableStoreMachineMethods.first { + paymentMethod = first + } + } + } + } + } + + private func paymentRow(_ method: CheckoutPaymentMethod, isInAppGroup: Bool) -> some View { + let subtitle = paymentSubtitle(for: method, isInAppGroup: isInAppGroup) + return Button { + paymentMethod = method + } label: { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .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) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(method.label) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + } + Spacer() + + Circle() + .stroke(paymentMethod == method ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2) + .frame(width: 24, height: 24) + .background( + Circle() + .fill(paymentMethod == method ? AppColors.tertiary : Color.clear) + ) + } + .padding(14) + } + .buttonStyle(.plain) + } + + private func paymentSubtitle(for method: CheckoutPaymentMethod, isInAppGroup: Bool) -> String? { + if isInAppGroup == false { + if method == .pix { + return "Pagamento presencial (QR da loja)" + } + return nil + } + 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) + .contentShape(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) { + HStack { + Text("Total a pagar") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textMuted) + Spacer() + Text(formatCurrency(totalValue)) + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + } + + Button { + Task { await handleConfirmPaymentTap() } + } label: { + HStack(spacing: 10) { + Text("Confirmar e Pagar") + .font(AppTypography.heading2) + Image(systemName: "checkmark") + .font(.system(size: 18, weight: .bold)) + } + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity, minHeight: 54) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(canConfirmPayment == false || isValidatingAddress || isSubmittingOrder) + .opacity((canConfirmPayment && isValidatingAddress == false && isSubmittingOrder == false) ? 1 : 0.65) + } + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, UIDevice.bottomNotch + 40) + .background(AppColors.surface.opacity(0.98)) + } + + private func summaryRow(_ title: String, _ value: String, valueColor: Color? = nil) -> some View { + HStack { + Text(title) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Spacer() + Text(value) + .font(AppTypography.heading3) + .foregroundStyle(valueColor ?? AppColors.textMuted) + } + } + + private var customerAddressLabel: String { + if let address = selectedCustomerAddress { + let street = (address.address ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let number = (address.number ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let neighborhood = (address.neighborhood ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let city = (address.city ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let state = (address.state ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let firstLine = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ") + let secondLine = [neighborhood, city, state].filter { $0.isEmpty == false }.joined(separator: " - ") + let joined = [firstLine, secondLine].filter { $0.isEmpty == false }.joined(separator: "\n") + if joined.isEmpty == false { return joined } + } + + let value = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? "Defina seu endereço" : value + } + + private var addressSectionTitle: String { + if isDeliveryMode { + return "ENDEREÇO DE ENTREGA" + } + let storeName = (appState.cart.storeName ?? "LOJA") + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() + return "ENDEREÇO DE \(storeName)" + } + + private var storeAddressLabel: String { + guard let address = storeInfo?.address else { return "Endereço da loja indisponível" } + let street = (address.street ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let number = (address.number ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let neighborhood = (address.neighborhood ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let city = (address.city ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let state = (address.state ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let firstLine = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ") + let secondLine = [neighborhood, city, state].filter { $0.isEmpty == false }.joined(separator: " - ") + let joined = [firstLine, secondLine].filter { $0.isEmpty == false }.joined(separator: "\n") + return joined.isEmpty ? "Endereço da loja indisponível" : joined + } + + private var deliveryFeeLabel: String { + if isDeliveryMode == false { + return formatCurrency(0) + } + if let baseDeliveryFee { + return formatCurrency(baseDeliveryFee) + } + return "Calculando..." + } + +} + +struct PixPaymentContext: Identifiable, Hashable { + let id: String + let orderId: String + let shortId: String? + let copyPaste: String + let qrCodeImageBase64: String? + let expirationDate: String? +} + +struct PaymentPixView: View { + let context: PixPaymentContext + @Environment(\.dismiss) var dismiss + + private var qrImageSource: String? { + guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines), + raw.isEmpty == false else { return nil } + if raw.lowercased().hasPrefix("data:image") { return raw } + return "data:image/png;base64,\(raw)" + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 14) { + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) + .fill(AppColors.surface) + .overlay( + VStack(spacing: 10) { + Text("Escaneie o QR Code abaixo ou copie o código para pagar no seu aplicativo do banco.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .padding(.top, 14) + + AsyncStoreImage(imageURL: qrImageSource) + .frame(width: 220, height: 220) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(AppColors.tertiary.opacity(0.35), lineWidth: 2) + ) + + Text("AGUARDANDO PAGAMENTO") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(AppColors.brandSoft) + .clipShape(Capsule()) + } + ) + .frame(maxWidth: .infinity, minHeight: 360) + + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) + .fill(AppColors.surface) + .overlay( + VStack(spacing: 8) { + Text("Código PIX") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + Text(context.copyPaste) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(AppColors.textMuted) + .lineLimit(3) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + if let expirationDate = context.expirationDate, expirationDate.isEmpty == false { + Text("Expira em: \(expirationDate)") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + } + .padding(.vertical, 14) + ) + + PrimaryButton(title: "Copiar Código PIX") { + copyToClipboard(context.copyPaste) + SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0) + } + + Button("Já realizei o pagamento") { + dismiss() + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 8) + } + .padding(20) + } + .background(AppColors.backgroundLight) + .navigationTitle("Pagamento via PIX") + .navigationBarTitleDisplayMode(.inline) + } + + private func copyToClipboard(_ value: String) { +#if canImport(UIKit) + UIPasteboard.general.string = value +#elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) +#endif + } +} + +struct PaymentCardView: View { + let total: Double + @Environment(\.dismiss) var dismiss + @State var cardHolderName = "" + @State var cardNumber = "" + @State var expiry = "" + @State var cvv = "" + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 14) { + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) + .fill(AppColors.surface) + .overlay( + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Total do Pedido") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + Text(formatCurrency(total)) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + } + Spacer() + } + .padding(14) + ) + .frame(height: 88) + + Text("Dados do Cartão") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + VStack(spacing: 10) { + labeledField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber) + labeledField("Nome no Cartão", placeholder: "Como impresso no cartão", text: $cardHolderName) + HStack(spacing: 10) { + labeledField("Validade", placeholder: "MM/AA", text: $expiry) + labeledField("CVV", placeholder: "•••", text: $cvv) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + + PrimaryButton(title: "Salvar e Pagar") { + SnackbarCenter.shared.show(title: "Fluxo de cartão em construção.", style: .info, icon: "creditcard.fill", duration: 2.0) + } + + Button("Apenas Pagar") { + dismiss() + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textMuted) + .frame(maxWidth: .infinity, minHeight: 48) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .buttonStyle(.plain) + } + .padding(20) + } + .background(AppColors.backgroundLight) + .navigationTitle("Pagamento") + .navigationBarTitleDisplayMode(.inline) + } + + private func labeledField(_ label: String, placeholder: String, text: Binding) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + TextField(placeholder, text: text) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .padding(.horizontal, 12) + .frame(height: 46) + .background(AppColors.backgroundLight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index 01d2425..ac32edf 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -40,6 +40,13 @@ struct HomeView: View { .padding(.top, headerExpandedHeight + contentTopSpacing) .padding(.bottom, contentBottomSpacing) } + .refreshable { + await bootstrapStoresFlow( + forceLocationRefresh: true, + category: selectedCategory == "all" ? nil : selectedCategory, + refreshCategories: true + ) + } .background(scrollOffsetObserver) .simultaneousGesture( DragGesture(minimumDistance: 0) @@ -73,6 +80,20 @@ struct HomeView: View { } collapseBaseOffset = scrollOffset } + .onChange(of: addressCacheScope) { _, _ in + guard hasRequestedLocation else { return } + Task { + AppContentCache.shared.invalidate(prefix: "stores:") + AppContentCache.shared.invalidate(prefix: "store-info:") + AppContentCache.shared.invalidate(prefix: "store-catalog:") + AppImageCache.shared.invalidateAll() + await bootstrapStoresFlow( + forceLocationRefresh: true, + category: selectedCategory == "all" ? nil : selectedCategory, + refreshCategories: true + ) + } + } } private var contentStack: some View { @@ -309,18 +330,32 @@ struct HomeView: View { } } - private var sortedStores: [StoreSummary] { + private var storesByPositiveReviews: [StoreSummary] { stores.sorted { lhs, rhs in - (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude) + let lhsPositive = lhs.positiveReviews ?? lhs.reviewsCount ?? 0 + let rhsPositive = rhs.positiveReviews ?? rhs.reviewsCount ?? 0 + if lhsPositive != rhsPositive { + return lhsPositive > rhsPositive + } + + let lhsRating = lhs.rating ?? 0 + let rhsRating = rhs.rating ?? 0 + if lhsRating != rhsRating { + return lhsRating > rhsRating + } + + return (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude) } } private var featuredStoresCards: [FeaturedStoreCardModel] { - Array(sortedStores.prefix(6)).map(mapStoreToCard) + Array(storesByPositiveReviews.prefix(5)).map(mapStoreToCard) } private var nearbyStoreCards: [FeaturedStoreCardModel] { - Array(sortedStores.prefix(20)).map(mapStoreToCard) + let featuredIds = Set(storesByPositiveReviews.prefix(5).map(\.id)) + let remaining = storesByPositiveReviews.filter { featuredIds.contains($0.id) == false } + return Array(remaining.prefix(20)).map(mapStoreToCard) } private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel { @@ -330,7 +365,7 @@ struct HomeView: View { id: store.id, name: store.name, rating: store.rating ?? 0, - reviews: "0", + reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0), distance: formatDistance(store.distance), category: store.category ?? "Loja", promoText: nil, @@ -338,20 +373,24 @@ struct HomeView: View { iconName: "storefront", imageURL: coverURL ?? logoURL, logoURL: logoURL, - coverURL: coverURL + coverURL: coverURL, + isOpen: store.isOpen ?? true, + statusLabel: store.statusLabel ) } private func resolveStoreMediaURL(_ raw: String?) -> String? { - guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), - raw.isEmpty == false else { return nil } + guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + normalized.isEmpty == false else { return nil } - if raw.lowercased().hasPrefix("http://") || raw.lowercased().hasPrefix("https://") { - return raw + normalized = normalized.replacingOccurrences(of: "\\/", with: "/") + let lower = normalized.lowercased() + if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") { + return normalized } let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let path = raw.hasPrefix("/") ? raw : "/\(raw)" + let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)" return "\(base)\(path)" } @@ -385,6 +424,26 @@ struct HomeView: View { } do { + let storesCacheKey = homeStoresCacheKey( + lat: coordinate?.0, + lng: coordinate?.1, + category: category + ) + + if forceLocationRefresh == false, + let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) { + isLoadingStores = false + stores = cachedStores + if refreshCategories || (category == nil && categories.count <= 1) { + await loadHomeCategories(withFallbackStores: cachedStores) + if categories.contains(where: { $0.id == selectedCategory }) == false { + selectedCategory = "all" + } + } + storesError = nil + return + } + let response = try await ApiService().listStores( lat: coordinate?.0, lng: coordinate?.1, @@ -398,6 +457,7 @@ struct HomeView: View { } let results = response.result ?? [] stores = results + AppContentCache.shared.set(results, for: storesCacheKey, ttl: 180) if refreshCategories || (category == nil && categories.count <= 1) { await loadHomeCategories(withFallbackStores: results) if categories.contains(where: { $0.id == selectedCategory }) == false { @@ -412,7 +472,6 @@ struct HomeView: View { } } - @ViewBuilder private var scrollOffsetObserver: some View { #if os(iOS) @@ -427,4 +486,19 @@ struct HomeView: View { EmptyView() #endif } + + private var addressCacheScope: String { + let selected = appState.address.selectedId ?? "nil" + let display = appState.address.display + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return "\(selected)|\(display)" + } + + private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String { + let normalizedCategory = category?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "all" + let latKey = lat.map { String(format: "%.4f", $0) } ?? "nil" + let lngKey = lng.map { String(format: "%.4f", $0) } ?? "nil" + return "stores:\(latKey):\(lngKey):\(normalizedCategory)" + } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift index 8514e8e..c2971d2 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift @@ -32,7 +32,7 @@ struct MainTabView: View { private var customTabBar: some View { HStack(spacing: 12) { tabBarButton(tab: .home, title: "Home", icon: "house.fill") - tabBarButton(tab: .cart, title: "Carrinho", icon: "cart.fill") + tabBarButton(tab: .cart, title: "Carrinho", icon: "cart.fill", badgeCount: appState.cart.totalItems) tabBarButton(tab: .profile, title: "Perfil", icon: "person.fill") } .padding(.horizontal, 14) @@ -46,14 +46,27 @@ struct MainTabView: View { .shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y) } - private func tabBarButton(tab: MainTab, title: String, icon: String) -> some View { + private func tabBarButton(tab: MainTab, title: String, icon: String, badgeCount: Int = 0) -> some View { let isActive = selectedTab == tab return Button { selectedTab = tab } label: { HStack(spacing: 8) { - Image(systemName: icon) - .font(.system(size: 18, weight: .semibold)) + ZStack(alignment: .topTrailing) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + + if badgeCount > 0 { + Text("\(min(badgeCount, 99))") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(Color.white) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.red) + .clipShape(Capsule()) + .offset(x: 9, y: -8) + } + } if isActive { Text(title) .font(AppTypography.heading3) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift new file mode 100644 index 0000000..2a29537 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift @@ -0,0 +1,106 @@ +import Foundation +import SwiftUI + +struct PizzaFlavorAddonsSheet: View { + let flavor: StoreCatalogProduct + @Binding var quantities: [String: Int] + + @Environment(\.dismiss) var dismiss + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 12) { + Text(flavor.name) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + if flavor.addonGroups.isEmpty { + Text("Este sabor não possui adicionais.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } else { + ForEach(flavor.addonGroups) { group in + VStack(alignment: .leading, spacing: 10) { + Text(group.name) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + ForEach(group.items) { item in + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(item.name) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + Text("+ \(formatCurrency(item.price ?? 0))") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + + Spacer() + + HStack(spacing: 8) { + Button(action: { decrement(item.id) }) { + Image(systemName: "minus") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 26, height: 26) + .background(AppColors.brandSoft) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled((quantities[item.id] ?? 0) <= 0) + + Text("\(quantities[item.id] ?? 0)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(minWidth: 18) + + Button(action: { increment(item.id) }) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 26, height: 26) + .background(AppColors.tertiary) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } + } + } + .padding(12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + } + .padding(20) + } + .background(AppColors.backgroundLight.ignoresSafeArea()) + .navigationTitle("Adicionais") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Concluir") { dismiss() } + .foregroundStyle(AppColors.primary) + } + } + } + + private func increment(_ addonId: String) { + quantities[addonId, default: 0] += 1 + } + + private func decrement(_ addonId: String) { + let current = quantities[addonId] ?? 0 + if current <= 1 { + quantities.removeValue(forKey: addonId) + } else { + quantities[addonId] = current - 1 + } + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift new file mode 100644 index 0000000..5999afe --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift @@ -0,0 +1,229 @@ +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(Array(sizeItems.indices), id: \.self) { index in + sizeRow(sizeItems[index]) + } + } + .padding(12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + var stepDoughs: some View { + VStack(alignment: .leading, spacing: 10) { + Text("2. Massa") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + if doughs.count <= 1 { + Text(doughs.first?.name ?? "Massa tradicional") + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + } else { + ForEach(doughs) { dough in + radioRow( + title: dough.name ?? "Massa", + subtitle: nil, + isSelected: selectedDoughId == dough.id + ) { + selectedDoughId = dough.id + } + } + } + } + .padding(12) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + var stepCrusts: some View { + VStack(alignment: .leading, spacing: 10) { + Text("3. Borda") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + if crusts.count <= 1 { + Text(crustDescription(crusts.first)) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + } else { + ForEach(crusts) { crust in + radioRow( + title: crust.name ?? "Borda", + subtitle: crust.priceModifier ?? 0 > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil, + isSelected: selectedCrustId == crust.id + ) { + selectedCrustId = crust.id + } + } + } + } + .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) + + Text("Toque no sabor para escolher adicionais.") + .font(.caption) + .foregroundStyle(AppColors.textMuted) + + ForEach(Array(flavorItems.indices), id: \.self) { index in + let flavor = flavorItems[index] + let isSelected = selectedFlavorIds.contains(flavor.id) + let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price + let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed + let disableSwitch = isSelected == false && maxReached + + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(flavor.name) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + if let price { + Text(formatCurrency(price)) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + } + + Spacer() + + Toggle("", isOn: Binding( + get: { isSelected }, + set: { value in + if value { + addFlavor(flavor.id) + } else { + removeFlavor(flavor.id) + } + } + )) + .labelsHidden() + .disabled(disableSwitch) + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + .onTapGesture { + guard isSelected else { return } + guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return } + selectedFlavorForAddons = flavor + } + } + } + .padding(12) + .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 + + return radioRow( + title: title, + subtitle: subtitle, + isSelected: selected + ) { + selectedSizeId = size.id + } + } + + func radioRow(title: String, subtitle: String?, isSelected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 10) { + ZStack { + Circle() + .stroke(isSelected ? AppColors.primary : AppColors.textMuted.opacity(0.4), lineWidth: 2) + .frame(width: 20, height: 20) + if isSelected { + Circle() + .fill(AppColors.primary) + .frame(width: 10, height: 10) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + if let subtitle, subtitle.isEmpty == false { + Text(subtitle) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + } + + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + func crustDescription(_ crust: StorePizzaCrust?) -> String { + 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 + } + + func applyAutoSelections() { + if selectedSizeId != nil { + 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__" + } + } + } + + func trimFlavorSelectionByLimit() { + let limit = maxFlavorsAllowed + guard selectedFlavorIds.count > limit else { return } + let sorted = selectedFlavorIds.sorted() + selectedFlavorIds = Set(sorted.prefix(limit)) + } + + func addFlavor(_ flavorId: String) { + if selectedFlavorIds.contains(flavorId) { return } + if selectedFlavorIds.count >= maxFlavorsAllowed { return } + selectedFlavorIds.insert(flavorId) + } + + func removeFlavor(_ flavorId: String) { + selectedFlavorIds.remove(flavorId) + flavorAddonQuantities.removeValue(forKey: flavorId) + } + + func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift new file mode 100644 index 0000000..8ff0b03 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift @@ -0,0 +1,319 @@ +import Foundation +import SwiftUI + +struct PizzaProductDetailSheet: View { + let category: StoreCatalogCategory + let storeId: String + let resolveImageURL: (String?) -> String? + let currentQuantityForItemId: (String) -> Int + let onAdd: (CartItemState) -> Void + + @Environment(\.dismiss) var dismiss + + @State var selectedSizeId: String? = nil + @State var selectedDoughId: String? = nil + @State var selectedCrustId: String? = nil + @State var selectedFlavorIds: Set = [] + @State var flavorAddonQuantities: [String: [String: Int]] = [:] + @State var selectedFlavorForAddons: StoreCatalogProduct? = nil + @State var quantity: Int = 1 + + var flavors: [StoreCatalogProduct] { + category.products + } + + var pizzaConfig: StorePizzaConfig? { + category.pizzaConfig + } + + var sizes: [StorePizzaSize] { + pizzaConfig?.sizes ?? [] + } + + var doughs: [StorePizzaDough] { + (pizzaConfig?.doughs ?? []).filter { $0.active ?? true } + } + + var crusts: [StorePizzaCrust] { + (pizzaConfig?.crusts ?? []).filter { $0.active ?? true } + } + + private var representativeImage: String? { + let firstImage = flavors + .compactMap(\.image) + .first { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } + return resolveImageURL(firstImage) + } + + private var selectedSize: StorePizzaSize? { + guard let selectedSizeId else { return nil } + return sizes.first(where: { $0.id == selectedSizeId }) + } + + private var selectedDoughName: String? { + guard let selectedDoughId else { return nil } + return doughs.first(where: { $0.id == selectedDoughId })?.name + } + + private var selectedCrust: StorePizzaCrust? { + guard let selectedCrustId else { return nil } + return crusts.first(where: { $0.id == selectedCrustId }) + } + + var maxFlavorsAllowed: Int { + max(1, selectedSize?.maxFlavors ?? 1) + } + + var isDoughReady: Bool { + selectedSizeId != nil && (doughs.isEmpty || selectedDoughId != nil) + } + + var isCrustReady: Bool { + isDoughReady && (crusts.isEmpty || selectedCrustId != nil) + } + + var canShowFlavors: Bool { + isCrustReady + } + + private var selectedFlavorProducts: [StoreCatalogProduct] { + flavors + .filter { selectedFlavorIds.contains($0.id) } + .sorted { $0.name < $1.name } + } + + private var canConfirm: Bool { + selectedSizeId != nil && isDoughReady && isCrustReady && selectedFlavorIds.isEmpty == false && quantity > 0 + } + + private var crustPriceModifier: Double { + selectedCrust?.priceModifier ?? 0 + } + + private var addonsTotal: Double { + selectedFlavorProducts.reduce(0) { partial, flavor in + let byAddon = flavorAddonQuantities[flavor.id] ?? [:] + let pricesByAddon = Dictionary(uniqueKeysWithValues: flavor.addonGroups.flatMap(\.items).map { ($0.id, $0.price ?? 0) }) + let subtotal = byAddon.reduce(0.0) { line, pair in + line + (Double(max(0, pair.value)) * (pricesByAddon[pair.key] ?? 0)) + } + return partial + subtotal + } + } + + 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 + } + + private var unitPrice: Double { + basePizzaPrice + crustPriceModifier + addonsTotal + } + + private var totalPrice: Double { + unitPrice * Double(quantity) + } + + private var cartItemId: String { + var tokens: [String] = [] + if let selectedSizeId { tokens.append("size:\(selectedSizeId)") } + if let selectedDoughId { tokens.append("dough:\(selectedDoughId)") } + if let selectedCrustId { tokens.append("crust:\(selectedCrustId)") } + + let flavorsToken = selectedFlavorIds.sorted().joined(separator: ",") + tokens.append("flavors:\(flavorsToken)") + + let addonsToken = flavorAddonQuantities + .flatMap { flavorId, addons in + addons + .filter { $0.value > 0 } + .map { "\(flavorId):\($0.key):\($0.value)" } + } + .sorted() + .joined(separator: ",") + if addonsToken.isEmpty == false { + tokens.append("addons:\(addonsToken)") + } + + return "\(storeId)::pizza::\(category.id)::" + tokens.joined(separator: "|") + } + + private var selectedAddonsPayload: [CartItemAddonState] { + var payload: [CartItemAddonState] = [] + for flavor in selectedFlavorProducts { + let byAddon = flavorAddonQuantities[flavor.id] ?? [:] + let addonMap = Dictionary(uniqueKeysWithValues: flavor.addonGroups.flatMap(\.items).map { ($0.id, $0) }) + for (addonId, qty) in byAddon { + guard qty > 0, let addon = addonMap[addonId] else { continue } + payload.append( + CartItemAddonState( + id: "\(flavor.id)::\(addon.id)", + name: "\(flavor.name) • \(addon.name)", + quantity: qty, + unitPrice: addon.price ?? 0 + ) + ) + } + } + return payload + } + + private var selectedDetailsText: String? { + var chunks: [String] = [] + if let selectedSizeName = selectedSize?.name { + chunks.append("Tamanho: \(selectedSizeName)") + } + if let selectedDoughName, selectedDoughName.isEmpty == false { + chunks.append("Massa: \(selectedDoughName)") + } + if let crustName = selectedCrust?.name, crustName.isEmpty == false { + chunks.append("Borda: \(crustName)") + } + if selectedFlavorProducts.isEmpty == false { + chunks.append("Sabores: " + selectedFlavorProducts.map(\.name).joined(separator: ", ")) + } + return chunks.isEmpty ? nil : chunks.joined(separator: " • ") + } + + private var addButtonTitle: String { + if canConfirm == false { + return "Selecione as opções" + } + return "Adicionar • \(formatCurrency(totalPrice))" + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + AsyncStoreImage(imageURL: representativeImage) + .frame(height: 220) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + + Text("Pizza de varios sabores") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + Text("Escolha o tamanho da sua fome") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + + Text(formatCurrency(unitPrice)) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.primary) + + stepSizes + + if selectedSizeId != nil { + stepDoughs + } + + if isDoughReady { + stepCrusts + } + + if canShowFlavors { + stepFlavors + } + } + .padding(20) + .padding(.bottom, 90) + } + .safeAreaInset(edge: .bottom) { + HStack(spacing: 12) { + HStack(spacing: 10) { + Button(action: { if quantity > 1 { quantity -= 1 } }) { + Image(systemName: "minus") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 32, height: 32) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(quantity <= 1) + + Text("\(quantity)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(minWidth: 20) + + Button(action: { quantity += 1 }) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 32, height: 32) + .background(AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .frame(height: 48) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + + PrimaryButton(title: addButtonTitle) { + guard canConfirm else { return } + let item = CartItemState( + id: cartItemId, + productId: selectedFlavorProducts.first?.id ?? category.id, + storeId: storeId, + name: "Pizza de varios sabores", + imageURL: representativeImage, + details: selectedDetailsText, + addons: selectedAddonsPayload, + quantity: quantity, + unitPrice: unitPrice + ) + onAdd(item) + dismiss() + } + .disabled(canConfirm == false) + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 12) + .background(.ultraThinMaterial) + } + .sheet(item: $selectedFlavorForAddons) { flavor in + NavigationStack { + PizzaFlavorAddonsSheet( + flavor: flavor, + quantities: Binding( + get: { flavorAddonQuantities[flavor.id] ?? [:] }, + set: { flavorAddonQuantities[flavor.id] = $0 } + ) + ) + } + } + .background(AppColors.backgroundLight.ignoresSafeArea()) + .navigationTitle("Monte sua pizza") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Fechar") { dismiss() } + .foregroundStyle(AppColors.primary) + } + } + .onAppear { + applyAutoSelections() + let existing = currentQuantityForItemId(cartItemId) + if existing > 0 { + quantity = existing + } + } + .onChange(of: selectedSizeId) { _, _ in + trimFlavorSelectionByLimit() + applyAutoSelections() + } + .onChange(of: selectedFlavorIds) { _, newValue in + let selected = newValue + flavorAddonQuantities = flavorAddonQuantities.filter { selected.contains($0.key) } + } + } + +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift index 1e3240b..cf4149d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift @@ -8,17 +8,26 @@ struct ProductDetailSheet: View { let currentQuantityForItemId: (String) -> Int let onAdd: (CartItemState) -> Void @Environment(\.dismiss) var dismiss - @State var selectedAddonItemIds: Set = [] + @State var selectedAddonQuantities: [String: Int] = [:] @State var quantity: Int = 0 - private var selectedAddonItems: [StoreAddonItem] { - product.addonGroups - .flatMap(\.items) - .filter { selectedAddonItemIds.contains($0.id) } + private var addonItemsById: [String: StoreAddonItem] { + Dictionary(uniqueKeysWithValues: product.addonGroups.flatMap(\.items).map { ($0.id, $0) }) + } + + private var selectedAddonItems: [(item: StoreAddonItem, quantity: Int)] { + selectedAddonQuantities + .compactMap { key, qty in + guard qty > 0, let item = addonItemsById[key] else { return nil } + return (item, qty) + } + .sorted { $0.item.name < $1.item.name } } private var addonsTotal: Double { - selectedAddonItems.reduce(0) { $0 + ($1.price ?? 0) } + selectedAddonItems.reduce(0) { partial, pair in + partial + (Double(pair.quantity) * (pair.item.price ?? 0)) + } } private var unitPrice: Double { @@ -30,16 +39,29 @@ struct ProductDetailSheet: View { } private var cartItemId: String { - let addonKey = selectedAddonItemIds.sorted().joined(separator: ",") + let addonKey = encodedAddonKey return "\(storeId)::\(product.id)::\(addonKey)" } private var selectedAddonsSummary: String? { - let names = selectedAddonItems.map(\.name) + let names = selectedAddonItems.map { pair in + pair.quantity > 1 ? "\(pair.item.name) x\(pair.quantity)" : pair.item.name + } if names.isEmpty { return nil } return names.joined(separator: ", ") } + private var selectedAddonsPayload: [CartItemAddonState] { + selectedAddonItems.map { pair in + CartItemAddonState( + id: pair.item.id, + name: pair.item.name, + quantity: pair.quantity, + unitPrice: pair.item.price ?? 0 + ) + } + } + private var addButtonTitle: String { if quantity <= 0 { return "Remover do carrinho" @@ -72,6 +94,10 @@ struct ProductDetailSheet: View { Text("Inclui adicionais: \(formatCurrency(addonsTotal))") .font(.caption) .foregroundStyle(AppColors.textMuted) + } else { + Text("Sem adicionais") + .font(.caption) + .foregroundStyle(AppColors.textMuted) } if product.addonGroups.isEmpty == false { @@ -86,28 +112,47 @@ struct ProductDetailSheet: View { .foregroundStyle(AppColors.textPrimary) ForEach(group.items) { item in - Button { - if selectedAddonItemIds.contains(item.id) { - selectedAddonItemIds.remove(item.id) - } else { - selectedAddonItemIds.insert(item.id) - } - } label: { - HStack { - Image(systemName: selectedAddonItemIds.contains(item.id) ? "checkmark.circle.fill" : "circle") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(selectedAddonItemIds.contains(item.id) ? AppColors.primary : AppColors.textMuted) - + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { Text(item.name) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) - Spacer() Text(String(format: "+ R$ %.2f", item.price ?? 0).replacingOccurrences(of: ".", with: ",")) - .font(AppTypography.body) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + + Spacer() + + HStack(spacing: 8) { + Button(action: { decrementAddon(item.id) }) { + Image(systemName: "minus") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 26, height: 26) + .background(AppColors.brandSoft) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(quantity(forAddonId: item.id) <= 0 || quantity <= 0) + + Text("\(quantity(forAddonId: item.id))") + .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) + .frame(minWidth: 18) + + Button(action: { incrementAddon(item.id) }) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 26, height: 26) + .background(AppColors.tertiary) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(quantity <= 0) } } - .buttonStyle(.plain) } } .padding(12) @@ -160,7 +205,9 @@ struct ProductDetailSheet: View { productId: product.id, storeId: storeId, name: product.name, + imageURL: imageURL, details: selectedAddonsSummary, + addons: selectedAddonsPayload, quantity: quantity, unitPrice: unitPrice ) @@ -183,14 +230,50 @@ struct ProductDetailSheet: View { } } .onAppear { - quantity = currentQuantityForItemId(cartItemId) + let existing = currentQuantityForItemId(cartItemId) + quantity = existing > 0 ? existing : 1 } - .onChange(of: selectedAddonItemIds) { _, _ in - quantity = currentQuantityForItemId(cartItemId) + .onChange(of: selectedAddonQuantities) { _, _ in + // Keep the main quantity stable when changing addon quantities. + // Only hydrate from cart if this exact configuration already exists. + let existingQuantity = currentQuantityForItemId(cartItemId) + if existingQuantity > 0 { + quantity = existingQuantity + } + } + .onChange(of: quantity) { _, newValue in + if newValue <= 0 { + selectedAddonQuantities.removeAll() + } } } private func formatCurrency(_ value: Double) -> String { String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } + + private var encodedAddonKey: String { + let tokens = selectedAddonQuantities + .filter { $0.value > 0 } + .map { "\($0.key):\($0.value)" } + .sorted() + return tokens.isEmpty ? "base" : tokens.joined(separator: ",") + } + + private func quantity(forAddonId addonId: String) -> Int { + selectedAddonQuantities[addonId] ?? 0 + } + + private func incrementAddon(_ addonId: String) { + selectedAddonQuantities[addonId, default: 0] += 1 + } + + private func decrementAddon(_ addonId: String) { + let current = selectedAddonQuantities[addonId] ?? 0 + if current <= 1 { + selectedAddonQuantities.removeValue(forKey: addonId) + } else { + selectedAddonQuantities[addonId] = current - 1 + } + } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index 639fa06..00c0d04 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -192,6 +192,8 @@ struct ProfileView: View { private func logout() { tokenStore.clear() SessionStateStore.clearActiveUser() + AppContentCache.shared.invalidate() + AppImageCache.shared.invalidateAll() appState = AppState() root = .auth } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailPizzaSupport.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailPizzaSupport.swift new file mode 100644 index 0000000..f8e1eb4 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailPizzaSupport.swift @@ -0,0 +1,32 @@ +import Foundation + +struct StoreCatalogListItem: Identifiable { + let id: String + let product: StoreCatalogProduct + let title: String + let description: String? + let imageURL: String? + let isPizzaSummary: Bool + let pizzaCategoryId: String? + let pizzaProductIds: [String] + + init( + id: String, + product: StoreCatalogProduct, + title: String, + description: String?, + imageURL: String?, + isPizzaSummary: Bool = false, + pizzaCategoryId: String? = nil, + pizzaProductIds: [String] = [] + ) { + self.id = id + self.product = product + self.title = title + self.description = description + self.imageURL = imageURL + self.isPizzaSummary = isPizzaSummary + self.pizzaCategoryId = pizzaCategoryId + self.pizzaProductIds = pizzaProductIds + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift index e75f0d2..ba3b30c 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailSupport.swift @@ -134,22 +134,8 @@ struct AsyncStoreImage: View { let imageURL: String? var body: some View { - Group { - if let imageURL, - let url = URL(string: imageURL) { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFill() - default: - fallback - } - } - } else { - fallback - } + CachedRemoteImage(imageURL: imageURL) { + fallback } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppColors.brandSoft) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift index fe68ac4..a7cfc30 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -56,11 +56,54 @@ extension StoreDetailView { return "R$ --" } + var isStoreOpen: Bool { + info?.isOpen ?? true + } + + var summaryCardHeight: CGFloat { + summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight) + } + + var closedStoreBannerText: String { + let label = info?.statusLabel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if label.isEmpty { + return "Loja fechada • Consulte o horário de abertura" + } + let normalized = label.lowercased() + if normalized.hasPrefix("fechado") { + let cleaned = label.replacingOccurrences(of: "Fechado", with: "") + .replacingOccurrences(of: "fechado", with: "") + .trimmingCharacters(in: CharacterSet(charactersIn: " -:•")) + if cleaned.isEmpty == false { + return "Loja fechada • \(cleaned)" + } + } + return "Loja fechada • \(label)" + } + @MainActor - func loadStoreData() async { + func loadStoreData(forceRefresh: Bool = false) async { isLoading = true errorMessage = nil + let infoCacheKey = "store-info:\(storeId)" + let catalogCacheKey = "store-catalog:\(storeId)" + + if forceRefresh == false, + let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self), + let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) { + info = cachedInfo + categories = cachedCatalog + selectedCategoryId = cachedCatalog.first?.id + isLoading = false + return + } + + if forceRefresh { + AppContentCache.shared.invalidate(prefix: "store-info:\(storeId)") + AppContentCache.shared.invalidate(prefix: "store-catalog:\(storeId)") + } + do { async let infoRequest = ApiService().storeInfo(storeId: storeId) async let catalogRequest = ApiService().storeCatalog(storeId: storeId) @@ -80,6 +123,10 @@ extension StoreDetailView { info = infoResponse.result categories = catalogResponse.result ?? [] selectedCategoryId = categories.first?.id + if let info = infoResponse.result { + AppContentCache.shared.set(info, for: infoCacheKey, ttl: 300) + } + AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: 300) isLoading = false } catch { isLoading = false @@ -96,15 +143,16 @@ extension StoreDetailView { } func resolvedURL(_ raw: String?) -> String? { - guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), - raw.isEmpty == false else { return nil } + guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + normalized.isEmpty == false else { return nil } - let lower = raw.lowercased() - if lower.hasPrefix("http://") || lower.hasPrefix("https://") { - return raw + normalized = normalized.replacingOccurrences(of: "\\/", with: "/") + let lower = normalized.lowercased() + if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") { + return normalized } let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let path = raw.hasPrefix("/") ? raw : "/\(raw)" + let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)" return "\(base)\(path)" } @@ -113,6 +161,23 @@ extension StoreDetailView { return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } + func listPriceLabel(for product: StoreCatalogProduct, in category: StoreCatalogCategory) -> String { + guard product.type?.lowercased() == "pizza", product.pizzaPrices.isEmpty == false else { + return formatCurrency(product.price) + } + + if let firstSizeId = category.pizzaConfig?.sizes.first?.id, + let firstSizePrice = product.pizzaPrices[firstSizeId] { + return "A partir de \(formatCurrency(firstSizePrice))" + } + + if let fallback = product.pizzaPrices.sorted(by: { $0.key < $1.key }).first?.value { + return "A partir de \(formatCurrency(fallback))" + } + + return formatCurrency(product.price) + } + var topSectionHeight: CGFloat { cardTopInset + summaryCardHeight } @@ -146,7 +211,53 @@ extension StoreDetailView { .reduce(0) { $0 + $1.quantity } } + func quantityInCart(for item: StoreCatalogListItem) -> Int { + if item.isPizzaSummary { + let ids = Set(item.pizzaProductIds) + return appState.cart.items + .filter { $0.storeId == storeId && ids.contains($0.productId) } + .reduce(0) { $0 + $1.quantity } + } + return quantityInCart(for: item.product.id) + } + + func listItems(for category: StoreCatalogCategory) -> [StoreCatalogListItem] { + if category.isPizzaCategory { + guard let first = category.products.first else { return [] } + let representativeImage = category.products + .compactMap(\.image) + .first { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } + + return [ + StoreCatalogListItem( + id: "\(category.id)::pizza-summary", + product: first, + title: "Pizza de varios sabores", + description: "Escolha o tamanho da sua fome", + imageURL: representativeImage ?? first.image, + isPizzaSummary: true, + pizzaCategoryId: category.id, + pizzaProductIds: category.products.map(\.id) + ) + ] + } + + return category.products.map { product in + StoreCatalogListItem( + id: product.id, + product: product, + title: product.name, + description: product.description, + imageURL: product.image + ) + } + } + func requestAddToCart(_ item: CartItemState) { + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } pendingCartAction = .add if shouldAskForStoreSwitch(for: storeId) { pendingCartItem = item @@ -158,6 +269,10 @@ extension StoreDetailView { } func requestSetCartItem(_ item: CartItemState) { + guard isStoreOpen || item.quantity <= 0 else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } pendingCartAction = .set if shouldAskForStoreSwitch(for: storeId) && item.quantity > 0 { pendingCartItem = item @@ -169,6 +284,10 @@ extension StoreDetailView { } func requestOpenProductSheet(_ product: StoreCatalogProduct) { + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } pendingCartAction = .openProductSheet if shouldAskForStoreSwitch(for: storeId) { pendingCartItem = nil @@ -179,6 +298,22 @@ extension StoreDetailView { selectedProduct = product } + func requestOpenPizzaSheet(categoryId: String) { + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } + pendingCartAction = .openPizzaSheet + if shouldAskForStoreSwitch(for: storeId) { + pendingCartItem = nil + pendingProductSheet = nil + pendingPizzaCategoryId = categoryId + showSwitchStoreAlert = true + return + } + selectedPizzaCategoryId = categoryId + } + func applyAddToCart(_ item: CartItemState) { if appState.cart.storeId == nil { appState.cart.storeId = storeId @@ -224,4 +359,5 @@ enum CartAction { case add case set case openProductSheet + case openPizzaSheet } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift index eeb84b3..19f3732 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift @@ -22,9 +22,11 @@ struct StoreDetailView: View { @State var categories: [StoreCatalogCategory] = [] @State var selectedCategoryId: String? = nil @State var selectedProduct: StoreCatalogProduct? = nil + @State var selectedPizzaCategoryId: String? = nil @State var showSwitchStoreAlert = false @State var pendingCartItem: CartItemState? = nil @State var pendingProductSheet: StoreCatalogProduct? = nil + @State var pendingPizzaCategoryId: String? = nil @State var pendingCartAction: CartAction = .add @State var didLoad = false @State var categoryHeaderOffsets: [String: CGFloat] = [:] @@ -32,7 +34,8 @@ struct StoreDetailView: View { @State var scrollOffsetY: CGFloat = 0 let cardTopInset: CGFloat = 168 - let summaryCardHeight: CGFloat = 170 + let summaryCardBaseHeight: CGFloat = 170 + let closedBannerHeight: CGFloat = 44 let coverVisibleUntilY: CGFloat = 253 let storeLogoSize: CGFloat = 84 @@ -53,6 +56,9 @@ struct StoreDetailView: View { sectionedProducts } } + .refreshable { + await loadStoreData(forceRefresh: true) + } .ignoresSafeArea(edges: .top) .background(ScrollOffsetReader(offsetY: $scrollOffsetY)) } @@ -64,6 +70,7 @@ struct StoreDetailView: View { .zIndex(20) } } + .saturation(isStoreOpen ? 1 : 0) } } .navigationBarBackButtonHidden(true) @@ -72,7 +79,7 @@ struct StoreDetailView: View { .task { guard didLoad == false else { return } didLoad = true - await loadStoreData() + await loadStoreData(forceRefresh: false) } .sheet(item: $selectedProduct) { product in NavigationStack { @@ -84,15 +91,52 @@ struct StoreDetailView: View { currentQuantity(forCartItemId: itemId) }, onAdd: { item in + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } requestSetCartItem(item) } ) } } + .sheet( + isPresented: Binding( + get: { selectedPizzaCategoryId != nil }, + set: { isPresented in + if isPresented == false { + selectedPizzaCategoryId = nil + } + } + ) + ) { + if let category = categories.first(where: { $0.id == selectedPizzaCategoryId }) { + NavigationStack { + PizzaProductDetailSheet( + category: category, + storeId: storeId, + resolveImageURL: { raw in resolvedURL(raw) }, + currentQuantityForItemId: { itemId in + currentQuantity(forCartItemId: itemId) + }, + onAdd: { item in + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } + requestSetCartItem(item) + } + ) + } + } else { + ProgressView() + } + } .alert("Trocar de loja?", isPresented: $showSwitchStoreAlert) { Button("Cancelar", role: .cancel) { pendingCartItem = nil pendingProductSheet = nil + pendingPizzaCategoryId = nil } Button("Limpar carrinho e adicionar", role: .destructive) { appState.cart.clear() @@ -106,9 +150,13 @@ struct StoreDetailView: View { case .openProductSheet: guard let pendingProductSheet else { return } selectedProduct = pendingProductSheet + case .openPizzaSheet: + guard let pendingPizzaCategoryId else { return } + selectedPizzaCategoryId = pendingPizzaCategoryId } self.pendingCartItem = nil self.pendingProductSheet = nil + self.pendingPizzaCategoryId = nil } } message: { Text("Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?") @@ -193,34 +241,44 @@ struct StoreDetailView: View { } private var summaryCard: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 6) { - Text(storeName) - .font(AppTypography.heading2) - .foregroundStyle(AppColors.textPrimary) - .lineLimit(2) - .padding(.top, 30) + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text(storeName) + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(2) + .padding(.top, 30) - Text(storeSubtitle) - .font(.caption) - .foregroundStyle(AppColors.textMuted) - .lineLimit(1) + Text(storeSubtitle) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + } + + Spacer() + + ratingChip } - Spacer() - - ratingChip + HStack(spacing: 0) { + statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min") + Divider().frame(height: 34) + statItem(title: "ENTREGA", value: deliveryValueLabel) + } + .padding(.vertical, 4) } + .padding(16) - HStack(spacing: 0) { - statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min") - Divider().frame(height: 34) - statItem(title: "ENTREGA", value: deliveryValueLabel) + if isStoreOpen == false { + Text(closedStoreBannerText) + .font(AppTypography.heading3) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity, minHeight: closedBannerHeight) + .background(AppColors.brandDark) } - .padding(.vertical, 4) } - .padding(16) .frame(height: summaryCardHeight) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) @@ -256,8 +314,8 @@ struct StoreDetailView: View { ForEach(categories, id: \.id) { category in Section { VStack(spacing: 12) { - ForEach(category.products) { product in - productCard(product) + ForEach(listItems(for: category)) { item in + productCard(item, in: category) } } .padding(.horizontal, 16) @@ -331,16 +389,19 @@ struct StoreDetailView: View { max(8, safeTop - 44) } - private func productCard(_ product: StoreCatalogProduct) -> some View { - HStack(spacing: 12) { + private func productCard(_ item: StoreCatalogListItem, in category: StoreCatalogCategory) -> some View { + let product = item.product + let hasSelectableAddons = product.addonGroups.contains { $0.items.isEmpty == false } + + return HStack(spacing: 12) { VStack(alignment: .leading, spacing: 8) { - Text(product.name) + Text(item.title) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) .lineLimit(2) .multilineTextAlignment(.leading) - if let description = product.description, description.isEmpty == false { + if let description = item.description, description.isEmpty == false { Text(description) .font(.caption) .foregroundStyle(AppColors.textMuted) @@ -348,7 +409,7 @@ struct StoreDetailView: View { .multilineTextAlignment(.leading) } - Text(formatCurrency(product.price)) + Text(listPriceLabel(for: product, in: category)) .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) } @@ -356,18 +417,27 @@ struct StoreDetailView: View { Spacer() ZStack(alignment: .bottomTrailing) { - AsyncStoreImage(imageURL: resolvedURL(product.image)) + AsyncStoreImage(imageURL: resolvedURL(item.imageURL)) .frame(width: 92, height: 92) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) Button { - if product.addonGroups.isEmpty { + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } + if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId { + requestOpenPizzaSheet(categoryId: pizzaCategoryId) + return + } + if hasSelectableAddons == false { let basePrice = product.price ?? 0 let item = CartItemState( id: "\(storeId)::\(product.id)::base", productId: product.id, storeId: storeId, name: product.name, + imageURL: resolvedURL(product.image), quantity: 1, unitPrice: basePrice ) @@ -384,7 +454,7 @@ struct StoreDetailView: View { .background(AppColors.tertiary) .clipShape(Circle()) - let qty = quantityInCart(for: product.id) + let qty = quantityInCart(for: item) if qty > 0 { Text("\(qty)") .font(.system(size: 10, weight: .bold)) @@ -399,14 +469,31 @@ struct StoreDetailView: View { } .offset(x: 3, y: -3) .frame(width: 30, height: 30) + .contentShape(Circle()) } .buttonStyle(.plain) + .frame(width: 30, height: 30) + .disabled(isStoreOpen == false) + .opacity(isStoreOpen ? 1 : 0.65) .offset(x: 7, y: 7) } } .padding(12) .background(AppColors.surface) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .onTapGesture { + guard isStoreOpen else { + SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) + return + } + if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId { + requestOpenPizzaSheet(categoryId: pizzaCategoryId) + return + } + guard hasSelectableAddons else { return } + requestOpenProductSheet(product) + } } } diff --git a/reference_img/IMG_0442.PNG b/reference_img/IMG_0442.PNG new file mode 100644 index 0000000000000000000000000000000000000000..36c8b330b67439c5bcdc7eccc216fdfd0fc6e8a0 GIT binary patch literal 465720 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