Merge pull request 'feature/card-brand-logo' (#5) from feature/card-brand-logo into main

Reviewed-on: Loverde-Company-LTDA/Pedi-Foods-Skip#5
This commit is contained in:
2026-06-12 16:47:46 -03:00
74 changed files with 4055 additions and 777 deletions

View File

@@ -127,47 +127,123 @@ Used when interacting with a specific store (Catalog, Checkout).
**Campos internos não retornados para o app:** `keyHash`, `strongHash`, `source`, `isShadow`, `profile_metrics`, `behavioral_stats`, `otpHash`, `otpHashParams`, `otpExpiresAt`.
### 4. Update Profile
**POST** `/api/customer/:id`
**PATCH** `/api/customer/profile`
**Headers:**
- `Authorization: Bearer <JWT_Token>`
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
- `Content-Type: application/json`
**Notes:**
- Todos os campos são opcionais — envie apenas o que mudou.
- `profilePicture` aceita base64 data URL (máx 2 MB decodificado).
- A resposta inclui `profilePictureUrl` apenas quando imagem foi enviada.
**Body:**
```json
{
"name": "Daniel A. Loverde",
"cpf": "12345678901",
"phoneNumber": "5511988888888",
"profilePicture": "data:image/jpeg;base64,/9j/4AAQSkZJRgAB..."
}
```
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `name` | string | não | Nome de exibição |
| `cpf` | string | não | 11 dígitos, formatado ou raw |
| `phoneNumber` | string | não | Com DDI, sem `+`: `"5519991670000"` |
| `profilePicture` | string | não | base64 data URL — jpeg, png ou webp — máx 2 MB decoded |
**Formato `profilePicture`:**
```
data:image/jpeg;base64,<base64data>
data:image/png;base64,<base64data>
data:image/webp;base64,<base64data>
```
**Response (sucesso):**
```json
{
"error": false,
"code": "PROFILE_UPDATED",
"profilePictureUrl": "/uploads/profiles/e30188cf_1780615191653.jpg"
}
```
> `profilePictureUrl` é URL relativa. Prefixar com `https://atomenta.com.br` para exibir a imagem.
**Erros possíveis:**
| Código | HTTP | Descrição |
|---|---|---|
| `INVALID_CPF` | 400 | CPF não tem 11 dígitos |
| `INVALID_PROFILE_IMAGE` | 400 | Não é data URL válido ou excede 2 MB |
| `NO_FIELDS` | 400 | Nenhum campo reconhecido no body |
| `CUSTOMER_NOT_FOUND` | 404 | — |
### 5. Set Default Address
**PATCH** `/api/customer/addresses/:addressId/default`
**Headers:**
- `Authorization: Bearer <JWT_Token>`
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
**Body:**
**Notes:**
- Define um endereço como padrão pelo seu `id`.
- Define `isDefault: true` no endereço alvo e `isDefault: false` em todos os outros.
- O `id` de cada endereço é retornado no campo `address_book` do perfil (`GET /api/customer/profile`).
- Ao salvar um endereço via `PATCH /api/customer/profile`, o backend gera automaticamente um `id` UUID caso o endereço não possua um.
- Se nenhum endereço tiver `isDefault: true`, o primeiro do array é tratado como padrão.
**Response 200:**
```json
{
"name": "Daniel A. Loverde", // Fields to update
"phoneNumber": "+5511988888888",
"biometricsEnabled": true,
"address_book": [
{
"label": "Casa",
"type": "residential",
"address": "Avenida das Andorinhas",
"number": "477",
"neighborhood": "Jardim Andorinhas",
"city": "Campinas",
"state": "SP",
"zipCode": "13101-400",
"country": "Brasil",
"complement": "Apto 78",
"lat_long": [-22.9064, -47.0616]
}
]
"error": false,
"code": "ADDRESS_DEFAULT_SET"
}
```
**Regra de coordenadas (`address_book.lat_long`):**
**Erros:**
- Se existir `zipCode` válido e houver cache do CEP no Atomenta (`/api/public/cep/:cep`) para o mesmo usuário, a API valida o ponto enviado.
- Se não houver cache local, o Atomenta consulta a AwesomeAPI e atualiza o cache antes de validar.
- Se `lat_long` estiver ausente/inválido, a API preenche com as coordenadas do CEP cacheado.
- Se a distância entre `lat_long` enviado e o ponto do CEP for maior que `1.5 km`, a API substitui `lat_long` pelo ponto do CEP cacheado.
- Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil.
| Código | HTTP | Descrição |
|---|---|---|
| `ADDRESS_NOT_FOUND` | 404 | Nenhum endereço com esse `id` no `address_book` |
| `CUSTOMER_NOT_FOUND` | 404 | — |
### 5. List Favorite Stores
**Exemplo de `address_book` no perfil (com `id` e `isDefault`):**
```json
"address_book": [
{
"id": "a1b2c3d4-...",
"label": "Casa",
"type": "residential",
"address": "Estrada Vicinal Nene Moro",
"number": "SN",
"neighborhood": "Curtume",
"city": "Aguaí",
"state": "SP",
"zipCode": "13868070",
"country": "Brasil",
"lat_long": [-22.06743, -46.98018],
"isDefault": true
}
]
```
---
### 6. List Favorite Stores
**GET** `/api/customer/favorites`
**Headers:**
@@ -580,6 +656,44 @@ Use este endpoint ao trocar endereço na tela de pagamento para validar cobertur
}
```
**Exemplo com pizza (tamanho + sabores + borda):**
```json
{
"items": [
{
"productId": "prod_pizza_calabresa",
"name": "Pizza Calabresa",
"qty": 1,
"price": 65.00,
"choices": [
"Tamanho: Grande (+R$ 55,00)",
"Sabor: Calabresa",
"Massa: Tradicional",
"Borda: Catupiry (+R$ 10,00)"
]
}
]
}
```
**Como montar `choices[]` para pizza:**
| Linha | Formato | Obrigatório |
|---|---|---|
| Tamanho | `"Tamanho: <NomeDoTamanho> (+R$ X,XX)"` | Sim |
| Sabor (único) | `"Sabor: <NomeDeSabor>"` | Sim |
| Sabor N (múltiplos) | `"Sabor 1: <Sabor>"`, `"Sabor 2: <Sabor>"` | Sim quando maxFlavors > 1 |
| Massa | `"Massa: <NomeDaMassa>"` | Não |
| Borda sem custo | `"Borda: <NomeDaBorda>"` | Não |
| Borda com custo | `"Borda: <NomeDaBorda> (+R$ X,XX)"` | Não |
**Regras:**
- `choices[]` é um array de strings. O backend converte para `options[]` na normalização do pedido.
- O `price` do item deve refletir o preço total da pizza (tamanho + modificador de borda).
- Os nomes de tamanho, massa e borda devem corresponder aos `id` ou `name` retornados pelo catalog endpoint.
- Máximo de sabores definido por `pizzaConfig.sizes[n].maxFlavors`.
**Regras para `items[].addons`:**
- `qty` do adicional é aceito no backend (ex.: `2 ovos`).
@@ -929,4 +1043,246 @@ Para dashboard da loja e análise de produto, usar:
- O painel `/store/reviews` segue funcional com os campos legados (`rate`, `message`, `itemFeedback`, `improvementFeedback`, `deliveryFeedback`).
- Campos novos coexistem para evolução gradual sem quebrar o fluxo atual.
---
## Feature Control (Feature Flags)
O Feature Control permite ativar/desativar funcionalidades do app remotamente sem publicar uma nova versão. O app nunca chama o Atomenta diretamente — usa um **BFF (Backend for Frontend)** que protege o `Atomenta-Token` do cliente.
### Arquitetura
```
App iOS → POST /feature-control/bootstrap → BFF → Atomenta /api/feature-control/evaluate
```
O BFF mantém cache de 60 segundos. Em caso de timeout ou erro upstream, retorna flags de fallback estáticas definidas nas variáveis de ambiente do BFF.
---
### 1. Bootstrap — Avaliar flags na inicialização
**`POST /feature-control/bootstrap`**
Deve ser chamado uma vez por sessão do app (ao abrir ou ao autenticar). Retorna os valores de todos os flags solicitados para o contexto do usuário.
**Headers:**
```
Content-Type: application/json
Authorization: Bearer <JWT_Token> ← opcional, mas recomendado
```
> Se o `Authorization` for enviado, o BFF extrai `subjectId` automaticamente do JWT (`sub` / `customerId` / `id`). Se não for enviado, `context.subjectId` é obrigatório.
**Request body:**
```json
{
"environment": "production",
"keys": [
"at.ios.only",
"at.android.only",
"at.promo",
"at.city.aguai"
],
"context": {
"subjectType": "customer",
"subjectId": "e30188cf-cf69-471e-bc59-4c92e0f9ad7b",
"platform": "ios",
"appVersion": "2.3.1",
"attributes": {
"city": "Aguaí"
}
}
}
```
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `environment` | string | não | `"production"` (default) ou `"sandbox"` |
| `keys` | string[] | sim | Lista de flags a avaliar (máx 100) |
| `context.subjectType` | string | sim | `"customer"` \| `"user"` \| `"store"` \| `"anonymous"` |
| `context.subjectId` | string | sim* | ID do usuário. *Dispensável se JWT enviado no header |
| `context.platform` | string | não | `"ios"` ou `"android"` |
| `context.appVersion` | string | não | Versão semântica, ex: `"2.3.1"` |
| `context.storeId` | string | não | Para flags segmentadas por loja |
| `context.attributes` | object | não | Atributos extras (ex: `city`, `tier`) |
**Response 200 — fonte live:**
```json
{
"ok": true,
"source": "live",
"configVersion": 7,
"evaluatedAt": "2026-06-05T12:00:00.000Z",
"flags": {
"atiosonly": true,
"atandroidonly": false,
"atpromo": true,
"atcityaguai": true
},
"raw": {
"at.ios.only": {
"enabled": true,
"variant": "on",
"payload": null,
"reason": "rollout"
},
"at.promo": {
"enabled": true,
"variant": "on",
"payload": null,
"reason": "rollout"
},
"at.city.aguai": {
"enabled": true,
"variant": "on",
"payload": null,
"reason": "segment"
},
"at.android.only": {
"enabled": false,
"variant": "off",
"payload": null,
"reason": "default"
}
}
}
```
**Response 200 — fonte fallback** (quando BFF não consegue atingir Atomenta):
```json
{
"ok": true,
"source": "fallback",
"configVersion": 0,
"evaluatedAt": "2026-06-05T12:00:00.000Z",
"flags": { "ios": true, "promo": true },
"raw": { ... },
"upstreamError": {
"status": 504,
"code": "FEATURE_CONTROL_UPSTREAM_TIMEOUT",
"message": "Timeout no upstream"
}
}
```
#### Como usar `raw` no app (Swift)
O app usa o campo **`raw`**, indexado pela chave original completa. O campo `flags` contém uma versão simplificada (pontos removidos), mas o app Swift usa `raw` para manter compatibilidade direta com os nomes de flag.
| Chave original | Acesso via `raw` | `flags` simplificado |
|---|---|---|
| `at.ios.only` | `raw["at.ios.only"]` | `flags["atiosonly"]` |
| `at.android.only` | `raw["at.android.only"]` | `flags["atandroidonly"]` |
| `at.promo` | `raw["at.promo"]` | `flags["atpromo"]` |
| `at.city.aguai` | `raw["at.city.aguai"]` | `flags["atcityaguai"]` |
> **Use sempre `raw`** para acessar flags no app — as chaves simplificadas em `flags` são geradas mecanicamente e menos legíveis.
**Regra de leitura em `raw`:**
- `raw["at.promo"].enabled == true` → flag habilitado
- `raw["at.promo"].variant == "on"` → variante ativa
- `raw["at.promo"].payload` → dados extras opcionais (JSON livre)
**Exemplo Swift (padrão atual do app):**
```swift
// FeatureFlagsState.isEnabled() já lê raw automaticamente
if appState.featureFlags.isEnabled("at.promo") {
// mostrar módulo de promoções
}
if appState.featureFlags.isEnabled("at.ios.only") {
// funcionalidade exclusiva iOS
}
```
#### `source` e estratégia de fallback
| `source` | Significado | Ação recomendada |
|---|---|---|
| `"live"` | Dados frescos do Atomenta | Usar normalmente |
| `"fallback"` | BFF usou defaults estáticos (timeout/erro) | Usar com cautela; re-tentar na próxima sessão |
---
### 2. Telemetria de Exposição
**`POST /feature-control/telemetry/exposure`**
Registra quais flags o usuário visualizou (para análise de rollout). Envio em batch, **fire-and-forget** — não bloquear UX aguardando resposta.
**Headers:**
```
Content-Type: application/json
Authorization: Bearer <JWT_Token>
```
**Request body:**
```json
{
"events": [
{
"featureKey": "at.promo",
"variant": "on",
"subjectType": "customer",
"storeId": "store_1777215327582_akum6"
},
{
"featureKey": "at.ios.only",
"variant": "on",
"subjectType": "customer"
}
]
}
```
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `featureKey` | string | sim | Chave original com prefixo `fc.` |
| `variant` | string | não | Variante exposta (default `"off"`) |
| `subjectType` | string | não | Default `"customer"` |
| `storeId` | string | não | Quando relevante |
Máximo de **100 eventos por request**.
**Response 200:**
```json
{
"ok": true,
"code": "FEATURE_CONTROL_EXPOSURE_ACCEPTED",
"result": { "count": 2 }
}
```
---
### 3. Flags disponíveis (referência)
Padrão de nomenclatura: `at.<domínio>.<descrição>` (pontos como separador, sem hífens).
| Flag | Descrição | Default |
|---|---|---|
| `at.ios.only` | Funcionalidades exclusivas iOS | `true` |
| `at.android.only` | Funcionalidades exclusivas Android | `true` |
| `at.promo` | Módulo de promoções ativo | `true` |
| `at.city.aguai` | Expansão para cidade de Aguaí/SP | `true` |
> Novos flags são criados pelo time Pedi Foods no painel `/feature-control`. Consulte o time antes de codificar um flag inexistente. A lista completa de flags ativos está disponível no painel admin → Feature Control.
---
### 4. Fluxo recomendado no app
```
1. App abre / usuário autentica
2. POST /feature-control/bootstrap (com JWT no header)
3. Salvar flags em memória/UserDefaults para a sessão
4. Renderizar UI baseada nos flags
5. POST /feature-control/telemetry/exposure (fire-and-forget, por flag visualizado)
6. Ao fechar sessão ou após 15 min, repetir passo 2 na próxima abertura
```
> **Não** chamar bootstrap a cada tela — apenas na inicialização da sessão. O BFF tem cache de 60s no servidor; o app deve ter cache local mínimo de 15 minutos para evitar latência desnecessária.
---
Autor: Daniel Arantes Loverde

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
# ⚠️ INFORMAÇÕES CONFIDENCIAIS E PROPRIEDADE INTELECTUAL ⚠️
## 🚨 AVISO LEGAL IMPORTANTE
**ATENÇÃO: Este repositório contém informações CONFIDENCIAIS e PROPRIETÁRIAS.**
### ⛔ PROIBIÇÕES ABSOLUTAS
- **NÃO** compartilhe este código com terceiros
- **NÃO** faça fork, clone ou cópia sem autorização expressa
- **NÃO** utilize qualquer parte deste código em outros projetos
- **NÃO** distribua, venda ou comercialize este software
- **NÃO** reverse engineer ou descompile o código
- **NÃO** acesse sem permissão explícita do proprietário
### 🔒 DIREITOS DE PROPRIEDADE INTELECTUAL
Este software é propriedade exclusiva de **Daniel Arantes Loverde** e está protegido por:
- **Lei de Propriedade Intelectual (Lei 9.610/98)**
- **Lei de Software (Lei 9.609/98)**
- **Lei Geral de Proteção de Dados (LGPD - Lei 13.709/18)**
- **Direitos Autorais e Patentes**
### ⚖️ CONSEQUÊNCIAS LEGAIS
O acesso não autorizado, uso indevido, cópia ou distribuição deste código constitui:
- **CRIME** de violação de propriedade intelectual
- **CRIME** de roubo de propriedade intelectual
- **CRIME** de violação de direitos autorais
- **CRIME** de acesso não autorizado a sistema informatizado
**Penas previstas: 2 a 5 anos de reclusão + multa**
### 🛡️ MEDIDAS DE SEGURANÇA
- **Monitoramento**: Todo acesso é registrado e monitorado
- **Rastreamento**: IPs e atividades são rastreados
- **Logs**: Sistema completo de auditoria
- **Criptografia**: Dados protegidos com AES-256
- **Autenticação**: Acesso restrito com JWT + OTP
### 📧 CONTATO AUTORIZADO
**ÚNICO CONTATO VÁLIDO:**
- **Email**: daniel@loverde.com.br
- **Autorização**: Apenas via email oficial
### 🔐 CLASSIFICAÇÃO
**NÍVEL DE CONFIDENCIALIDADE: MÁXIMO**
- **Restrito**: Apenas para o proprietário
- **Não Divulgar**: Informações proprietárias
- **Não Compartilhar**: Código fonte protegido
---
## ⚠️ DISCLAIMER FINAL
**Ao acessar este repositório, você concorda que:**
1. **Reconhece** que este é um software proprietário
2. **Compromete-se** a não violar os direitos de propriedade intelectual
3. **Aceita** as consequências legais por qualquer violação
4. **Entende** que o acesso é monitorado e rastreado
5. **Concorda** em respeitar todas as leis aplicáveis
---
## Sobre este repositório
App **PediFoods iOS** — projeto [Skip](https://skip.tools) dual-platform em
Swift/SwiftUI (`pedi-foods/`), transpilado para Android via skipstone. É o app
de produção e a **fonte da verdade** para requests, responses e comportamento;
o port nativo Android é construído em paridade com ele.
Build e execução: ver `pedi-foods/README.md` (Skip, `skip checkup`/`skip verify`,
Xcode + emulador Android). Testes via `swift test` / `skip test`.
---
## Fluxo Git (desenvolvimento)
- Ao terminar cada task ou fatia lógica (ex.: fatia de checkout, ajuste de tela, correção de testes): fazer **commit imediato** com mensagem clara (`feat:`, `fix:`, `chore:`).
- Não commitar artefactos de build (`.build/`, saída do skipstone, `Darwin/` gerado) nem ficheiros de estado local irrelevantes para o projeto rodar a partir do código-fonte, salvo pedido explícito.
- O agente faz commit local; o push é sempre feito pelo Daniel após revisão.
---
**© 2024 Daniel Arantes Loverde. Todos os direitos reservados.**
**Última atualização: Junho 2026**
---
*Este documento é parte integrante do software e deve ser mantido confidencial.*
Autor: Daniel Arantes Loverde

View File

@@ -27,11 +27,10 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`:
```json
{
"fc.promo": { "enabled": true, "variant": "on" },
"fc.promo-codes": { "enabled": true, "variant": "on" },
"fc.android": { "enabled": true, "variant": "on" },
"fc.city-aguai": { "enabled": true, "variant": "on" },
"fc.ios": { "enabled": true, "variant": "on" }
"at.ios.only": { "enabled": true, "variant": "on" },
"at.android.only": { "enabled": true, "variant": "on" },
"at.promo": { "enabled": true, "variant": "on" },
"at.city.aguai": { "enabled": true, "variant": "on" }
}
```
@@ -40,7 +39,7 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`:
```json
{
"environment": "production",
"keys": ["fc.promo", "fc.promo-codes", "fc.android", "fc.city-aguai", "fc.ios"],
"keys": ["at.ios.only", "at.android.only", "at.promo", "at.city.aguai"],
"context": {
"subjectType": "customer",
"subjectId": "cust_123",
@@ -48,8 +47,7 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`:
"platform": "ios",
"appVersion": "2.3.1",
"attributes": {
"city": "Belo Horizonte",
"tier": "gold"
"city": "Aguaí"
}
}
}
@@ -66,14 +64,19 @@ Se o app enviar `Authorization: Bearer <JWT>`, o BFF tenta extrair `subjectId` d
"configVersion": 7,
"evaluatedAt": "2026-04-16T12:00:00.000Z",
"flags": {
"promo": true,
"promoCodes": true,
"android": true,
"cityAguai": true,
"ios": true
"atiosonly": true,
"atandroidonly": false,
"atpromo": true,
"atcityaguai": true
},
"raw": {
"fc.promo": {
"at.ios.only": {
"enabled": true,
"variant": "on",
"payload": null,
"reason": "rollout"
},
"at.promo": {
"enabled": true,
"variant": "on",
"payload": null,
@@ -83,6 +86,9 @@ Se o app enviar `Authorization: Bearer <JWT>`, o BFF tenta extrair `subjectId` d
}
```
> O app Swift usa o campo **`raw`** indexado pela chave original (ex: `raw["at.promo"]`). O campo `flags` é uma versão simplificada gerada mecanicamente (pontos removidos).
```
Quando o upstream falha (ex.: `429`, `500`, timeout), o BFF devolve `fallback` com defaults estáticos.
## Execução local

BIN
logo/alelocard_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
logo/amexcard_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
logo/hipercard_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
logo/mastercard_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
logo/sodexo_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
logo/visacard_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1,16 +1,34 @@
{
"colors" : [
{
"idiom" : "universal",
"color" : {
"color-space" : "srgb",
"components" : {
"red" : "0.9529411764705882",
"green" : "0.9607843137254902",
"blue" : "0.9686274509803922",
"alpha" : "1.0000000000000000"
"alpha" : "1.000",
"blue" : "0.969",
"green" : "0.961",
"red" : "0.953"
}
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.102",
"green" : "0.102",
"red" : "0.102"
}
},
"idiom" : "universal"
}
],
"info" : {

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "alelocard_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "amexcard_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "hipercard_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "icon_pix.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

View File

@@ -1,52 +0,0 @@
{
"images": [
{
"idiom": "universal",
"filename": "login_light.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
},
{
"idiom": "universal",
"filename": "login_dark.png",
"scale": "1x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
},
{
"idiom": "universal",
"scale": "2x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
},
{
"idiom": "universal",
"scale": "3x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "mastercard_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "placeholder-pizza.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "sodexo_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "visacard_logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -162,8 +162,6 @@
};
/* End PBXResourcesBuildPhase section */
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
499CD43A2AC5B799001AE8D8 /* Sources */ = {
isa = PBXSourcesBuildPhase;
@@ -269,7 +267,7 @@
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.0.7;
minimumVersion = 1.0.10;
};
};
/* End XCRemoteSwiftPackageReference section */

View File

@@ -23,7 +23,7 @@
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
<color key="backgroundColor" red="0.9529411765" green="0.9607843137" blue="0.9686274510" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="backgroundColor" name="LaunchBackground"/>
<constraints>
<constraint firstItem="W6R-s6-hHG" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="F0M-uu-fA3"/>
<constraint firstItem="W6R-s6-hHG" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="M3D-C3-C0c"/>

View File

@@ -79,32 +79,13 @@
"comment" : "A small, secondary text label displaying the price of an item.",
"isCommentAutoGenerated" : true
},
"1. Tamanho" : {
"comment" : "A heading for the size selection step in the pizza product detail sheet.",
"isCommentAutoGenerated" : true
"0000 0000 0000 0000" : {
},
"1km" : {
"comment" : "A label for the minimum distance filter option.",
"isCommentAutoGenerated" : true
},
"2. Massa" : {
},
"3. Borda" : {
},
"4. Sabores (%lld/%lld)" : {
"comment" : "A heading that describes the step where the user selects pizza flavors. The number in the parentheses indicates the maximum number of flavors that can be selected.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "4. Sabores (%1$lld/%2$lld)"
}
}
}
},
"10km" : {
"comment" : "A label displayed next to the far end of the distance slider in the filters modal.",
"isCommentAutoGenerated" : true
@@ -156,6 +137,9 @@
},
"Adicionar loja aos favoritos" : {
},
"Adicionar novo cartão" : {
},
"Adicionar novo endereço" : {
"comment" : "A button label that translates to \"Add new address\" in English.",
@@ -176,15 +160,17 @@
"Alterar" : {
"comment" : "A button label that translates to \"Change\" in English.",
"isCommentAutoGenerated" : true
},
"Ao menos um cartão deve permanecer" : {
},
"Ao menos um endereço deve permanecer" : {
},
"Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)" : {
"comment" : "A text that informs the user that registering implies acceptance of the app's terms of use and privacy policy. The text is clickable and navigates to the respective views when tapped.",
"isCommentAutoGenerated" : true
},
"Apenas Pagar" : {
"comment" : "A button that allows the user to pay without saving the payment information.",
"isCommentAutoGenerated" : true
},
"Aplicar" : {
"comment" : "A button that applies a coupon code to a cart.",
"isCommentAutoGenerated" : true
@@ -229,10 +215,6 @@
},
"Atualizando status do pedido..." : {
},
"Atualize os dados do endereço abaixo." : {
"comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.",
"isCommentAutoGenerated" : true
},
"Avaliar Pedido" : {
@@ -300,6 +282,9 @@
},
"Carregando cardápio..." : {
},
"Carregando endereços..." : {
},
"Carregando pedido..." : {
@@ -311,6 +296,9 @@
"Carrinho" : {
"comment" : "A label for the cart section of the app.",
"isCommentAutoGenerated" : true
},
"Cartão de Crédito" : {
},
"Categorias" : {
"comment" : "A heading for the list of available categories in the filters modal.",
@@ -332,10 +320,6 @@
"comment" : "A label for the comment section in the order review view.",
"isCommentAutoGenerated" : true
},
"Concluir" : {
"comment" : "The text for a button that confirms and closes a sheet.",
"isCommentAutoGenerated" : true
},
"Conexão segura e criptografada" : {
"comment" : "A description below the \"Verificar e Entrar\" button, emphasizing the security of the login process.",
"isCommentAutoGenerated" : true
@@ -376,9 +360,8 @@
"comment" : "A section header for entering and applying a discount coupon.",
"isCommentAutoGenerated" : true
},
"Dados do Cartão" : {
"comment" : "The header text for the section that collects payment card information.",
"isCommentAutoGenerated" : true
"Cupons de Desconto" : {
},
"Dark" : {
"comment" : "Menu item indicating that the appearance should be in dark mode",
@@ -439,6 +422,9 @@
}
}
}
},
"Definir como principal" : {
},
"Desconto" : {
"comment" : "A label for a discount applied to an order.",
@@ -464,6 +450,9 @@
"Em uma escala de 0 a 10, qual é a chance de você indicar o Pedi Foods para um amigo?" : {
"comment" : "A description under the NPS scale question in the order review view.",
"isCommentAutoGenerated" : true
},
"Endereço de cobrança" : {
},
"ENDEREÇO DE ENTREGA" : {
"comment" : "A label displayed above the address section when delivery is selected.",
@@ -498,6 +487,9 @@
"Escolha o tamanho da sua fome" : {
"comment" : "A label displayed below the pizza size selection.",
"isCommentAutoGenerated" : true
},
"Escolha seu sabor" : {
},
"Este sabor não possui adicionais." : {
"comment" : "A message displayed when a pizza flavor does not have any add-ons.",
@@ -630,6 +622,12 @@
"Já tem uma conta?" : {
"comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.",
"isCommentAutoGenerated" : true
},
"Ligar para %@" : {
},
"Ligar para a loja?" : {
},
"Light" : {
"comment" : "Menu item indicating that the appearance should be in light mode",
@@ -668,9 +666,15 @@
"Limpar carrinho e adicionar" : {
"comment" : "A button that clears the current cart and adds the selected product.",
"isCommentAutoGenerated" : true
},
"Limpar e adicionar" : {
},
"Loja não informou métodos presenciais." : {
},
"Mais rápido e seguro" : {
},
"MÉTODO DE PAGAMENTO" : {
"comment" : "A label displayed above the payment methods section in the checkout view.",
@@ -683,6 +687,9 @@
"Meu Perfil" : {
"comment" : "The title of the user profile view.",
"isCommentAutoGenerated" : true
},
"Meus Cartões" : {
},
"Meus Endereços" : {
"comment" : "A label displayed at the top of the view.",
@@ -701,6 +708,9 @@
},
"Motivo do cancelamento" : {
},
"Na entrega/retirada com a loja" : {
},
"Name" : {
"comment" : "Placeholder title for the Name field in a form",
@@ -742,6 +752,9 @@
},
"Não tem conta ainda?" : {
},
"Nenhum cartão cadastrado" : {
},
"Nenhum endereço cadastrado" : {
"comment" : "A message displayed when a user has no addresses saved.",
@@ -783,6 +796,12 @@
}
}
}
},
"Novo cartão" : {
},
"Novo Cartão" : {
},
"Novo endereço" : {
"comment" : "A label for a form to add a new address.",
@@ -791,6 +810,9 @@
"Novo por aqui?" : {
"comment" : "A text that appears at the bottom of the screen, inviting users to create an account.",
"isCommentAutoGenerated" : true
},
"Número do cartão" : {
},
"O que pode melhorar?" : {
"comment" : "A label for the section where users can select tags they think could improve an order.",
@@ -823,13 +845,21 @@
"comment" : "A label describing the sorting options available in the filters modal.",
"isCommentAutoGenerated" : true
},
"Pagamento" : {
"comment" : "The title of the screen where a user can enter and save payment information.",
"isCommentAutoGenerated" : true
"ou" : {
},
"Pagamento via PIX" : {
"comment" : "The navigation title for the PaymentPixView.",
"isCommentAutoGenerated" : true
},
"Pagar Na Maquininha" : {
},
"Pagar Pelo App" : {
},
"Pague mais rápido nas próximas compras" : {
},
"Pedido #%@" : {
@@ -861,7 +891,16 @@
},
"Pizza de varios sabores" : {
"comment" : "A title displayed above the main content of the view.",
"isCommentAutoGenerated" : true
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Escolha seu sabor"
}
}
}
},
"Política de Privacidade" : {
"comment" : "The title of the privacy policy section.",
@@ -904,13 +943,12 @@
"comment" : "A label describing the price filter section in the filters modal.",
"isCommentAutoGenerated" : true
},
"Preencha os dados abaixo para adicionar um endereço." : {
"comment" : "A description below the form to add a new address, instructing the user to fill in the required information.",
"isCommentAutoGenerated" : true
},
"Preencha os dados abaixo para começar." : {
"comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.",
"isCommentAutoGenerated" : true
},
"Principal" : {
},
"PRINCIPAL" : {
"comment" : "A label indicating that a particular address is the user's primary address.",
@@ -978,6 +1016,12 @@
"Salvar Alterações" : {
"comment" : "The text on a button that saves changes to a user's profile.",
"isCommentAutoGenerated" : true
},
"Salvar cartão" : {
},
"Salvar Cartão" : {
},
"Save" : {
"comment" : "Button title indicating that the current contents should be saved",
@@ -1008,6 +1052,12 @@
}
}
}
},
"Selecionar Cartão" : {
},
"Selecionar endereço" : {
},
"Sem adicionais" : {
@@ -1046,6 +1096,16 @@
"comment" : "A message indicating that the user's cart is empty.",
"isCommentAutoGenerated" : true
},
"Seu carrinho tem itens de %@. Deseja limpar e adicionar itens de %@?" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Seu carrinho tem itens de %1$@. Deseja limpar e adicionar itens de %2$@?"
}
}
}
},
"Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?" : {
"comment" : "A message displayed when the user attempts to add a product to their cart from a different store. Asks if the user wants to clear their current cart before adding the new product.",
"isCommentAutoGenerated" : true
@@ -1064,6 +1124,9 @@
"Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque." : {
"comment" : "A tagline displayed in the login view.",
"isCommentAutoGenerated" : true
},
"Substituir carrinho?" : {
},
"Subtotal" : {
@@ -1165,6 +1228,9 @@
"Total do Pedido" : {
"comment" : "The label above the total price of the order in the payment card view.",
"isCommentAutoGenerated" : true
},
"Trocar" : {
},
"Trocar de loja?" : {
"comment" : "A title for an alert that prompts the user to switch stores.",
@@ -1173,10 +1239,16 @@
"Trocar Foto" : {
"comment" : "A button label that allows a user to change their profile picture.",
"isCommentAutoGenerated" : true
},
"Trocar Pagamento" : {
},
"UPLOAD" : {
"comment" : "A button label that says \"UPLOAD\".",
"isCommentAutoGenerated" : true
},
"Vence %@" : {
},
"Ver Detalhes" : {
"comment" : "A button that shows order details when pressed.",
@@ -1276,6 +1348,9 @@
}
}
}
},
"WhatsApp não encontrado. Deseja ligar para %@?" : {
}
},
"version" : "1.1"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,83 @@
import Foundation
struct SavedCard: Decodable, Identifiable, Hashable {
let id: String
let nickname: String?
let holderName: String
let last4: String
let brand: String?
let expiryMonth: String
let expiryYear: String
let isDefault: Bool
var displayLabel: String {
if let nickname, nickname.isEmpty == false { return nickname }
let brandLabel = (brand ?? "Cartão").capitalized
return "\(brandLabel) •••• \(last4)"
}
var expiryLabel: String { "\(expiryMonth)/\(expiryYear)" }
}
struct SaveCardCreditCardPayload: Encodable {
let holderName: String
let number: String
let expiryMonth: String
let expiryYear: String
let ccv: String
}
struct SaveCardHolderInfoPayload: Encodable {
let name: String
let email: String
let cpfCnpj: String
let postalCode: String
let addressNumber: String
let phone: String
}
struct SaveCardPayload: Encodable {
let creditCard: SaveCardCreditCardPayload
let creditCardHolderInfo: SaveCardHolderInfoPayload
let nickname: String?
let isDefault: Bool
}
struct UpdateCardPayload: Encodable {
let nickname: String?
let isDefault: Bool?
}
struct SavedCardResult: Decodable {
let id: String
let holderName: String
let last4: String
let brand: String?
let expiryMonth: String
let expiryYear: String
let isDefault: Bool
}
struct CreditCardOrderPayload: Encodable {
let holderName: String
let number: String
let expiryMonth: String
let expiryYear: String
let ccv: String
}
struct ChangePaymentMethodPayload: Encodable {
let paymentMethod: String
let clientCpfCnpj: String?
let creditCard: CreditCardOrderPayload?
let creditCardHolderInfo: SaveCardHolderInfoPayload?
let savedCardId: String?
}
struct ChangePaymentMethodResult: Decodable {
let paymentMethod: String?
let paymentLocation: String?
let paymentId: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
}

View File

@@ -82,7 +82,6 @@ final class ApiClient {
}
func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
#if canImport(LCEssentials) && os(iOS)
return try await sendWithLCEssentials(request)
#else
@@ -111,10 +110,15 @@ private extension ApiClient {
guard let data = responseString.data(using: .utf8) else {
throw NetworkError.decodeError("Resposta nao UTF-8")
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorFlag = object["error"] as? Bool, errorFlag {
let message = object["message"] as? String ?? object["msg"] as? String
throw NetworkError.httpError(200, message ?? "Erro no servidor")
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw NetworkError.decodeError(String(data: data, encoding: .utf8))
throw NetworkError.decodeError(sanitizedBody(data))
}
} catch {
let mapped = mapError(error)
@@ -151,6 +155,7 @@ private extension ApiClient {
case "POST": return .post
case "PUT": return .put
case "DELETE": return .delete
case "PATCH": return .patch
default: return .get
}
}
@@ -247,6 +252,12 @@ private extension ApiClient {
throw NetworkError.unauthorized(payload?.message)
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorFlag = object["error"] as? Bool, errorFlag {
let message = object["message"] as? String ?? object["msg"] as? String
throw NetworkError.httpError(http.statusCode, message ?? "Erro no servidor")
}
if http.statusCode == 429 {
let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "")
throw NetworkError.rateLimited(retryAfter)
@@ -263,7 +274,7 @@ private extension ApiClient {
do {
return try JSONDecoder().decode(type, from: data)
} catch {
throw NetworkError.decodeError(String(data: data, encoding: .utf8))
throw NetworkError.decodeError(sanitizedBody(data))
}
}
@@ -309,7 +320,15 @@ private extension ApiClient {
if let code = payload?.code, code.isEmpty == false {
return "Erro: \(code)"
}
return String(data: data, encoding: .utf8)
// fallback if error true is present but without a classic structure
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let errorFlag = object["error"] as? Bool, errorFlag {
if let msg = object["message"] as? String ?? object["msg"] as? String {
return msg
}
}
return sanitizedBody(data)
}
func serverPayload(from data: Data) -> ApiErrorDescriptor? {
@@ -318,9 +337,10 @@ private extension ApiClient {
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let errorFlag = object["error"] as? Bool ?? false
let code = object["code"] as? String
let message = object["message"] as? String
if code != nil || message != nil {
let message = object["message"] as? String ?? object["msg"] as? String ?? object["error_description"] as? String
if errorFlag || code != nil || message != nil {
return ApiErrorDescriptor(code: code, message: message)
}
}
@@ -344,35 +364,89 @@ private extension ApiClient {
#if canImport(LCEssentials) && os(iOS)
func serverMessage(from error: NSError) -> String? {
if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String,
!reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let data = reason.data(using: .utf8),
let parsed = serverMessage(from: data),
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return parsed
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] {
if let value = error.userInfo[key] as? String,
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let data = value.data(using: .utf8),
let parsed = serverMessage(from: data),
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
if t.hasPrefix("{") { continue }
if let safe = sanitizedMessage(t) { return safe }
}
let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
} else if let valueData = error.userInfo[key] as? Data {
if let parsed = serverMessage(from: valueData) {
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
}
}
}
for (_, value) in error.userInfo {
if let str = value as? String,
str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"),
let data = str.data(using: .utf8),
let parsed = serverMessage(from: data) {
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
}
return reason
}
if let description = error.userInfo[NSLocalizedDescriptionKey] as? String,
!description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!description.lowercased().contains("nsurlerrordomain") {
return description
!description.lowercased().contains("nsurlerrordomain"),
let safe = sanitizedMessage(description) {
return safe
}
return nil
}
func serverPayload(from error: NSError) -> ApiErrorDescriptor? {
if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String,
let data = reason.data(using: .utf8),
let payload = serverPayload(from: data) {
return payload
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data"] {
if let value = error.userInfo[key] as? String,
let data = value.data(using: .utf8),
let payload = serverPayload(from: data) {
return payload
} else if let valueData = error.userInfo[key] as? Data,
let payload = serverPayload(from: valueData) {
return payload
}
}
for (_, value) in error.userInfo {
if let str = value as? String,
str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"),
let data = str.data(using: .utf8),
let payload = serverPayload(from: data) {
return payload
}
}
return nil
}
#endif
func sanitizedBody(_ data: Data) -> String? {
guard let raw = String(data: data, encoding: .utf8) else { return nil }
return sanitizedMessage(raw)
}
func sanitizedMessage(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return nil }
// Drop data URLs (base64 images)
if trimmed.lowercased().hasPrefix("data:image") { return "Erro ao processar imagem." }
// Drop fields containing base64,
if trimmed.contains("base64,") { return "Resposta do servidor inválida." }
// Truncate long strings (raw JSON bodies, etc.)
if trimmed.count > 300 {
return String(trimmed.prefix(300)) + ""
}
return trimmed
}
func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL

View File

@@ -15,32 +15,25 @@ struct CustomerProfileUpdatePayload: Encodable {
}
struct CustomerIdentityUpdatePayload: Encodable {
let name: String
let email: String
let phoneNumber: String
let name: String?
let email: String?
let phoneNumber: String?
let profilePicture: String?
let addressBook: [CustomerAddressPayload]
enum CodingKeys: String, CodingKey {
case name
case email
case phoneNumber
case phone
case profilePicture
case profile_picture
case addressBook = "address_book"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(email, forKey: .email)
try container.encode(phoneNumber, forKey: .phoneNumber)
try container.encode(phoneNumber, forKey: .phone)
try container.encode(addressBook, forKey: .addressBook)
try container.encodeIfPresent(name, forKey: .name)
try container.encodeIfPresent(email, forKey: .email)
try container.encodeIfPresent(phoneNumber, forKey: .phoneNumber)
if let profilePicture, profilePicture.isEmpty == false {
try container.encode(profilePicture, forKey: .profilePicture)
try container.encode(profilePicture, forKey: .profile_picture)
}
}
}

View File

@@ -4,6 +4,13 @@ import Foundation
struct EmptyResult: Decodable {}
struct ProfilePatchEnvelope: Decodable {
let error: Bool
let code: String?
let message: String?
let profilePictureUrl: String?
}
struct RegistrationResult: Decodable {
let id: String?
let name: String?
@@ -46,18 +53,23 @@ struct CustomerAddress: Decodable {
let state: String?
let zipCode: String?
let latLong: [Double]?
let isDefault: Bool?
init(id: String?, label: String?, address: String?, number: String?,
complement: String?, neighborhood: String?, city: String?,
state: String?, zipCode: String?, latLong: [Double]?, isDefault: Bool?) {
self.id = id; self.label = label; self.address = address
self.number = number; self.complement = complement
self.neighborhood = neighborhood; self.city = city
self.state = state; self.zipCode = zipCode
self.latLong = latLong; self.isDefault = isDefault
}
enum CodingKeys: String, CodingKey {
case id
case label
case address
case number
case complement
case neighborhood
case city
case state
case zipCode
case id, label, address, number, complement
case neighborhood, city, state, zipCode
case latLong = "lat_long"
case isDefault
}
}

View File

@@ -7,6 +7,12 @@ struct CreateOrderPayload: Encodable {
let paymentMethod: String
let deliveryType: String
let address: CreateOrderAddressPayload?
// Cartão salvo
let savedCardId: String?
// Novo cartão (checkout transparente)
let clientCpfCnpj: String?
let creditCard: CreditCardOrderPayload?
let creditCardHolderInfo: SaveCardHolderInfoPayload?
}
struct CreateOrderCustomerPayload: Encodable {
@@ -16,15 +22,16 @@ struct CreateOrderCustomerPayload: Encodable {
let asaasId: String?
}
struct CreateOrderItemPayload: Encodable {
struct CreateOrderItemPayload: Codable {
let productId: String
let name: String
let qty: Int
let price: Double
let addons: [CreateOrderAddonPayload]
let choices: [String]?
}
struct CreateOrderAddonPayload: Encodable {
struct CreateOrderAddonPayload: Codable {
let addonId: String
let name: String
let qty: Int

View File

@@ -40,6 +40,10 @@ struct AppOrderSummary: Decodable, Identifiable {
case storeLogo
case store_logo
case logo
case storeImage
case store_image
case storeImageUrl
case logoUrl
case date
case createdAt
case updatedAt
@@ -62,7 +66,7 @@ struct AppOrderSummary: Decodable, Identifiable {
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl])
let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate
@@ -97,6 +101,7 @@ struct PublicOrderResult: Codable, Identifiable {
let customerOtp: String?
let confirmOtp: String?
let cancellationReason: String?
let fullAddress: String?
let deliveryAddress: PublicOrderDeliveryAddress?
let review: PublicOrderReview?
let items: [PublicOrderItem]
@@ -133,12 +138,17 @@ struct PublicOrderResult: Codable, Identifiable {
case storeLogo
case store_logo
case logo
case storeImage
case store_image
case storeImageUrl
case logoUrl
case createdAt
case updatedAt
case otp
case customerOtp
case confirmOtp
case cancellationReason
case fullAddress
case address
case deliveryAddress
case delivery_address
@@ -148,6 +158,8 @@ struct PublicOrderResult: Codable, Identifiable {
case orderReview
case items
case timeline
case history
case orderedAt
}
init(
@@ -178,6 +190,7 @@ struct PublicOrderResult: Codable, Identifiable {
customerOtp: String? = nil,
confirmOtp: String? = nil,
cancellationReason: String? = nil,
fullAddress: String? = nil,
deliveryAddress: PublicOrderDeliveryAddress? = nil,
review: PublicOrderReview? = nil,
items: [PublicOrderItem] = [],
@@ -210,6 +223,7 @@ struct PublicOrderResult: Codable, Identifiable {
self.customerOtp = customerOtp
self.confirmOtp = confirmOtp
self.cancellationReason = cancellationReason
self.fullAddress = fullAddress
self.deliveryAddress = deliveryAddress
self.review = review
self.items = items
@@ -248,13 +262,14 @@ struct PublicOrderResult: Codable, Identifiable {
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt, .orderedAt])
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
otp = ApiService.decodeFlexibleString(from: container, keys: [.otp])
customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp])
confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp])
cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason])
fullAddress = ApiService.decodeFlexibleString(from: container, keys: [.fullAddress])
deliveryAddress = (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .address))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .deliveryAddress))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .delivery_address))
@@ -263,7 +278,9 @@ struct PublicOrderResult: Codable, Identifiable {
review = (try? container.decode(PublicOrderReview.self, forKey: .review))
?? (try? container.decode(PublicOrderReview.self, forKey: .orderReview))
items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? []
timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) ?? []
timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline))
?? (try? container.decode([PublicOrderTimelineEvent].self, forKey: .history))
?? []
}
func encode(to encoder: Encoder) throws {
@@ -294,6 +311,7 @@ struct PublicOrderResult: Codable, Identifiable {
try container.encodeIfPresent(customerOtp, forKey: .customerOtp)
try container.encodeIfPresent(confirmOtp, forKey: .confirmOtp)
try container.encodeIfPresent(cancellationReason, forKey: .cancellationReason)
try container.encodeIfPresent(fullAddress, forKey: .fullAddress)
try container.encodeIfPresent(deliveryAddress, forKey: .address)
try container.encodeIfPresent(review, forKey: .review)
try container.encode(items, forKey: .items)
@@ -537,20 +555,23 @@ struct PublicOrderReview: Codable {
struct PublicOrderItem: Codable, Identifiable {
let id: String
let productId: String?
let name: String?
let qty: Int?
let price: Double?
enum CodingKeys: String, CodingKey {
case id
case productId
case name
case qty
case quantity
case price
}
init(id: String = UUID().uuidString, name: String?, qty: Int?, price: Double?) {
init(id: String = UUID().uuidString, productId: String? = nil, name: String?, qty: Int?, price: Double?) {
self.id = id
self.productId = productId
self.name = name
self.qty = qty
self.price = price
@@ -559,6 +580,7 @@ struct PublicOrderItem: Codable, Identifiable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
productId = try? container.decode(String.self, forKey: .productId)
name = try? container.decode(String.self, forKey: .name)
qty = ApiService.decodeFlexibleInt(from: container, keys: [.qty, .quantity])
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
@@ -567,6 +589,7 @@ struct PublicOrderItem: Codable, Identifiable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(productId, forKey: .productId)
try container.encodeIfPresent(name, forKey: .name)
try container.encodeIfPresent(qty, forKey: .qty)
try container.encodeIfPresent(price, forKey: .price)
@@ -581,6 +604,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
let completed: Bool?
let message: String?
let time: String?
let date: String?
enum CodingKeys: String, CodingKey {
case id
@@ -589,7 +613,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
case active
case completed
case message
case event
case time
case date
case createdAt
case updatedAt
}
@@ -601,7 +627,8 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
active: Bool? = nil,
completed: Bool? = nil,
message: String?,
time: String?
time: String?,
date: String? = nil
) {
self.id = id
self.status = status
@@ -610,6 +637,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
self.completed = completed
self.message = message
self.time = time
self.date = date
}
init(from decoder: Decoder) throws {
@@ -619,10 +647,12 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
label = try? container.decode(String.self, forKey: .label)
active = try? container.decode(Bool.self, forKey: .active)
completed = try? container.decode(Bool.self, forKey: .completed)
message = try? container.decode(String.self, forKey: .message)
message = (try? container.decode(String.self, forKey: .message))
?? (try? container.decode(String.self, forKey: .event))
time = (try? container.decode(String.self, forKey: .time))
?? (try? container.decode(String.self, forKey: .createdAt))
?? (try? container.decode(String.self, forKey: .updatedAt))
date = try? container.decode(String.self, forKey: .date)
}
func encode(to encoder: Encoder) throws {
@@ -634,6 +664,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
try container.encodeIfPresent(completed, forKey: .completed)
try container.encodeIfPresent(message, forKey: .message)
try container.encodeIfPresent(time, forKey: .time)
try container.encodeIfPresent(date, forKey: .date)
}
}

View File

@@ -155,28 +155,17 @@ final class ApiService {
return envelope
}
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ProfilePatchEnvelope {
let payload = CustomerIdentityUpdatePayload(
name: name,
email: email,
phoneNumber: phoneNumber,
profilePicture: profilePicture,
addressBook: (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:))
name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name,
email: email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : email,
phoneNumber: phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : phoneNumber,
profilePicture: profilePicture
)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
} else {
invalidateFavoritesCache()
}
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let envelope: ProfilePatchEnvelope = try await send(req)
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
return envelope
}
@@ -223,6 +212,13 @@ final class ApiService {
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook)
}
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
return envelope
}
func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
let digits = zipCode.filter(\.isNumber)
let normalized = String(digits.prefix(8))
@@ -426,6 +422,49 @@ final class ApiService {
)
return try await sendEnvelope(req)
}
// MARK: - Cards
func listCards() async throws -> ApiEnvelope<[SavedCard]> {
let req = ApiRequest(path: "/api/customer/cards", method: "GET", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
}
func saveCard(payload: SaveCardPayload) async throws -> ApiEnvelope<SavedCardResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/cards", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func updateCard(cardId: String, payload: UpdateCardPayload) async throws -> ApiEnvelope<SavedCardResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "PATCH", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
func deleteCard(cardId: String) async throws -> ApiEnvelope<EmptyResult> {
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "DELETE", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
}
func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope<ChangePaymentMethodResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
// MARK: - Profile CPF
func updateProfileCpf(cpf: String) async throws -> ApiEnvelope<EmptyResult> {
let payload = ["cpf": cpf]
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let result: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
if result.error == false {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
}
return result
}
}
extension ApiService {
static func decodeFlexibleString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? {

View File

@@ -258,7 +258,7 @@ final class FeatureControlService {
}
private func featureKeys() -> [String] {
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only"
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only,at.cupons"
let items = raw
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }

View File

@@ -9,6 +9,7 @@ struct AppState {
var featureFlags = FeatureFlagsState()
var homeFilters = HomeFiltersState()
var activeModal: AppModal? = nil
var shouldNavigateToOrders: Bool = false
}
enum FeatureFlagValue: Codable, Equatable {
@@ -92,6 +93,7 @@ struct ProfileState {
var email: String = ""
var phone: String = ""
var profilePicture: String = ""
var cpf: String = ""
}
struct AddressState {
@@ -171,6 +173,7 @@ struct CartItemState: Identifiable {
var name: String
var imageURL: String? = nil
var details: String? = nil
var choices: [String]? = nil
var addons: [CartItemAddonState] = []
var quantity: Int
var unitPrice: Double
@@ -274,7 +277,8 @@ extension CartState {
qty: $0.quantity,
price: $0.unitPrice
)
}
},
choices: item.choices?.isEmpty == false ? item.choices : nil
)
}
}

View File

@@ -2,10 +2,12 @@ import SwiftUI
struct TermsOfUseView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
screenHeader
Text("Termos de Uso")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
@@ -16,17 +18,40 @@ struct TermsOfUseView: View {
.padding(24)
}
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Termos de Uso")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
}
private var screenHeader: some View {
ZStack {
Text("Termos de Uso")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
}
struct PrivacyPolicyView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
screenHeader
Text("Política de Privacidade")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
@@ -37,7 +62,28 @@ struct PrivacyPolicyView: View {
.padding(24)
}
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Privacidade")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
}
private var screenHeader: some View {
ZStack {
Text("Privacidade")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
}

View File

@@ -2,7 +2,6 @@ import SwiftUI
struct AddAddressFormView: View {
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@State var label = ""
@State var zipCode = ""
@State var address = ""
@@ -31,38 +30,18 @@ struct AddAddressFormView: View {
!state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
@ViewBuilder private var logoImage: some View {
SwiftUI.Image("pedifoods")
.resizable()
}
var body: some View {
ZStack {
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
AppColors.backgroundLight.ignoresSafeArea()
ScrollView {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
screenHeader(
title: existingAddress == nil ? "Novo endereço" : "Editar endereço",
onBack: { dismiss() }
)
.padding(.horizontal, 24)
.padding(.bottom, 12)
logoImage
.scaledToFit()
.frame(width: 120, height: 120)
Text(existingAddress == nil ? "Novo endereço" : "Editar endereço")
.font(AppTypography.heading1)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
Text(existingAddress == nil ? "Preencha os dados abaixo para adicionar um endereço." : "Atualize os dados do endereço abaixo.")
.font(AppTypography.body)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
.multilineTextAlignment(.center)
.padding(.top, 8)
.padding(.bottom, 20)
.padding(.horizontal, 20)
.padding(.bottom, 20)
VStack(alignment: .leading, spacing: 16) {
LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", keyboardType: .default, text: $label)
@@ -127,7 +106,7 @@ struct AddAddressFormView: View {
Spacer().frame(height: 120)
}
}
.padding(.top, 0)
.padding(.top, 18)
}
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
@@ -140,8 +119,7 @@ struct AddAddressFormView: View {
ZStack {
Text(title)
.font(AppTypography.heading2)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: onBack) {
Image(systemName: "chevron.left")
@@ -177,7 +155,8 @@ struct AddAddressFormView: View {
city: clean(city),
state: clean(state),
zipCode: optional(normalizeZipCodeForAPI(zipCode)),
latLong: latLong
latLong: latLong,
isDefault: existingAddress?.isDefault
)
isLoading = true
zipLookupMessage = nil

View File

@@ -0,0 +1,461 @@
import SwiftUI
struct AddCardFormView: View {
let appState: AppState
let isFirstCard: Bool
let onCardAdded: (SavedCard) -> Void
@Environment(\.dismiss) var dismiss
@State private var cardNumber = ""
@State private var holderName = ""
@State private var expiry = ""
@State private var cvv = ""
@State private var cpf = ""
@State private var nickname = ""
@State private var isDefault = false
@State private var isSaving = false
@State private var addresses: [CustomerAddress] = []
@State private var selectedAddress: CustomerAddress? = nil
@State private var isLoadingAddresses = false
@State private var showAddressPicker = false
private var detectedBrandLogo: String? {
let clean = cardNumber.filter(\.isNumber)
guard clean.isEmpty == false else { return nil }
if clean.hasPrefix("506766") || clean.hasPrefix("603389") { return "sodexo_logo" }
if clean.hasPrefix("5067") || clean.hasPrefix("5078") || clean.hasPrefix("6278") || clean.hasPrefix("6367") { return "alelocard_logo" }
if clean.hasPrefix("3841") || clean.hasPrefix("606282") || clean.hasPrefix("637095") || clean.hasPrefix("637568") { return "hipercard_logo" }
if clean.hasPrefix("34") || clean.hasPrefix("37") { return "amexcard_logo" }
if clean.hasPrefix("4") { return "visacard_logo" }
let prefix2 = Int(clean.prefix(2)) ?? 0
if (51...59).contains(prefix2) { return "mastercard_logo" }
if let p4 = Int(clean.prefix(4)), (2221...2720).contains(p4) { return "mastercard_logo" }
return nil
}
private var selectedAddressZip: String {
(selectedAddress?.zipCode ?? "").filter(\.isNumber)
}
private var canSave: Bool {
let digits = cardNumber.filter(\.isNumber)
let cpfDigits = cpf.filter(\.isNumber)
let parts = expiry.split(separator: "/")
return digits.count >= 13
&& holderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
&& parts.count == 2
&& cvv.count >= 3
&& cpfDigits.count == 11
&& selectedAddress != nil
&& selectedAddressZip.count >= 7
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
screenHeader
formSection("Dados do Cartão") {
cardNumberField
labeledField("Nome no cartão", placeholder: "Como impresso no cartão", text: $holderName, autocap: true)
HStack(spacing: 12) {
labeledField("Validade", placeholder: "MM/AA", text: $expiry, keyboard: .numberPad)
.onChange(of: expiry) { _, v in expiry = formatExpiry(v) }
labeledField("CVV", placeholder: "•••", text: $cvv, keyboard: .numberPad)
.onChange(of: cvv) { _, v in cvv = String(v.filter(\.isNumber).prefix(4)) }
}
}
formSection("Identificação do Titular") {
labeledField("CPF", placeholder: "000.000.000-00", text: $cpf, keyboard: .numberPad)
.onChange(of: cpf) { _, v in cpf = formatCPF(v.filter(\.isNumber)) }
addressPickerRow
}
formSection("Opções") {
labeledField("Apelido (opcional)", placeholder: "Ex: Cartão do Nubank", text: $nickname)
if isFirstCard == false {
Toggle(isOn: $isDefault) {
Text("Definir como principal")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
.tint(AppColors.primary)
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
Button(action: { Task { await saveCard() } }) {
Group {
if isSaving {
ProgressView().tint(Color(hex: "#0E1A06"))
} else {
Text("Salvar Cartão").font(AppTypography.heading2)
}
}
.foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56)
.background(canSave && !isSaving ? Color(hex: "#C8F06E") : Color(hex: "#C8F06E").opacity(0.45))
.clipShape(Capsule())
}
.buttonStyle(.plain)
.disabled(!canSave || isSaving)
.padding(.top, 4)
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, 40)
}
.background(AppColors.backgroundLight)
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.sheet(isPresented: $showAddressPicker) {
addressPickerSheet
}
.task { await loadData() }
}
// MARK: - Address picker row
private var addressPickerRow: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Endereço de cobrança")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
Button {
if addresses.isEmpty == false { showAddressPicker = true }
} label: {
HStack(spacing: 10) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 18))
.foregroundStyle(selectedAddress != nil ? AppColors.primary : AppColors.textMuted)
VStack(alignment: .leading, spacing: 2) {
if isLoadingAddresses {
Text("Carregando endereços...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
} else if let addr = selectedAddress {
Text(addressDisplayTitle(addr))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
if let sub = addressDisplaySubtitle(addr) {
Text(sub)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
}
} else if addresses.isEmpty {
Text("Nenhum endereço cadastrado")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
} else {
Text("Selecionar endereço")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
}
Spacer()
if addresses.isEmpty == false {
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppColors.textMuted)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
.disabled(isLoadingAddresses || addresses.isEmpty)
}
}
// MARK: - Address picker sheet
private var addressPickerSheet: some View {
NavigationStack {
ScrollView(showsIndicators: false) {
VStack(spacing: 10) {
ForEach(Array(addresses.enumerated()), id: \.offset) { _, addr in
Button {
selectedAddress = addr
showAddressPicker = false
} label: {
HStack(spacing: 12) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 22))
.foregroundStyle(isSelected(addr) ? AppColors.primary : AppColors.textMuted)
VStack(alignment: .leading, spacing: 3) {
Text(addressDisplayTitle(addr))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
if let sub = addressDisplaySubtitle(addr) {
Text(sub)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
}
}
Spacer()
if isSelected(addr) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(AppColors.primary)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
.padding(.top, 14)
.padding(.bottom, 30)
}
.background(AppColors.backgroundLight)
.navigationTitle("Endereço de cobrança")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Fechar") { showAddressPicker = false }
.foregroundStyle(AppColors.textPrimary)
}
}
}
}
// MARK: - Sub-views
private var screenHeader: some View {
ZStack {
Text("Novo Cartão")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var cardNumberField: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Número do cartão")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
HStack(spacing: 8) {
TextField("0000 0000 0000 0000", text: $cardNumber)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.keyboardType(.numberPad)
.onChange(of: cardNumber) { _, v in cardNumber = formatCardNumber(v.filter(\.isNumber)) }
let digits = cardNumber.filter(\.isNumber)
if let logo = detectedBrandLogo {
Image(logo)
.resizable()
.scaledToFit()
.frame(width: 40, height: 26)
} else if digits.count >= 4 {
Image(systemName: "creditcard")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
.frame(width: 40, height: 26)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
private func labeledField(
_ label: String,
placeholder: String,
text: Binding<String>,
keyboard: UIKeyboardType = .default,
autocap: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: text)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.keyboardType(keyboard)
.autocorrectionDisabled()
.textInputAutocapitalization(autocap ? .characters : .never)
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
private func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.padding(.leading, 2)
content()
}
}
// MARK: - Helpers
private func isSelected(_ addr: CustomerAddress) -> Bool {
guard let sel = selectedAddress else { return false }
if let id = addr.id, let selId = sel.id { return id == selId }
return addr.address == sel.address && addr.number == sel.number
}
private func addressDisplayTitle(_ addr: CustomerAddress) -> String {
let label = addr.label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if label.isEmpty == false { return label }
let street = addr.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let number = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let base = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ")
return base.isEmpty ? "Endereço" : base
}
private func addressDisplaySubtitle(_ addr: CustomerAddress) -> String? {
let parts = [
addr.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines),
addr.city?.trimmingCharacters(in: .whitespacesAndNewlines),
addr.state?.trimmingCharacters(in: .whitespacesAndNewlines)
].compactMap { v -> String? in
guard let v, v.isEmpty == false else { return nil }
return v
}
return parts.isEmpty ? nil : parts.joined(separator: ", ")
}
// MARK: - Load & Save
@MainActor
private func loadData() async {
holderName = appState.profile.name
cpf = formatCPF(appState.profile.cpf.filter(\.isNumber))
isDefault = isFirstCard
isLoadingAddresses = true
defer { isLoadingAddresses = false }
if let result = try? await ApiService().profile(forceRefresh: false).result {
let book = result.addressBook ?? []
addresses = book
selectedAddress = book.first
}
}
@MainActor
private func saveCard() async {
guard canSave, let addr = selectedAddress else { return }
isSaving = true
defer { isSaving = false }
let parts = expiry.split(separator: "/")
let month = String(parts[0])
let year: String = {
let y = String(parts[1])
return y.count == 2 ? "20\(y)" : y
}()
let cleanNumber = cardNumber.filter(\.isNumber)
let cleanCpf = cpf.filter(\.isNumber)
let zip = selectedAddressZip
let addrNumber = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "0"
let creditCard = SaveCardCreditCardPayload(
holderName: holderName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased(),
number: cleanNumber,
expiryMonth: month,
expiryYear: year,
ccv: cvv
)
let holderInfo = SaveCardHolderInfoPayload(
name: holderName.trimmingCharacters(in: .whitespacesAndNewlines),
email: appState.profile.email,
cpfCnpj: cleanCpf,
postalCode: zip,
addressNumber: addrNumber.isEmpty ? "0" : addrNumber,
phone: appState.profile.phone
)
let payload = SaveCardPayload(
creditCard: creditCard,
creditCardHolderInfo: holderInfo,
nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : nickname,
isDefault: isDefault || isFirstCard
)
do {
let response = try await ApiService().saveCard(payload: payload)
if response.error == false, let result = response.result {
let newCard = SavedCard(
id: result.id,
nickname: payload.nickname,
holderName: result.holderName,
last4: result.last4,
brand: result.brand,
expiryMonth: result.expiryMonth,
expiryYear: result.expiryYear,
isDefault: result.isDefault
)
onCardAdded(newCard)
dismiss()
SnackbarCenter.shared.show(title: "Cartão salvo com sucesso.", style: .success, icon: "creditcard.fill", duration: 2.5)
} else {
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível salvar o cartão.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
} catch {
SnackbarCenter.shared.show(title: "Erro ao salvar cartão. Verifique os dados e tente novamente.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
}
// MARK: - Formatters
private func formatCardNumber(_ digits: String) -> String {
let d = String(digits.prefix(16))
var result = ""
for (i, c) in d.enumerated() {
if i > 0 && i % 4 == 0 { result += " " }
result.append(c)
}
return result
}
private func formatExpiry(_ value: String) -> String {
let digits = String(value.filter(\.isNumber).prefix(4))
if digits.count > 2 { return "\(digits.prefix(2))/\(digits.dropFirst(2))" }
return digits
}
private func formatCPF(_ digits: String) -> String {
let d = String(digits.prefix(11))
if d.count <= 3 { return d }
if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" }
if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" }
return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))"
}
}

View File

@@ -118,6 +118,9 @@ struct AddressCard: View {
let item: AddressListItem
var onEdit: (() -> Void)? = nil
var onDelete: (() -> Void)? = nil
var onSetDefault: (() -> Void)? = nil
private var hasActions: Bool { onEdit != nil || onSetDefault != nil }
var body: some View {
HStack(spacing: 14) {
@@ -150,30 +153,30 @@ struct AddressCard: View {
Spacer(minLength: 8)
if onEdit != nil || onDelete != nil {
if hasActions {
Rectangle()
.fill(AppColors.backgroundLight)
.frame(width: 1, height: 96)
}
VStack(spacing: 24) {
VStack(spacing: 20) {
if let onEdit {
Button(action: onEdit) {
Image(systemName: "pencil")
.font(.system(size: 22))
.font(.system(size: 20))
.foregroundStyle(AppColors.textMuted)
}
}
if let onDelete {
Button(action: onDelete) {
Image(systemName: "trash")
.font(.system(size: 22))
if let onSetDefault, item.isPrimary == false {
Button(action: onSetDefault) {
Image(systemName: "star")
.font(.system(size: 20))
.foregroundStyle(AppColors.textMuted)
}
}
}
.frame(width: onEdit != nil || onDelete != nil ? 40 : 0)
.frame(width: hasActions ? 40 : 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 20)

View File

@@ -12,6 +12,7 @@ struct AddressesView: View {
@State var editingAddress: CustomerAddress? = nil
@State var openSwipeRowId: String? = nil
@State var deletingRowId: String? = nil
@State var settingDefaultRowId: String? = nil
let tabBarClearance: CGFloat = 96
@@ -54,7 +55,9 @@ struct AddressesView: View {
} else {
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
let rowId = addressRowId(for: address, index: index)
let isSelected: Bool = {
let isPrimary: Bool = {
if address.isDefault == true { return true }
if addresses.contains(where: { $0.isDefault == true }) { return false }
if let selectedId = appState.address.selectedId {
return address.id == selectedId
}
@@ -65,10 +68,10 @@ struct AddressesView: View {
Button {
selectAddress(address)
} label: {
AddressCard(item: addressToListItem(address, isPrimary: isSelected))
AddressCard(item: addressToListItem(address, isPrimary: isPrimary))
}
.buttonStyle(.plain)
} else {
} else if addresses.count > 1 {
SwipeToDeleteAddressRow(
rowId: rowId,
openRowId: $openSwipeRowId,
@@ -76,19 +79,30 @@ struct AddressesView: View {
onDelete: { deleteAddress(address, rowId: rowId) }
) {
AddressCard(
item: addressToListItem(address, isPrimary: isSelected),
onEdit: { beginEditing(address) }
item: addressToListItem(address, isPrimary: isPrimary),
onEdit: { beginEditing(address) },
onSetDefault: { setDefaultAddress(address, rowId: rowId) }
)
.appContentShape(Rectangle())
.onTapGesture {
if openSwipeRowId == rowId {
openSwipeRowId = nil
}
}
.simultaneousGesture(TapGesture().onEnded {
if openSwipeRowId == rowId { openSwipeRowId = nil }
})
.opacity(settingDefaultRowId == rowId ? 0.6 : 1.0)
}
.id(rowId)
.opacity(deletingRowId == rowId ? 0.6 : 1.0)
.disabled(deletingRowId != nil)
.disabled(deletingRowId != nil || settingDefaultRowId != nil)
} else {
AddressCard(
item: addressToListItem(address, isPrimary: isPrimary),
onEdit: { beginEditing(address) }
)
.overlay(alignment: .bottom) {
Text("Ao menos um endereço deve permanecer")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted.opacity(0.7))
.padding(.bottom, 8)
}
}
}
}
@@ -230,8 +244,65 @@ struct AddressesView: View {
return "idx:\(index):\(address.label ?? ""):\(address.address ?? ""):\(address.number ?? "")"
}
private func setDefaultAddress(_ address: CustomerAddress, rowId: String) {
guard settingDefaultRowId == nil else { return }
settingDefaultRowId = rowId
openSwipeRowId = nil
Task {
var resolvedId = address.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if resolvedId.isEmpty {
if let book = try? await ApiService().profile(forceRefresh: true).result?.addressBook {
await MainActor.run { addresses = book }
resolvedId = book.first {
$0.address == address.address &&
$0.number == address.number &&
$0.zipCode == address.zipCode
}?.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
}
guard resolvedId.isEmpty == false else {
await MainActor.run {
settingDefaultRowId = nil
SnackbarCenter.shared.show(title: "Não foi possível identificar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
return
}
do {
let response = try await ApiService().setDefaultAddress(addressId: resolvedId)
await MainActor.run {
settingDefaultRowId = nil
if response.error == false {
addresses = addresses.map { addr in
let isTarget = (addr.id ?? "") == resolvedId
return CustomerAddress(
id: addr.id, label: addr.label, address: addr.address,
number: addr.number, complement: addr.complement,
neighborhood: addr.neighborhood, city: addr.city,
state: addr.state, zipCode: addr.zipCode,
latLong: addr.latLong, isDefault: isTarget
)
}
selectAddress(address)
SnackbarCenter.shared.show(title: "Endereço principal atualizado.", style: .success, icon: "star.fill", duration: 2.5)
} else {
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível definir endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
} catch {
await MainActor.run {
settingDefaultRowId = nil
SnackbarCenter.shared.show(title: "Erro ao atualizar endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
}
}
private func deleteAddress(_ address: CustomerAddress, rowId: String) {
guard deletingRowId == nil else { return }
guard addresses.count > 1, deletingRowId == nil else { return }
deletingRowId = rowId
openSwipeRowId = nil
@@ -302,7 +373,7 @@ struct AddressesView: View {
do {
let service = ApiService()
let response = try await service.profile()
let response = try await service.profile(forceRefresh: true)
guard response.error == false else {
errorMessage = response.message ?? "Não foi possível carregar os endereços."
isLoading = false

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct CartView: View {
@Binding var appState: AppState
@Binding var selectedTab: MainTab
@State var openCheckout = false
@State var couponCode = ""
@State var appliedCouponCode: String? = nil
@@ -49,7 +50,7 @@ struct CartView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
.navigationDestination(isPresented: $openCheckout) {
CheckoutView(appState: $appState)
CheckoutView(appState: $appState, selectedTab: $selectedTab)
}
.task(id: deliveryFeeWatchKey) {
await refreshDeliveryFee()

View File

@@ -234,7 +234,13 @@ extension CheckoutView {
return
}
let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod)
// Crédito pelo app abre seleção de cartão antes de criar pedido
if useInAppPayment && effectivePaymentMethod == .creditCard {
showCardSelectionSheet = true
return
}
let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod, savedCardId: nil)
guard case .success(let payload) = payloadBuildResult else {
let message: String
if case .failure(let reason) = payloadBuildResult {
@@ -251,83 +257,31 @@ extension CheckoutView {
do {
let response = try await ApiService().createOrder(storeId: storeId, payload: payload)
if response.error {
SnackbarCenter.shared.show(
title: response.message ?? "Não foi possível criar o pedido.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
return
}
guard let result = response.result else {
SnackbarCenter.shared.show(title: "Resposta de pagamento inválida.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
return
}
let orderSnapshot = result.asPublicOrderResult()
SessionStateStore.saveTrackedOrder(orderSnapshot)
let orderId = result.id ?? UUID().uuidString
let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod)
if useInAppPayment == false || isInAppMethod == false {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
if response.error == false, let result = response.result {
let orderId = result.id ?? UUID().uuidString
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
} else {
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
return
}
if orderSnapshot.isPaymentConfirmed {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
return
}
SessionStateStore.savePendingCartOrderId(orderId)
if effectivePaymentMethod == .creditCard {
cardPaymentContext = CardPaymentContext(
orderId: orderId,
shortId: result.shortId,
total: totalValue
)
return
}
let pixFromPayment = result.payment?.pix
let pixFromPayload = result.paymentPayload
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.copyPaste
: pixFromPayload?.copyPaste
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.qrCodeImage
: pixFromPayload?.qrCodeImage
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.expirationDate
: pixFromPayload?.expirationDate
guard let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
SnackbarCenter.shared.show(title: "Código PIX não retornado pela API.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
return
}
pixPaymentContext = PixPaymentContext(
id: orderId,
orderId: orderId,
shortId: result.shortId,
copyPaste: copyPaste,
qrCodeImageBase64: qrCodeImage,
expirationDate: expirationDate
)
handleOrderResponse(response, effectivePaymentMethod: effectivePaymentMethod)
} catch {
SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
func buildCreateOrderPayload(paymentMethod: CheckoutPaymentMethod) -> Result<CreateOrderPayload, CheckoutPayloadValidationError> {
func buildCreateOrderPayload(
paymentMethod: CheckoutPaymentMethod,
savedCardId: String? = nil,
creditCard: CreditCardOrderPayload? = nil,
creditCardHolderInfo: SaveCardHolderInfoPayload? = nil,
clientCpfCnpj: String? = nil
) -> Result<CreateOrderPayload, CheckoutPayloadValidationError> {
guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) }
let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -360,18 +314,101 @@ extension CheckoutView {
return .success(
CreateOrderPayload(
customer: CreateOrderCustomerPayload(
name: profileName,
phone: profilePhone,
email: profileEmail,
asaasId: nil
),
items: appState.cart.toOrderItemsPayload(),
total: totalValue,
paymentMethod: paymentMethod.rawValue,
deliveryType: deliveryType.rawValue,
address: addressPayload
customer: CreateOrderCustomerPayload(
name: profileName,
phone: profilePhone,
email: profileEmail,
asaasId: nil
),
items: appState.cart.toOrderItemsPayload(),
total: totalValue,
paymentMethod: paymentMethod.rawValue,
deliveryType: deliveryType.rawValue,
address: addressPayload,
savedCardId: savedCardId,
clientCpfCnpj: clientCpfCnpj,
creditCard: creditCard,
creditCardHolderInfo: creditCardHolderInfo
)
)
}
@MainActor
func confirmOrderWithSavedCard(cardId: String, storeId: String) async {
let payloadResult = buildCreateOrderPayload(paymentMethod: .creditCard, savedCardId: cardId)
guard case .success(let payload) = payloadResult else { return }
isSubmittingOrder = true
defer { isSubmittingOrder = false }
do {
let response = try await ApiService().createOrder(storeId: storeId, payload: payload)
handleOrderResponse(response, effectivePaymentMethod: .creditCard)
} catch {
SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
func handleOrderResponse(_ response: ApiEnvelope<CreateOrderResult>, effectivePaymentMethod: CheckoutPaymentMethod) {
if response.error {
SnackbarCenter.shared.show(
title: response.message ?? "Não foi possível criar o pedido.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
return
}
guard let result = response.result else {
SnackbarCenter.shared.show(title: "Resposta de pagamento inválida.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
return
}
let orderSnapshot = result.asPublicOrderResult()
SessionStateStore.saveTrackedOrder(orderSnapshot)
let orderId = result.id ?? UUID().uuidString
if orderSnapshot.isPaymentConfirmed {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
return
}
SessionStateStore.savePendingCartOrderId(orderId)
let pixFromPayment = result.payment?.pix
let pixFromPayload = result.paymentPayload
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.copyPaste : pixFromPayload?.copyPaste
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.qrCodeImage : pixFromPayload?.qrCodeImage
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.expirationDate : pixFromPayload?.expirationDate
if let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
let itemsData = (try? JSONEncoder().encode(appState.cart.toOrderItemsPayload())).flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
let storeId = appState.cart.storeId ?? ""
pixPaymentContext = PixPaymentContext(
id: orderId,
orderId: orderId,
shortId: result.shortId,
storeId: storeId,
copyPaste: copyPaste,
qrCodeImageBase64: qrCodeImage,
expirationDate: expirationDate,
total: totalValue,
profileName: appState.profile.name,
profileEmail: appState.profile.email,
profilePhone: appState.profile.phone,
addressZip: selectedCustomerAddress?.zipCode,
addressNumber: selectedCustomerAddress?.number,
deliveryType: deliveryType.rawValue,
itemsJSON: itemsData
)
return
}
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ struct MainTabView: View {
}
case .cart:
NavigationStack {
CartView(appState: $appState)
CartView(appState: $appState, selectedTab: $selectedTab)
}
case .profile:
NavigationStack {

View File

@@ -4,10 +4,20 @@ struct OrderDetailsView: View {
let order: PublicOrderResult
let orderId: String
let initialShortId: String?
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@State private var storeContactPhone: String? = nil
@State private var resolvedStoreLogoURL: String? = nil
@State private var showCallAlert = false
@State private var navigateToStore = false
@State private var showClearCartAlert = false
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 14) {
screenHeader
statusCard
storeCard
itemsCard
@@ -22,8 +32,44 @@ struct OrderDetailsView: View {
.padding(.bottom, UIDevice.bottomNotch)
}
.background(AppColors.backgroundLight)
.navigationTitle("Detalhes do Pedido")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.navigationDestination(isPresented: $navigateToStore) {
if let storeId = order.storeId, storeId.isEmpty == false {
StoreDetailView(
storeId: storeId,
storeName: order.storeName ?? "Loja",
storeCoverURL: nil,
storeLogoURL: order.storeLogoURL,
storeCategory: nil,
storeRating: nil,
storeDistance: nil,
storeDeliveryFee: nil,
appState: $appState
)
}
}
.alert("Ligar para a loja?", isPresented: $showCallAlert) {
Button("Ligar para \(order.storeName ?? "a loja")") {
if let phone = storeContactPhone {
openTel(phone)
}
}
Button("Cancelar", role: .cancel) {}
} message: {
Text("WhatsApp não encontrado. Deseja ligar para \(order.storeName ?? "a loja")?")
}
.task {
await loadStoreContactPhone()
}
.alert("Substituir carrinho?", isPresented: $showClearCartAlert) {
Button("Limpar e adicionar", role: .destructive) {
applyReorder(clearFirst: true)
}
Button("Cancelar", role: .cancel) {}
} message: {
Text("Seu carrinho tem itens de \(appState.cart.storeName ?? appState.cart.storeId ?? "outra loja"). Deseja limpar e adicionar itens de \(order.storeName ?? "esta loja")?")
}
.appBottomSafeAreaInset {
VStack {
reorderButton
@@ -35,6 +81,27 @@ struct OrderDetailsView: View {
}
}
private var screenHeader: some View {
ZStack {
Text("Detalhes do Pedido")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var statusCard: some View {
HStack(spacing: 14) {
Circle()
@@ -54,6 +121,26 @@ struct OrderDetailsView: View {
Text(statusDateText)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
if let reason = cancellationReasonText {
Text(reason)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 2)
}
if let addr = deliveryAddressSummary {
HStack(spacing: 4) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 11))
.foregroundStyle(AppColors.textMuted)
Text(addr)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(2)
}
.padding(.top, 2)
}
}
Spacer(minLength: 0)
@@ -64,30 +151,39 @@ struct OrderDetailsView: View {
}
private var storeCard: some View {
HStack(spacing: 12) {
AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL))
.frame(width: 54, height: 54)
.clipShape(Circle())
.background(AppColors.brandSoft, in: Circle())
VStack(alignment: .leading, spacing: 4) {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Text(storeSubtitle)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Button {
if order.storeId?.isEmpty == false {
navigateToStore = true
}
} label: {
HStack(spacing: 12) {
AsyncStoreImage(imageURL: resolvedMediaURL(resolvedStoreLogoURL ?? order.storeLogoURL))
.frame(width: 54, height: 54)
.clipShape(Circle())
.background(AppColors.brandSoft, in: Circle())
Spacer(minLength: 0)
VStack(alignment: .leading, spacing: 4) {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Text(storeSubtitle)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
Text("Ver loja")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
Spacer(minLength: 0)
if order.storeId?.isEmpty == false {
Text("Ver loja")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
}
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.buttonStyle(.plain)
}
private var itemsCard: some View {
@@ -216,27 +312,28 @@ struct OrderDetailsView: View {
private var reorderButton: some View {
Button("Pedir Novamente") {
SnackbarCenter.shared.show(
title: "Recompra será integrada com o catálogo em breve.",
style: .info,
icon: "cart.badge.plus",
duration: 2.0
)
reorder()
}
.font(AppTypography.heading2)
.foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56)
.background(Color(hex: "#C8F06E"))
.background(order.items.isEmpty ? Color(hex: "#C8F06E").opacity(0.45) : Color(hex: "#C8F06E"))
.clipShape(Capsule())
.buttonStyle(.plain)
.disabled(order.items.isEmpty)
}
private var helpFooter: some View {
Text("Precisa de ajuda com esse pedido?")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
Button {
handleHelpTap()
} label: {
Text("Precisa de ajuda com esse pedido?")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
}
.buttonStyle(.plain)
}
private var subtotal: Double {
@@ -296,6 +393,24 @@ struct OrderDetailsView: View {
return "Pedido #\(displayOrderTitle)"
}
private var deliveryAddressSummary: String? {
if let full = order.fullAddress, full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
return full.trimmingCharacters(in: .whitespacesAndNewlines)
}
let line1 = deliveryAddressLine
let line2 = deliveryAddressLine2
let combined = [line1, line2].filter { $0.isEmpty == false }.joined(separator: ", ")
return combined.isEmpty ? nil : combined
}
private var cancellationReasonText: String? {
guard statusTitle.contains("cancelado"),
let reason = order.cancellationReason,
reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
else { return nil }
return "Motivo: \(reason.trimmingCharacters(in: .whitespacesAndNewlines))"
}
private var statusTitle: String {
let status = normalized(order.status)
if status.contains("CANCEL") { return "Pedido cancelado" }
@@ -309,6 +424,12 @@ struct OrderDetailsView: View {
}
private var statusDateText: String {
if let event = order.timeline.first,
let date = event.date, date.isEmpty == false {
let time = event.time.flatMap { $0.isEmpty ? nil : $0 }
let combined = time.map { "\(date) às \($0)" } ?? date
return "\(statusDatePrefix) \(combined)"
}
if let formatted = formatDate(order.updatedAt ?? order.createdAt) {
return "\(statusDatePrefix) \(formatted)"
}
@@ -342,14 +463,81 @@ struct OrderDetailsView: View {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
private func reorder() {
guard order.items.isEmpty == false else { return }
let cartStoreId = appState.cart.storeId ?? appState.cart.items.first?.storeId ?? ""
let orderStoreId = order.storeId ?? ""
let cartHasDifferentStore = cartStoreId.isEmpty == false
&& orderStoreId.isEmpty == false
&& cartStoreId != orderStoreId
&& appState.cart.items.isEmpty == false
if cartHasDifferentStore {
showClearCartAlert = true
} else {
applyReorder(clearFirst: false)
}
}
private func applyReorder(clearFirst: Bool) {
if clearFirst {
appState.cart.clear()
}
let storeId = order.storeId ?? ""
if appState.cart.storeId == nil || appState.cart.storeId?.isEmpty == true {
appState.cart.storeId = storeId
appState.cart.storeName = order.storeName
}
var addedCount = 0
for item in order.items {
let name = item.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard name.isEmpty == false else { continue }
let qty = max(1, item.qty ?? 1)
let price = item.price ?? 0
let cartItem = CartItemState(
id: UUID().uuidString,
productId: item.productId ?? item.id,
storeId: storeId,
name: name,
imageURL: nil,
details: nil,
addons: [],
quantity: qty,
unitPrice: price
)
appState.cart.add(item: cartItem)
addedCount += qty
}
let label = addedCount == 1 ? "1 item adicionado ao carrinho." : "\(addedCount) itens adicionados ao carrinho."
SnackbarCenter.shared.show(title: label, style: .success, icon: "cart.badge.plus", duration: 2.5)
}
private func formatDate(_ isoValue: String?) -> String? {
guard let isoValue, isoValue.isEmpty == false else { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var date = iso.date(from: isoValue)
let optionSets: [ISO8601DateFormatter.Options] = [
[.withInternetDateTime, .withFractionalSeconds],
[.withInternetDateTime],
[.withFullDate, .withTime, .withColonSeparatorInTime],
[.withFullDate, .withTime, .withColonSeparatorInTime, .withTimeZone],
[.withFullDate]
]
var date: Date? = nil
for options in optionSets {
iso.formatOptions = options
if let d = iso.date(from: isoValue) {
date = d
break
}
}
if date == nil {
iso.formatOptions = [.withInternetDateTime]
date = iso.date(from: isoValue)
let fallback = DateFormatter()
fallback.locale = Locale(identifier: "en_US_POSIX")
for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSZ", "yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd"] {
fallback.dateFormat = fmt
if let d = fallback.date(from: isoValue) { date = d; break }
}
}
guard let date else { return nil }
@@ -359,6 +547,64 @@ struct OrderDetailsView: View {
return formatter.string(from: date)
}
@MainActor
private func loadStoreContactPhone() async {
if let inline = order.storePhone, inline.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
storeContactPhone = inline
}
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines),
storeId.isEmpty == false else { return }
do {
let response = try await ApiService().storeInfo(storeId: storeId)
if response.error == false, let result = response.result {
let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if phone.isEmpty == false {
storeContactPhone = phone
}
if let logo = result.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
resolvedStoreLogoURL = logo
}
}
} catch {}
}
private func handleHelpTap() {
guard let phoneRaw = storeContactPhone,
phoneRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
SnackbarCenter.shared.show(
title: "Telefone da loja indisponível.",
style: .warning,
icon: "exclamationmark.triangle.fill",
duration: 2.8
)
return
}
if let waURL = makeWhatsAppURL(from: phoneRaw) {
openURL(waURL)
} else {
showCallAlert = true
}
}
private func openTel(_ phoneRaw: String) {
let digits = phoneRaw.filter(\.isNumber)
if digits.isEmpty { return }
if let url = URL(string: "tel://\(digits)") {
openURL(url)
}
}
private func makeWhatsAppURL(from phoneRaw: String) -> URL? {
var digits = phoneRaw.filter(\.isNumber)
if digits.isEmpty { return nil }
if digits.hasPrefix("0") { digits = String(digits.drop(while: { $0 == "0" })) }
if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) {
digits = "55" + digits
}
guard digits.count >= 12 else { return nil }
return URL(string: "https://wa.me/\(digits)")
}
private func normalized(_ value: String?) -> String {
(value ?? "")
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)

View File

@@ -12,6 +12,8 @@ private struct TrackingStep: Identifiable {
struct OrderTrackingView: View {
let orderId: String
let initialShortId: String?
var postOrderBack: (() -> Void)? = nil
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@State var isLoading = true
@@ -27,6 +29,7 @@ struct OrderTrackingView: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
screenHeader
topHeader
orderTitleSection
statusBanner
@@ -43,8 +46,8 @@ struct OrderTrackingView: View {
.padding(.bottom, UIDevice.bottomNotch + 45)
}
.background(AppColors.backgroundLight)
.navigationTitle("Pedido \(displayOrderTitle)")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
Button("Fechar", role: .cancel) {}
} message: {
@@ -97,6 +100,33 @@ struct OrderTrackingView: View {
self.reviewSavedObserver = nil
}
private var screenHeader: some View {
ZStack {
Text("Pedido \(displayOrderTitle)")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: {
if let postOrderBack {
postOrderBack()
} else {
dismiss()
}
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var topHeader: some View {
HStack(spacing: 10) {
Circle()

View File

@@ -1,6 +1,7 @@
import SwiftUI
struct OrdersView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@State var isLoading = false
@State var errorMessage: String? = nil
@@ -8,6 +9,8 @@ struct OrdersView: View {
@State var hasLoadedOnce = false
@State var storeRatingByStoreId: [String: Double] = [:]
@State var storeRatingByStoreName: [String: Double] = [:]
@State var storeLogoByStoreId: [String: String] = [:]
@State var storeLogoByStoreName: [String: String] = [:]
@State var selectedOrderRoute: OrderRouteContext? = nil
var body: some View {
@@ -58,7 +61,8 @@ struct OrdersView: View {
initialShortId: context.shortId,
fallbackPaymentMethod: context.paymentMethod,
fallbackTotal: context.total,
routeIntent: context.intent
routeIntent: context.intent,
appState: $appState
)
}
}
@@ -104,7 +108,7 @@ struct OrdersView: View {
return VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL))
AsyncStoreImage(imageURL: resolvedMediaURL(storeLogoURL(for: order)))
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
@@ -464,20 +468,25 @@ struct OrdersView: View {
var byId: [String: Double] = [:]
var byName: [String: Double] = [:]
var logoById: [String: String] = [:]
var logoByName: [String: String] = [:]
for store in storeList {
guard let rating = store.rating, rating > 0 else { continue }
let storeId = normalizedOrderId(store.id)
if storeId.isEmpty == false {
byId[storeId] = rating
}
let nameKey = normalizedStoreName(store.name)
if nameKey.isEmpty == false {
byName[nameKey] = rating
if let rating = store.rating, rating > 0 {
if storeId.isEmpty == false { byId[storeId] = rating }
if nameKey.isEmpty == false { byName[nameKey] = rating }
}
if let logo = store.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
if storeId.isEmpty == false { logoById[storeId] = logo }
if nameKey.isEmpty == false { logoByName[nameKey] = logo }
}
}
storeRatingByStoreId = byId
storeRatingByStoreName = byName
storeLogoByStoreId = logoById
storeLogoByStoreName = logoByName
}
private func storeRating(for order: AppOrderSummary) -> Double? {
@@ -492,6 +501,17 @@ struct OrdersView: View {
return nil
}
private func storeLogoURL(for order: AppOrderSummary) -> String? {
if let url = order.storeLogoURL, url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
return url
}
let storeId = normalizedOrderId(order.storeId)
if storeId.isEmpty == false, let logo = storeLogoByStoreId[storeId] { return logo }
let nameKey = normalizedStoreName(order.storeName)
if nameKey.isEmpty == false, let logo = storeLogoByStoreName[nameKey] { return logo }
return nil
}
private func resolvedMediaURL(_ raw: String?) -> String? {
ImageSourceResolver.resolve(raw)
}
@@ -629,11 +649,11 @@ struct OrderEntryDestinationView: View {
let fallbackPaymentMethod: String?
let fallbackTotal: Double?
let routeIntent: OrderRouteIntent
@Binding var appState: AppState
@State var isResolvingRoute = true
@State var didResolve = false
@State var pixContext: PixPaymentContext? = nil
@State var cardContext: CardPaymentContext? = nil
@State var orderTrackingContext: OrderTrackingContext? = nil
@State var orderDetails: PublicOrderResult? = nil
@@ -651,6 +671,7 @@ struct OrderEntryDestinationView: View {
} else if let pixContext {
PaymentPixView(
context: pixContext,
appState: $appState,
onPaymentConfirmed: {
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
},
@@ -658,18 +679,8 @@ struct OrderEntryDestinationView: View {
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
}
)
} else if let cardContext {
PaymentCardView(
context: cardContext,
onPaymentConfirmed: {
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
},
onOpenTracking: {
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
}
)
} else if let orderDetails {
OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId)
OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId, appState: $appState)
} else {
OrderTrackingView(orderId: orderId, initialShortId: initialShortId)
}
@@ -712,24 +723,30 @@ struct OrderEntryDestinationView: View {
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.expirationDate
: pixFromPayload?.expirationDate
let storeId = order.storeId ?? ""
pixContext = PixPaymentContext(
id: order.id,
orderId: order.id,
shortId: order.shortId ?? initialShortId,
storeId: storeId,
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? (copyPaste ?? "")
: "Código PIX indisponível no momento. Aguarde e tente novamente.",
qrCodeImageBase64: qrCodeImage,
expirationDate: expirationDate
expirationDate: expirationDate,
total: order.total ?? 0,
profileName: "",
profileEmail: "",
profilePhone: "",
addressZip: nil,
addressNumber: nil,
deliveryType: order.deliveryType ?? "DELIVERY",
itemsJSON: "[]"
)
return
}
if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" {
cardContext = CardPaymentContext(
orderId: order.id,
shortId: order.shortId ?? initialShortId,
total: order.total ?? fallbackTotal ?? 0
)
orderTrackingContext = OrderTrackingContext(orderId: order.id, shortId: order.shortId ?? initialShortId)
return
}
}

View File

@@ -10,6 +10,7 @@ struct PizzaFlavorAddonsSheet: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 12) {
screenHeader
Text(flavor.name)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
@@ -77,12 +78,28 @@ struct PizzaFlavorAddonsSheet: View {
.padding(20)
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Adicionais")
.appInlineNavigationTitle()
.appTopBarTrailingToolbar {
Button("Concluir") { dismiss() }
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
}
private var screenHeader: some View {
ZStack {
Text("Adicionais")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
.foregroundStyle(AppColors.primary)
Spacer()
}
}
}

View File

@@ -2,28 +2,39 @@ import Foundation
import SwiftUI
extension PizzaProductDetailSheet {
var stepSizes: some View {
let sizeItems = sizes
return VStack(alignment: .leading, spacing: 10) {
Text("1. Tamanho")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
ForEach(0..<sizeItems.count, id: \.self) { index in
sizeRow(sizeItems[index])
// MARK: - Steps
var stepSizes: some View {
accordionSection(
step: 0,
label: "Tamanho",
summary: selectedSize.map { "\($0.name ?? "") • Até \(max(1, $0.maxFlavors ?? 1)) sabor(es)" }
) {
ForEach(sizes) { size in
radioRow(
title: size.name ?? "Tamanho",
subtitle: "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)",
isSelected: selectedSizeId == size.id
) {
selectedSizeId = size.id
applyAutoSelections()
withAnimation(.easeInOut(duration: 0.2)) {
expandedStep = nextStep(after: 0)
}
}
}
}
.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)
accordionSection(
step: 1,
label: "Massa",
summary: doughs.count <= 1
? (doughs.first?.name ?? "Tradicional")
: doughs.first(where: { $0.id == selectedDoughId })?.name
) {
if doughs.count <= 1 {
Text(doughs.first?.name ?? "Massa tradicional")
.font(AppTypography.body)
@@ -36,21 +47,24 @@ extension PizzaProductDetailSheet {
isSelected: selectedDoughId == dough.id
) {
selectedDoughId = dough.id
applyAutoSelections()
withAnimation(.easeInOut(duration: 0.2)) {
expandedStep = nextStep(after: 1)
}
}
}
}
}
.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)
accordionSection(
step: 2,
label: "Borda",
summary: crusts.count <= 1
? crustDescription(crusts.first)
: crusts.first(where: { $0.id == selectedCrustId }).map { crustDescription($0) }
) {
if crusts.count <= 1 {
Text(crustDescription(crusts.first))
.font(AppTypography.body)
@@ -59,38 +73,40 @@ extension PizzaProductDetailSheet {
ForEach(crusts) { crust in
radioRow(
title: crust.name ?? "Borda",
subtitle: crust.priceModifier ?? 0 > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil,
subtitle: (crust.priceModifier ?? 0) > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil,
isSelected: selectedCrustId == crust.id
) {
selectedCrustId = crust.id
withAnimation(.easeInOut(duration: 0.2)) {
expandedStep = 3
}
}
}
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
var stepFlavors: some View {
let flavorItems = flavors
return VStack(alignment: .leading, spacing: 10) {
Text("4. Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
accordionSection(
step: 3,
label: "Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))",
summary: selectedFlavorIds.isEmpty ? nil
: selectedFlavorProducts.map(\.name).joined(separator: ", ")
) {
Text("Toque no sabor para escolher adicionais.")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
ForEach(0..<flavorItems.count, id: \.self) { index in
let flavor = flavorItems[index]
ForEach(flavors) { flavor in
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) {
AsyncStoreImage(imageURL: resolveImageURL(flavor.image))
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
VStack(alignment: .leading, spacing: 4) {
Text(flavor.name)
.font(AppTypography.body)
@@ -101,21 +117,15 @@ extension PizzaProductDetailSheet {
.foregroundStyle(AppColors.textMuted)
}
}
Spacer()
Toggle("", isOn: Binding(
get: { isSelected },
set: { value in
if value {
addFlavor(flavor.id)
} else {
removeFlavor(flavor.id)
}
if value { addFlavor(flavor.id) } else { removeFlavor(flavor.id) }
}
))
.labelsHidden()
.disabled(disableSwitch)
.disabled(!isSelected && maxReached)
}
.padding(.vertical, 2)
.appContentShape(Rectangle())
@@ -126,23 +136,90 @@ extension PizzaProductDetailSheet {
}
}
}
.padding(12)
}
// MARK: - Accordion container
func accordionSection(
step: Int,
label: String,
summary: String?,
@ViewBuilder content: () -> some View
) -> some View {
let isExpanded = expandedStep == step
let isDone = summary != nil
return VStack(spacing: 0) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
expandedStep = isExpanded ? -1 : step
}
} label: {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(isDone || isExpanded ? AppColors.primary : AppColors.textMuted.opacity(0.25))
.frame(width: 26, height: 26)
if isDone && !isExpanded {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
} else {
Text("\(step + 1)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
}
}
Text(label)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Spacer()
if let summary, !isExpanded {
Text(summary)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: 140, alignment: .trailing)
}
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AppColors.textMuted)
}
.padding(14)
}
.buttonStyle(.plain)
if isExpanded {
Divider().padding(.horizontal, 14)
VStack(alignment: .leading, spacing: 10) {
content()
}
.padding(14)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
func sizeRow(_ size: StorePizzaSize) -> some View {
let title = size.name ?? "Tamanho"
let subtitle = "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)"
let selected = selectedSizeId == size.id
// MARK: - Helpers
return radioRow(
title: title,
subtitle: subtitle,
isSelected: selected
) {
selectedSizeId = size.id
func nextStep(after step: Int) -> Int {
if step == 0 {
if doughs.count > 1 { return 1 }
if crusts.count > 1 { return 2 }
return 3
}
if step == 1 {
if crusts.count > 1 { return 2 }
return 3
}
return 3
}
func radioRow(title: String, subtitle: String?, isSelected: Bool, action: @escaping () -> Void) -> some View {
@@ -158,7 +235,6 @@ extension PizzaProductDetailSheet {
.frame(width: 10, height: 10)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(AppTypography.body)
@@ -169,7 +245,6 @@ extension PizzaProductDetailSheet {
.foregroundStyle(AppColors.textMuted)
}
}
Spacer()
}
.appContentShape(Rectangle())
@@ -181,40 +256,29 @@ extension PizzaProductDetailSheet {
guard let crust else { return "Sem borda especial" }
let name = crust.name ?? "Borda"
let modifier = crust.priceModifier ?? 0
if modifier > 0 {
return "\(name) (+ \(formatCurrency(modifier)))"
}
return name
return modifier > 0 ? "\(name) (+ \(formatCurrency(modifier)))" : name
}
func applyAutoSelections() {
if selectedSizeId != nil {
if doughs.count == 1 {
selectedDoughId = doughs.first?.id
} else if doughs.isEmpty {
selectedDoughId = "__none__"
}
if doughs.count == 1 { selectedDoughId = doughs.first?.id }
else if doughs.isEmpty { selectedDoughId = "__none__" }
}
if isDoughReady {
if crusts.count == 1 {
selectedCrustId = crusts.first?.id
} else if crusts.isEmpty {
selectedCrustId = "__none__"
}
if crusts.count == 1 { selectedCrustId = crusts.first?.id }
else if crusts.isEmpty { selectedCrustId = "__none__" }
}
}
func trimFlavorSelectionByLimit() {
let limit = maxFlavorsAllowed
guard selectedFlavorIds.count > limit else { return }
let sorted = selectedFlavorIds.sorted()
selectedFlavorIds = Set(sorted.prefix(limit))
selectedFlavorIds = Set(selectedFlavorIds.sorted().prefix(limit))
}
func addFlavor(_ flavorId: String) {
if selectedFlavorIds.contains(flavorId) { return }
if selectedFlavorIds.count >= maxFlavorsAllowed { return }
guard !selectedFlavorIds.contains(flavorId),
selectedFlavorIds.count < maxFlavorsAllowed else { return }
selectedFlavorIds.insert(flavorId)
}

View File

@@ -17,6 +17,7 @@ struct PizzaProductDetailSheet: View {
@State var flavorAddonQuantities: [String: [String: Int]] = [:]
@State var selectedFlavorForAddons: StoreCatalogProduct? = nil
@State var quantity: Int = 1
@State var expandedStep: Int = 0
var flavors: [StoreCatalogProduct] {
category.products
@@ -45,7 +46,7 @@ struct PizzaProductDetailSheet: View {
return resolveImageURL(firstImage)
}
private var selectedSize: StorePizzaSize? {
var selectedSize: StorePizzaSize? {
guard let selectedSizeId else { return nil }
return sizes.first(where: { $0.id == selectedSizeId })
}
@@ -76,7 +77,7 @@ struct PizzaProductDetailSheet: View {
isCrustReady
}
private var selectedFlavorProducts: [StoreCatalogProduct] {
var selectedFlavorProducts: [StoreCatalogProduct] {
flavors
.filter { selectedFlavorIds.contains($0.id) }
.sorted { $0.name < $1.name }
@@ -102,12 +103,12 @@ struct PizzaProductDetailSheet: View {
}
private var basePizzaPrice: Double {
selectedFlavorProducts
.map { flavor in
guard let selectedSizeId else { return flavor.price ?? 0 }
return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0
}
.max() ?? 0
let prices = selectedFlavorProducts.map { flavor in
guard let selectedSizeId else { return flavor.price ?? 0 }
return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0
}
guard prices.isEmpty == false else { return 0 }
return prices.reduce(0, +) / Double(prices.count)
}
private var unitPrice: Double {
@@ -179,6 +180,34 @@ struct PizzaProductDetailSheet: View {
return chunks.isEmpty ? nil : chunks.joined(separator: "")
}
private var pizzaChoices: [String] {
var choices: [String] = []
if let sizeName = selectedSize?.name {
let sizePrice = basePizzaPrice
if sizePrice > 0 {
choices.append("Tamanho: \(sizeName) (+\(formatCurrency(sizePrice)))")
} else {
choices.append("Tamanho: \(sizeName)")
}
}
if let crustName = selectedCrust?.name, crustName.isEmpty == false {
let mod = crustPriceModifier
if mod > 0 {
choices.append("Borda: \(crustName) (+\(formatCurrency(mod)))")
} else {
choices.append("Borda: \(crustName)")
}
}
if let doughName = selectedDoughName, doughName.isEmpty == false {
choices.append("Massa: \(doughName)")
}
let flavorCount = selectedFlavorProducts.count
for flavor in selectedFlavorProducts {
choices.append(flavorCount > 1 ? "Meio \(flavor.name)" : flavor.name)
}
return choices
}
private var addButtonTitle: String {
if canConfirm == false {
return "Selecione as opções"
@@ -189,11 +218,20 @@ struct PizzaProductDetailSheet: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
AsyncStoreImage(imageURL: representativeImage)
screenHeader
Rectangle()
.fill(AppColors.brandSoft)
.frame(maxWidth: .infinity)
.frame(height: 220)
.overlay(
Image("placeholder-pizza")
.resizable()
.scaledToFill()
.clipped()
)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
Text("Pizza de varios sabores")
Text("Escolha seu sabor")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
@@ -206,18 +244,9 @@ struct PizzaProductDetailSheet: View {
.foregroundStyle(AppColors.primary)
stepSizes
if selectedSizeId != nil {
stepDoughs
}
if isDoughReady {
stepCrusts
}
if canShowFlavors {
stepFlavors
}
if selectedSizeId != nil { stepDoughs }
if isDoughReady { stepCrusts }
if canShowFlavors { stepFlavors }
}
.padding(20)
.padding(.bottom, 90)
@@ -262,9 +291,10 @@ struct PizzaProductDetailSheet: View {
id: cartItemId,
productId: selectedFlavorProducts.first?.id ?? category.id,
storeId: storeId,
name: "Pizza de varios sabores",
name: "Escolha seu sabor",
imageURL: representativeImage,
details: selectedDetailsText,
choices: pizzaChoices.isEmpty ? nil : pizzaChoices,
addons: selectedAddonsPayload,
quantity: quantity,
unitPrice: unitPrice
@@ -291,12 +321,8 @@ struct PizzaProductDetailSheet: View {
}
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Monte sua pizza")
.appInlineNavigationTitle()
.appTopBarTrailingToolbar {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.onAppear {
applyAutoSelections()
let existing = currentQuantityForItemId(cartItemId)
@@ -314,4 +340,25 @@ struct PizzaProductDetailSheet: View {
}
}
private var screenHeader: some View {
ZStack {
Text("Monte sua pizza")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
}

View File

@@ -72,6 +72,7 @@ struct ProductDetailSheet: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
screenHeader
AsyncStoreImage(imageURL: imageURL)
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
@@ -221,12 +222,8 @@ struct ProductDetailSheet: View {
.background(.ultraThinMaterial)
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Detalhes")
.appInlineNavigationTitle()
.appTopBarTrailingToolbar {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.onAppear {
let existing = currentQuantityForItemId(cartItemId)
quantity = existing > 0 ? existing : 1
@@ -246,6 +243,27 @@ struct ProductDetailSheet: View {
}
}
private var screenHeader: some View {
ZStack {
Text("Detalhes")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}

View File

@@ -14,6 +14,7 @@ struct ProfileView: View {
@State var openAddressesOnboarding = false
@State var onboardingMessage: String? = nil
@State var showLogoutAlert = false
@State private var openOrders = false
let tabBarClearance: CGFloat = 120
var body: some View {
@@ -28,7 +29,7 @@ struct ProfileView: View {
VStack(spacing: 14) {
NavigationLink {
OrdersView()
OrdersView(appState: $appState)
} label: {
ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos")
}
@@ -41,12 +42,12 @@ struct ProfileView: View {
}
.buttonStyle(.plain)
// NavigationLink {
// Text("Meus Cartões")
// } label: {
// ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões")
// }
// .buttonStyle(.plain)
NavigationLink {
SavedCardsView(appState: $appState)
} label: {
ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões")
}
.buttonStyle(.plain)
NavigationLink {
MyReviewsView()
@@ -55,12 +56,14 @@ struct ProfileView: View {
}
.buttonStyle(.plain)
// NavigationLink {
// Text("Cupons de Desconto")
// } label: {
// ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO")
// }
// .buttonStyle(.plain)
if appState.featureFlags.isEnabled("at.cupons") {
NavigationLink {
Text("Cupons de Desconto")
} label: {
ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO")
}
.buttonStyle(.plain)
}
// NavigationLink {
// Text("Ajuda")
@@ -120,6 +123,15 @@ struct ProfileView: View {
AddressesView(message: onboardingMessage, appState: $appState)
}
}
.navigationDestination(isPresented: $openOrders) {
OrdersView(appState: $appState)
}
.onChange(of: appState.shouldNavigateToOrders) { _, val in
if val {
appState.shouldNavigateToOrders = false
openOrders = true
}
}
}
private var header: some View {

View File

@@ -24,6 +24,8 @@ struct MyReviewsView: View {
@State var isLoading = false
@State var loadError: String? = nil
@Environment(\.dismiss) var dismiss
init(initialOrder: ReviewDraft? = nil) {
self.initialOrder = initialOrder
}
@@ -31,6 +33,7 @@ struct MyReviewsView: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 12) {
screenHeader
if isLoading && reviews.isEmpty && pendingReviews.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
@@ -66,8 +69,8 @@ struct MyReviewsView: View {
.padding(.bottom, UIDevice.bottomNotch + 30)
}
.background(AppColors.backgroundLight)
.navigationTitle("Minhas Avaliações")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.navigationDestination(item: $selectedDraft) { draft in
OrderReviewView(draft: draft) {
Task { await loadReviewsFromBackend(forceRefresh: true) }
@@ -86,6 +89,27 @@ struct MyReviewsView: View {
}
}
private var screenHeader: some View {
ZStack {
Text("Minhas Avaliações")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var emptyState: some View {
VStack(spacing: 8) {
Text("Você não tem avaliações nem pendências no momento.")
@@ -434,6 +458,7 @@ struct OrderReviewView: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) {
screenHeader
topSection
orderItemsSection
if existingReview != nil {
@@ -450,8 +475,8 @@ struct OrderReviewView: View {
.padding(.bottom, UIDevice.bottomNotch + 140)
}
.background(AppColors.backgroundLight)
.navigationTitle("Avaliar Pedido")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.onAppear {
if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) {
existingReview = cachedReview
@@ -488,6 +513,27 @@ struct OrderReviewView: View {
}
}
private var screenHeader: some View {
ZStack {
Text("Avaliar Pedido")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var newReviewContent: some View {
VStack(alignment: .leading, spacing: 24) {
starsSection
@@ -802,6 +848,7 @@ struct OrderReviewView: View {
.frame(height: 110)
.padding(.horizontal, 8)
.padding(.vertical, 8)
.foregroundStyle(AppColors.textPrimary)
.scrollContentBackground(.hidden)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))

View File

@@ -0,0 +1,244 @@
import SwiftUI
struct SavedCardsView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@State private var cards: [SavedCard] = []
@State private var isLoading = false
@State private var errorMessage: String? = nil
@State private var openSwipeRowId: String? = nil
@State private var deletingCardId: String? = nil
@State private var showAddCard = false
private var canDelete: Bool { cards.count > 1 }
private let tabBarClearance: CGFloat = 96
var body: some View {
ZStack {
AppColors.backgroundLight.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
screenHeader
VStack(spacing: 12) {
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 32)
} else if let errorMessage {
Text(errorMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.multilineTextAlignment(.center)
.padding(.top, 32)
} else if cards.isEmpty {
Text("Nenhum cartão cadastrado")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 32)
} else {
ForEach(cards) { card in
if canDelete {
SwipeToDeleteAddressRow(
rowId: card.id,
openRowId: $openSwipeRowId,
isDeleting: deletingCardId == card.id,
onDelete: { deleteCard(card) }
) {
cardRow(card)
.appContentShape(Rectangle())
.onTapGesture {
if openSwipeRowId == card.id { openSwipeRowId = nil }
}
}
.id(card.id)
.opacity(deletingCardId == card.id ? 0.6 : 1.0)
.disabled(deletingCardId != nil)
} else {
cardRow(card)
}
}
}
}
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, tabBarClearance + 20)
}
VStack {
Spacer()
addCardButton
.padding(.bottom, tabBarClearance)
}
}
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.sheet(isPresented: $showAddCard) {
NavigationStack {
AddCardFormView(appState: appState, isFirstCard: cards.isEmpty) { newCard in
cards.append(newCard)
}
}
}
.task { await loadCards() }
}
private var screenHeader: some View {
ZStack {
Text("Meus Cartões")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var addCardButton: some View {
ZStack(alignment: .bottom) {
Rectangle()
.fill(AppColors.backgroundLight)
.frame(height: 136)
Button(action: { showAddCard = true }) {
HStack(spacing: 12) {
Image(systemName: "creditcard.fill")
.font(.system(size: 20))
Text("Adicionar novo cartão")
.font(AppTypography.heading3)
}
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, 20)
.padding(.bottom, 14)
}
}
private func cardRow(_ card: SavedCard) -> some View {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(AppColors.backgroundLight)
.frame(width: 52, height: 52)
if let logo = brandLogoName(for: card.brand) {
Image(logo)
.resizable()
.scaledToFit()
.frame(width: 36, height: 24)
} else {
Image(systemName: "creditcard.fill")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
}
}
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(card.displayLabel)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if card.isDefault {
Text("Principal")
.font(AppTypography.caption)
.foregroundStyle(AppColors.primary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(AppColors.brandSoft)
.clipShape(Capsule())
}
}
Text("Vence \(card.expiryLabel)")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
if !canDelete {
Text("Ao menos um cartão deve permanecer")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted.opacity(0.7))
}
}
Spacer(minLength: 0)
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private func brandLogoName(for brand: String?) -> String? {
switch brand?.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: .diacriticInsensitive, locale: .current) {
case "visa": return "visacard_logo"
case "mastercard", "master": return "mastercard_logo"
case "amex", "american express", "americanexpress": return "amexcard_logo"
case "hipercard": return "hipercard_logo"
case "alelo": return "alelocard_logo"
case "sodexo": return "sodexo_logo"
default: return nil
}
}
@MainActor
private func loadCards() async {
guard isLoading == false else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response = try await ApiService().listCards()
if response.error == false {
cards = response.result ?? []
} else {
errorMessage = response.message ?? "Erro ao carregar cartões."
}
} catch {
errorMessage = "Não foi possível carregar seus cartões."
}
}
private func deleteCard(_ card: SavedCard) {
guard canDelete, deletingCardId == nil else { return }
deletingCardId = card.id
openSwipeRowId = nil
Task {
do {
let response = try await ApiService().deleteCard(cardId: card.id)
if response.error == false {
cards.removeAll { $0.id == card.id }
} else {
SnackbarCenter.shared.show(
title: response.message ?? "Não foi possível excluir o cartão.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
}
} catch {
SnackbarCenter.shared.show(
title: "Erro ao excluir cartão.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
}
deletingCardId = nil
}
}
}

View File

@@ -37,7 +37,7 @@ struct ScrollOffsetReader: View {
)
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in
if baseline == nil { baseline = minY }
let offset = max(0, (baseline ?? 0) - minY)
let offset = (baseline ?? 0) - minY
if abs(offsetY - offset) > 0.5 {
offsetY = offset
}
@@ -47,6 +47,7 @@ struct ScrollOffsetReader: View {
struct AsyncStoreImage: View {
let imageURL: String?
var fallbackImageName: String = "placeholder-product"
var body: some View {
CachedRemoteImage(imageURL: imageURL) {
@@ -58,7 +59,7 @@ struct AsyncStoreImage: View {
}
private var fallback: some View {
Image("placeholder-product")
Image(fallbackImageName)
.resizable()
.scaledToFill()
}

View File

@@ -26,16 +26,19 @@ extension StoreDetailView {
var heroSection: some View {
ZStack(alignment: .top) {
AsyncStoreImage(imageURL: resolvedURL(storeCoverURL))
.frame(height: topSectionHeight)
.overlay(
LinearGradient(
colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)],
startPoint: .top,
endPoint: .bottom
)
)
.frame(height: topSectionHeight + stretchAmount)
.offset(y: -stretchAmount)
.ignoresSafeArea(.container, edges: .top)
LinearGradient(
colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: topSectionHeight)
.ignoresSafeArea(.container, edges: .top)
.allowsHitTesting(false)
VStack(spacing: 0) {
HStack {
heroIconButton(icon: "chevron.left") {
@@ -266,9 +269,17 @@ extension StoreDetailView {
Spacer()
ZStack(alignment: .bottomTrailing) {
AsyncStoreImage(imageURL: resolvedURL(item.imageURL))
.frame(width: 92, height: 92)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
Group {
if item.isPizzaSummary {
Image("placeholder-pizza")
.resizable()
.scaledToFill()
} else {
AsyncStoreImage(imageURL: resolvedURL(item.imageURL))
}
}
.frame(width: 92, height: 92)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
Button {
guard isStoreOpen else {

View File

@@ -286,7 +286,7 @@ extension StoreDetailView {
StoreCatalogListItem(
id: "\(category.id)::pizza-summary",
product: first,
title: "Pizza de varios sabores",
title: "Escolha seu sabor",
description: "Escolha o tamanho da sua fome",
imageURL: representativeImage ?? first.image,
isPizzaSummary: true,

View File

@@ -38,6 +38,7 @@ struct StoreDetailView: View {
@State var scrollOffset: CGFloat = 0
var isCategoryTabsPinned: Bool { scrollOffset >= topSectionHeight }
var stretchAmount: CGFloat { max(0, -scrollOffset) }
let cardTopInset: CGFloat = 180
let summaryCardBaseHeight: CGFloat = 212
@@ -45,25 +46,39 @@ struct StoreDetailView: View {
let coverVisibleUntilY: CGFloat = 253
let storeLogoSize: CGFloat = 84
var safeAreaTop: CGFloat {
#if canImport(UIKit)
return UIDevice.appSafeAreaTop
#else
return 0
#endif
}
var body: some View {
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
ScrollOffsetReader(offsetY: $scrollOffset)
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
topSection
Section {
ZStack(alignment: .top) {
ScrollView(showsIndicators: false) {
ScrollOffsetReader(offsetY: $scrollOffset)
LazyVStack(spacing: 0) {
topSection
categoryTabs(proxy: proxy, isPinned: false)
.opacity(isCategoryTabsPinned ? 0 : 1)
sectionedProducts
} header: {
categoryTabs(proxy: proxy, isPinned: isCategoryTabsPinned)
}
}
}
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
.refreshable {
await loadStoreData(forceRefresh: true)
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
.refreshable {
await loadStoreData(forceRefresh: true)
}
.ignoresSafeArea(edges: .top)
.background(AppColors.backgroundLight)
categoryTabs(proxy: proxy, isPinned: true)
.opacity(isCategoryTabsPinned ? 1 : 0)
.allowsHitTesting(isCategoryTabsPinned)
.zIndex(10)
}
.ignoresSafeArea(edges: .top)
.background(AppColors.backgroundLight)
.saturation(isStoreOpen ? 1 : 0)
}
.navigationBarBackButtonHidden(true)

View File

@@ -11,6 +11,7 @@ struct UserProfileView: View {
@State var name: String = ""
@State var email: String = ""
@State var phone: String = ""
@State var cpf: String = ""
@State var profilePicture: String = ""
@State var isSaving = false
@@ -21,6 +22,7 @@ struct UserProfileView: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 22) {
screenHeader
avatarSection
formSection
saveButton
@@ -30,8 +32,8 @@ struct UserProfileView: View {
.padding(.bottom, UIDevice.bottomNotch + 24)
}
.background(AppColors.backgroundLight)
.navigationTitle("Meu Perfil")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.onAppear {
hydrateFromAppState()
}
@@ -42,6 +44,27 @@ struct UserProfileView: View {
#endif
}
private var screenHeader: some View {
ZStack {
Text("Meu Perfil")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var avatarSection: some View {
VStack(spacing: 12) {
Circle()
@@ -97,6 +120,14 @@ struct UserProfileView: View {
phone = masked
}
}
textFieldSection(title: "CPF", placeholder: "000.000.000-00", text: $cpf)
.keyboardType(.numberPad)
.onChange(of: cpf) { _, newValue in
let digits = newValue.filter(\.isNumber)
let masked = formatCPF(digits)
if masked != newValue { cpf = masked }
}
.appNoAutoCap()
}
.padding(16)
@@ -148,6 +179,15 @@ struct UserProfileView: View {
email = appState.profile.email
phone = formatPhoneForDisplay(appState.profile.phone)
profilePicture = appState.profile.profilePicture
cpf = formatCPF(appState.profile.cpf.filter(\.isNumber))
}
private func formatCPF(_ digits: String) -> String {
let d = String(digits.prefix(11))
if d.count <= 3 { return d }
if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" }
if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" }
return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))"
}
@MainActor
@@ -158,6 +198,7 @@ struct UserProfileView: View {
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPhone = normalizePhoneNumberForAPI(phone)
let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
let newPhoto = cleanPhoto.hasPrefix("data:") ? cleanPhoto : nil
isSaving = true
defer { isSaving = false }
@@ -167,7 +208,7 @@ struct UserProfileView: View {
name: cleanName,
email: cleanEmail,
phoneNumber: normalizedPhone,
profilePicture: cleanPhoto.isEmpty ? nil : cleanPhoto
profilePicture: newPhoto
)
if response.error {
@@ -180,12 +221,38 @@ struct UserProfileView: View {
return
}
let customer = response.result
appState.profile.id = customer?.id ?? appState.profile.id
appState.profile.name = customer?.name ?? cleanName
appState.profile.email = customer?.email ?? cleanEmail
appState.profile.phone = customer?.phoneNumber ?? normalizedPhone
appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto
appState.profile.name = cleanName
appState.profile.email = cleanEmail
appState.profile.phone = normalizedPhone
if let pictureUrl = response.profilePictureUrl, pictureUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
appState.profile.profilePicture = ImageSourceResolver.resolve(pictureUrl) ?? pictureUrl
} else if cleanPhoto.isEmpty == false {
appState.profile.profilePicture = cleanPhoto
}
let cleanCpf = cpf.filter(\.isNumber)
if cleanCpf.count == 11 {
guard isValidCPF(cleanCpf) else {
SnackbarCenter.shared.show(
title: "CPF inválido. Verifique e tente novamente.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
return
}
let cpfResponse = try await ApiService().updateProfileCpf(cpf: cleanCpf)
if cpfResponse.error {
SnackbarCenter.shared.show(
title: cpfResponse.message ?? "Não foi possível atualizar o CPF.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
return
}
appState.profile.cpf = cleanCpf
}
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email)
)
@@ -241,14 +308,29 @@ struct UserProfileView: View {
}
}
private func isValidCPF(_ digits: String) -> Bool {
guard digits.count == 11, digits.unicodeScalars.allSatisfy({ CharacterSet.decimalDigits.contains($0) }) else { return false }
guard Set(digits).count > 1 else { return false }
func checkDigit(_ d: String, _ length: Int) -> Bool {
let sum = d.prefix(length).enumerated().reduce(0) { acc, pair in
acc + (Int(String(pair.element)) ?? 0) * (length + 1 - pair.offset)
}
let rem = (sum * 10) % 11
let expected = rem == 10 ? 0 : rem
return Int(String(d[d.index(d.startIndex, offsetBy: length)])) == expected
}
return checkDigit(digits, 9) && checkDigit(digits, 10)
}
#if os(iOS)
@MainActor
private func applySelectedPhoto(_ item: PhotosPickerItem?) async {
guard let item else { return }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
guard let image = UIImage(data: data),
let jpegData = image.jpegData(compressionQuality: 0.82) else { return }
guard let image = UIImage(data: data) else { return }
let resized = resizedIfNeeded(image, maxSide: 600)
guard let jpegData = resized.jpegData(compressionQuality: 0.8) else { return }
profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
} catch {
SnackbarCenter.shared.show(
@@ -259,5 +341,17 @@ struct UserProfileView: View {
)
}
}
private func resizedIfNeeded(_ image: UIImage, maxSide: CGFloat) -> UIImage {
let w = image.size.width
let h = image.size.height
guard w > maxSide || h > maxSide else { return image }
let scale = maxSide / max(w, h)
let newSize = CGSize(width: w * scale, height: h * scale)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
#endif
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB