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`. **Campos internos não retornados para o app:** `keyHash`, `strongHash`, `source`, `isShadow`, `profile_metrics`, `behavioral_stats`, `otpHash`, `otpHashParams`, `otpExpiresAt`.
### 4. Update Profile ### 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:** **Headers:**
- `Authorization: Bearer <JWT_Token>` - `Authorization: Bearer <JWT_Token>`
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a` - `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 ```json
{ {
"name": "Daniel A. Loverde", // Fields to update "error": false,
"phoneNumber": "+5511988888888", "code": "ADDRESS_DEFAULT_SET"
"biometricsEnabled": true,
"address_book": [
{
"label": "Casa",
"type": "residential",
"address": "Avenida das Andorinhas",
"number": "477",
"neighborhood": "Jardim Andorinhas",
"city": "Campinas",
"state": "SP",
"zipCode": "13101-400",
"country": "Brasil",
"complement": "Apto 78",
"lat_long": [-22.9064, -47.0616]
}
]
} }
``` ```
**Regra de coordenadas (`address_book.lat_long`):** **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. | Código | HTTP | Descrição |
- 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. | `ADDRESS_NOT_FOUND` | 404 | Nenhum endereço com esse `id` no `address_book` |
- 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. | `CUSTOMER_NOT_FOUND` | 404 | — |
- Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil.
### 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` **GET** `/api/customer/favorites`
**Headers:** **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`:** **Regras para `items[].addons`:**
- `qty` do adicional é aceito no backend (ex.: `2 ovos`). - `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`). - 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. - 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 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 ```json
{ {
"fc.promo": { "enabled": true, "variant": "on" }, "at.ios.only": { "enabled": true, "variant": "on" },
"fc.promo-codes": { "enabled": true, "variant": "on" }, "at.android.only": { "enabled": true, "variant": "on" },
"fc.android": { "enabled": true, "variant": "on" }, "at.promo": { "enabled": true, "variant": "on" },
"fc.city-aguai": { "enabled": true, "variant": "on" }, "at.city.aguai": { "enabled": true, "variant": "on" }
"fc.ios": { "enabled": true, "variant": "on" }
} }
``` ```
@@ -40,7 +39,7 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`:
```json ```json
{ {
"environment": "production", "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": { "context": {
"subjectType": "customer", "subjectType": "customer",
"subjectId": "cust_123", "subjectId": "cust_123",
@@ -48,8 +47,7 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`:
"platform": "ios", "platform": "ios",
"appVersion": "2.3.1", "appVersion": "2.3.1",
"attributes": { "attributes": {
"city": "Belo Horizonte", "city": "Aguaí"
"tier": "gold"
} }
} }
} }
@@ -66,14 +64,19 @@ Se o app enviar `Authorization: Bearer <JWT>`, o BFF tenta extrair `subjectId` d
"configVersion": 7, "configVersion": 7,
"evaluatedAt": "2026-04-16T12:00:00.000Z", "evaluatedAt": "2026-04-16T12:00:00.000Z",
"flags": { "flags": {
"promo": true, "atiosonly": true,
"promoCodes": true, "atandroidonly": false,
"android": true, "atpromo": true,
"cityAguai": true, "atcityaguai": true
"ios": true
}, },
"raw": { "raw": {
"fc.promo": { "at.ios.only": {
"enabled": true,
"variant": "on",
"payload": null,
"reason": "rollout"
},
"at.promo": {
"enabled": true, "enabled": true,
"variant": "on", "variant": "on",
"payload": null, "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. Quando o upstream falha (ex.: `429`, `500`, timeout), o BFF devolve `fallback` com defaults estáticos.
## Execução local ## 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" : [ "colors" : [
{ {
"idiom" : "universal",
"color" : { "color" : {
"color-space" : "srgb", "color-space" : "srgb",
"components" : { "components" : {
"red" : "0.9529411764705882", "alpha" : "1.000",
"green" : "0.9607843137254902", "blue" : "0.969",
"blue" : "0.9686274509803922", "green" : "0.961",
"alpha" : "1.0000000000000000" "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" : { "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 PBXResourcesBuildPhase section */
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
499CD43A2AC5B799001AE8D8 /* Sources */ = { 499CD43A2AC5B799001AE8D8 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
@@ -269,7 +267,7 @@
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git"; repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 1.0.7; minimumVersion = 1.0.10;
}; };
}; };
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */

View File

@@ -23,7 +23,7 @@
</imageView> </imageView>
</subviews> </subviews>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/> <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> <constraints>
<constraint firstItem="W6R-s6-hHG" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="F0M-uu-fA3"/> <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"/> <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.", "comment" : "A small, secondary text label displaying the price of an item.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"1. Tamanho" : { "0000 0000 0000 0000" : {
"comment" : "A heading for the size selection step in the pizza product detail sheet.",
"isCommentAutoGenerated" : true
}, },
"1km" : { "1km" : {
"comment" : "A label for the minimum distance filter option.", "comment" : "A label for the minimum distance filter option.",
"isCommentAutoGenerated" : true "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" : { "10km" : {
"comment" : "A label displayed next to the far end of the distance slider in the filters modal.", "comment" : "A label displayed next to the far end of the distance slider in the filters modal.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -156,6 +137,9 @@
}, },
"Adicionar loja aos favoritos" : { "Adicionar loja aos favoritos" : {
},
"Adicionar novo cartão" : {
}, },
"Adicionar novo endereço" : { "Adicionar novo endereço" : {
"comment" : "A button label that translates to \"Add new address\" in English.", "comment" : "A button label that translates to \"Add new address\" in English.",
@@ -176,15 +160,17 @@
"Alterar" : { "Alterar" : {
"comment" : "A button label that translates to \"Change\" in English.", "comment" : "A button label that translates to \"Change\" in English.",
"isCommentAutoGenerated" : true "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)" : { "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.", "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 "isCommentAutoGenerated" : true
}, },
"Apenas Pagar" : {
"comment" : "A button that allows the user to pay without saving the payment information.",
"isCommentAutoGenerated" : true
},
"Aplicar" : { "Aplicar" : {
"comment" : "A button that applies a coupon code to a cart.", "comment" : "A button that applies a coupon code to a cart.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -229,10 +215,6 @@
}, },
"Atualizando status do pedido..." : { "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" : { "Avaliar Pedido" : {
@@ -300,6 +282,9 @@
}, },
"Carregando cardápio..." : { "Carregando cardápio..." : {
},
"Carregando endereços..." : {
}, },
"Carregando pedido..." : { "Carregando pedido..." : {
@@ -311,6 +296,9 @@
"Carrinho" : { "Carrinho" : {
"comment" : "A label for the cart section of the app.", "comment" : "A label for the cart section of the app.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Cartão de Crédito" : {
}, },
"Categorias" : { "Categorias" : {
"comment" : "A heading for the list of available categories in the filters modal.", "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.", "comment" : "A label for the comment section in the order review view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Concluir" : {
"comment" : "The text for a button that confirms and closes a sheet.",
"isCommentAutoGenerated" : true
},
"Conexão segura e criptografada" : { "Conexão segura e criptografada" : {
"comment" : "A description below the \"Verificar e Entrar\" button, emphasizing the security of the login process.", "comment" : "A description below the \"Verificar e Entrar\" button, emphasizing the security of the login process.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -376,9 +360,8 @@
"comment" : "A section header for entering and applying a discount coupon.", "comment" : "A section header for entering and applying a discount coupon.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Dados do Cartão" : { "Cupons de Desconto" : {
"comment" : "The header text for the section that collects payment card information.",
"isCommentAutoGenerated" : true
}, },
"Dark" : { "Dark" : {
"comment" : "Menu item indicating that the appearance should be in dark mode", "comment" : "Menu item indicating that the appearance should be in dark mode",
@@ -439,6 +422,9 @@
} }
} }
} }
},
"Definir como principal" : {
}, },
"Desconto" : { "Desconto" : {
"comment" : "A label for a discount applied to an order.", "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?" : { "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.", "comment" : "A description under the NPS scale question in the order review view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Endereço de cobrança" : {
}, },
"ENDEREÇO DE ENTREGA" : { "ENDEREÇO DE ENTREGA" : {
"comment" : "A label displayed above the address section when delivery is selected.", "comment" : "A label displayed above the address section when delivery is selected.",
@@ -498,6 +487,9 @@
"Escolha o tamanho da sua fome" : { "Escolha o tamanho da sua fome" : {
"comment" : "A label displayed below the pizza size selection.", "comment" : "A label displayed below the pizza size selection.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Escolha seu sabor" : {
}, },
"Este sabor não possui adicionais." : { "Este sabor não possui adicionais." : {
"comment" : "A message displayed when a pizza flavor does not have any add-ons.", "comment" : "A message displayed when a pizza flavor does not have any add-ons.",
@@ -630,6 +622,12 @@
"Já tem uma conta?" : { "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.", "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 "isCommentAutoGenerated" : true
},
"Ligar para %@" : {
},
"Ligar para a loja?" : {
}, },
"Light" : { "Light" : {
"comment" : "Menu item indicating that the appearance should be in light mode", "comment" : "Menu item indicating that the appearance should be in light mode",
@@ -668,9 +666,15 @@
"Limpar carrinho e adicionar" : { "Limpar carrinho e adicionar" : {
"comment" : "A button that clears the current cart and adds the selected product.", "comment" : "A button that clears the current cart and adds the selected product.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Limpar e adicionar" : {
}, },
"Loja não informou métodos presenciais." : { "Loja não informou métodos presenciais." : {
},
"Mais rápido e seguro" : {
}, },
"MÉTODO DE PAGAMENTO" : { "MÉTODO DE PAGAMENTO" : {
"comment" : "A label displayed above the payment methods section in the checkout view.", "comment" : "A label displayed above the payment methods section in the checkout view.",
@@ -683,6 +687,9 @@
"Meu Perfil" : { "Meu Perfil" : {
"comment" : "The title of the user profile view.", "comment" : "The title of the user profile view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Meus Cartões" : {
}, },
"Meus Endereços" : { "Meus Endereços" : {
"comment" : "A label displayed at the top of the view.", "comment" : "A label displayed at the top of the view.",
@@ -701,6 +708,9 @@
}, },
"Motivo do cancelamento" : { "Motivo do cancelamento" : {
},
"Na entrega/retirada com a loja" : {
}, },
"Name" : { "Name" : {
"comment" : "Placeholder title for the Name field in a form", "comment" : "Placeholder title for the Name field in a form",
@@ -742,6 +752,9 @@
}, },
"Não tem conta ainda?" : { "Não tem conta ainda?" : {
},
"Nenhum cartão cadastrado" : {
}, },
"Nenhum endereço cadastrado" : { "Nenhum endereço cadastrado" : {
"comment" : "A message displayed when a user has no addresses saved.", "comment" : "A message displayed when a user has no addresses saved.",
@@ -783,6 +796,12 @@
} }
} }
} }
},
"Novo cartão" : {
},
"Novo Cartão" : {
}, },
"Novo endereço" : { "Novo endereço" : {
"comment" : "A label for a form to add a new address.", "comment" : "A label for a form to add a new address.",
@@ -791,6 +810,9 @@
"Novo por aqui?" : { "Novo por aqui?" : {
"comment" : "A text that appears at the bottom of the screen, inviting users to create an account.", "comment" : "A text that appears at the bottom of the screen, inviting users to create an account.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Número do cartão" : {
}, },
"O que pode melhorar?" : { "O que pode melhorar?" : {
"comment" : "A label for the section where users can select tags they think could improve an order.", "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.", "comment" : "A label describing the sorting options available in the filters modal.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Pagamento" : { "ou" : {
"comment" : "The title of the screen where a user can enter and save payment information.",
"isCommentAutoGenerated" : true
}, },
"Pagamento via PIX" : { "Pagamento via PIX" : {
"comment" : "The navigation title for the PaymentPixView.", "comment" : "The navigation title for the PaymentPixView.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Pagar Na Maquininha" : {
},
"Pagar Pelo App" : {
},
"Pague mais rápido nas próximas compras" : {
}, },
"Pedido #%@" : { "Pedido #%@" : {
@@ -861,7 +891,16 @@
}, },
"Pizza de varios sabores" : { "Pizza de varios sabores" : {
"comment" : "A title displayed above the main content of the view.", "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" : { "Política de Privacidade" : {
"comment" : "The title of the privacy policy section.", "comment" : "The title of the privacy policy section.",
@@ -904,13 +943,12 @@
"comment" : "A label describing the price filter section in the filters modal.", "comment" : "A label describing the price filter section in the filters modal.",
"isCommentAutoGenerated" : true "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." : { "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.", "comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Principal" : {
}, },
"PRINCIPAL" : { "PRINCIPAL" : {
"comment" : "A label indicating that a particular address is the user's primary address.", "comment" : "A label indicating that a particular address is the user's primary address.",
@@ -978,6 +1016,12 @@
"Salvar Alterações" : { "Salvar Alterações" : {
"comment" : "The text on a button that saves changes to a user's profile.", "comment" : "The text on a button that saves changes to a user's profile.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Salvar cartão" : {
},
"Salvar Cartão" : {
}, },
"Save" : { "Save" : {
"comment" : "Button title indicating that the current contents should be saved", "comment" : "Button title indicating that the current contents should be saved",
@@ -1008,6 +1052,12 @@
} }
} }
} }
},
"Selecionar Cartão" : {
},
"Selecionar endereço" : {
}, },
"Sem adicionais" : { "Sem adicionais" : {
@@ -1046,6 +1096,16 @@
"comment" : "A message indicating that the user's cart is empty.", "comment" : "A message indicating that the user's cart is empty.",
"isCommentAutoGenerated" : true "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?" : { "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.", "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 "isCommentAutoGenerated" : true
@@ -1064,6 +1124,9 @@
"Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque." : { "Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque." : {
"comment" : "A tagline displayed in the login view.", "comment" : "A tagline displayed in the login view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Substituir carrinho?" : {
}, },
"Subtotal" : { "Subtotal" : {
@@ -1165,6 +1228,9 @@
"Total do Pedido" : { "Total do Pedido" : {
"comment" : "The label above the total price of the order in the payment card view.", "comment" : "The label above the total price of the order in the payment card view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Trocar" : {
}, },
"Trocar de loja?" : { "Trocar de loja?" : {
"comment" : "A title for an alert that prompts the user to switch stores.", "comment" : "A title for an alert that prompts the user to switch stores.",
@@ -1173,10 +1239,16 @@
"Trocar Foto" : { "Trocar Foto" : {
"comment" : "A button label that allows a user to change their profile picture.", "comment" : "A button label that allows a user to change their profile picture.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Trocar Pagamento" : {
}, },
"UPLOAD" : { "UPLOAD" : {
"comment" : "A button label that says \"UPLOAD\".", "comment" : "A button label that says \"UPLOAD\".",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Vence %@" : {
}, },
"Ver Detalhes" : { "Ver Detalhes" : {
"comment" : "A button that shows order details when pressed.", "comment" : "A button that shows order details when pressed.",
@@ -1276,6 +1348,9 @@
} }
} }
} }
},
"WhatsApp não encontrado. Deseja ligar para %@?" : {
} }
}, },
"version" : "1.1" "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 { func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
#if canImport(LCEssentials) && os(iOS) #if canImport(LCEssentials) && os(iOS)
return try await sendWithLCEssentials(request) return try await sendWithLCEssentials(request)
#else #else
@@ -111,10 +110,15 @@ private extension ApiClient {
guard let data = responseString.data(using: .utf8) else { guard let data = responseString.data(using: .utf8) else {
throw NetworkError.decodeError("Resposta nao UTF-8") 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 { do {
return try JSONDecoder().decode(T.self, from: data) return try JSONDecoder().decode(T.self, from: data)
} catch { } catch {
throw NetworkError.decodeError(String(data: data, encoding: .utf8)) throw NetworkError.decodeError(sanitizedBody(data))
} }
} catch { } catch {
let mapped = mapError(error) let mapped = mapError(error)
@@ -151,6 +155,7 @@ private extension ApiClient {
case "POST": return .post case "POST": return .post
case "PUT": return .put case "PUT": return .put
case "DELETE": return .delete case "DELETE": return .delete
case "PATCH": return .patch
default: return .get default: return .get
} }
} }
@@ -247,6 +252,12 @@ private extension ApiClient {
throw NetworkError.unauthorized(payload?.message) 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 { if http.statusCode == 429 {
let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "") let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "")
throw NetworkError.rateLimited(retryAfter) throw NetworkError.rateLimited(retryAfter)
@@ -263,7 +274,7 @@ private extension ApiClient {
do { do {
return try JSONDecoder().decode(type, from: data) return try JSONDecoder().decode(type, from: data)
} catch { } 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 { if let code = payload?.code, code.isEmpty == false {
return "Erro: \(code)" 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? { func serverPayload(from data: Data) -> ApiErrorDescriptor? {
@@ -318,9 +337,10 @@ private extension ApiClient {
} }
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { 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 code = object["code"] as? String
let message = object["message"] as? String let message = object["message"] as? String ?? object["msg"] as? String ?? object["error_description"] as? String
if code != nil || message != nil { if errorFlag || code != nil || message != nil {
return ApiErrorDescriptor(code: code, message: message) return ApiErrorDescriptor(code: code, message: message)
} }
} }
@@ -344,35 +364,89 @@ private extension ApiClient {
#if canImport(LCEssentials) && os(iOS) #if canImport(LCEssentials) && os(iOS)
func serverMessage(from error: NSError) -> String? { func serverMessage(from error: NSError) -> String? {
if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String, for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] {
!reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let value = error.userInfo[key] as? String,
if let data = reason.data(using: .utf8), !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let parsed = serverMessage(from: data), if let data = value.data(using: .utf8),
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let parsed = serverMessage(from: data),
return parsed !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, if let description = error.userInfo[NSLocalizedDescriptionKey] as? String,
!description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!description.lowercased().contains("nsurlerrordomain") { !description.lowercased().contains("nsurlerrordomain"),
return description let safe = sanitizedMessage(description) {
return safe
} }
return nil return nil
} }
func serverPayload(from error: NSError) -> ApiErrorDescriptor? { func serverPayload(from error: NSError) -> ApiErrorDescriptor? {
if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String, for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data"] {
let data = reason.data(using: .utf8), if let value = error.userInfo[key] as? String,
let payload = serverPayload(from: data) { let data = value.data(using: .utf8),
return payload 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 return nil
} }
#endif #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 { func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else { guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL throw NetworkError.invalidURL

View File

@@ -15,32 +15,25 @@ struct CustomerProfileUpdatePayload: Encodable {
} }
struct CustomerIdentityUpdatePayload: Encodable { struct CustomerIdentityUpdatePayload: Encodable {
let name: String let name: String?
let email: String let email: String?
let phoneNumber: String let phoneNumber: String?
let profilePicture: String? let profilePicture: String?
let addressBook: [CustomerAddressPayload]
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name case name
case email case email
case phoneNumber case phoneNumber
case phone
case profilePicture case profilePicture
case profile_picture
case addressBook = "address_book"
} }
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name) try container.encodeIfPresent(name, forKey: .name)
try container.encode(email, forKey: .email) try container.encodeIfPresent(email, forKey: .email)
try container.encode(phoneNumber, forKey: .phoneNumber) try container.encodeIfPresent(phoneNumber, forKey: .phoneNumber)
try container.encode(phoneNumber, forKey: .phone)
try container.encode(addressBook, forKey: .addressBook)
if let profilePicture, profilePicture.isEmpty == false { if let profilePicture, profilePicture.isEmpty == false {
try container.encode(profilePicture, forKey: .profilePicture) 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 EmptyResult: Decodable {}
struct ProfilePatchEnvelope: Decodable {
let error: Bool
let code: String?
let message: String?
let profilePictureUrl: String?
}
struct RegistrationResult: Decodable { struct RegistrationResult: Decodable {
let id: String? let id: String?
let name: String? let name: String?
@@ -46,18 +53,23 @@ struct CustomerAddress: Decodable {
let state: String? let state: String?
let zipCode: String? let zipCode: String?
let latLong: [Double]? 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 { enum CodingKeys: String, CodingKey {
case id case id, label, address, number, complement
case label case neighborhood, city, state, zipCode
case address
case number
case complement
case neighborhood
case city
case state
case zipCode
case latLong = "lat_long" case latLong = "lat_long"
case isDefault
} }
} }

View File

@@ -7,6 +7,12 @@ struct CreateOrderPayload: Encodable {
let paymentMethod: String let paymentMethod: String
let deliveryType: String let deliveryType: String
let address: CreateOrderAddressPayload? 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 { struct CreateOrderCustomerPayload: Encodable {
@@ -16,15 +22,16 @@ struct CreateOrderCustomerPayload: Encodable {
let asaasId: String? let asaasId: String?
} }
struct CreateOrderItemPayload: Encodable { struct CreateOrderItemPayload: Codable {
let productId: String let productId: String
let name: String let name: String
let qty: Int let qty: Int
let price: Double let price: Double
let addons: [CreateOrderAddonPayload] let addons: [CreateOrderAddonPayload]
let choices: [String]?
} }
struct CreateOrderAddonPayload: Encodable { struct CreateOrderAddonPayload: Codable {
let addonId: String let addonId: String
let name: String let name: String
let qty: Int let qty: Int

View File

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

View File

@@ -155,28 +155,17 @@ final class ApiService {
return envelope return envelope
} }
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ApiEnvelope<CustomerProfile> { func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ProfilePatchEnvelope {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerIdentityUpdatePayload( let payload = CustomerIdentityUpdatePayload(
name: name, name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name,
email: email, email: email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : email,
phoneNumber: phoneNumber, phoneNumber: phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : phoneNumber,
profilePicture: profilePicture, profilePicture: profilePicture
addressBook: (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:))
) )
let body = try JSONEncoder().encode(payload) let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req) let envelope: ProfilePatchEnvelope = try await send(req)
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())" AppContentCache.shared.invalidate(prefix: profileCachePrefix)
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
} else {
invalidateFavoritesCache()
}
return envelope return envelope
} }
@@ -223,6 +212,13 @@ final class ApiService {
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook) 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> { func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
let digits = zipCode.filter(\.isNumber) let digits = zipCode.filter(\.isNumber)
let normalized = String(digits.prefix(8)) let normalized = String(digits.prefix(8))
@@ -426,6 +422,49 @@ final class ApiService {
) )
return try await sendEnvelope(req) 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 { extension ApiService {
static func decodeFlexibleString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? { 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] { 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 let items = raw
.split(separator: ",") .split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }

View File

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

View File

@@ -2,10 +2,12 @@ import SwiftUI
struct TermsOfUseView: View { struct TermsOfUseView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
screenHeader
Text("Termos de Uso") Text("Termos de Uso")
.font(AppTypography.heading1) .font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
@@ -16,17 +18,40 @@ struct TermsOfUseView: View {
.padding(24) .padding(24)
} }
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()) .background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Termos de Uso") .appHiddenNavigationBar()
.appInlineNavigationTitle() .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 { struct PrivacyPolicyView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
screenHeader
Text("Política de Privacidade") Text("Política de Privacidade")
.font(AppTypography.heading1) .font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
@@ -37,7 +62,28 @@ struct PrivacyPolicyView: View {
.padding(24) .padding(24)
} }
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()) .background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Privacidade") .appHiddenNavigationBar()
.appInlineNavigationTitle() .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 { struct AddAddressFormView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@State var label = "" @State var label = ""
@State var zipCode = "" @State var zipCode = ""
@State var address = "" @State var address = ""
@@ -31,38 +30,18 @@ struct AddAddressFormView: View {
!state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
@ViewBuilder private var logoImage: some View {
SwiftUI.Image("pedifoods")
.resizable()
}
var body: some View { var body: some View {
ZStack { ZStack {
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea() AppColors.backgroundLight.ignoresSafeArea()
ScrollView { ScrollView(showsIndicators: false) {
VStack(spacing: 0) { VStack(spacing: 0) {
screenHeader( screenHeader(
title: existingAddress == nil ? "Novo endereço" : "Editar endereço", title: existingAddress == nil ? "Novo endereço" : "Editar endereço",
onBack: { dismiss() } onBack: { dismiss() }
) )
.padding(.horizontal, 24) .padding(.horizontal, 20)
.padding(.bottom, 12) .padding(.bottom, 20)
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)
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", keyboardType: .default, text: $label) LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", keyboardType: .default, text: $label)
@@ -127,7 +106,7 @@ struct AddAddressFormView: View {
Spacer().frame(height: 120) Spacer().frame(height: 120)
} }
} }
.padding(.top, 0) .padding(.top, 18)
} }
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
.appHiddenNavigationBar() .appHiddenNavigationBar()
@@ -140,8 +119,7 @@ struct AddAddressFormView: View {
ZStack { ZStack {
Text(title) Text(title)
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
HStack { HStack {
Button(action: onBack) { Button(action: onBack) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
@@ -177,7 +155,8 @@ struct AddAddressFormView: View {
city: clean(city), city: clean(city),
state: clean(state), state: clean(state),
zipCode: optional(normalizeZipCodeForAPI(zipCode)), zipCode: optional(normalizeZipCodeForAPI(zipCode)),
latLong: latLong latLong: latLong,
isDefault: existingAddress?.isDefault
) )
isLoading = true isLoading = true
zipLookupMessage = nil 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 let item: AddressListItem
var onEdit: (() -> Void)? = nil var onEdit: (() -> Void)? = nil
var onDelete: (() -> Void)? = nil var onDelete: (() -> Void)? = nil
var onSetDefault: (() -> Void)? = nil
private var hasActions: Bool { onEdit != nil || onSetDefault != nil }
var body: some View { var body: some View {
HStack(spacing: 14) { HStack(spacing: 14) {
@@ -150,30 +153,30 @@ struct AddressCard: View {
Spacer(minLength: 8) Spacer(minLength: 8)
if onEdit != nil || onDelete != nil { if hasActions {
Rectangle() Rectangle()
.fill(AppColors.backgroundLight) .fill(AppColors.backgroundLight)
.frame(width: 1, height: 96) .frame(width: 1, height: 96)
} }
VStack(spacing: 24) { VStack(spacing: 20) {
if let onEdit { if let onEdit {
Button(action: onEdit) { Button(action: onEdit) {
Image(systemName: "pencil") Image(systemName: "pencil")
.font(.system(size: 22)) .font(.system(size: 20))
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
} }
if let onDelete { if let onSetDefault, item.isPrimary == false {
Button(action: onDelete) { Button(action: onSetDefault) {
Image(systemName: "trash") Image(systemName: "star")
.font(.system(size: 22)) .font(.system(size: 20))
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
} }
} }
.frame(width: onEdit != nil || onDelete != nil ? 40 : 0) .frame(width: hasActions ? 40 : 0)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 20) .padding(.vertical, 20)

View File

@@ -12,6 +12,7 @@ struct AddressesView: View {
@State var editingAddress: CustomerAddress? = nil @State var editingAddress: CustomerAddress? = nil
@State var openSwipeRowId: String? = nil @State var openSwipeRowId: String? = nil
@State var deletingRowId: String? = nil @State var deletingRowId: String? = nil
@State var settingDefaultRowId: String? = nil
let tabBarClearance: CGFloat = 96 let tabBarClearance: CGFloat = 96
@@ -54,7 +55,9 @@ struct AddressesView: View {
} else { } else {
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
let rowId = addressRowId(for: address, index: index) 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 { if let selectedId = appState.address.selectedId {
return address.id == selectedId return address.id == selectedId
} }
@@ -65,10 +68,10 @@ struct AddressesView: View {
Button { Button {
selectAddress(address) selectAddress(address)
} label: { } label: {
AddressCard(item: addressToListItem(address, isPrimary: isSelected)) AddressCard(item: addressToListItem(address, isPrimary: isPrimary))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} else { } else if addresses.count > 1 {
SwipeToDeleteAddressRow( SwipeToDeleteAddressRow(
rowId: rowId, rowId: rowId,
openRowId: $openSwipeRowId, openRowId: $openSwipeRowId,
@@ -76,19 +79,30 @@ struct AddressesView: View {
onDelete: { deleteAddress(address, rowId: rowId) } onDelete: { deleteAddress(address, rowId: rowId) }
) { ) {
AddressCard( AddressCard(
item: addressToListItem(address, isPrimary: isSelected), item: addressToListItem(address, isPrimary: isPrimary),
onEdit: { beginEditing(address) } onEdit: { beginEditing(address) },
onSetDefault: { setDefaultAddress(address, rowId: rowId) }
) )
.appContentShape(Rectangle()) .appContentShape(Rectangle())
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
if openSwipeRowId == rowId { if openSwipeRowId == rowId { openSwipeRowId = nil }
openSwipeRowId = nil })
} .opacity(settingDefaultRowId == rowId ? 0.6 : 1.0)
}
} }
.id(rowId) .id(rowId)
.opacity(deletingRowId == rowId ? 0.6 : 1.0) .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 ?? "")" 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) { private func deleteAddress(_ address: CustomerAddress, rowId: String) {
guard deletingRowId == nil else { return } guard addresses.count > 1, deletingRowId == nil else { return }
deletingRowId = rowId deletingRowId = rowId
openSwipeRowId = nil openSwipeRowId = nil
@@ -302,7 +373,7 @@ struct AddressesView: View {
do { do {
let service = ApiService() let service = ApiService()
let response = try await service.profile() let response = try await service.profile(forceRefresh: true)
guard response.error == false else { guard response.error == false else {
errorMessage = response.message ?? "Não foi possível carregar os endereços." errorMessage = response.message ?? "Não foi possível carregar os endereços."
isLoading = false isLoading = false

View File

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

View File

@@ -234,7 +234,13 @@ extension CheckoutView {
return 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 { guard case .success(let payload) = payloadBuildResult else {
let message: String let message: String
if case .failure(let reason) = payloadBuildResult { if case .failure(let reason) = payloadBuildResult {
@@ -251,83 +257,31 @@ extension CheckoutView {
do { do {
let response = try await ApiService().createOrder(storeId: storeId, payload: payload) 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) let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod)
if useInAppPayment == false || isInAppMethod == false { if useInAppPayment == false || isInAppMethod == false {
appState.cart.clear() if response.error == false, let result = response.result {
SessionStateStore.clearPendingCartOrder() let orderId = result.id ?? UUID().uuidString
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId) 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 return
} }
handleOrderResponse(response, effectivePaymentMethod: effectivePaymentMethod)
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
)
} catch { } catch {
SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) 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) } guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) }
let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines) let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -360,18 +314,101 @@ extension CheckoutView {
return .success( return .success(
CreateOrderPayload( CreateOrderPayload(
customer: CreateOrderCustomerPayload( customer: CreateOrderCustomerPayload(
name: profileName, name: profileName,
phone: profilePhone, phone: profilePhone,
email: profileEmail, email: profileEmail,
asaasId: nil asaasId: nil
), ),
items: appState.cart.toOrderItemsPayload(), items: appState.cart.toOrderItemsPayload(),
total: totalValue, total: totalValue,
paymentMethod: paymentMethod.rawValue, paymentMethod: paymentMethod.rawValue,
deliveryType: deliveryType.rawValue, deliveryType: deliveryType.rawValue,
address: addressPayload 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: case .cart:
NavigationStack { NavigationStack {
CartView(appState: $appState) CartView(appState: $appState, selectedTab: $selectedTab)
} }
case .profile: case .profile:
NavigationStack { NavigationStack {

View File

@@ -4,10 +4,20 @@ struct OrderDetailsView: View {
let order: PublicOrderResult let order: PublicOrderResult
let orderId: String let orderId: String
let initialShortId: 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 { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 14) { VStack(spacing: 14) {
screenHeader
statusCard statusCard
storeCard storeCard
itemsCard itemsCard
@@ -22,8 +32,44 @@ struct OrderDetailsView: View {
.padding(.bottom, UIDevice.bottomNotch) .padding(.bottom, UIDevice.bottomNotch)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Detalhes do Pedido") .appHiddenNavigationBar()
.appInlineNavigationTitle() .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 { .appBottomSafeAreaInset {
VStack { VStack {
reorderButton 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 { private var statusCard: some View {
HStack(spacing: 14) { HStack(spacing: 14) {
Circle() Circle()
@@ -54,6 +121,26 @@ struct OrderDetailsView: View {
Text(statusDateText) Text(statusDateText)
.font(AppTypography.body) .font(AppTypography.body)
.foregroundStyle(AppColors.textMuted) .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) Spacer(minLength: 0)
@@ -64,30 +151,39 @@ struct OrderDetailsView: View {
} }
private var storeCard: some View { private var storeCard: some View {
HStack(spacing: 12) { Button {
AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL)) if order.storeId?.isEmpty == false {
.frame(width: 54, height: 54) navigateToStore = true
.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)
} }
} 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") Spacer(minLength: 0)
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645")) 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) .buttonStyle(.plain)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
} }
private var itemsCard: some View { private var itemsCard: some View {
@@ -216,27 +312,28 @@ struct OrderDetailsView: View {
private var reorderButton: some View { private var reorderButton: some View {
Button("Pedir Novamente") { Button("Pedir Novamente") {
SnackbarCenter.shared.show( reorder()
title: "Recompra será integrada com o catálogo em breve.",
style: .info,
icon: "cart.badge.plus",
duration: 2.0
)
} }
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(Color(hex: "#0E1A06")) .foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56) .frame(maxWidth: .infinity, minHeight: 56)
.background(Color(hex: "#C8F06E")) .background(order.items.isEmpty ? Color(hex: "#C8F06E").opacity(0.45) : Color(hex: "#C8F06E"))
.clipShape(Capsule()) .clipShape(Capsule())
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(order.items.isEmpty)
} }
private var helpFooter: some View { private var helpFooter: some View {
Text("Precisa de ajuda com esse pedido?") Button {
.font(AppTypography.body) handleHelpTap()
.foregroundStyle(Color(hex: "#A5D645")) } label: {
.frame(maxWidth: .infinity, alignment: .center) Text("Precisa de ajuda com esse pedido?")
.padding(.vertical, 8) .font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
}
.buttonStyle(.plain)
} }
private var subtotal: Double { private var subtotal: Double {
@@ -296,6 +393,24 @@ struct OrderDetailsView: View {
return "Pedido #\(displayOrderTitle)" 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 { private var statusTitle: String {
let status = normalized(order.status) let status = normalized(order.status)
if status.contains("CANCEL") { return "Pedido cancelado" } if status.contains("CANCEL") { return "Pedido cancelado" }
@@ -309,6 +424,12 @@ struct OrderDetailsView: View {
} }
private var statusDateText: String { 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) { if let formatted = formatDate(order.updatedAt ?? order.createdAt) {
return "\(statusDatePrefix) \(formatted)" return "\(statusDatePrefix) \(formatted)"
} }
@@ -342,14 +463,81 @@ struct OrderDetailsView: View {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") 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? { private func formatDate(_ isoValue: String?) -> String? {
guard let isoValue, isoValue.isEmpty == false else { return nil } guard let isoValue, isoValue.isEmpty == false else { return nil }
let iso = ISO8601DateFormatter() let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let optionSets: [ISO8601DateFormatter.Options] = [
var date = iso.date(from: isoValue) [.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 { if date == nil {
iso.formatOptions = [.withInternetDateTime] let fallback = DateFormatter()
date = iso.date(from: isoValue) 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 } guard let date else { return nil }
@@ -359,6 +547,64 @@ struct OrderDetailsView: View {
return formatter.string(from: date) 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 { private func normalized(_ value: String?) -> String {
(value ?? "") (value ?? "")
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)

View File

@@ -12,6 +12,8 @@ private struct TrackingStep: Identifiable {
struct OrderTrackingView: View { struct OrderTrackingView: View {
let orderId: String let orderId: String
let initialShortId: String? let initialShortId: String?
var postOrderBack: (() -> Void)? = nil
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL @Environment(\.openURL) var openURL
@State var isLoading = true @State var isLoading = true
@@ -27,6 +29,7 @@ struct OrderTrackingView: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 16) { VStack(spacing: 16) {
screenHeader
topHeader topHeader
orderTitleSection orderTitleSection
statusBanner statusBanner
@@ -43,8 +46,8 @@ struct OrderTrackingView: View {
.padding(.bottom, UIDevice.bottomNotch + 45) .padding(.bottom, UIDevice.bottomNotch + 45)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Pedido \(displayOrderTitle)") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) { .alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
Button("Fechar", role: .cancel) {} Button("Fechar", role: .cancel) {}
} message: { } message: {
@@ -97,6 +100,33 @@ struct OrderTrackingView: View {
self.reviewSavedObserver = nil 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 { private var topHeader: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
Circle() Circle()

View File

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

View File

@@ -10,6 +10,7 @@ struct PizzaFlavorAddonsSheet: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
screenHeader
Text(flavor.name) Text(flavor.name)
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
@@ -77,12 +78,28 @@ struct PizzaFlavorAddonsSheet: View {
.padding(20) .padding(20)
} }
.background(AppColors.backgroundLight.ignoresSafeArea()) .background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Adicionais") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.appTopBarTrailingToolbar { }
Button("Concluir") { dismiss() }
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) .buttonStyle(.plain)
.foregroundStyle(AppColors.primary) Spacer()
}
} }
} }

View File

@@ -2,28 +2,39 @@ import Foundation
import SwiftUI import SwiftUI
extension PizzaProductDetailSheet { 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 // MARK: - Steps
sizeRow(sizeItems[index])
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 { var stepDoughs: some View {
VStack(alignment: .leading, spacing: 10) { accordionSection(
Text("2. Massa") step: 1,
.font(AppTypography.heading3) label: "Massa",
.foregroundStyle(AppColors.textPrimary) summary: doughs.count <= 1
? (doughs.first?.name ?? "Tradicional")
: doughs.first(where: { $0.id == selectedDoughId })?.name
) {
if doughs.count <= 1 { if doughs.count <= 1 {
Text(doughs.first?.name ?? "Massa tradicional") Text(doughs.first?.name ?? "Massa tradicional")
.font(AppTypography.body) .font(AppTypography.body)
@@ -36,21 +47,24 @@ extension PizzaProductDetailSheet {
isSelected: selectedDoughId == dough.id isSelected: selectedDoughId == dough.id
) { ) {
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 { var stepCrusts: some View {
VStack(alignment: .leading, spacing: 10) { accordionSection(
Text("3. Borda") step: 2,
.font(AppTypography.heading3) label: "Borda",
.foregroundStyle(AppColors.textPrimary) summary: crusts.count <= 1
? crustDescription(crusts.first)
: crusts.first(where: { $0.id == selectedCrustId }).map { crustDescription($0) }
) {
if crusts.count <= 1 { if crusts.count <= 1 {
Text(crustDescription(crusts.first)) Text(crustDescription(crusts.first))
.font(AppTypography.body) .font(AppTypography.body)
@@ -59,38 +73,40 @@ extension PizzaProductDetailSheet {
ForEach(crusts) { crust in ForEach(crusts) { crust in
radioRow( radioRow(
title: crust.name ?? "Borda", 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 isSelected: selectedCrustId == crust.id
) { ) {
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 { var stepFlavors: some View {
let flavorItems = flavors accordionSection(
return VStack(alignment: .leading, spacing: 10) { step: 3,
Text("4. Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))") label: "Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))",
.font(AppTypography.heading3) summary: selectedFlavorIds.isEmpty ? nil
.foregroundStyle(AppColors.textPrimary) : selectedFlavorProducts.map(\.name).joined(separator: ", ")
) {
Text("Toque no sabor para escolher adicionais.") Text("Toque no sabor para escolher adicionais.")
.font(.caption) .font(.caption)
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
ForEach(0..<flavorItems.count, id: \.self) { index in ForEach(flavors) { flavor in
let flavor = flavorItems[index]
let isSelected = selectedFlavorIds.contains(flavor.id) let isSelected = selectedFlavorIds.contains(flavor.id)
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed
let disableSwitch = isSelected == false && maxReached
HStack(spacing: 10) { HStack(spacing: 10) {
AsyncStoreImage(imageURL: resolveImageURL(flavor.image))
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(flavor.name) Text(flavor.name)
.font(AppTypography.body) .font(AppTypography.body)
@@ -101,21 +117,15 @@ extension PizzaProductDetailSheet {
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
} }
Spacer() Spacer()
Toggle("", isOn: Binding( Toggle("", isOn: Binding(
get: { isSelected }, get: { isSelected },
set: { value in set: { value in
if value { if value { addFlavor(flavor.id) } else { removeFlavor(flavor.id) }
addFlavor(flavor.id)
} else {
removeFlavor(flavor.id)
}
} }
)) ))
.labelsHidden() .labelsHidden()
.disabled(disableSwitch) .disabled(!isSelected && maxReached)
} }
.padding(.vertical, 2) .padding(.vertical, 2)
.appContentShape(Rectangle()) .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) .background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
} }
func sizeRow(_ size: StorePizzaSize) -> some View { // MARK: - Helpers
let title = size.name ?? "Tamanho"
let subtitle = "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)"
let selected = selectedSizeId == size.id
return radioRow( func nextStep(after step: Int) -> Int {
title: title, if step == 0 {
subtitle: subtitle, if doughs.count > 1 { return 1 }
isSelected: selected if crusts.count > 1 { return 2 }
) { return 3
selectedSizeId = size.id
} }
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 { func radioRow(title: String, subtitle: String?, isSelected: Bool, action: @escaping () -> Void) -> some View {
@@ -158,7 +235,6 @@ extension PizzaProductDetailSheet {
.frame(width: 10, height: 10) .frame(width: 10, height: 10)
} }
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(title) Text(title)
.font(AppTypography.body) .font(AppTypography.body)
@@ -169,7 +245,6 @@ extension PizzaProductDetailSheet {
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
} }
Spacer() Spacer()
} }
.appContentShape(Rectangle()) .appContentShape(Rectangle())
@@ -181,40 +256,29 @@ extension PizzaProductDetailSheet {
guard let crust else { return "Sem borda especial" } guard let crust else { return "Sem borda especial" }
let name = crust.name ?? "Borda" let name = crust.name ?? "Borda"
let modifier = crust.priceModifier ?? 0 let modifier = crust.priceModifier ?? 0
if modifier > 0 { return modifier > 0 ? "\(name) (+ \(formatCurrency(modifier)))" : name
return "\(name) (+ \(formatCurrency(modifier)))"
}
return name
} }
func applyAutoSelections() { func applyAutoSelections() {
if selectedSizeId != nil { if selectedSizeId != nil {
if doughs.count == 1 { if doughs.count == 1 { selectedDoughId = doughs.first?.id }
selectedDoughId = doughs.first?.id else if doughs.isEmpty { selectedDoughId = "__none__" }
} else if doughs.isEmpty {
selectedDoughId = "__none__"
}
} }
if isDoughReady { if isDoughReady {
if crusts.count == 1 { if crusts.count == 1 { selectedCrustId = crusts.first?.id }
selectedCrustId = crusts.first?.id else if crusts.isEmpty { selectedCrustId = "__none__" }
} else if crusts.isEmpty {
selectedCrustId = "__none__"
}
} }
} }
func trimFlavorSelectionByLimit() { func trimFlavorSelectionByLimit() {
let limit = maxFlavorsAllowed let limit = maxFlavorsAllowed
guard selectedFlavorIds.count > limit else { return } guard selectedFlavorIds.count > limit else { return }
let sorted = selectedFlavorIds.sorted() selectedFlavorIds = Set(selectedFlavorIds.sorted().prefix(limit))
selectedFlavorIds = Set(sorted.prefix(limit))
} }
func addFlavor(_ flavorId: String) { func addFlavor(_ flavorId: String) {
if selectedFlavorIds.contains(flavorId) { return } guard !selectedFlavorIds.contains(flavorId),
if selectedFlavorIds.count >= maxFlavorsAllowed { return } selectedFlavorIds.count < maxFlavorsAllowed else { return }
selectedFlavorIds.insert(flavorId) selectedFlavorIds.insert(flavorId)
} }

View File

@@ -17,6 +17,7 @@ struct PizzaProductDetailSheet: View {
@State var flavorAddonQuantities: [String: [String: Int]] = [:] @State var flavorAddonQuantities: [String: [String: Int]] = [:]
@State var selectedFlavorForAddons: StoreCatalogProduct? = nil @State var selectedFlavorForAddons: StoreCatalogProduct? = nil
@State var quantity: Int = 1 @State var quantity: Int = 1
@State var expandedStep: Int = 0
var flavors: [StoreCatalogProduct] { var flavors: [StoreCatalogProduct] {
category.products category.products
@@ -45,7 +46,7 @@ struct PizzaProductDetailSheet: View {
return resolveImageURL(firstImage) return resolveImageURL(firstImage)
} }
private var selectedSize: StorePizzaSize? { var selectedSize: StorePizzaSize? {
guard let selectedSizeId else { return nil } guard let selectedSizeId else { return nil }
return sizes.first(where: { $0.id == selectedSizeId }) return sizes.first(where: { $0.id == selectedSizeId })
} }
@@ -76,7 +77,7 @@ struct PizzaProductDetailSheet: View {
isCrustReady isCrustReady
} }
private var selectedFlavorProducts: [StoreCatalogProduct] { var selectedFlavorProducts: [StoreCatalogProduct] {
flavors flavors
.filter { selectedFlavorIds.contains($0.id) } .filter { selectedFlavorIds.contains($0.id) }
.sorted { $0.name < $1.name } .sorted { $0.name < $1.name }
@@ -102,12 +103,12 @@ struct PizzaProductDetailSheet: View {
} }
private var basePizzaPrice: Double { private var basePizzaPrice: Double {
selectedFlavorProducts let prices = selectedFlavorProducts.map { flavor in
.map { flavor in guard let selectedSizeId else { return flavor.price ?? 0 }
guard let selectedSizeId else { return flavor.price ?? 0 } return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0
return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0 }
} guard prices.isEmpty == false else { return 0 }
.max() ?? 0 return prices.reduce(0, +) / Double(prices.count)
} }
private var unitPrice: Double { private var unitPrice: Double {
@@ -179,6 +180,34 @@ struct PizzaProductDetailSheet: View {
return chunks.isEmpty ? nil : chunks.joined(separator: "") 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 { private var addButtonTitle: String {
if canConfirm == false { if canConfirm == false {
return "Selecione as opções" return "Selecione as opções"
@@ -189,11 +218,20 @@ struct PizzaProductDetailSheet: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
AsyncStoreImage(imageURL: representativeImage) screenHeader
Rectangle()
.fill(AppColors.brandSoft)
.frame(maxWidth: .infinity)
.frame(height: 220) .frame(height: 220)
.overlay(
Image("placeholder-pizza")
.resizable()
.scaledToFill()
.clipped()
)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
Text("Pizza de varios sabores") Text("Escolha seu sabor")
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
@@ -206,18 +244,9 @@ struct PizzaProductDetailSheet: View {
.foregroundStyle(AppColors.primary) .foregroundStyle(AppColors.primary)
stepSizes stepSizes
if selectedSizeId != nil { stepDoughs }
if selectedSizeId != nil { if isDoughReady { stepCrusts }
stepDoughs if canShowFlavors { stepFlavors }
}
if isDoughReady {
stepCrusts
}
if canShowFlavors {
stepFlavors
}
} }
.padding(20) .padding(20)
.padding(.bottom, 90) .padding(.bottom, 90)
@@ -262,9 +291,10 @@ struct PizzaProductDetailSheet: View {
id: cartItemId, id: cartItemId,
productId: selectedFlavorProducts.first?.id ?? category.id, productId: selectedFlavorProducts.first?.id ?? category.id,
storeId: storeId, storeId: storeId,
name: "Pizza de varios sabores", name: "Escolha seu sabor",
imageURL: representativeImage, imageURL: representativeImage,
details: selectedDetailsText, details: selectedDetailsText,
choices: pizzaChoices.isEmpty ? nil : pizzaChoices,
addons: selectedAddonsPayload, addons: selectedAddonsPayload,
quantity: quantity, quantity: quantity,
unitPrice: unitPrice unitPrice: unitPrice
@@ -291,12 +321,8 @@ struct PizzaProductDetailSheet: View {
} }
} }
.background(AppColors.backgroundLight.ignoresSafeArea()) .background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Monte sua pizza") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.appTopBarTrailingToolbar {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
.onAppear { .onAppear {
applyAutoSelections() applyAutoSelections()
let existing = currentQuantityForItemId(cartItemId) 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 { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
screenHeader
AsyncStoreImage(imageURL: imageURL) AsyncStoreImage(imageURL: imageURL)
.frame(height: 220) .frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
@@ -221,12 +222,8 @@ struct ProductDetailSheet: View {
.background(.ultraThinMaterial) .background(.ultraThinMaterial)
} }
.background(AppColors.backgroundLight.ignoresSafeArea()) .background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Detalhes") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.appTopBarTrailingToolbar {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
.onAppear { .onAppear {
let existing = currentQuantityForItemId(cartItemId) let existing = currentQuantityForItemId(cartItemId)
quantity = existing > 0 ? existing : 1 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 { private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
} }

View File

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

View File

@@ -24,6 +24,8 @@ struct MyReviewsView: View {
@State var isLoading = false @State var isLoading = false
@State var loadError: String? = nil @State var loadError: String? = nil
@Environment(\.dismiss) var dismiss
init(initialOrder: ReviewDraft? = nil) { init(initialOrder: ReviewDraft? = nil) {
self.initialOrder = initialOrder self.initialOrder = initialOrder
} }
@@ -31,6 +33,7 @@ struct MyReviewsView: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 12) { VStack(spacing: 12) {
screenHeader
if isLoading && reviews.isEmpty && pendingReviews.isEmpty { if isLoading && reviews.isEmpty && pendingReviews.isEmpty {
ProgressView() ProgressView()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -66,8 +69,8 @@ struct MyReviewsView: View {
.padding(.bottom, UIDevice.bottomNotch + 30) .padding(.bottom, UIDevice.bottomNotch + 30)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Minhas Avaliações") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.navigationDestination(item: $selectedDraft) { draft in .navigationDestination(item: $selectedDraft) { draft in
OrderReviewView(draft: draft) { OrderReviewView(draft: draft) {
Task { await loadReviewsFromBackend(forceRefresh: true) } 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 { private var emptyState: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
Text("Você não tem avaliações nem pendências no momento.") Text("Você não tem avaliações nem pendências no momento.")
@@ -434,6 +458,7 @@ struct OrderReviewView: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
screenHeader
topSection topSection
orderItemsSection orderItemsSection
if existingReview != nil { if existingReview != nil {
@@ -450,8 +475,8 @@ struct OrderReviewView: View {
.padding(.bottom, UIDevice.bottomNotch + 140) .padding(.bottom, UIDevice.bottomNotch + 140)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Avaliar Pedido") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.onAppear { .onAppear {
if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) { if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) {
existingReview = cachedReview 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 { private var newReviewContent: some View {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
starsSection starsSection
@@ -802,6 +848,7 @@ struct OrderReviewView: View {
.frame(height: 110) .frame(height: 110)
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 8) .padding(.vertical, 8)
.foregroundStyle(AppColors.textPrimary)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(AppColors.surface) .background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .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 .onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in
if baseline == nil { baseline = minY } if baseline == nil { baseline = minY }
let offset = max(0, (baseline ?? 0) - minY) let offset = (baseline ?? 0) - minY
if abs(offsetY - offset) > 0.5 { if abs(offsetY - offset) > 0.5 {
offsetY = offset offsetY = offset
} }
@@ -47,6 +47,7 @@ struct ScrollOffsetReader: View {
struct AsyncStoreImage: View { struct AsyncStoreImage: View {
let imageURL: String? let imageURL: String?
var fallbackImageName: String = "placeholder-product"
var body: some View { var body: some View {
CachedRemoteImage(imageURL: imageURL) { CachedRemoteImage(imageURL: imageURL) {
@@ -58,7 +59,7 @@ struct AsyncStoreImage: View {
} }
private var fallback: some View { private var fallback: some View {
Image("placeholder-product") Image(fallbackImageName)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
} }

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ struct UserProfileView: View {
@State var name: String = "" @State var name: String = ""
@State var email: String = "" @State var email: String = ""
@State var phone: String = "" @State var phone: String = ""
@State var cpf: String = ""
@State var profilePicture: String = "" @State var profilePicture: String = ""
@State var isSaving = false @State var isSaving = false
@@ -21,6 +22,7 @@ struct UserProfileView: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 22) { VStack(spacing: 22) {
screenHeader
avatarSection avatarSection
formSection formSection
saveButton saveButton
@@ -30,8 +32,8 @@ struct UserProfileView: View {
.padding(.bottom, UIDevice.bottomNotch + 24) .padding(.bottom, UIDevice.bottomNotch + 24)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Meu Perfil") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.onAppear { .onAppear {
hydrateFromAppState() hydrateFromAppState()
} }
@@ -42,6 +44,27 @@ struct UserProfileView: View {
#endif #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 { private var avatarSection: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
Circle() Circle()
@@ -97,6 +120,14 @@ struct UserProfileView: View {
phone = masked 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() .appNoAutoCap()
} }
.padding(16) .padding(16)
@@ -148,6 +179,15 @@ struct UserProfileView: View {
email = appState.profile.email email = appState.profile.email
phone = formatPhoneForDisplay(appState.profile.phone) phone = formatPhoneForDisplay(appState.profile.phone)
profilePicture = appState.profile.profilePicture 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 @MainActor
@@ -158,6 +198,7 @@ struct UserProfileView: View {
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPhone = normalizePhoneNumberForAPI(phone) let normalizedPhone = normalizePhoneNumberForAPI(phone)
let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines) let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
let newPhoto = cleanPhoto.hasPrefix("data:") ? cleanPhoto : nil
isSaving = true isSaving = true
defer { isSaving = false } defer { isSaving = false }
@@ -167,7 +208,7 @@ struct UserProfileView: View {
name: cleanName, name: cleanName,
email: cleanEmail, email: cleanEmail,
phoneNumber: normalizedPhone, phoneNumber: normalizedPhone,
profilePicture: cleanPhoto.isEmpty ? nil : cleanPhoto profilePicture: newPhoto
) )
if response.error { if response.error {
@@ -180,12 +221,38 @@ struct UserProfileView: View {
return return
} }
let customer = response.result appState.profile.name = cleanName
appState.profile.id = customer?.id ?? appState.profile.id appState.profile.email = cleanEmail
appState.profile.name = customer?.name ?? cleanName appState.profile.phone = normalizedPhone
appState.profile.email = customer?.email ?? cleanEmail if let pictureUrl = response.profilePictureUrl, pictureUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
appState.profile.phone = customer?.phoneNumber ?? normalizedPhone appState.profile.profilePicture = ImageSourceResolver.resolve(pictureUrl) ?? pictureUrl
appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto } 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.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email) 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) #if os(iOS)
@MainActor @MainActor
private func applySelectedPhoto(_ item: PhotosPickerItem?) async { private func applySelectedPhoto(_ item: PhotosPickerItem?) async {
guard let item else { return } guard let item else { return }
do { do {
guard let data = try await item.loadTransferable(type: Data.self) else { return } guard let data = try await item.loadTransferable(type: Data.self) else { return }
guard let image = UIImage(data: data), guard let image = UIImage(data: data) else { return }
let jpegData = image.jpegData(compressionQuality: 0.82) 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())" profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
} catch { } catch {
SnackbarCenter.shared.show( 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 #endif
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB