This commit is contained in:
Daniel Arantes Loverde
2026-02-15 08:18:06 -03:00
parent d7b8b1864a
commit 0f5ad4c268
35 changed files with 4090 additions and 309 deletions

View File

@@ -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,38 +299,37 @@ 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
"acceptPix": true
},
"debit": {
"visa": true,
"master": true,
"elo": true
"openingHours": { "...": "..." },
"address": {
"street": "Rua X",
"neighborhood": "Centro",
"city": "Campinas",
"state": "SP",
"zipcode": "13000-000",
"latitude": -22.9,
"longitude": -47.0
}
}
},
"openingHours": { ... },
"address": { ... }
}
}
```
### 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.

View File

@@ -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).

View 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
}

View File

@@ -37,6 +37,7 @@ struct FeaturedStoreCard: View {
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if store.isOpen {
HStack(spacing: 6) {
Image(systemName: "star.fill")
.font(.caption)
@@ -54,6 +55,11 @@ struct FeaturedStoreCard: View {
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
} else {
Text(store.statusLabel?.isEmpty == false ? (store.statusLabel ?? "Fechado") : "Fechado")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
Text(store.category)
.font(.caption)
@@ -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 {

View File

@@ -95,10 +95,13 @@ 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 {
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 {
@@ -111,7 +114,6 @@ struct ContentView: View {
} 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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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?

View 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?
}

View 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
}
}
}

View File

@@ -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
}
}

View 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

View File

@@ -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))
}
}

View File

@@ -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
)
}
)
}
}
}

View File

@@ -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 {

View File

@@ -3,13 +3,19 @@ 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")
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
Text("Meu Carrinho")
.font(AppTypography.heading1)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 20)
if appState.cart.items.isEmpty {
@@ -21,55 +27,134 @@ struct CartView: View {
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.frame(maxWidth: .infinity, minHeight: 280, alignment: .center)
} else {
ScrollView(showsIndicators: false) {
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(.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))
.navigationDestination(isPresented: $openCheckout) {
CheckoutView(appState: $appState)
}
}
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(.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)
}
.padding(.horizontal, 20)
}
}
PrimaryButton(title: "Finalizar pedido", action: {})
.padding(.horizontal, 20)
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))
}
.padding(.top, 10)
.padding(.bottom, 12)
.background(.ultraThinMaterial)
.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 {

View 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"
}
}
}

View 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
)
)
}
}

View 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: ",")
}
}

View File

@@ -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)"
}
}

View File

@@ -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) {
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)

View File

@@ -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: ",")
}
}

View File

@@ -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: ",")
}
}

View File

@@ -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) }
}
}
}

View File

@@ -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)
.foregroundStyle(AppColors.textPrimary)
.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)
}
}
}
}
.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: 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()
}
.onChange(of: selectedAddonItemIds) { _, _ in
quantity = currentQuantityForItemId(cartItemId)
}
}
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
}
}
}

View File

@@ -192,6 +192,8 @@ struct ProfileView: View {
private func logout() {
tokenStore.clear()
SessionStateStore.clearActiveUser()
AppContentCache.shared.invalidate()
AppImageCache.shared.invalidateAll()
appState = AppState()
root = .auth
}

View File

@@ -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
}
}

View File

@@ -134,23 +134,9 @@ 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:
CachedRemoteImage(imageURL: imageURL) {
fallback
}
}
} else {
fallback
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.brandSoft)
.clipped()

View File

@@ -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
}

View File

@@ -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,6 +241,7 @@ struct StoreDetailView: View {
}
private var summaryCard: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
@@ -221,6 +270,15 @@ struct StoreDetailView: View {
.padding(.vertical, 4)
}
.padding(16)
if isStoreOpen == false {
Text(closedStoreBannerText)
.font(AppTypography.heading3)
.foregroundStyle(Color.white)
.frame(maxWidth: .infinity, minHeight: closedBannerHeight)
.background(AppColors.brandDark)
}
}
.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

BIN
reference_img/IMG_0443.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB