Payments
This commit is contained in:
@@ -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.<OTP>`.
|
||||
- Passo 1: enviar `email` + `phoneNumber` para receber código por email.
|
||||
- Passo 2: enviar `email` + `phoneNumber` e o OTP no header `Authorization: Bearer <OTP>`.
|
||||
|
||||
**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://<host>:<port>/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 <Users_JWT_Token>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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://<host>:<port>/socket.io/` (usar `wss://` em produção com TLS)
|
||||
|
||||
**Auth no handshake:**
|
||||
- Enviar JWT no campo `auth.token`.
|
||||
- Exemplo: `Bearer <JWT_TOKEN>`.
|
||||
|
||||
**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).
|
||||
|
||||
162
pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift
Normal file
162
pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift
Normal file
@@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct CachedRemoteImage<Placeholder: View>: 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<Void, Never>?
|
||||
|
||||
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[..<commaIndex]).lowercased()
|
||||
guard header.contains(";base64") else { return nil }
|
||||
|
||||
let payloadStart = source.index(after: commaIndex)
|
||||
let payload = String(source[payloadStart...])
|
||||
.replacingOccurrences(of: "\\/", with: "/")
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
.replacingOccurrences(of: "\r", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
|
||||
guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else { return nil }
|
||||
#if canImport(UIKit)
|
||||
return UIImage(data: data)
|
||||
#elseif canImport(AppKit)
|
||||
return NSImage(data: data)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -37,22 +37,28 @@ struct FeaturedStoreCard: View {
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color(hex: "#F5B335"))
|
||||
Text(String(format: "%.1f", store.rating))
|
||||
.font(.caption)
|
||||
if store.isOpen {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color(hex: "#F5B335"))
|
||||
Text(String(format: "%.1f", store.rating))
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text("(\(store.reviews))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text("·")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text(store.distance)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
} else {
|
||||
Text(store.statusLabel?.isEmpty == false ? (store.statusLabel ?? "Fechado") : "Fechado")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text("(\(store.reviews))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text("·")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text(store.distance)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
Text(store.category)
|
||||
@@ -62,6 +68,8 @@ struct FeaturedStoreCard: View {
|
||||
.padding(14)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
.saturation(store.isOpen ? 1 : 0)
|
||||
.opacity(store.isOpen ? 1 : 0.9)
|
||||
}
|
||||
|
||||
private var mediaBlock: some View {
|
||||
@@ -77,23 +85,7 @@ struct FeaturedStoreCard: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var mediaImage: some View {
|
||||
if let imageURL = store.imageURL,
|
||||
let url = URL(string: imageURL) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
default:
|
||||
storeIconPlaceholder
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
} else {
|
||||
storeIconPlaceholder
|
||||
}
|
||||
AsyncStoreImage(imageURL: store.imageURL)
|
||||
}
|
||||
|
||||
private var storeIconPlaceholder: some View {
|
||||
@@ -130,6 +122,8 @@ struct FeaturedStoreCardModel: Identifiable {
|
||||
let imageURL: String?
|
||||
let logoURL: String?
|
||||
let coverURL: String?
|
||||
let isOpen: Bool
|
||||
let statusLabel: String?
|
||||
}
|
||||
|
||||
struct SpecialOfferCard: View {
|
||||
|
||||
@@ -95,22 +95,24 @@ struct ContentView: View {
|
||||
dismissAddressPickerIfAddressExists()
|
||||
}
|
||||
|
||||
// Only refresh profile when there is no local address cache.
|
||||
// This avoids forcing Profile flow on startup and still recovers
|
||||
// existing addresses already registered in backend.
|
||||
if hasConfiguredAddress() == false {
|
||||
do {
|
||||
let response = try await ApiService().profile()
|
||||
if response.error == false, let customer = response.result {
|
||||
hydrateAppState(with: customer)
|
||||
}
|
||||
} catch let error as ApiServiceError {
|
||||
if case .sessionExpired = error {
|
||||
forceLogoutToStart()
|
||||
}
|
||||
} catch {
|
||||
// Keep local state when backend refresh fails transiently.
|
||||
if let cachedCart = SessionStateStore.loadCart() {
|
||||
appState.cart = cachedCart
|
||||
}
|
||||
|
||||
// Always refresh profile when authenticated.
|
||||
// This keeps profile/address/cart scope consistent after relogin
|
||||
// and avoids stale local state during checkout payload generation.
|
||||
do {
|
||||
let response = try await ApiService().profile()
|
||||
if response.error == false, let customer = response.result {
|
||||
hydrateAppState(with: customer)
|
||||
}
|
||||
} catch let error as ApiServiceError {
|
||||
if case .sessionExpired = error {
|
||||
forceLogoutToStart()
|
||||
}
|
||||
} catch {
|
||||
// Keep local state when backend refresh fails transiently.
|
||||
}
|
||||
|
||||
isBootstrappingSession = false
|
||||
@@ -125,6 +127,11 @@ struct ContentView: View {
|
||||
SessionStateStore.setActiveUserKey(
|
||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||
)
|
||||
if let cachedCart = SessionStateStore.loadCart() {
|
||||
appState.cart = cachedCart
|
||||
} else {
|
||||
appState.cart = CartState()
|
||||
}
|
||||
|
||||
let addresses = customer.addressBook ?? []
|
||||
guard addresses.isEmpty == false else {
|
||||
@@ -158,6 +165,8 @@ struct ContentView: View {
|
||||
private func forceLogoutToStart() {
|
||||
tokenStore.clear()
|
||||
SessionStateStore.clearActiveUser()
|
||||
AppContentCache.shared.invalidate()
|
||||
AppImageCache.shared.invalidateAll()
|
||||
isBootstrappingSession = false
|
||||
appState = AppState()
|
||||
selectedTab = .home
|
||||
|
||||
@@ -33,13 +33,15 @@ enum AppDarkColors {
|
||||
enum AppTypography {
|
||||
static let fontFamily = "Plus Jakarta Sans"
|
||||
|
||||
static let heading1 = Font.custom(fontFamily, size: 28).weight(.heavy)
|
||||
static let heading2 = Font.custom(fontFamily, size: 20).weight(.heavy)
|
||||
static let heading3 = Font.custom(fontFamily, size: 16).weight(.bold)
|
||||
static let body = Font.custom(fontFamily, size: 16).weight(.regular)
|
||||
static let button = Font.custom(fontFamily, size: 14).weight(.heavy)
|
||||
static let caption = Font.custom(fontFamily, size: 10).weight(.bold)
|
||||
static let overline = Font.custom(fontFamily, size: 11).weight(.bold)
|
||||
// Avoid applying dynamic weight on custom font descriptors to prevent
|
||||
// SwiftUI runtime warnings on some platforms/toolchains.
|
||||
static let heading1 = Font.custom(fontFamily, size: 28)
|
||||
static let heading2 = Font.custom(fontFamily, size: 20)
|
||||
static let heading3 = Font.custom(fontFamily, size: 16)
|
||||
static let body = Font.custom(fontFamily, size: 16)
|
||||
static let button = Font.custom(fontFamily, size: 14)
|
||||
static let caption = Font.custom(fontFamily, size: 10)
|
||||
static let overline = Font.custom(fontFamily, size: 11)
|
||||
|
||||
static let bodyLineHeight: CGFloat = 1.6
|
||||
static let buttonLetterSpacing: CGFloat = 0.08
|
||||
|
||||
@@ -49,6 +49,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lldx" : {
|
||||
|
||||
},
|
||||
"+ %@" : {
|
||||
"comment" : "A small, secondary text label displaying the price of an item.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"1. Tamanho" : {
|
||||
"comment" : "A heading for the size selection step in the pizza product detail sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"2. Massa" : {
|
||||
|
||||
},
|
||||
"3. Borda" : {
|
||||
|
||||
},
|
||||
"4. Sabores (%lld/%lld)" : {
|
||||
"comment" : "A heading that describes the step where the user selects pizza flavors. The number in the parentheses indicates the maximum number of flavors that can be selected.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "4. Sabores (%1$lld/%2$lld)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Add" : {
|
||||
"comment" : "Button in items list that will cause a new item to be added",
|
||||
"extractionState" : "stale",
|
||||
@@ -91,14 +120,30 @@
|
||||
"comment" : "A message displayed when the cart is empty, encouraging the user to add products.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"AGUARDANDO PAGAMENTO" : {
|
||||
"comment" : "A status text indicating that the payment is pending.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ajuda" : {
|
||||
"comment" : "A link to the help section of the app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Alterar" : {
|
||||
"comment" : "A button label that translates to \"Change\" in English.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)" : {
|
||||
"comment" : "A text that informs the user that registering implies acceptance of the app's terms of use and privacy policy. The text is clickable and navigates to the respective views when tapped.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Apenas Pagar" : {
|
||||
"comment" : "A button that allows the user to pay without saving the payment information.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Aplicar" : {
|
||||
"comment" : "A button that applies a coupon code to a cart.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Appearance" : {
|
||||
"comment" : "Settings select label for the interface style of the controls (light, dark, or default)",
|
||||
"extractionState" : "stale",
|
||||
@@ -195,10 +240,22 @@
|
||||
"comment" : "A section header that lists available food categories.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Código PIX" : {
|
||||
"comment" : "The title of the field that displays the PIX code.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Concluir" : {
|
||||
"comment" : "The text for a button that confirms and closes a sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Conexão segura e criptografada" : {
|
||||
"comment" : "A description below the \"Verificar e Entrar\" button, emphasizing the security of the login process.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Confirmar e Pagar" : {
|
||||
"comment" : "A button label that translates to \"Confirm and Pay\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Conteúdo da política de privacidade..." : {
|
||||
"comment" : "A placeholder text describing the content of the privacy policy.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -215,6 +272,18 @@
|
||||
"comment" : "A link that directs users to create an account.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Cupom aplicado: %@" : {
|
||||
"comment" : "A text label displaying the code of the coupon that has been successfully applied to the cart. The argument is the code of the applied coupon.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Cupom de Desconto" : {
|
||||
"comment" : "A section header for entering and applying a discount coupon.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Dados do Cartão" : {
|
||||
"comment" : "The header text for the section that collects payment card information.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Dark" : {
|
||||
"comment" : "Menu item indicating that the appearance should be in dark mode",
|
||||
"extractionState" : "stale",
|
||||
@@ -289,19 +358,43 @@
|
||||
},
|
||||
"Editar endereço" : {
|
||||
|
||||
},
|
||||
"ENDEREÇO DE ENTREGA" : {
|
||||
"comment" : "A label displayed above the address section when delivery is selected.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Entrar" : {
|
||||
"comment" : "A link that directs the user to the login screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Entrega" : {
|
||||
"comment" : "A text describing delivery mode.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : {
|
||||
"comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Escaneie o QR Code abaixo ou copie o código para pagar no seu aplicativo do banco." : {
|
||||
"comment" : "A description under the QR code that instructs the user on how to pay using their bank app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Escolha o tamanho da sua fome" : {
|
||||
"comment" : "A label displayed below the pizza size selection.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Este sabor não possui adicionais." : {
|
||||
"comment" : "A message displayed when a pizza flavor does not have any add-ons.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Excluir" : {
|
||||
"comment" : "A button label that translates to \"Delete\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Expira em: %@" : {
|
||||
"comment" : "A label displaying the expiration date of a payment code. The text inside the parentheses should be replaced with the actual expiration date.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Favorite" : {
|
||||
"comment" : "Item editor title label for marking the item as a favorite",
|
||||
"extractionState" : "stale",
|
||||
@@ -344,6 +437,10 @@
|
||||
"comment" : "A description of the filters feature that will be added to the home screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Finalizar Pedido" : {
|
||||
"comment" : "The title of the checkout screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hello [%@](https://skip.tools)!" : {
|
||||
"comment" : "Welcome tab contents",
|
||||
"extractionState" : "stale",
|
||||
@@ -411,10 +508,22 @@
|
||||
"comment" : "A footnote at the bottom of the checkout view showing the cost of any selected additional items. The argument is the total cost of the selected additional items.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Inserir cupom" : {
|
||||
"comment" : "A placeholder text for a text field where a user can input a coupon code.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Insira o código de 8 dígitos enviado" : {
|
||||
"comment" : "A description below the text field where the user inputs their OTP code.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ir para o Pagamento" : {
|
||||
"comment" : "A button label that translates to \"Go to Payment\" in English.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Já realizei o pagamento" : {
|
||||
"comment" : "A button that lets the user know they've completed the payment.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Já tem uma conta?" : {
|
||||
"comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -453,6 +562,17 @@
|
||||
"comment" : "A button that clears the current cart and adds the selected product.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Loja não informou métodos presenciais." : {
|
||||
|
||||
},
|
||||
"MÉTODO DE PAGAMENTO" : {
|
||||
"comment" : "A label displayed above the payment methods section in the checkout view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Meu Carrinho" : {
|
||||
"comment" : "The title of the user's cart screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Meus Endereços" : {
|
||||
"comment" : "A label displayed at the top of the view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -461,6 +581,10 @@
|
||||
"comment" : "A button that links to their reviews.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Monte sua pizza" : {
|
||||
"comment" : "The title of the sheet that allows users to customize and add pizzas to their cart.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Name" : {
|
||||
"comment" : "Placeholder title for the Name field in a form",
|
||||
"extractionState" : "stale",
|
||||
@@ -491,6 +615,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Não" : {
|
||||
"comment" : "The label of a button in an alert that says \"No\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Não recebeu o código?" : {
|
||||
"comment" : "A question displayed below the button to re-send the OTP.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -550,6 +678,14 @@
|
||||
},
|
||||
"O que vai querer pedir hoje?\n " : {
|
||||
|
||||
},
|
||||
"Pagamento" : {
|
||||
"comment" : "The title of the screen where a user can enter and save payment information.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pagamento via PIX" : {
|
||||
"comment" : "The navigation title for the PaymentPixView.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"para %@" : {
|
||||
"comment" : "A text label displaying the email address to which the one-time password (OTP) was sent. The text is truncated to fit within one line.",
|
||||
@@ -567,6 +703,10 @@
|
||||
"comment" : "A label for the \"Profile\" tab in the main tab view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pizza de varios sabores" : {
|
||||
"comment" : "A title displayed above the main content of the view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Política de Privacidade" : {
|
||||
"comment" : "The title of the privacy policy section.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -627,6 +767,18 @@
|
||||
},
|
||||
"RESTAURANT" : {
|
||||
|
||||
},
|
||||
"Resumo de Valores" : {
|
||||
"comment" : "A section header in the cart view that translates to \"Summary of Values\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"RESUMO DO PEDIDO" : {
|
||||
"comment" : "The title of the section that summarizes the order details.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Retirada" : {
|
||||
"comment" : "A label for the \"Pickup\" option in the checkout interface.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sair" : {
|
||||
|
||||
@@ -667,6 +819,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Sem adicionais" : {
|
||||
|
||||
},
|
||||
"Settings" : {
|
||||
"comment" : "Tab bar item title for the Settings tab",
|
||||
@@ -706,6 +861,14 @@
|
||||
"comment" : "A message displayed when the user attempts to add a product to their cart from a different store. Asks if the user wants to clear their current cart before adding the new product.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?" : {
|
||||
"comment" : "An alert that appears when a user tries to place an order from an address that is not served by the store. The alert offers the user the option to either keep the current address or to switch to a different one",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sim" : {
|
||||
"comment" : "The text for a button that confirms an action. In this case, it confirms the user's choice to continue with the order despite the address not being served by the store.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"System" : {
|
||||
"comment" : "Menu item indicating that the appearance should be in the default system mode",
|
||||
"extractionState" : "stale",
|
||||
@@ -747,6 +910,10 @@
|
||||
"comment" : "A link to the app's \"Terms of Use\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"TIPO DE ENTREGA" : {
|
||||
"comment" : "A label displayed above the section that lets users choose between delivery and pickup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Title" : {
|
||||
"comment" : "Label for the item editor form indicating the title of the item",
|
||||
"extractionState" : "stale",
|
||||
@@ -777,10 +944,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Toque no sabor para escolher adicionais." : {
|
||||
"comment" : "A description below the list of pizza flavors, explaining that tapping a flavor will open an additional options list.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Total" : {
|
||||
"comment" : "A label displayed above the total price in the cart.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Total a pagar" : {
|
||||
"comment" : "A label describing the total amount to be paid.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Total do Pedido" : {
|
||||
"comment" : "The label above the total price of the order in the payment card view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Trocar de loja?" : {
|
||||
"comment" : "A title for an alert that prompts the user to switch stores.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -66,11 +66,49 @@ struct StoreSummary: Decodable {
|
||||
let cover: String?
|
||||
let category: String?
|
||||
let rating: Double?
|
||||
let reviewsCount: Int?
|
||||
let positiveReviews: Int?
|
||||
let deliveryTime: String?
|
||||
let deliveryFee: Double?
|
||||
let distance: Double?
|
||||
let isOpen: Bool?
|
||||
let statusLabel: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case logo
|
||||
case cover
|
||||
case category
|
||||
case rating
|
||||
case reviewsCount
|
||||
case totalReviews
|
||||
case reviews
|
||||
case positiveReviews
|
||||
case positive_reviews
|
||||
case deliveryTime
|
||||
case deliveryFee
|
||||
case distance
|
||||
case isOpen
|
||||
case statusLabel
|
||||
}
|
||||
|
||||
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)) ?? "Loja"
|
||||
logo = try? container.decode(String.self, forKey: .logo)
|
||||
cover = try? container.decode(String.self, forKey: .cover)
|
||||
category = try? container.decode(String.self, forKey: .category)
|
||||
rating = ApiService.decodeFlexibleDouble(from: container, keys: [.rating])
|
||||
reviewsCount = ApiService.decodeFlexibleInt(from: container, keys: [.reviewsCount, .totalReviews, .reviews])
|
||||
positiveReviews = ApiService.decodeFlexibleInt(from: container, keys: [.positiveReviews, .positive_reviews])
|
||||
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
|
||||
deliveryFee = ApiService.decodeFlexibleDouble(from: container, keys: [.deliveryFee])
|
||||
distance = ApiService.decodeFlexibleDouble(from: container, keys: [.distance])
|
||||
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
|
||||
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
|
||||
}
|
||||
}
|
||||
|
||||
struct StoreInfoResult: Decodable {
|
||||
@@ -107,6 +145,7 @@ struct StoreAddressInfo: Decodable {
|
||||
let neighborhood: String?
|
||||
let city: String?
|
||||
let state: String?
|
||||
let zipCode: String?
|
||||
let latitude: Double?
|
||||
let longitude: Double?
|
||||
|
||||
@@ -116,6 +155,8 @@ struct StoreAddressInfo: Decodable {
|
||||
case neighborhood
|
||||
case city
|
||||
case state
|
||||
case zipCode
|
||||
case zipcode
|
||||
case latitude
|
||||
case longitude
|
||||
}
|
||||
@@ -127,26 +168,112 @@ struct StoreAddressInfo: Decodable {
|
||||
neighborhood = try? container.decode(String.self, forKey: .neighborhood)
|
||||
city = try? container.decode(String.self, forKey: .city)
|
||||
state = try? container.decode(String.self, forKey: .state)
|
||||
zipCode = (try? container.decode(String.self, forKey: .zipCode))
|
||||
?? (try? container.decode(String.self, forKey: .zipcode))
|
||||
latitude = ApiService.decodeFlexibleDouble(from: container, keys: [.latitude])
|
||||
longitude = ApiService.decodeFlexibleDouble(from: container, keys: [.longitude])
|
||||
}
|
||||
}
|
||||
|
||||
struct StorePaymentMethodsInfo: Decodable {
|
||||
let paymentOnDelivery: Bool?
|
||||
let paymentOnPickup: Bool?
|
||||
let acceptPix: Bool?
|
||||
let acceptCash: Bool?
|
||||
let acceptCreditCard: Bool?
|
||||
let acceptDebitCard: Bool?
|
||||
let acceptCreditVisa: Bool?
|
||||
let acceptCreditMaster: Bool?
|
||||
let acceptCreditElo: Bool?
|
||||
let acceptCreditAmex: Bool?
|
||||
let acceptCreditHipercard: Bool?
|
||||
let acceptDebitVisa: Bool?
|
||||
let acceptDebitMaster: Bool?
|
||||
let acceptDebitElo: Bool?
|
||||
let acceptVoucherAlelo: Bool?
|
||||
let acceptVoucherSodexo: Bool?
|
||||
let acceptVoucherTicket: Bool?
|
||||
let acceptVoucherVR: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case paymentOnDelivery
|
||||
case paymentOnPickup
|
||||
case acceptPix
|
||||
case acceptCash
|
||||
case acceptCreditCard
|
||||
case acceptDebitCard
|
||||
case acceptCreditVisa
|
||||
case acceptCreditMaster
|
||||
case acceptCreditElo
|
||||
case acceptCreditAmex
|
||||
case acceptCreditHipercard
|
||||
case acceptDebitVisa
|
||||
case acceptDebitMaster
|
||||
case acceptDebitElo
|
||||
case acceptVoucherAlelo
|
||||
case acceptVoucherSodexo
|
||||
case acceptVoucherTicket
|
||||
case acceptVoucherVR
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
paymentOnDelivery = try? container.decode(Bool.self, forKey: .paymentOnDelivery)
|
||||
paymentOnPickup = try? container.decode(Bool.self, forKey: .paymentOnPickup)
|
||||
acceptPix = try? container.decode(Bool.self, forKey: .acceptPix)
|
||||
acceptCash = try? container.decode(Bool.self, forKey: .acceptCash)
|
||||
acceptCreditCard = try? container.decode(Bool.self, forKey: .acceptCreditCard)
|
||||
acceptDebitCard = try? container.decode(Bool.self, forKey: .acceptDebitCard)
|
||||
acceptCreditVisa = try? container.decode(Bool.self, forKey: .acceptCreditVisa)
|
||||
acceptCreditMaster = try? container.decode(Bool.self, forKey: .acceptCreditMaster)
|
||||
acceptCreditElo = try? container.decode(Bool.self, forKey: .acceptCreditElo)
|
||||
acceptCreditAmex = try? container.decode(Bool.self, forKey: .acceptCreditAmex)
|
||||
acceptCreditHipercard = try? container.decode(Bool.self, forKey: .acceptCreditHipercard)
|
||||
acceptDebitVisa = try? container.decode(Bool.self, forKey: .acceptDebitVisa)
|
||||
acceptDebitMaster = try? container.decode(Bool.self, forKey: .acceptDebitMaster)
|
||||
acceptDebitElo = try? container.decode(Bool.self, forKey: .acceptDebitElo)
|
||||
acceptVoucherAlelo = try? container.decode(Bool.self, forKey: .acceptVoucherAlelo)
|
||||
acceptVoucherSodexo = try? container.decode(Bool.self, forKey: .acceptVoucherSodexo)
|
||||
acceptVoucherTicket = try? container.decode(Bool.self, forKey: .acceptVoucherTicket)
|
||||
acceptVoucherVR = try? container.decode(Bool.self, forKey: .acceptVoucherVR)
|
||||
}
|
||||
|
||||
var hasAnyCreditCard: Bool {
|
||||
(acceptCreditCard ?? false)
|
||||
|| (acceptCreditVisa ?? false)
|
||||
|| (acceptCreditMaster ?? false)
|
||||
|| (acceptCreditElo ?? false)
|
||||
|| (acceptCreditAmex ?? false)
|
||||
|| (acceptCreditHipercard ?? false)
|
||||
}
|
||||
|
||||
var hasAnyDebitCard: Bool {
|
||||
(acceptDebitCard ?? false)
|
||||
|| (acceptDebitVisa ?? false)
|
||||
|| (acceptDebitMaster ?? false)
|
||||
|| (acceptDebitElo ?? false)
|
||||
}
|
||||
|
||||
var hasAnyVoucher: Bool {
|
||||
(acceptVoucherAlelo ?? false)
|
||||
|| (acceptVoucherSodexo ?? false)
|
||||
|| (acceptVoucherTicket ?? false)
|
||||
|| (acceptVoucherVR ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
struct StoreCatalogCategory: Decodable {
|
||||
let id: String
|
||||
let name: String
|
||||
let isPizzaCategory: Bool
|
||||
let pizzaConfig: StorePizzaConfig?
|
||||
let products: [StoreCatalogProduct]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case isPizzaCategory
|
||||
case pizzaConfig
|
||||
case products
|
||||
}
|
||||
|
||||
@@ -154,21 +281,26 @@ struct StoreCatalogCategory: Decodable {
|
||||
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)) ?? "Categoria"
|
||||
isPizzaCategory = (try? container.decode(Bool.self, forKey: .isPizzaCategory)) ?? false
|
||||
pizzaConfig = try? container.decode(StorePizzaConfig.self, forKey: .pizzaConfig)
|
||||
products = (try? container.decode([StoreCatalogProduct].self, forKey: .products)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct StoreCatalogProduct: Decodable, Identifiable {
|
||||
let id: String
|
||||
let type: String?
|
||||
let name: String
|
||||
let description: String?
|
||||
let image: String?
|
||||
let price: Double?
|
||||
let originalPrice: Double?
|
||||
let pizzaPrices: [String: Double]
|
||||
let addonGroups: [StoreAddonGroup]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
case name
|
||||
case description
|
||||
case desc
|
||||
@@ -178,6 +310,7 @@ struct StoreCatalogProduct: Decodable, Identifiable {
|
||||
case price
|
||||
case originalPrice
|
||||
case oldPrice
|
||||
case pizzaPrices
|
||||
case addonGroups
|
||||
case addons
|
||||
}
|
||||
@@ -185,6 +318,7 @@ struct StoreCatalogProduct: Decodable, Identifiable {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
||||
type = try? container.decode(String.self, forKey: .type)
|
||||
name = (try? container.decode(String.self, forKey: .name)) ?? "Produto"
|
||||
description = (try? container.decode(String.self, forKey: .description)) ?? (try? container.decode(String.self, forKey: .desc))
|
||||
image = (try? container.decode(String.self, forKey: .image))
|
||||
@@ -192,10 +326,33 @@ struct StoreCatalogProduct: Decodable, Identifiable {
|
||||
?? (try? container.decode(String.self, forKey: .photo))
|
||||
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
|
||||
originalPrice = ApiService.decodeFlexibleDouble(from: container, keys: [.originalPrice, .oldPrice])
|
||||
pizzaPrices = StoreCatalogProduct.decodePizzaPrices(container: container)
|
||||
addonGroups = (try? container.decode([StoreAddonGroup].self, forKey: .addonGroups))
|
||||
?? (try? container.decode([StoreAddonGroup].self, forKey: .addons))
|
||||
?? []
|
||||
}
|
||||
|
||||
private static func decodePizzaPrices(container: KeyedDecodingContainer<CodingKeys>) -> [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?
|
||||
|
||||
145
pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift
Normal file
145
pedi-foods/Sources/PediFoods/Services/ApiOrderModels.swift
Normal file
@@ -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?
|
||||
}
|
||||
80
pedi-foods/Sources/PediFoods/Services/ApiPizzaModels.swift
Normal file
80
pedi-foods/Sources/PediFoods/Services/ApiPizzaModels.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CreateOrderResult> {
|
||||
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<ValidateDeliveryAddressResult> {
|
||||
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<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
|
||||
@@ -256,4 +268,25 @@ extension ApiService {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func decodeFlexibleInt<K: CodingKey>(from container: KeyedDecodingContainer<K>, 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
|
||||
}
|
||||
}
|
||||
|
||||
145
pedi-foods/Sources/PediFoods/Services/AppCache.swift
Normal file
145
pedi-foods/Sources/PediFoods/Services/AppCache.swift
Normal file
@@ -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<T>(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<T>(_ 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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
41
pedi-foods/Sources/PediFoods/Views/Main/CheckoutTypes.swift
Normal file
41
pedi-foods/Sources/PediFoods/Views/Main/CheckoutTypes.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
335
pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift
Normal file
335
pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift
Normal file
@@ -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<CreateOrderPayload, CheckoutPayloadValidationError> {
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
711
pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift
Normal file
711
pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift
Normal file
@@ -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<Bool> {
|
||||
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<Content: View>(
|
||||
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<String>) -> 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: ",")
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: ",")
|
||||
}
|
||||
}
|
||||
@@ -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: ",")
|
||||
}
|
||||
}
|
||||
@@ -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<String> = []
|
||||
@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) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,17 +8,26 @@ struct ProductDetailSheet: View {
|
||||
let currentQuantityForItemId: (String) -> Int
|
||||
let onAdd: (CartItemState) -> Void
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var selectedAddonItemIds: Set<String> = []
|
||||
@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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,8 @@ struct ProfileView: View {
|
||||
private func logout() {
|
||||
tokenStore.clear()
|
||||
SessionStateStore.clearActiveUser()
|
||||
AppContentCache.shared.invalidate()
|
||||
AppImageCache.shared.invalidateAll()
|
||||
appState = AppState()
|
||||
root = .auth
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BIN
reference_img/IMG_0442.PNG
Normal file
BIN
reference_img/IMG_0442.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 455 KiB |
BIN
reference_img/IMG_0443.PNG
Normal file
BIN
reference_img/IMG_0443.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 404 KiB |
BIN
reference_img/payment_card.png
Normal file
BIN
reference_img/payment_card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
BIN
reference_img/payment_pix.png
Normal file
BIN
reference_img/payment_pix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
Reference in New Issue
Block a user