Merge pull request 'feature/card-brand-logo' (#5) from feature/card-brand-logo into main
Reviewed-on: Loverde-Company-LTDA/Pedi-Foods-Skip#5
@@ -127,47 +127,123 @@ Used when interacting with a specific store (Catalog, Checkout).
|
||||
**Campos internos não retornados para o app:** `keyHash`, `strongHash`, `source`, `isShadow`, `profile_metrics`, `behavioral_stats`, `otpHash`, `otpHashParams`, `otpExpiresAt`.
|
||||
|
||||
### 4. Update Profile
|
||||
**POST** `/api/customer/:id`
|
||||
**PATCH** `/api/customer/profile`
|
||||
|
||||
**Headers:**
|
||||
|
||||
- `Authorization: Bearer <JWT_Token>`
|
||||
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
|
||||
- `Content-Type: application/json`
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Todos os campos são opcionais — envie apenas o que mudou.
|
||||
- `profilePicture` aceita base64 data URL (máx 2 MB decodificado).
|
||||
- A resposta inclui `profilePictureUrl` apenas quando imagem foi enviada.
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Daniel A. Loverde",
|
||||
"cpf": "12345678901",
|
||||
"phoneNumber": "5511988888888",
|
||||
"profilePicture": "data:image/jpeg;base64,/9j/4AAQSkZJRgAB..."
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Tipo | Obrigatório | Descrição |
|
||||
|---|---|---|---|
|
||||
| `name` | string | não | Nome de exibição |
|
||||
| `cpf` | string | não | 11 dígitos, formatado ou raw |
|
||||
| `phoneNumber` | string | não | Com DDI, sem `+`: `"5519991670000"` |
|
||||
| `profilePicture` | string | não | base64 data URL — jpeg, png ou webp — máx 2 MB decoded |
|
||||
|
||||
**Formato `profilePicture`:**
|
||||
|
||||
```
|
||||
data:image/jpeg;base64,<base64data>
|
||||
data:image/png;base64,<base64data>
|
||||
data:image/webp;base64,<base64data>
|
||||
```
|
||||
|
||||
**Response (sucesso):**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": false,
|
||||
"code": "PROFILE_UPDATED",
|
||||
"profilePictureUrl": "/uploads/profiles/e30188cf_1780615191653.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
> `profilePictureUrl` é URL relativa. Prefixar com `https://atomenta.com.br` para exibir a imagem.
|
||||
|
||||
**Erros possíveis:**
|
||||
|
||||
| Código | HTTP | Descrição |
|
||||
|---|---|---|
|
||||
| `INVALID_CPF` | 400 | CPF não tem 11 dígitos |
|
||||
| `INVALID_PROFILE_IMAGE` | 400 | Não é data URL válido ou excede 2 MB |
|
||||
| `NO_FIELDS` | 400 | Nenhum campo reconhecido no body |
|
||||
| `CUSTOMER_NOT_FOUND` | 404 | — |
|
||||
|
||||
### 5. Set Default Address
|
||||
**PATCH** `/api/customer/addresses/:addressId/default`
|
||||
|
||||
**Headers:**
|
||||
|
||||
- `Authorization: Bearer <JWT_Token>`
|
||||
- `Atomenta-Token: 550e8400-e29b-41d4-a716-44665544000a`
|
||||
|
||||
**Body:**
|
||||
**Notes:**
|
||||
|
||||
- Define um endereço como padrão pelo seu `id`.
|
||||
- Define `isDefault: true` no endereço alvo e `isDefault: false` em todos os outros.
|
||||
- O `id` de cada endereço é retornado no campo `address_book` do perfil (`GET /api/customer/profile`).
|
||||
- Ao salvar um endereço via `PATCH /api/customer/profile`, o backend gera automaticamente um `id` UUID caso o endereço não possua um.
|
||||
- Se nenhum endereço tiver `isDefault: true`, o primeiro do array é tratado como padrão.
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Daniel A. Loverde", // Fields to update
|
||||
"phoneNumber": "+5511988888888",
|
||||
"biometricsEnabled": true,
|
||||
"address_book": [
|
||||
{
|
||||
"label": "Casa",
|
||||
"type": "residential",
|
||||
"address": "Avenida das Andorinhas",
|
||||
"number": "477",
|
||||
"neighborhood": "Jardim Andorinhas",
|
||||
"city": "Campinas",
|
||||
"state": "SP",
|
||||
"zipCode": "13101-400",
|
||||
"country": "Brasil",
|
||||
"complement": "Apto 78",
|
||||
"lat_long": [-22.9064, -47.0616]
|
||||
}
|
||||
]
|
||||
"error": false,
|
||||
"code": "ADDRESS_DEFAULT_SET"
|
||||
}
|
||||
```
|
||||
|
||||
**Regra de coordenadas (`address_book.lat_long`):**
|
||||
**Erros:**
|
||||
|
||||
- Se existir `zipCode` válido e houver cache do CEP no Atomenta (`/api/public/cep/:cep`) para o mesmo usuário, a API valida o ponto enviado.
|
||||
- Se não houver cache local, o Atomenta consulta a AwesomeAPI e atualiza o cache antes de validar.
|
||||
- Se `lat_long` estiver ausente/inválido, a API preenche com as coordenadas do CEP cacheado.
|
||||
- Se a distância entre `lat_long` enviado e o ponto do CEP for maior que `1.5 km`, a API substitui `lat_long` pelo ponto do CEP cacheado.
|
||||
- Objetivo: evitar discrepâncias grandes entre endereço e coordenada salva no perfil.
|
||||
| Código | HTTP | Descrição |
|
||||
|---|---|---|
|
||||
| `ADDRESS_NOT_FOUND` | 404 | Nenhum endereço com esse `id` no `address_book` |
|
||||
| `CUSTOMER_NOT_FOUND` | 404 | — |
|
||||
|
||||
### 5. List Favorite Stores
|
||||
**Exemplo de `address_book` no perfil (com `id` e `isDefault`):**
|
||||
|
||||
```json
|
||||
"address_book": [
|
||||
{
|
||||
"id": "a1b2c3d4-...",
|
||||
"label": "Casa",
|
||||
"type": "residential",
|
||||
"address": "Estrada Vicinal Nene Moro",
|
||||
"number": "SN",
|
||||
"neighborhood": "Curtume",
|
||||
"city": "Aguaí",
|
||||
"state": "SP",
|
||||
"zipCode": "13868070",
|
||||
"country": "Brasil",
|
||||
"lat_long": [-22.06743, -46.98018],
|
||||
"isDefault": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. List Favorite Stores
|
||||
**GET** `/api/customer/favorites`
|
||||
|
||||
**Headers:**
|
||||
@@ -580,6 +656,44 @@ Use este endpoint ao trocar endereço na tela de pagamento para validar cobertur
|
||||
}
|
||||
```
|
||||
|
||||
**Exemplo com pizza (tamanho + sabores + borda):**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"productId": "prod_pizza_calabresa",
|
||||
"name": "Pizza Calabresa",
|
||||
"qty": 1,
|
||||
"price": 65.00,
|
||||
"choices": [
|
||||
"Tamanho: Grande (+R$ 55,00)",
|
||||
"Sabor: Calabresa",
|
||||
"Massa: Tradicional",
|
||||
"Borda: Catupiry (+R$ 10,00)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Como montar `choices[]` para pizza:**
|
||||
|
||||
| Linha | Formato | Obrigatório |
|
||||
|---|---|---|
|
||||
| Tamanho | `"Tamanho: <NomeDoTamanho> (+R$ X,XX)"` | Sim |
|
||||
| Sabor (único) | `"Sabor: <NomeDeSabor>"` | Sim |
|
||||
| Sabor N (múltiplos) | `"Sabor 1: <Sabor>"`, `"Sabor 2: <Sabor>"` | Sim quando maxFlavors > 1 |
|
||||
| Massa | `"Massa: <NomeDaMassa>"` | Não |
|
||||
| Borda sem custo | `"Borda: <NomeDaBorda>"` | Não |
|
||||
| Borda com custo | `"Borda: <NomeDaBorda> (+R$ X,XX)"` | Não |
|
||||
|
||||
**Regras:**
|
||||
- `choices[]` é um array de strings. O backend converte para `options[]` na normalização do pedido.
|
||||
- O `price` do item deve refletir o preço total da pizza (tamanho + modificador de borda).
|
||||
- Os nomes de tamanho, massa e borda devem corresponder aos `id` ou `name` retornados pelo catalog endpoint.
|
||||
- Máximo de sabores definido por `pizzaConfig.sizes[n].maxFlavors`.
|
||||
|
||||
**Regras para `items[].addons`:**
|
||||
|
||||
- `qty` do adicional é aceito no backend (ex.: `2 ovos`).
|
||||
@@ -929,4 +1043,246 @@ Para dashboard da loja e análise de produto, usar:
|
||||
- O painel `/store/reviews` segue funcional com os campos legados (`rate`, `message`, `itemFeedback`, `improvementFeedback`, `deliveryFeedback`).
|
||||
- Campos novos coexistem para evolução gradual sem quebrar o fluxo atual.
|
||||
|
||||
---
|
||||
|
||||
## Feature Control (Feature Flags)
|
||||
|
||||
O Feature Control permite ativar/desativar funcionalidades do app remotamente sem publicar uma nova versão. O app nunca chama o Atomenta diretamente — usa um **BFF (Backend for Frontend)** que protege o `Atomenta-Token` do cliente.
|
||||
|
||||
### Arquitetura
|
||||
|
||||
```
|
||||
App iOS → POST /feature-control/bootstrap → BFF → Atomenta /api/feature-control/evaluate
|
||||
```
|
||||
|
||||
O BFF mantém cache de 60 segundos. Em caso de timeout ou erro upstream, retorna flags de fallback estáticas definidas nas variáveis de ambiente do BFF.
|
||||
|
||||
---
|
||||
|
||||
### 1. Bootstrap — Avaliar flags na inicialização
|
||||
|
||||
**`POST /feature-control/bootstrap`**
|
||||
|
||||
Deve ser chamado uma vez por sessão do app (ao abrir ou ao autenticar). Retorna os valores de todos os flags solicitados para o contexto do usuário.
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <JWT_Token> ← opcional, mas recomendado
|
||||
```
|
||||
|
||||
> Se o `Authorization` for enviado, o BFF extrai `subjectId` automaticamente do JWT (`sub` / `customerId` / `id`). Se não for enviado, `context.subjectId` é obrigatório.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"environment": "production",
|
||||
"keys": [
|
||||
"at.ios.only",
|
||||
"at.android.only",
|
||||
"at.promo",
|
||||
"at.city.aguai"
|
||||
],
|
||||
"context": {
|
||||
"subjectType": "customer",
|
||||
"subjectId": "e30188cf-cf69-471e-bc59-4c92e0f9ad7b",
|
||||
"platform": "ios",
|
||||
"appVersion": "2.3.1",
|
||||
"attributes": {
|
||||
"city": "Aguaí"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Tipo | Obrigatório | Descrição |
|
||||
|---|---|---|---|
|
||||
| `environment` | string | não | `"production"` (default) ou `"sandbox"` |
|
||||
| `keys` | string[] | sim | Lista de flags a avaliar (máx 100) |
|
||||
| `context.subjectType` | string | sim | `"customer"` \| `"user"` \| `"store"` \| `"anonymous"` |
|
||||
| `context.subjectId` | string | sim* | ID do usuário. *Dispensável se JWT enviado no header |
|
||||
| `context.platform` | string | não | `"ios"` ou `"android"` |
|
||||
| `context.appVersion` | string | não | Versão semântica, ex: `"2.3.1"` |
|
||||
| `context.storeId` | string | não | Para flags segmentadas por loja |
|
||||
| `context.attributes` | object | não | Atributos extras (ex: `city`, `tier`) |
|
||||
|
||||
**Response 200 — fonte live:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"source": "live",
|
||||
"configVersion": 7,
|
||||
"evaluatedAt": "2026-06-05T12:00:00.000Z",
|
||||
"flags": {
|
||||
"atiosonly": true,
|
||||
"atandroidonly": false,
|
||||
"atpromo": true,
|
||||
"atcityaguai": true
|
||||
},
|
||||
"raw": {
|
||||
"at.ios.only": {
|
||||
"enabled": true,
|
||||
"variant": "on",
|
||||
"payload": null,
|
||||
"reason": "rollout"
|
||||
},
|
||||
"at.promo": {
|
||||
"enabled": true,
|
||||
"variant": "on",
|
||||
"payload": null,
|
||||
"reason": "rollout"
|
||||
},
|
||||
"at.city.aguai": {
|
||||
"enabled": true,
|
||||
"variant": "on",
|
||||
"payload": null,
|
||||
"reason": "segment"
|
||||
},
|
||||
"at.android.only": {
|
||||
"enabled": false,
|
||||
"variant": "off",
|
||||
"payload": null,
|
||||
"reason": "default"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200 — fonte fallback** (quando BFF não consegue atingir Atomenta):
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"source": "fallback",
|
||||
"configVersion": 0,
|
||||
"evaluatedAt": "2026-06-05T12:00:00.000Z",
|
||||
"flags": { "ios": true, "promo": true },
|
||||
"raw": { ... },
|
||||
"upstreamError": {
|
||||
"status": 504,
|
||||
"code": "FEATURE_CONTROL_UPSTREAM_TIMEOUT",
|
||||
"message": "Timeout no upstream"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Como usar `raw` no app (Swift)
|
||||
|
||||
O app usa o campo **`raw`**, indexado pela chave original completa. O campo `flags` contém uma versão simplificada (pontos removidos), mas o app Swift usa `raw` para manter compatibilidade direta com os nomes de flag.
|
||||
|
||||
| Chave original | Acesso via `raw` | `flags` simplificado |
|
||||
|---|---|---|
|
||||
| `at.ios.only` | `raw["at.ios.only"]` | `flags["atiosonly"]` |
|
||||
| `at.android.only` | `raw["at.android.only"]` | `flags["atandroidonly"]` |
|
||||
| `at.promo` | `raw["at.promo"]` | `flags["atpromo"]` |
|
||||
| `at.city.aguai` | `raw["at.city.aguai"]` | `flags["atcityaguai"]` |
|
||||
|
||||
> **Use sempre `raw`** para acessar flags no app — as chaves simplificadas em `flags` são geradas mecanicamente e menos legíveis.
|
||||
|
||||
**Regra de leitura em `raw`:**
|
||||
- `raw["at.promo"].enabled == true` → flag habilitado
|
||||
- `raw["at.promo"].variant == "on"` → variante ativa
|
||||
- `raw["at.promo"].payload` → dados extras opcionais (JSON livre)
|
||||
|
||||
**Exemplo Swift (padrão atual do app):**
|
||||
```swift
|
||||
// FeatureFlagsState.isEnabled() já lê raw automaticamente
|
||||
if appState.featureFlags.isEnabled("at.promo") {
|
||||
// mostrar módulo de promoções
|
||||
}
|
||||
|
||||
if appState.featureFlags.isEnabled("at.ios.only") {
|
||||
// funcionalidade exclusiva iOS
|
||||
}
|
||||
```
|
||||
|
||||
#### `source` e estratégia de fallback
|
||||
|
||||
| `source` | Significado | Ação recomendada |
|
||||
|---|---|---|
|
||||
| `"live"` | Dados frescos do Atomenta | Usar normalmente |
|
||||
| `"fallback"` | BFF usou defaults estáticos (timeout/erro) | Usar com cautela; re-tentar na próxima sessão |
|
||||
|
||||
---
|
||||
|
||||
### 2. Telemetria de Exposição
|
||||
|
||||
**`POST /feature-control/telemetry/exposure`**
|
||||
|
||||
Registra quais flags o usuário visualizou (para análise de rollout). Envio em batch, **fire-and-forget** — não bloquear UX aguardando resposta.
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <JWT_Token>
|
||||
```
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"featureKey": "at.promo",
|
||||
"variant": "on",
|
||||
"subjectType": "customer",
|
||||
"storeId": "store_1777215327582_akum6"
|
||||
},
|
||||
{
|
||||
"featureKey": "at.ios.only",
|
||||
"variant": "on",
|
||||
"subjectType": "customer"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Tipo | Obrigatório | Descrição |
|
||||
|---|---|---|---|
|
||||
| `featureKey` | string | sim | Chave original com prefixo `fc.` |
|
||||
| `variant` | string | não | Variante exposta (default `"off"`) |
|
||||
| `subjectType` | string | não | Default `"customer"` |
|
||||
| `storeId` | string | não | Quando relevante |
|
||||
|
||||
Máximo de **100 eventos por request**.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"code": "FEATURE_CONTROL_EXPOSURE_ACCEPTED",
|
||||
"result": { "count": 2 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Flags disponíveis (referência)
|
||||
|
||||
Padrão de nomenclatura: `at.<domínio>.<descrição>` (pontos como separador, sem hífens).
|
||||
|
||||
| Flag | Descrição | Default |
|
||||
|---|---|---|
|
||||
| `at.ios.only` | Funcionalidades exclusivas iOS | `true` |
|
||||
| `at.android.only` | Funcionalidades exclusivas Android | `true` |
|
||||
| `at.promo` | Módulo de promoções ativo | `true` |
|
||||
| `at.city.aguai` | Expansão para cidade de Aguaí/SP | `true` |
|
||||
|
||||
> Novos flags são criados pelo time Pedi Foods no painel `/feature-control`. Consulte o time antes de codificar um flag inexistente. A lista completa de flags ativos está disponível no painel admin → Feature Control.
|
||||
|
||||
---
|
||||
|
||||
### 4. Fluxo recomendado no app
|
||||
|
||||
```
|
||||
1. App abre / usuário autentica
|
||||
2. POST /feature-control/bootstrap (com JWT no header)
|
||||
3. Salvar flags em memória/UserDefaults para a sessão
|
||||
4. Renderizar UI baseada nos flags
|
||||
5. POST /feature-control/telemetry/exposure (fire-and-forget, por flag visualizado)
|
||||
6. Ao fechar sessão ou após 15 min, repetir passo 2 na próxima abertura
|
||||
```
|
||||
|
||||
> **Não** chamar bootstrap a cada tela — apenas na inicialização da sessão. O BFF tem cache de 60s no servidor; o app deve ter cache local mínimo de 15 minutos para evitar latência desnecessária.
|
||||
|
||||
---
|
||||
|
||||
Autor: Daniel Arantes Loverde
|
||||
|
||||
99
README.md
Normal file
@@ -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
|
||||
@@ -27,11 +27,10 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`:
|
||||
|
||||
```json
|
||||
{
|
||||
"fc.promo": { "enabled": true, "variant": "on" },
|
||||
"fc.promo-codes": { "enabled": true, "variant": "on" },
|
||||
"fc.android": { "enabled": true, "variant": "on" },
|
||||
"fc.city-aguai": { "enabled": true, "variant": "on" },
|
||||
"fc.ios": { "enabled": true, "variant": "on" }
|
||||
"at.ios.only": { "enabled": true, "variant": "on" },
|
||||
"at.android.only": { "enabled": true, "variant": "on" },
|
||||
"at.promo": { "enabled": true, "variant": "on" },
|
||||
"at.city.aguai": { "enabled": true, "variant": "on" }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -40,7 +39,7 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`:
|
||||
```json
|
||||
{
|
||||
"environment": "production",
|
||||
"keys": ["fc.promo", "fc.promo-codes", "fc.android", "fc.city-aguai", "fc.ios"],
|
||||
"keys": ["at.ios.only", "at.android.only", "at.promo", "at.city.aguai"],
|
||||
"context": {
|
||||
"subjectType": "customer",
|
||||
"subjectId": "cust_123",
|
||||
@@ -48,8 +47,7 @@ Exemplo de `FEATURE_CONTROL_DEFAULTS_JSON`:
|
||||
"platform": "ios",
|
||||
"appVersion": "2.3.1",
|
||||
"attributes": {
|
||||
"city": "Belo Horizonte",
|
||||
"tier": "gold"
|
||||
"city": "Aguaí"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,14 +64,19 @@ Se o app enviar `Authorization: Bearer <JWT>`, o BFF tenta extrair `subjectId` d
|
||||
"configVersion": 7,
|
||||
"evaluatedAt": "2026-04-16T12:00:00.000Z",
|
||||
"flags": {
|
||||
"promo": true,
|
||||
"promoCodes": true,
|
||||
"android": true,
|
||||
"cityAguai": true,
|
||||
"ios": true
|
||||
"atiosonly": true,
|
||||
"atandroidonly": false,
|
||||
"atpromo": true,
|
||||
"atcityaguai": true
|
||||
},
|
||||
"raw": {
|
||||
"fc.promo": {
|
||||
"at.ios.only": {
|
||||
"enabled": true,
|
||||
"variant": "on",
|
||||
"payload": null,
|
||||
"reason": "rollout"
|
||||
},
|
||||
"at.promo": {
|
||||
"enabled": true,
|
||||
"variant": "on",
|
||||
"payload": null,
|
||||
@@ -83,6 +86,9 @@ Se o app enviar `Authorization: Bearer <JWT>`, o BFF tenta extrair `subjectId` d
|
||||
}
|
||||
```
|
||||
|
||||
> O app Swift usa o campo **`raw`** indexado pela chave original (ex: `raw["at.promo"]`). O campo `flags` é uma versão simplificada gerada mecanicamente (pontos removidos).
|
||||
```
|
||||
|
||||
Quando o upstream falha (ex.: `429`, `500`, timeout), o BFF devolve `fallback` com defaults estáticos.
|
||||
|
||||
## Execução local
|
||||
|
||||
BIN
logo/alelocard_logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
logo/amexcard_logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
logo/hipercard_logo.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
logo/mastercard_logo.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
logo/sodexo_logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
logo/visacard_logo.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
@@ -1,16 +1,34 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"red" : "0.9529411764705882",
|
||||
"green" : "0.9607843137254902",
|
||||
"blue" : "0.9686274509803922",
|
||||
"alpha" : "1.0000000000000000"
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.969",
|
||||
"green" : "0.961",
|
||||
"red" : "0.953"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.102",
|
||||
"green" : "0.102",
|
||||
"red" : "0.102"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
12
pedi-foods/Darwin/Assets.xcassets/alelocard_logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "alelocard_logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/alelocard_logo.imageset/alelocard_logo.png
vendored
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
12
pedi-foods/Darwin/Assets.xcassets/amexcard_logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "amexcard_logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/amexcard_logo.imageset/amexcard_logo.png
vendored
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
12
pedi-foods/Darwin/Assets.xcassets/hipercard_logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "hipercard_logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/hipercard_logo.imageset/hipercard_logo.png
vendored
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
12
pedi-foods/Darwin/Assets.xcassets/icon_pix.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_pix.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/icon_pix.imageset/icon_pix.png
vendored
Normal file
|
After Width: | Height: | Size: 835 B |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 2.8 MiB |
12
pedi-foods/Darwin/Assets.xcassets/mastercard_logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "mastercard_logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/mastercard_logo.imageset/mastercard_logo.png
vendored
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
12
pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "placeholder-pizza.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/placeholder-pizza.imageset/placeholder-pizza.png
vendored
Normal file
|
After Width: | Height: | Size: 667 KiB |
12
pedi-foods/Darwin/Assets.xcassets/sodexo_logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "sodexo_logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/sodexo_logo.imageset/sodexo_logo.png
vendored
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
12
pedi-foods/Darwin/Assets.xcassets/visacard_logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "visacard_logo.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/visacard_logo.imageset/visacard_logo.png
vendored
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
@@ -162,8 +162,6 @@
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
499CD43A2AC5B799001AE8D8 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
@@ -269,7 +267,7 @@
|
||||
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.0.7;
|
||||
minimumVersion = 1.0.10;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</imageView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
|
||||
<color key="backgroundColor" red="0.9529411765" green="0.9607843137" blue="0.9686274510" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<color key="backgroundColor" name="LaunchBackground"/>
|
||||
<constraints>
|
||||
<constraint firstItem="W6R-s6-hHG" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="F0M-uu-fA3"/>
|
||||
<constraint firstItem="W6R-s6-hHG" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="M3D-C3-C0c"/>
|
||||
|
||||
@@ -79,32 +79,13 @@
|
||||
"comment" : "A small, secondary text label displaying the price of an item.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"1. Tamanho" : {
|
||||
"comment" : "A heading for the size selection step in the pizza product detail sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
"0000 0000 0000 0000" : {
|
||||
|
||||
},
|
||||
"1km" : {
|
||||
"comment" : "A label for the minimum distance filter option.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"2. Massa" : {
|
||||
|
||||
},
|
||||
"3. Borda" : {
|
||||
|
||||
},
|
||||
"4. Sabores (%lld/%lld)" : {
|
||||
"comment" : "A heading that describes the step where the user selects pizza flavors. The number in the parentheses indicates the maximum number of flavors that can be selected.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "4. Sabores (%1$lld/%2$lld)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"10km" : {
|
||||
"comment" : "A label displayed next to the far end of the distance slider in the filters modal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -156,6 +137,9 @@
|
||||
},
|
||||
"Adicionar loja aos favoritos" : {
|
||||
|
||||
},
|
||||
"Adicionar novo cartão" : {
|
||||
|
||||
},
|
||||
"Adicionar novo endereço" : {
|
||||
"comment" : "A button label that translates to \"Add new address\" in English.",
|
||||
@@ -176,15 +160,17 @@
|
||||
"Alterar" : {
|
||||
"comment" : "A button label that translates to \"Change\" in English.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ao menos um cartão deve permanecer" : {
|
||||
|
||||
},
|
||||
"Ao menos um endereço deve permanecer" : {
|
||||
|
||||
},
|
||||
"Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)" : {
|
||||
"comment" : "A text that informs the user that registering implies acceptance of the app's terms of use and privacy policy. The text is clickable and navigates to the respective views when tapped.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Apenas Pagar" : {
|
||||
"comment" : "A button that allows the user to pay without saving the payment information.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Aplicar" : {
|
||||
"comment" : "A button that applies a coupon code to a cart.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -229,10 +215,6 @@
|
||||
},
|
||||
"Atualizando status do pedido..." : {
|
||||
|
||||
},
|
||||
"Atualize os dados do endereço abaixo." : {
|
||||
"comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Avaliar Pedido" : {
|
||||
|
||||
@@ -300,6 +282,9 @@
|
||||
},
|
||||
"Carregando cardápio..." : {
|
||||
|
||||
},
|
||||
"Carregando endereços..." : {
|
||||
|
||||
},
|
||||
"Carregando pedido..." : {
|
||||
|
||||
@@ -311,6 +296,9 @@
|
||||
"Carrinho" : {
|
||||
"comment" : "A label for the cart section of the app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Cartão de Crédito" : {
|
||||
|
||||
},
|
||||
"Categorias" : {
|
||||
"comment" : "A heading for the list of available categories in the filters modal.",
|
||||
@@ -332,10 +320,6 @@
|
||||
"comment" : "A label for the comment section in the order review view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Concluir" : {
|
||||
"comment" : "The text for a button that confirms and closes a sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Conexão segura e criptografada" : {
|
||||
"comment" : "A description below the \"Verificar e Entrar\" button, emphasizing the security of the login process.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -376,9 +360,8 @@
|
||||
"comment" : "A section header for entering and applying a discount coupon.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Dados do Cartão" : {
|
||||
"comment" : "The header text for the section that collects payment card information.",
|
||||
"isCommentAutoGenerated" : true
|
||||
"Cupons de Desconto" : {
|
||||
|
||||
},
|
||||
"Dark" : {
|
||||
"comment" : "Menu item indicating that the appearance should be in dark mode",
|
||||
@@ -439,6 +422,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Definir como principal" : {
|
||||
|
||||
},
|
||||
"Desconto" : {
|
||||
"comment" : "A label for a discount applied to an order.",
|
||||
@@ -464,6 +450,9 @@
|
||||
"Em uma escala de 0 a 10, qual é a chance de você indicar o Pedi Foods para um amigo?" : {
|
||||
"comment" : "A description under the NPS scale question in the order review view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Endereço de cobrança" : {
|
||||
|
||||
},
|
||||
"ENDEREÇO DE ENTREGA" : {
|
||||
"comment" : "A label displayed above the address section when delivery is selected.",
|
||||
@@ -498,6 +487,9 @@
|
||||
"Escolha o tamanho da sua fome" : {
|
||||
"comment" : "A label displayed below the pizza size selection.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Escolha seu sabor" : {
|
||||
|
||||
},
|
||||
"Este sabor não possui adicionais." : {
|
||||
"comment" : "A message displayed when a pizza flavor does not have any add-ons.",
|
||||
@@ -630,6 +622,12 @@
|
||||
"Já tem uma conta?" : {
|
||||
"comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ligar para %@" : {
|
||||
|
||||
},
|
||||
"Ligar para a loja?" : {
|
||||
|
||||
},
|
||||
"Light" : {
|
||||
"comment" : "Menu item indicating that the appearance should be in light mode",
|
||||
@@ -668,9 +666,15 @@
|
||||
"Limpar carrinho e adicionar" : {
|
||||
"comment" : "A button that clears the current cart and adds the selected product.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Limpar e adicionar" : {
|
||||
|
||||
},
|
||||
"Loja não informou métodos presenciais." : {
|
||||
|
||||
},
|
||||
"Mais rápido e seguro" : {
|
||||
|
||||
},
|
||||
"MÉTODO DE PAGAMENTO" : {
|
||||
"comment" : "A label displayed above the payment methods section in the checkout view.",
|
||||
@@ -683,6 +687,9 @@
|
||||
"Meu Perfil" : {
|
||||
"comment" : "The title of the user profile view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Meus Cartões" : {
|
||||
|
||||
},
|
||||
"Meus Endereços" : {
|
||||
"comment" : "A label displayed at the top of the view.",
|
||||
@@ -701,6 +708,9 @@
|
||||
},
|
||||
"Motivo do cancelamento" : {
|
||||
|
||||
},
|
||||
"Na entrega/retirada com a loja" : {
|
||||
|
||||
},
|
||||
"Name" : {
|
||||
"comment" : "Placeholder title for the Name field in a form",
|
||||
@@ -742,6 +752,9 @@
|
||||
},
|
||||
"Não tem conta ainda?" : {
|
||||
|
||||
},
|
||||
"Nenhum cartão cadastrado" : {
|
||||
|
||||
},
|
||||
"Nenhum endereço cadastrado" : {
|
||||
"comment" : "A message displayed when a user has no addresses saved.",
|
||||
@@ -783,6 +796,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Novo cartão" : {
|
||||
|
||||
},
|
||||
"Novo Cartão" : {
|
||||
|
||||
},
|
||||
"Novo endereço" : {
|
||||
"comment" : "A label for a form to add a new address.",
|
||||
@@ -791,6 +810,9 @@
|
||||
"Novo por aqui?" : {
|
||||
"comment" : "A text that appears at the bottom of the screen, inviting users to create an account.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Número do cartão" : {
|
||||
|
||||
},
|
||||
"O que pode melhorar?" : {
|
||||
"comment" : "A label for the section where users can select tags they think could improve an order.",
|
||||
@@ -823,13 +845,21 @@
|
||||
"comment" : "A label describing the sorting options available in the filters modal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pagamento" : {
|
||||
"comment" : "The title of the screen where a user can enter and save payment information.",
|
||||
"isCommentAutoGenerated" : true
|
||||
"ou" : {
|
||||
|
||||
},
|
||||
"Pagamento via PIX" : {
|
||||
"comment" : "The navigation title for the PaymentPixView.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pagar Na Maquininha" : {
|
||||
|
||||
},
|
||||
"Pagar Pelo App" : {
|
||||
|
||||
},
|
||||
"Pague mais rápido nas próximas compras" : {
|
||||
|
||||
},
|
||||
"Pedido #%@" : {
|
||||
|
||||
@@ -861,7 +891,16 @@
|
||||
},
|
||||
"Pizza de varios sabores" : {
|
||||
"comment" : "A title displayed above the main content of the view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
"extractionState" : "stale",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Escolha seu sabor"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Política de Privacidade" : {
|
||||
"comment" : "The title of the privacy policy section.",
|
||||
@@ -904,13 +943,12 @@
|
||||
"comment" : "A label describing the price filter section in the filters modal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Preencha os dados abaixo para adicionar um endereço." : {
|
||||
"comment" : "A description below the form to add a new address, instructing the user to fill in the required information.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Preencha os dados abaixo para começar." : {
|
||||
"comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Principal" : {
|
||||
|
||||
},
|
||||
"PRINCIPAL" : {
|
||||
"comment" : "A label indicating that a particular address is the user's primary address.",
|
||||
@@ -978,6 +1016,12 @@
|
||||
"Salvar Alterações" : {
|
||||
"comment" : "The text on a button that saves changes to a user's profile.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Salvar cartão" : {
|
||||
|
||||
},
|
||||
"Salvar Cartão" : {
|
||||
|
||||
},
|
||||
"Save" : {
|
||||
"comment" : "Button title indicating that the current contents should be saved",
|
||||
@@ -1008,6 +1052,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Selecionar Cartão" : {
|
||||
|
||||
},
|
||||
"Selecionar endereço" : {
|
||||
|
||||
},
|
||||
"Sem adicionais" : {
|
||||
|
||||
@@ -1046,6 +1096,16 @@
|
||||
"comment" : "A message indicating that the user's cart is empty.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Seu carrinho tem itens de %@. Deseja limpar e adicionar itens de %@?" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Seu carrinho tem itens de %1$@. Deseja limpar e adicionar itens de %2$@?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?" : {
|
||||
"comment" : "A message displayed when the user attempts to add a product to their cart from a different store. Asks if the user wants to clear their current cart before adding the new product.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -1064,6 +1124,9 @@
|
||||
"Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque." : {
|
||||
"comment" : "A tagline displayed in the login view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Substituir carrinho?" : {
|
||||
|
||||
},
|
||||
"Subtotal" : {
|
||||
|
||||
@@ -1165,6 +1228,9 @@
|
||||
"Total do Pedido" : {
|
||||
"comment" : "The label above the total price of the order in the payment card view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Trocar" : {
|
||||
|
||||
},
|
||||
"Trocar de loja?" : {
|
||||
"comment" : "A title for an alert that prompts the user to switch stores.",
|
||||
@@ -1173,10 +1239,16 @@
|
||||
"Trocar Foto" : {
|
||||
"comment" : "A button label that allows a user to change their profile picture.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Trocar Pagamento" : {
|
||||
|
||||
},
|
||||
"UPLOAD" : {
|
||||
"comment" : "A button label that says \"UPLOAD\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Vence %@" : {
|
||||
|
||||
},
|
||||
"Ver Detalhes" : {
|
||||
"comment" : "A button that shows order details when pressed.",
|
||||
@@ -1276,6 +1348,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"WhatsApp não encontrado. Deseja ligar para %@?" : {
|
||||
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
|
||||
BIN
pedi-foods/Sources/PediFoods/Resources/alelocard_logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/amexcard_logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/hipercard_logo.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/icon_pix.png
Normal file
|
After Width: | Height: | Size: 835 B |
BIN
pedi-foods/Sources/PediFoods/Resources/mastercard_logo.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/placeholder-pizza.png
Normal file
|
After Width: | Height: | Size: 667 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/sodexo_logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/visacard_logo.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
83
pedi-foods/Sources/PediFoods/Services/ApiCardModels.swift
Normal 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?
|
||||
}
|
||||
@@ -82,7 +82,6 @@ final class ApiClient {
|
||||
}
|
||||
|
||||
func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
|
||||
|
||||
#if canImport(LCEssentials) && os(iOS)
|
||||
return try await sendWithLCEssentials(request)
|
||||
#else
|
||||
@@ -111,10 +110,15 @@ private extension ApiClient {
|
||||
guard let data = responseString.data(using: .utf8) else {
|
||||
throw NetworkError.decodeError("Resposta nao UTF-8")
|
||||
}
|
||||
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let errorFlag = object["error"] as? Bool, errorFlag {
|
||||
let message = object["message"] as? String ?? object["msg"] as? String
|
||||
throw NetworkError.httpError(200, message ?? "Erro no servidor")
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
throw NetworkError.decodeError(String(data: data, encoding: .utf8))
|
||||
throw NetworkError.decodeError(sanitizedBody(data))
|
||||
}
|
||||
} catch {
|
||||
let mapped = mapError(error)
|
||||
@@ -151,6 +155,7 @@ private extension ApiClient {
|
||||
case "POST": return .post
|
||||
case "PUT": return .put
|
||||
case "DELETE": return .delete
|
||||
case "PATCH": return .patch
|
||||
default: return .get
|
||||
}
|
||||
}
|
||||
@@ -247,6 +252,12 @@ private extension ApiClient {
|
||||
throw NetworkError.unauthorized(payload?.message)
|
||||
}
|
||||
|
||||
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let errorFlag = object["error"] as? Bool, errorFlag {
|
||||
let message = object["message"] as? String ?? object["msg"] as? String
|
||||
throw NetworkError.httpError(http.statusCode, message ?? "Erro no servidor")
|
||||
}
|
||||
|
||||
if http.statusCode == 429 {
|
||||
let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "")
|
||||
throw NetworkError.rateLimited(retryAfter)
|
||||
@@ -263,7 +274,7 @@ private extension ApiClient {
|
||||
do {
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
} catch {
|
||||
throw NetworkError.decodeError(String(data: data, encoding: .utf8))
|
||||
throw NetworkError.decodeError(sanitizedBody(data))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +320,15 @@ private extension ApiClient {
|
||||
if let code = payload?.code, code.isEmpty == false {
|
||||
return "Erro: \(code)"
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
|
||||
// fallback if error true is present but without a classic structure
|
||||
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let errorFlag = object["error"] as? Bool, errorFlag {
|
||||
if let msg = object["message"] as? String ?? object["msg"] as? String {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return sanitizedBody(data)
|
||||
}
|
||||
|
||||
func serverPayload(from data: Data) -> ApiErrorDescriptor? {
|
||||
@@ -318,9 +337,10 @@ private extension ApiClient {
|
||||
}
|
||||
|
||||
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
let errorFlag = object["error"] as? Bool ?? false
|
||||
let code = object["code"] as? String
|
||||
let message = object["message"] as? String
|
||||
if code != nil || message != nil {
|
||||
let message = object["message"] as? String ?? object["msg"] as? String ?? object["error_description"] as? String
|
||||
if errorFlag || code != nil || message != nil {
|
||||
return ApiErrorDescriptor(code: code, message: message)
|
||||
}
|
||||
}
|
||||
@@ -344,35 +364,89 @@ private extension ApiClient {
|
||||
|
||||
#if canImport(LCEssentials) && os(iOS)
|
||||
func serverMessage(from error: NSError) -> String? {
|
||||
if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String,
|
||||
!reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let data = reason.data(using: .utf8),
|
||||
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] {
|
||||
if let value = error.userInfo[key] as? String,
|
||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let data = value.data(using: .utf8),
|
||||
let parsed = serverMessage(from: data),
|
||||
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return parsed
|
||||
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if t.hasPrefix("{") { continue }
|
||||
if let safe = sanitizedMessage(t) { return safe }
|
||||
}
|
||||
let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
|
||||
} else if let valueData = error.userInfo[key] as? Data {
|
||||
if let parsed = serverMessage(from: valueData) {
|
||||
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (_, value) in error.userInfo {
|
||||
if let str = value as? String,
|
||||
str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"),
|
||||
let data = str.data(using: .utf8),
|
||||
let parsed = serverMessage(from: data) {
|
||||
let t = parsed.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !t.hasPrefix("{"), let safe = sanitizedMessage(t) { return safe }
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
if let description = error.userInfo[NSLocalizedDescriptionKey] as? String,
|
||||
!description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!description.lowercased().contains("nsurlerrordomain") {
|
||||
return description
|
||||
!description.lowercased().contains("nsurlerrordomain"),
|
||||
let safe = sanitizedMessage(description) {
|
||||
return safe
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func serverPayload(from error: NSError) -> ApiErrorDescriptor? {
|
||||
if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String,
|
||||
let data = reason.data(using: .utf8),
|
||||
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data"] {
|
||||
if let value = error.userInfo[key] as? String,
|
||||
let data = value.data(using: .utf8),
|
||||
let payload = serverPayload(from: data) {
|
||||
return payload
|
||||
} else if let valueData = error.userInfo[key] as? Data,
|
||||
let payload = serverPayload(from: valueData) {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
for (_, value) in error.userInfo {
|
||||
if let str = value as? String,
|
||||
str.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{"),
|
||||
let data = str.data(using: .utf8),
|
||||
let payload = serverPayload(from: data) {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
|
||||
func sanitizedBody(_ data: Data) -> String? {
|
||||
guard let raw = String(data: data, encoding: .utf8) else { return nil }
|
||||
return sanitizedMessage(raw)
|
||||
}
|
||||
|
||||
func sanitizedMessage(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.isEmpty == false else { return nil }
|
||||
// Drop data URLs (base64 images)
|
||||
if trimmed.lowercased().hasPrefix("data:image") { return "Erro ao processar imagem." }
|
||||
// Drop fields containing base64,
|
||||
if trimmed.contains("base64,") { return "Resposta do servidor inválida." }
|
||||
// Truncate long strings (raw JSON bodies, etc.)
|
||||
if trimmed.count > 300 {
|
||||
return String(trimmed.prefix(300)) + "…"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
|
||||
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
|
||||
throw NetworkError.invalidURL
|
||||
|
||||
@@ -15,32 +15,25 @@ struct CustomerProfileUpdatePayload: Encodable {
|
||||
}
|
||||
|
||||
struct CustomerIdentityUpdatePayload: Encodable {
|
||||
let name: String
|
||||
let email: String
|
||||
let phoneNumber: String
|
||||
let name: String?
|
||||
let email: String?
|
||||
let phoneNumber: String?
|
||||
let profilePicture: String?
|
||||
let addressBook: [CustomerAddressPayload]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case email
|
||||
case phoneNumber
|
||||
case phone
|
||||
case profilePicture
|
||||
case profile_picture
|
||||
case addressBook = "address_book"
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(email, forKey: .email)
|
||||
try container.encode(phoneNumber, forKey: .phoneNumber)
|
||||
try container.encode(phoneNumber, forKey: .phone)
|
||||
try container.encode(addressBook, forKey: .addressBook)
|
||||
try container.encodeIfPresent(name, forKey: .name)
|
||||
try container.encodeIfPresent(email, forKey: .email)
|
||||
try container.encodeIfPresent(phoneNumber, forKey: .phoneNumber)
|
||||
if let profilePicture, profilePicture.isEmpty == false {
|
||||
try container.encode(profilePicture, forKey: .profilePicture)
|
||||
try container.encode(profilePicture, forKey: .profile_picture)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@ import Foundation
|
||||
|
||||
struct EmptyResult: Decodable {}
|
||||
|
||||
struct ProfilePatchEnvelope: Decodable {
|
||||
let error: Bool
|
||||
let code: String?
|
||||
let message: String?
|
||||
let profilePictureUrl: String?
|
||||
}
|
||||
|
||||
struct RegistrationResult: Decodable {
|
||||
let id: String?
|
||||
let name: String?
|
||||
@@ -46,18 +53,23 @@ struct CustomerAddress: Decodable {
|
||||
let state: String?
|
||||
let zipCode: String?
|
||||
let latLong: [Double]?
|
||||
let isDefault: Bool?
|
||||
|
||||
init(id: String?, label: String?, address: String?, number: String?,
|
||||
complement: String?, neighborhood: String?, city: String?,
|
||||
state: String?, zipCode: String?, latLong: [Double]?, isDefault: Bool?) {
|
||||
self.id = id; self.label = label; self.address = address
|
||||
self.number = number; self.complement = complement
|
||||
self.neighborhood = neighborhood; self.city = city
|
||||
self.state = state; self.zipCode = zipCode
|
||||
self.latLong = latLong; self.isDefault = isDefault
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case label
|
||||
case address
|
||||
case number
|
||||
case complement
|
||||
case neighborhood
|
||||
case city
|
||||
case state
|
||||
case zipCode
|
||||
case id, label, address, number, complement
|
||||
case neighborhood, city, state, zipCode
|
||||
case latLong = "lat_long"
|
||||
case isDefault
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ struct CreateOrderPayload: Encodable {
|
||||
let paymentMethod: String
|
||||
let deliveryType: String
|
||||
let address: CreateOrderAddressPayload?
|
||||
// Cartão salvo
|
||||
let savedCardId: String?
|
||||
// Novo cartão (checkout transparente)
|
||||
let clientCpfCnpj: String?
|
||||
let creditCard: CreditCardOrderPayload?
|
||||
let creditCardHolderInfo: SaveCardHolderInfoPayload?
|
||||
}
|
||||
|
||||
struct CreateOrderCustomerPayload: Encodable {
|
||||
@@ -16,15 +22,16 @@ struct CreateOrderCustomerPayload: Encodable {
|
||||
let asaasId: String?
|
||||
}
|
||||
|
||||
struct CreateOrderItemPayload: Encodable {
|
||||
struct CreateOrderItemPayload: Codable {
|
||||
let productId: String
|
||||
let name: String
|
||||
let qty: Int
|
||||
let price: Double
|
||||
let addons: [CreateOrderAddonPayload]
|
||||
let choices: [String]?
|
||||
}
|
||||
|
||||
struct CreateOrderAddonPayload: Encodable {
|
||||
struct CreateOrderAddonPayload: Codable {
|
||||
let addonId: String
|
||||
let name: String
|
||||
let qty: Int
|
||||
|
||||
@@ -40,6 +40,10 @@ struct AppOrderSummary: Decodable, Identifiable {
|
||||
case storeLogo
|
||||
case store_logo
|
||||
case logo
|
||||
case storeImage
|
||||
case store_image
|
||||
case storeImageUrl
|
||||
case logoUrl
|
||||
case date
|
||||
case createdAt
|
||||
case updatedAt
|
||||
@@ -62,7 +66,7 @@ struct AppOrderSummary: Decodable, Identifiable {
|
||||
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
|
||||
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
|
||||
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
|
||||
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
|
||||
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl])
|
||||
let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date])
|
||||
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate
|
||||
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate
|
||||
@@ -97,6 +101,7 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
let customerOtp: String?
|
||||
let confirmOtp: String?
|
||||
let cancellationReason: String?
|
||||
let fullAddress: String?
|
||||
let deliveryAddress: PublicOrderDeliveryAddress?
|
||||
let review: PublicOrderReview?
|
||||
let items: [PublicOrderItem]
|
||||
@@ -133,12 +138,17 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
case storeLogo
|
||||
case store_logo
|
||||
case logo
|
||||
case storeImage
|
||||
case store_image
|
||||
case storeImageUrl
|
||||
case logoUrl
|
||||
case createdAt
|
||||
case updatedAt
|
||||
case otp
|
||||
case customerOtp
|
||||
case confirmOtp
|
||||
case cancellationReason
|
||||
case fullAddress
|
||||
case address
|
||||
case deliveryAddress
|
||||
case delivery_address
|
||||
@@ -148,6 +158,8 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
case orderReview
|
||||
case items
|
||||
case timeline
|
||||
case history
|
||||
case orderedAt
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -178,6 +190,7 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
customerOtp: String? = nil,
|
||||
confirmOtp: String? = nil,
|
||||
cancellationReason: String? = nil,
|
||||
fullAddress: String? = nil,
|
||||
deliveryAddress: PublicOrderDeliveryAddress? = nil,
|
||||
review: PublicOrderReview? = nil,
|
||||
items: [PublicOrderItem] = [],
|
||||
@@ -210,6 +223,7 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
self.customerOtp = customerOtp
|
||||
self.confirmOtp = confirmOtp
|
||||
self.cancellationReason = cancellationReason
|
||||
self.fullAddress = fullAddress
|
||||
self.deliveryAddress = deliveryAddress
|
||||
self.review = review
|
||||
self.items = items
|
||||
@@ -248,13 +262,14 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
|
||||
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
|
||||
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
|
||||
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
|
||||
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt])
|
||||
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl])
|
||||
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt, .orderedAt])
|
||||
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
|
||||
otp = ApiService.decodeFlexibleString(from: container, keys: [.otp])
|
||||
customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp])
|
||||
confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp])
|
||||
cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason])
|
||||
fullAddress = ApiService.decodeFlexibleString(from: container, keys: [.fullAddress])
|
||||
deliveryAddress = (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .address))
|
||||
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .deliveryAddress))
|
||||
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .delivery_address))
|
||||
@@ -263,7 +278,9 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
review = (try? container.decode(PublicOrderReview.self, forKey: .review))
|
||||
?? (try? container.decode(PublicOrderReview.self, forKey: .orderReview))
|
||||
items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? []
|
||||
timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) ?? []
|
||||
timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline))
|
||||
?? (try? container.decode([PublicOrderTimelineEvent].self, forKey: .history))
|
||||
?? []
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
@@ -294,6 +311,7 @@ struct PublicOrderResult: Codable, Identifiable {
|
||||
try container.encodeIfPresent(customerOtp, forKey: .customerOtp)
|
||||
try container.encodeIfPresent(confirmOtp, forKey: .confirmOtp)
|
||||
try container.encodeIfPresent(cancellationReason, forKey: .cancellationReason)
|
||||
try container.encodeIfPresent(fullAddress, forKey: .fullAddress)
|
||||
try container.encodeIfPresent(deliveryAddress, forKey: .address)
|
||||
try container.encodeIfPresent(review, forKey: .review)
|
||||
try container.encode(items, forKey: .items)
|
||||
@@ -537,20 +555,23 @@ struct PublicOrderReview: Codable {
|
||||
|
||||
struct PublicOrderItem: Codable, Identifiable {
|
||||
let id: String
|
||||
let productId: String?
|
||||
let name: String?
|
||||
let qty: Int?
|
||||
let price: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case productId
|
||||
case name
|
||||
case qty
|
||||
case quantity
|
||||
case price
|
||||
}
|
||||
|
||||
init(id: String = UUID().uuidString, name: String?, qty: Int?, price: Double?) {
|
||||
init(id: String = UUID().uuidString, productId: String? = nil, name: String?, qty: Int?, price: Double?) {
|
||||
self.id = id
|
||||
self.productId = productId
|
||||
self.name = name
|
||||
self.qty = qty
|
||||
self.price = price
|
||||
@@ -559,6 +580,7 @@ struct PublicOrderItem: Codable, Identifiable {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
||||
productId = try? container.decode(String.self, forKey: .productId)
|
||||
name = try? container.decode(String.self, forKey: .name)
|
||||
qty = ApiService.decodeFlexibleInt(from: container, keys: [.qty, .quantity])
|
||||
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
|
||||
@@ -567,6 +589,7 @@ struct PublicOrderItem: Codable, Identifiable {
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encodeIfPresent(productId, forKey: .productId)
|
||||
try container.encodeIfPresent(name, forKey: .name)
|
||||
try container.encodeIfPresent(qty, forKey: .qty)
|
||||
try container.encodeIfPresent(price, forKey: .price)
|
||||
@@ -581,6 +604,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
|
||||
let completed: Bool?
|
||||
let message: String?
|
||||
let time: String?
|
||||
let date: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
@@ -589,7 +613,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
|
||||
case active
|
||||
case completed
|
||||
case message
|
||||
case event
|
||||
case time
|
||||
case date
|
||||
case createdAt
|
||||
case updatedAt
|
||||
}
|
||||
@@ -601,7 +627,8 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
|
||||
active: Bool? = nil,
|
||||
completed: Bool? = nil,
|
||||
message: String?,
|
||||
time: String?
|
||||
time: String?,
|
||||
date: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.status = status
|
||||
@@ -610,6 +637,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
|
||||
self.completed = completed
|
||||
self.message = message
|
||||
self.time = time
|
||||
self.date = date
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -619,10 +647,12 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
|
||||
label = try? container.decode(String.self, forKey: .label)
|
||||
active = try? container.decode(Bool.self, forKey: .active)
|
||||
completed = try? container.decode(Bool.self, forKey: .completed)
|
||||
message = try? container.decode(String.self, forKey: .message)
|
||||
message = (try? container.decode(String.self, forKey: .message))
|
||||
?? (try? container.decode(String.self, forKey: .event))
|
||||
time = (try? container.decode(String.self, forKey: .time))
|
||||
?? (try? container.decode(String.self, forKey: .createdAt))
|
||||
?? (try? container.decode(String.self, forKey: .updatedAt))
|
||||
date = try? container.decode(String.self, forKey: .date)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
@@ -634,6 +664,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
|
||||
try container.encodeIfPresent(completed, forKey: .completed)
|
||||
try container.encodeIfPresent(message, forKey: .message)
|
||||
try container.encodeIfPresent(time, forKey: .time)
|
||||
try container.encodeIfPresent(date, forKey: .date)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -155,28 +155,17 @@ final class ApiService {
|
||||
return envelope
|
||||
}
|
||||
|
||||
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ApiEnvelope<CustomerProfile> {
|
||||
let currentProfile = try await profile(forceRefresh: true)
|
||||
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||
throw NetworkError.invalidResponse
|
||||
}
|
||||
|
||||
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ProfilePatchEnvelope {
|
||||
let payload = CustomerIdentityUpdatePayload(
|
||||
name: name,
|
||||
email: email,
|
||||
phoneNumber: phoneNumber,
|
||||
profilePicture: profilePicture,
|
||||
addressBook: (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:))
|
||||
name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name,
|
||||
email: email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : email,
|
||||
phoneNumber: phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : phoneNumber,
|
||||
profilePicture: profilePicture
|
||||
)
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
||||
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
|
||||
if envelope.error == false, envelope.result != nil {
|
||||
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
||||
} else {
|
||||
invalidateFavoritesCache()
|
||||
}
|
||||
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
|
||||
let envelope: ProfilePatchEnvelope = try await send(req)
|
||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||
return envelope
|
||||
}
|
||||
|
||||
@@ -223,6 +212,13 @@ final class ApiService {
|
||||
return try await updateCustomerAddressBook(customerId: customer.id, addressBook: updatedAddressBook)
|
||||
}
|
||||
|
||||
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
|
||||
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
|
||||
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
|
||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||
return envelope
|
||||
}
|
||||
|
||||
func lookupZipCode(_ zipCode: String) async throws -> ApiEnvelope<CepLookupResult> {
|
||||
let digits = zipCode.filter(\.isNumber)
|
||||
let normalized = String(digits.prefix(8))
|
||||
@@ -426,6 +422,49 @@ final class ApiService {
|
||||
)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
// MARK: - Cards
|
||||
|
||||
func listCards() async throws -> ApiEnvelope<[SavedCard]> {
|
||||
let req = ApiRequest(path: "/api/customer/cards", method: "GET", module: .customer, requiresAuth: true)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func saveCard(payload: SaveCardPayload) async throws -> ApiEnvelope<SavedCardResult> {
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer/cards", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func updateCard(cardId: String, payload: UpdateCardPayload) async throws -> ApiEnvelope<SavedCardResult> {
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "PATCH", module: .customer, requiresAuth: true, body: body)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func deleteCard(cardId: String) async throws -> ApiEnvelope<EmptyResult> {
|
||||
let req = ApiRequest(path: "/api/customer/cards/\(cardId)", method: "DELETE", module: .customer, requiresAuth: true)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope<ChangePaymentMethodResult> {
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body)
|
||||
return try await sendEnvelope(req)
|
||||
}
|
||||
|
||||
// MARK: - Profile CPF
|
||||
|
||||
func updateProfileCpf(cpf: String) async throws -> ApiEnvelope<EmptyResult> {
|
||||
let payload = ["cpf": cpf]
|
||||
let body = try JSONEncoder().encode(payload)
|
||||
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
|
||||
let result: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
|
||||
if result.error == false {
|
||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
extension ApiService {
|
||||
static func decodeFlexibleString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? {
|
||||
|
||||
@@ -258,7 +258,7 @@ final class FeatureControlService {
|
||||
}
|
||||
|
||||
private func featureKeys() -> [String] {
|
||||
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only"
|
||||
let raw = ProcessInfo.processInfo.environment["FEATURE_CONTROL_KEYS"] ?? "at.city.aguai,at.promo,at.ios.only,at.android.only,at.cupons"
|
||||
let items = raw
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
|
||||
@@ -9,6 +9,7 @@ struct AppState {
|
||||
var featureFlags = FeatureFlagsState()
|
||||
var homeFilters = HomeFiltersState()
|
||||
var activeModal: AppModal? = nil
|
||||
var shouldNavigateToOrders: Bool = false
|
||||
}
|
||||
|
||||
enum FeatureFlagValue: Codable, Equatable {
|
||||
@@ -92,6 +93,7 @@ struct ProfileState {
|
||||
var email: String = ""
|
||||
var phone: String = ""
|
||||
var profilePicture: String = ""
|
||||
var cpf: String = ""
|
||||
}
|
||||
|
||||
struct AddressState {
|
||||
@@ -171,6 +173,7 @@ struct CartItemState: Identifiable {
|
||||
var name: String
|
||||
var imageURL: String? = nil
|
||||
var details: String? = nil
|
||||
var choices: [String]? = nil
|
||||
var addons: [CartItemAddonState] = []
|
||||
var quantity: Int
|
||||
var unitPrice: Double
|
||||
@@ -274,7 +277,8 @@ extension CartState {
|
||||
qty: $0.quantity,
|
||||
price: $0.unitPrice
|
||||
)
|
||||
}
|
||||
},
|
||||
choices: item.choices?.isEmpty == false ? item.choices : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import SwiftUI
|
||||
|
||||
struct TermsOfUseView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
screenHeader
|
||||
Text("Termos de Uso")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
@@ -16,17 +18,40 @@ struct TermsOfUseView: View {
|
||||
.padding(24)
|
||||
}
|
||||
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
||||
.navigationTitle("Termos de Uso")
|
||||
.appInlineNavigationTitle()
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Termos de Uso")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivacyPolicyView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
screenHeader
|
||||
Text("Política de Privacidade")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
@@ -37,7 +62,28 @@ struct PrivacyPolicyView: View {
|
||||
.padding(24)
|
||||
}
|
||||
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
||||
.navigationTitle("Privacidade")
|
||||
.appInlineNavigationTitle()
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Privacidade")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
|
||||
struct AddAddressFormView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State var label = ""
|
||||
@State var zipCode = ""
|
||||
@State var address = ""
|
||||
@@ -31,37 +30,17 @@ struct AddAddressFormView: View {
|
||||
!state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
@ViewBuilder private var logoImage: some View {
|
||||
SwiftUI.Image("pedifoods")
|
||||
.resizable()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
|
||||
AppColors.backgroundLight.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
screenHeader(
|
||||
title: existingAddress == nil ? "Novo endereço" : "Editar endereço",
|
||||
onBack: { dismiss() }
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
logoImage
|
||||
.scaledToFit()
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Text(existingAddress == nil ? "Novo endereço" : "Editar endereço")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
|
||||
Text(existingAddress == nil ? "Preencha os dados abaixo para adicionar um endereço." : "Atualize os dados do endereço abaixo.")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
@@ -127,7 +106,7 @@ struct AddAddressFormView: View {
|
||||
Spacer().frame(height: 120)
|
||||
}
|
||||
}
|
||||
.padding(.top, 0)
|
||||
.padding(.top, 18)
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.appHiddenNavigationBar()
|
||||
@@ -140,8 +119,7 @@ struct AddAddressFormView: View {
|
||||
ZStack {
|
||||
Text(title)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
@@ -177,7 +155,8 @@ struct AddAddressFormView: View {
|
||||
city: clean(city),
|
||||
state: clean(state),
|
||||
zipCode: optional(normalizeZipCodeForAPI(zipCode)),
|
||||
latLong: latLong
|
||||
latLong: latLong,
|
||||
isDefault: existingAddress?.isDefault
|
||||
)
|
||||
isLoading = true
|
||||
zipLookupMessage = nil
|
||||
|
||||
461
pedi-foods/Sources/PediFoods/Views/Main/AddCardFormView.swift
Normal 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))"
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,9 @@ struct AddressCard: View {
|
||||
let item: AddressListItem
|
||||
var onEdit: (() -> Void)? = nil
|
||||
var onDelete: (() -> Void)? = nil
|
||||
var onSetDefault: (() -> Void)? = nil
|
||||
|
||||
private var hasActions: Bool { onEdit != nil || onSetDefault != nil }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
@@ -150,30 +153,30 @@ struct AddressCard: View {
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if onEdit != nil || onDelete != nil {
|
||||
if hasActions {
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight)
|
||||
.frame(width: 1, height: 96)
|
||||
}
|
||||
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: 20) {
|
||||
if let onEdit {
|
||||
Button(action: onEdit) {
|
||||
Image(systemName: "pencil")
|
||||
.font(.system(size: 22))
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
if let onDelete {
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 22))
|
||||
if let onSetDefault, item.isPrimary == false {
|
||||
Button(action: onSetDefault) {
|
||||
Image(systemName: "star")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: onEdit != nil || onDelete != nil ? 40 : 0)
|
||||
.frame(width: hasActions ? 40 : 0)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 20)
|
||||
|
||||
@@ -12,6 +12,7 @@ struct AddressesView: View {
|
||||
@State var editingAddress: CustomerAddress? = nil
|
||||
@State var openSwipeRowId: String? = nil
|
||||
@State var deletingRowId: String? = nil
|
||||
@State var settingDefaultRowId: String? = nil
|
||||
|
||||
let tabBarClearance: CGFloat = 96
|
||||
|
||||
@@ -54,7 +55,9 @@ struct AddressesView: View {
|
||||
} else {
|
||||
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
|
||||
let rowId = addressRowId(for: address, index: index)
|
||||
let isSelected: Bool = {
|
||||
let isPrimary: Bool = {
|
||||
if address.isDefault == true { return true }
|
||||
if addresses.contains(where: { $0.isDefault == true }) { return false }
|
||||
if let selectedId = appState.address.selectedId {
|
||||
return address.id == selectedId
|
||||
}
|
||||
@@ -65,10 +68,10 @@ struct AddressesView: View {
|
||||
Button {
|
||||
selectAddress(address)
|
||||
} label: {
|
||||
AddressCard(item: addressToListItem(address, isPrimary: isSelected))
|
||||
AddressCard(item: addressToListItem(address, isPrimary: isPrimary))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
} else if addresses.count > 1 {
|
||||
SwipeToDeleteAddressRow(
|
||||
rowId: rowId,
|
||||
openRowId: $openSwipeRowId,
|
||||
@@ -76,19 +79,30 @@ struct AddressesView: View {
|
||||
onDelete: { deleteAddress(address, rowId: rowId) }
|
||||
) {
|
||||
AddressCard(
|
||||
item: addressToListItem(address, isPrimary: isSelected),
|
||||
onEdit: { beginEditing(address) }
|
||||
item: addressToListItem(address, isPrimary: isPrimary),
|
||||
onEdit: { beginEditing(address) },
|
||||
onSetDefault: { setDefaultAddress(address, rowId: rowId) }
|
||||
)
|
||||
.appContentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if openSwipeRowId == rowId {
|
||||
openSwipeRowId = nil
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
if openSwipeRowId == rowId { openSwipeRowId = nil }
|
||||
})
|
||||
.opacity(settingDefaultRowId == rowId ? 0.6 : 1.0)
|
||||
}
|
||||
.id(rowId)
|
||||
.opacity(deletingRowId == rowId ? 0.6 : 1.0)
|
||||
.disabled(deletingRowId != nil)
|
||||
.disabled(deletingRowId != nil || settingDefaultRowId != nil)
|
||||
} else {
|
||||
AddressCard(
|
||||
item: addressToListItem(address, isPrimary: isPrimary),
|
||||
onEdit: { beginEditing(address) }
|
||||
)
|
||||
.overlay(alignment: .bottom) {
|
||||
Text("Ao menos um endereço deve permanecer")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted.opacity(0.7))
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,8 +244,65 @@ struct AddressesView: View {
|
||||
return "idx:\(index):\(address.label ?? ""):\(address.address ?? ""):\(address.number ?? "")"
|
||||
}
|
||||
|
||||
private func setDefaultAddress(_ address: CustomerAddress, rowId: String) {
|
||||
guard settingDefaultRowId == nil else { return }
|
||||
settingDefaultRowId = rowId
|
||||
openSwipeRowId = nil
|
||||
|
||||
Task {
|
||||
var resolvedId = address.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
if resolvedId.isEmpty {
|
||||
if let book = try? await ApiService().profile(forceRefresh: true).result?.addressBook {
|
||||
await MainActor.run { addresses = book }
|
||||
resolvedId = book.first {
|
||||
$0.address == address.address &&
|
||||
$0.number == address.number &&
|
||||
$0.zipCode == address.zipCode
|
||||
}?.id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
guard resolvedId.isEmpty == false else {
|
||||
await MainActor.run {
|
||||
settingDefaultRowId = nil
|
||||
SnackbarCenter.shared.show(title: "Não foi possível identificar o endereço.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await ApiService().setDefaultAddress(addressId: resolvedId)
|
||||
await MainActor.run {
|
||||
settingDefaultRowId = nil
|
||||
if response.error == false {
|
||||
addresses = addresses.map { addr in
|
||||
let isTarget = (addr.id ?? "") == resolvedId
|
||||
return CustomerAddress(
|
||||
id: addr.id, label: addr.label, address: addr.address,
|
||||
number: addr.number, complement: addr.complement,
|
||||
neighborhood: addr.neighborhood, city: addr.city,
|
||||
state: addr.state, zipCode: addr.zipCode,
|
||||
latLong: addr.latLong, isDefault: isTarget
|
||||
)
|
||||
}
|
||||
selectAddress(address)
|
||||
SnackbarCenter.shared.show(title: "Endereço principal atualizado.", style: .success, icon: "star.fill", duration: 2.5)
|
||||
} else {
|
||||
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível definir endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
settingDefaultRowId = nil
|
||||
SnackbarCenter.shared.show(title: "Erro ao atualizar endereço principal.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteAddress(_ address: CustomerAddress, rowId: String) {
|
||||
guard deletingRowId == nil else { return }
|
||||
guard addresses.count > 1, deletingRowId == nil else { return }
|
||||
deletingRowId = rowId
|
||||
openSwipeRowId = nil
|
||||
|
||||
@@ -302,7 +373,7 @@ struct AddressesView: View {
|
||||
|
||||
do {
|
||||
let service = ApiService()
|
||||
let response = try await service.profile()
|
||||
let response = try await service.profile(forceRefresh: true)
|
||||
guard response.error == false else {
|
||||
errorMessage = response.message ?? "Não foi possível carregar os endereços."
|
||||
isLoading = false
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
|
||||
struct CartView: View {
|
||||
@Binding var appState: AppState
|
||||
@Binding var selectedTab: MainTab
|
||||
@State var openCheckout = false
|
||||
@State var couponCode = ""
|
||||
@State var appliedCouponCode: String? = nil
|
||||
@@ -49,7 +50,7 @@ struct CartView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationDestination(isPresented: $openCheckout) {
|
||||
CheckoutView(appState: $appState)
|
||||
CheckoutView(appState: $appState, selectedTab: $selectedTab)
|
||||
}
|
||||
.task(id: deliveryFeeWatchKey) {
|
||||
await refreshDeliveryFee()
|
||||
|
||||
@@ -234,7 +234,13 @@ extension CheckoutView {
|
||||
return
|
||||
}
|
||||
|
||||
let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod)
|
||||
// Crédito pelo app → abre seleção de cartão antes de criar pedido
|
||||
if useInAppPayment && effectivePaymentMethod == .creditCard {
|
||||
showCardSelectionSheet = true
|
||||
return
|
||||
}
|
||||
|
||||
let payloadBuildResult = buildCreateOrderPayload(paymentMethod: effectivePaymentMethod, savedCardId: nil)
|
||||
guard case .success(let payload) = payloadBuildResult else {
|
||||
let message: String
|
||||
if case .failure(let reason) = payloadBuildResult {
|
||||
@@ -251,83 +257,31 @@ extension CheckoutView {
|
||||
|
||||
do {
|
||||
let response = try await ApiService().createOrder(storeId: storeId, payload: payload)
|
||||
if response.error {
|
||||
SnackbarCenter.shared.show(
|
||||
title: response.message ?? "Não foi possível criar o pedido.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let result = response.result else {
|
||||
SnackbarCenter.shared.show(title: "Resposta de pagamento inválida.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
|
||||
return
|
||||
}
|
||||
|
||||
let orderSnapshot = result.asPublicOrderResult()
|
||||
SessionStateStore.saveTrackedOrder(orderSnapshot)
|
||||
|
||||
let orderId = result.id ?? UUID().uuidString
|
||||
let isInAppMethod = availableInAppPaymentMethods.contains(effectivePaymentMethod)
|
||||
if useInAppPayment == false || isInAppMethod == false {
|
||||
if response.error == false, let result = response.result {
|
||||
let orderId = result.id ?? UUID().uuidString
|
||||
appState.cart.clear()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
|
||||
} else {
|
||||
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if orderSnapshot.isPaymentConfirmed {
|
||||
appState.cart.clear()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
orderTrackingContext = OrderTrackingContext(orderId: orderId, shortId: result.shortId)
|
||||
return
|
||||
}
|
||||
|
||||
SessionStateStore.savePendingCartOrderId(orderId)
|
||||
|
||||
if effectivePaymentMethod == .creditCard {
|
||||
cardPaymentContext = CardPaymentContext(
|
||||
orderId: orderId,
|
||||
shortId: result.shortId,
|
||||
total: totalValue
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let pixFromPayment = result.payment?.pix
|
||||
let pixFromPayload = result.paymentPayload
|
||||
|
||||
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.copyPaste
|
||||
: pixFromPayload?.copyPaste
|
||||
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.qrCodeImage
|
||||
: pixFromPayload?.qrCodeImage
|
||||
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.expirationDate
|
||||
: pixFromPayload?.expirationDate
|
||||
|
||||
guard let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
|
||||
SnackbarCenter.shared.show(title: "Código PIX não retornado pela API.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
|
||||
return
|
||||
}
|
||||
|
||||
pixPaymentContext = PixPaymentContext(
|
||||
id: orderId,
|
||||
orderId: orderId,
|
||||
shortId: result.shortId,
|
||||
copyPaste: copyPaste,
|
||||
qrCodeImageBase64: qrCodeImage,
|
||||
expirationDate: expirationDate
|
||||
)
|
||||
handleOrderResponse(response, effectivePaymentMethod: effectivePaymentMethod)
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(title: "Não foi possível criar o pedido.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
|
||||
}
|
||||
}
|
||||
|
||||
func buildCreateOrderPayload(paymentMethod: CheckoutPaymentMethod) -> Result<CreateOrderPayload, CheckoutPayloadValidationError> {
|
||||
func buildCreateOrderPayload(
|
||||
paymentMethod: CheckoutPaymentMethod,
|
||||
savedCardId: String? = nil,
|
||||
creditCard: CreditCardOrderPayload? = nil,
|
||||
creditCardHolderInfo: SaveCardHolderInfoPayload? = nil,
|
||||
clientCpfCnpj: String? = nil
|
||||
) -> Result<CreateOrderPayload, CheckoutPayloadValidationError> {
|
||||
guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) }
|
||||
|
||||
let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -370,8 +324,91 @@ extension CheckoutView {
|
||||
total: totalValue,
|
||||
paymentMethod: paymentMethod.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ struct MainTabView: View {
|
||||
}
|
||||
case .cart:
|
||||
NavigationStack {
|
||||
CartView(appState: $appState)
|
||||
CartView(appState: $appState, selectedTab: $selectedTab)
|
||||
}
|
||||
case .profile:
|
||||
NavigationStack {
|
||||
|
||||
@@ -4,10 +4,20 @@ struct OrderDetailsView: View {
|
||||
let order: PublicOrderResult
|
||||
let orderId: String
|
||||
let initialShortId: String?
|
||||
@Binding var appState: AppState
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.openURL) var openURL
|
||||
@State private var storeContactPhone: String? = nil
|
||||
@State private var resolvedStoreLogoURL: String? = nil
|
||||
@State private var showCallAlert = false
|
||||
@State private var navigateToStore = false
|
||||
@State private var showClearCartAlert = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 14) {
|
||||
screenHeader
|
||||
statusCard
|
||||
storeCard
|
||||
itemsCard
|
||||
@@ -22,8 +32,44 @@ struct OrderDetailsView: View {
|
||||
.padding(.bottom, UIDevice.bottomNotch)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Detalhes do Pedido")
|
||||
.appInlineNavigationTitle()
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.navigationDestination(isPresented: $navigateToStore) {
|
||||
if let storeId = order.storeId, storeId.isEmpty == false {
|
||||
StoreDetailView(
|
||||
storeId: storeId,
|
||||
storeName: order.storeName ?? "Loja",
|
||||
storeCoverURL: nil,
|
||||
storeLogoURL: order.storeLogoURL,
|
||||
storeCategory: nil,
|
||||
storeRating: nil,
|
||||
storeDistance: nil,
|
||||
storeDeliveryFee: nil,
|
||||
appState: $appState
|
||||
)
|
||||
}
|
||||
}
|
||||
.alert("Ligar para a loja?", isPresented: $showCallAlert) {
|
||||
Button("Ligar para \(order.storeName ?? "a loja")") {
|
||||
if let phone = storeContactPhone {
|
||||
openTel(phone)
|
||||
}
|
||||
}
|
||||
Button("Cancelar", role: .cancel) {}
|
||||
} message: {
|
||||
Text("WhatsApp não encontrado. Deseja ligar para \(order.storeName ?? "a loja")?")
|
||||
}
|
||||
.task {
|
||||
await loadStoreContactPhone()
|
||||
}
|
||||
.alert("Substituir carrinho?", isPresented: $showClearCartAlert) {
|
||||
Button("Limpar e adicionar", role: .destructive) {
|
||||
applyReorder(clearFirst: true)
|
||||
}
|
||||
Button("Cancelar", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Seu carrinho tem itens de \(appState.cart.storeName ?? appState.cart.storeId ?? "outra loja"). Deseja limpar e adicionar itens de \(order.storeName ?? "esta loja")?")
|
||||
}
|
||||
.appBottomSafeAreaInset {
|
||||
VStack {
|
||||
reorderButton
|
||||
@@ -35,6 +81,27 @@ struct OrderDetailsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Detalhes do Pedido")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusCard: some View {
|
||||
HStack(spacing: 14) {
|
||||
Circle()
|
||||
@@ -54,6 +121,26 @@ struct OrderDetailsView: View {
|
||||
Text(statusDateText)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
if let reason = cancellationReasonText {
|
||||
Text(reason)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
if let addr = deliveryAddressSummary {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Text(addr)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
@@ -64,8 +151,13 @@ struct OrderDetailsView: View {
|
||||
}
|
||||
|
||||
private var storeCard: some View {
|
||||
Button {
|
||||
if order.storeId?.isEmpty == false {
|
||||
navigateToStore = true
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL))
|
||||
AsyncStoreImage(imageURL: resolvedMediaURL(resolvedStoreLogoURL ?? order.storeLogoURL))
|
||||
.frame(width: 54, height: 54)
|
||||
.clipShape(Circle())
|
||||
.background(AppColors.brandSoft, in: Circle())
|
||||
@@ -81,14 +173,18 @@ struct OrderDetailsView: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if order.storeId?.isEmpty == false {
|
||||
Text("Ver loja")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(Color(hex: "#A5D645"))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var itemsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
@@ -216,28 +312,29 @@ struct OrderDetailsView: View {
|
||||
|
||||
private var reorderButton: some View {
|
||||
Button("Pedir Novamente") {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Recompra será integrada com o catálogo em breve.",
|
||||
style: .info,
|
||||
icon: "cart.badge.plus",
|
||||
duration: 2.0
|
||||
)
|
||||
reorder()
|
||||
}
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(Color(hex: "#0E1A06"))
|
||||
.frame(maxWidth: .infinity, minHeight: 56)
|
||||
.background(Color(hex: "#C8F06E"))
|
||||
.background(order.items.isEmpty ? Color(hex: "#C8F06E").opacity(0.45) : Color(hex: "#C8F06E"))
|
||||
.clipShape(Capsule())
|
||||
.buttonStyle(.plain)
|
||||
.disabled(order.items.isEmpty)
|
||||
}
|
||||
|
||||
private var helpFooter: some View {
|
||||
Button {
|
||||
handleHelpTap()
|
||||
} label: {
|
||||
Text("Precisa de ajuda com esse pedido?")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(Color(hex: "#A5D645"))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var subtotal: Double {
|
||||
order.items.reduce(0) { partial, item in
|
||||
@@ -296,6 +393,24 @@ struct OrderDetailsView: View {
|
||||
return "Pedido #\(displayOrderTitle)"
|
||||
}
|
||||
|
||||
private var deliveryAddressSummary: String? {
|
||||
if let full = order.fullAddress, full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
return full.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
let line1 = deliveryAddressLine
|
||||
let line2 = deliveryAddressLine2
|
||||
let combined = [line1, line2].filter { $0.isEmpty == false }.joined(separator: ", ")
|
||||
return combined.isEmpty ? nil : combined
|
||||
}
|
||||
|
||||
private var cancellationReasonText: String? {
|
||||
guard statusTitle.contains("cancelado"),
|
||||
let reason = order.cancellationReason,
|
||||
reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
else { return nil }
|
||||
return "Motivo: \(reason.trimmingCharacters(in: .whitespacesAndNewlines))"
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
let status = normalized(order.status)
|
||||
if status.contains("CANCEL") { return "Pedido cancelado" }
|
||||
@@ -309,6 +424,12 @@ struct OrderDetailsView: View {
|
||||
}
|
||||
|
||||
private var statusDateText: String {
|
||||
if let event = order.timeline.first,
|
||||
let date = event.date, date.isEmpty == false {
|
||||
let time = event.time.flatMap { $0.isEmpty ? nil : $0 }
|
||||
let combined = time.map { "\(date) às \($0)" } ?? date
|
||||
return "\(statusDatePrefix) \(combined)"
|
||||
}
|
||||
if let formatted = formatDate(order.updatedAt ?? order.createdAt) {
|
||||
return "\(statusDatePrefix) \(formatted)"
|
||||
}
|
||||
@@ -342,14 +463,81 @@ struct OrderDetailsView: View {
|
||||
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
private func reorder() {
|
||||
guard order.items.isEmpty == false else { return }
|
||||
let cartStoreId = appState.cart.storeId ?? appState.cart.items.first?.storeId ?? ""
|
||||
let orderStoreId = order.storeId ?? ""
|
||||
let cartHasDifferentStore = cartStoreId.isEmpty == false
|
||||
&& orderStoreId.isEmpty == false
|
||||
&& cartStoreId != orderStoreId
|
||||
&& appState.cart.items.isEmpty == false
|
||||
if cartHasDifferentStore {
|
||||
showClearCartAlert = true
|
||||
} else {
|
||||
applyReorder(clearFirst: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyReorder(clearFirst: Bool) {
|
||||
if clearFirst {
|
||||
appState.cart.clear()
|
||||
}
|
||||
let storeId = order.storeId ?? ""
|
||||
if appState.cart.storeId == nil || appState.cart.storeId?.isEmpty == true {
|
||||
appState.cart.storeId = storeId
|
||||
appState.cart.storeName = order.storeName
|
||||
}
|
||||
var addedCount = 0
|
||||
for item in order.items {
|
||||
let name = item.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard name.isEmpty == false else { continue }
|
||||
let qty = max(1, item.qty ?? 1)
|
||||
let price = item.price ?? 0
|
||||
let cartItem = CartItemState(
|
||||
id: UUID().uuidString,
|
||||
productId: item.productId ?? item.id,
|
||||
storeId: storeId,
|
||||
name: name,
|
||||
imageURL: nil,
|
||||
details: nil,
|
||||
addons: [],
|
||||
quantity: qty,
|
||||
unitPrice: price
|
||||
)
|
||||
appState.cart.add(item: cartItem)
|
||||
addedCount += qty
|
||||
}
|
||||
let label = addedCount == 1 ? "1 item adicionado ao carrinho." : "\(addedCount) itens adicionados ao carrinho."
|
||||
SnackbarCenter.shared.show(title: label, style: .success, icon: "cart.badge.plus", duration: 2.5)
|
||||
}
|
||||
|
||||
private func formatDate(_ isoValue: String?) -> String? {
|
||||
guard let isoValue, isoValue.isEmpty == false else { return nil }
|
||||
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
var date = iso.date(from: isoValue)
|
||||
let optionSets: [ISO8601DateFormatter.Options] = [
|
||||
[.withInternetDateTime, .withFractionalSeconds],
|
||||
[.withInternetDateTime],
|
||||
[.withFullDate, .withTime, .withColonSeparatorInTime],
|
||||
[.withFullDate, .withTime, .withColonSeparatorInTime, .withTimeZone],
|
||||
[.withFullDate]
|
||||
]
|
||||
var date: Date? = nil
|
||||
for options in optionSets {
|
||||
iso.formatOptions = options
|
||||
if let d = iso.date(from: isoValue) {
|
||||
date = d
|
||||
break
|
||||
}
|
||||
}
|
||||
if date == nil {
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
date = iso.date(from: isoValue)
|
||||
let fallback = DateFormatter()
|
||||
fallback.locale = Locale(identifier: "en_US_POSIX")
|
||||
for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSZ", "yyyy-MM-dd'T'HH:mm:ssZ",
|
||||
"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd"] {
|
||||
fallback.dateFormat = fmt
|
||||
if let d = fallback.date(from: isoValue) { date = d; break }
|
||||
}
|
||||
}
|
||||
guard let date else { return nil }
|
||||
|
||||
@@ -359,6 +547,64 @@ struct OrderDetailsView: View {
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadStoreContactPhone() async {
|
||||
if let inline = order.storePhone, inline.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
storeContactPhone = inline
|
||||
}
|
||||
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
storeId.isEmpty == false else { return }
|
||||
do {
|
||||
let response = try await ApiService().storeInfo(storeId: storeId)
|
||||
if response.error == false, let result = response.result {
|
||||
let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if phone.isEmpty == false {
|
||||
storeContactPhone = phone
|
||||
}
|
||||
if let logo = result.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
resolvedStoreLogoURL = logo
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private func handleHelpTap() {
|
||||
guard let phoneRaw = storeContactPhone,
|
||||
phoneRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Telefone da loja indisponível.",
|
||||
style: .warning,
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
duration: 2.8
|
||||
)
|
||||
return
|
||||
}
|
||||
if let waURL = makeWhatsAppURL(from: phoneRaw) {
|
||||
openURL(waURL)
|
||||
} else {
|
||||
showCallAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
private func openTel(_ phoneRaw: String) {
|
||||
let digits = phoneRaw.filter(\.isNumber)
|
||||
if digits.isEmpty { return }
|
||||
if let url = URL(string: "tel://\(digits)") {
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeWhatsAppURL(from phoneRaw: String) -> URL? {
|
||||
var digits = phoneRaw.filter(\.isNumber)
|
||||
if digits.isEmpty { return nil }
|
||||
if digits.hasPrefix("0") { digits = String(digits.drop(while: { $0 == "0" })) }
|
||||
if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) {
|
||||
digits = "55" + digits
|
||||
}
|
||||
guard digits.count >= 12 else { return nil }
|
||||
return URL(string: "https://wa.me/\(digits)")
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String {
|
||||
(value ?? "")
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
|
||||
@@ -12,6 +12,8 @@ private struct TrackingStep: Identifiable {
|
||||
struct OrderTrackingView: View {
|
||||
let orderId: String
|
||||
let initialShortId: String?
|
||||
var postOrderBack: (() -> Void)? = nil
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.openURL) var openURL
|
||||
|
||||
@State var isLoading = true
|
||||
@@ -27,6 +29,7 @@ struct OrderTrackingView: View {
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 16) {
|
||||
screenHeader
|
||||
topHeader
|
||||
orderTitleSection
|
||||
statusBanner
|
||||
@@ -43,8 +46,8 @@ struct OrderTrackingView: View {
|
||||
.padding(.bottom, UIDevice.bottomNotch + 45)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Pedido \(displayOrderTitle)")
|
||||
.appInlineNavigationTitle()
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
|
||||
Button("Fechar", role: .cancel) {}
|
||||
} message: {
|
||||
@@ -97,6 +100,33 @@ struct OrderTrackingView: View {
|
||||
self.reviewSavedObserver = nil
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Pedido \(displayOrderTitle)")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: {
|
||||
if let postOrderBack {
|
||||
postOrderBack()
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var topHeader: some View {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OrdersView: View {
|
||||
@Binding var appState: AppState
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State var isLoading = false
|
||||
@State var errorMessage: String? = nil
|
||||
@@ -8,6 +9,8 @@ struct OrdersView: View {
|
||||
@State var hasLoadedOnce = false
|
||||
@State var storeRatingByStoreId: [String: Double] = [:]
|
||||
@State var storeRatingByStoreName: [String: Double] = [:]
|
||||
@State var storeLogoByStoreId: [String: String] = [:]
|
||||
@State var storeLogoByStoreName: [String: String] = [:]
|
||||
@State var selectedOrderRoute: OrderRouteContext? = nil
|
||||
|
||||
var body: some View {
|
||||
@@ -58,7 +61,8 @@ struct OrdersView: View {
|
||||
initialShortId: context.shortId,
|
||||
fallbackPaymentMethod: context.paymentMethod,
|
||||
fallbackTotal: context.total,
|
||||
routeIntent: context.intent
|
||||
routeIntent: context.intent,
|
||||
appState: $appState
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -104,7 +108,7 @@ struct OrdersView: View {
|
||||
|
||||
return VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 12) {
|
||||
AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL))
|
||||
AsyncStoreImage(imageURL: resolvedMediaURL(storeLogoURL(for: order)))
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
|
||||
@@ -464,20 +468,25 @@ struct OrdersView: View {
|
||||
|
||||
var byId: [String: Double] = [:]
|
||||
var byName: [String: Double] = [:]
|
||||
var logoById: [String: String] = [:]
|
||||
var logoByName: [String: String] = [:]
|
||||
for store in storeList {
|
||||
guard let rating = store.rating, rating > 0 else { continue }
|
||||
let storeId = normalizedOrderId(store.id)
|
||||
if storeId.isEmpty == false {
|
||||
byId[storeId] = rating
|
||||
}
|
||||
let nameKey = normalizedStoreName(store.name)
|
||||
if nameKey.isEmpty == false {
|
||||
byName[nameKey] = rating
|
||||
if let rating = store.rating, rating > 0 {
|
||||
if storeId.isEmpty == false { byId[storeId] = rating }
|
||||
if nameKey.isEmpty == false { byName[nameKey] = rating }
|
||||
}
|
||||
if let logo = store.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
if storeId.isEmpty == false { logoById[storeId] = logo }
|
||||
if nameKey.isEmpty == false { logoByName[nameKey] = logo }
|
||||
}
|
||||
}
|
||||
|
||||
storeRatingByStoreId = byId
|
||||
storeRatingByStoreName = byName
|
||||
storeLogoByStoreId = logoById
|
||||
storeLogoByStoreName = logoByName
|
||||
}
|
||||
|
||||
private func storeRating(for order: AppOrderSummary) -> Double? {
|
||||
@@ -492,6 +501,17 @@ struct OrdersView: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func storeLogoURL(for order: AppOrderSummary) -> String? {
|
||||
if let url = order.storeLogoURL, url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
return url
|
||||
}
|
||||
let storeId = normalizedOrderId(order.storeId)
|
||||
if storeId.isEmpty == false, let logo = storeLogoByStoreId[storeId] { return logo }
|
||||
let nameKey = normalizedStoreName(order.storeName)
|
||||
if nameKey.isEmpty == false, let logo = storeLogoByStoreName[nameKey] { return logo }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolvedMediaURL(_ raw: String?) -> String? {
|
||||
ImageSourceResolver.resolve(raw)
|
||||
}
|
||||
@@ -629,11 +649,11 @@ struct OrderEntryDestinationView: View {
|
||||
let fallbackPaymentMethod: String?
|
||||
let fallbackTotal: Double?
|
||||
let routeIntent: OrderRouteIntent
|
||||
@Binding var appState: AppState
|
||||
|
||||
@State var isResolvingRoute = true
|
||||
@State var didResolve = false
|
||||
@State var pixContext: PixPaymentContext? = nil
|
||||
@State var cardContext: CardPaymentContext? = nil
|
||||
@State var orderTrackingContext: OrderTrackingContext? = nil
|
||||
@State var orderDetails: PublicOrderResult? = nil
|
||||
|
||||
@@ -651,6 +671,7 @@ struct OrderEntryDestinationView: View {
|
||||
} else if let pixContext {
|
||||
PaymentPixView(
|
||||
context: pixContext,
|
||||
appState: $appState,
|
||||
onPaymentConfirmed: {
|
||||
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
|
||||
},
|
||||
@@ -658,18 +679,8 @@ struct OrderEntryDestinationView: View {
|
||||
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
|
||||
}
|
||||
)
|
||||
} else if let cardContext {
|
||||
PaymentCardView(
|
||||
context: cardContext,
|
||||
onPaymentConfirmed: {
|
||||
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
|
||||
},
|
||||
onOpenTracking: {
|
||||
orderTrackingContext = OrderTrackingContext(orderId: cardContext.orderId, shortId: cardContext.shortId)
|
||||
}
|
||||
)
|
||||
} else if let orderDetails {
|
||||
OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId)
|
||||
OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId, appState: $appState)
|
||||
} else {
|
||||
OrderTrackingView(orderId: orderId, initialShortId: initialShortId)
|
||||
}
|
||||
@@ -712,24 +723,30 @@ struct OrderEntryDestinationView: View {
|
||||
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? pixFromPayment?.expirationDate
|
||||
: pixFromPayload?.expirationDate
|
||||
let storeId = order.storeId ?? ""
|
||||
pixContext = PixPaymentContext(
|
||||
id: order.id,
|
||||
orderId: order.id,
|
||||
shortId: order.shortId ?? initialShortId,
|
||||
storeId: storeId,
|
||||
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
? (copyPaste ?? "")
|
||||
: "Código PIX indisponível no momento. Aguarde e tente novamente.",
|
||||
qrCodeImageBase64: qrCodeImage,
|
||||
expirationDate: expirationDate
|
||||
expirationDate: expirationDate,
|
||||
total: order.total ?? 0,
|
||||
profileName: "",
|
||||
profileEmail: "",
|
||||
profilePhone: "",
|
||||
addressZip: nil,
|
||||
addressNumber: nil,
|
||||
deliveryType: order.deliveryType ?? "DELIVERY",
|
||||
itemsJSON: "[]"
|
||||
)
|
||||
return
|
||||
}
|
||||
if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" {
|
||||
cardContext = CardPaymentContext(
|
||||
orderId: order.id,
|
||||
shortId: order.shortId ?? initialShortId,
|
||||
total: order.total ?? fallbackTotal ?? 0
|
||||
)
|
||||
orderTrackingContext = OrderTrackingContext(orderId: order.id, shortId: order.shortId ?? initialShortId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ struct PizzaFlavorAddonsSheet: View {
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
screenHeader
|
||||
Text(flavor.name)
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
@@ -77,12 +78,28 @@ struct PizzaFlavorAddonsSheet: View {
|
||||
.padding(20)
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
.navigationTitle("Adicionais")
|
||||
.appInlineNavigationTitle()
|
||||
.appTopBarTrailingToolbar {
|
||||
Button("Concluir") { dismiss() }
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Adicionais")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,28 +2,39 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension PizzaProductDetailSheet {
|
||||
var stepSizes: some View {
|
||||
let sizeItems = sizes
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
Text("1. Tamanho")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
ForEach(0..<sizeItems.count, id: \.self) { index in
|
||||
sizeRow(sizeItems[index])
|
||||
// MARK: - Steps
|
||||
|
||||
var stepSizes: some View {
|
||||
accordionSection(
|
||||
step: 0,
|
||||
label: "Tamanho",
|
||||
summary: selectedSize.map { "\($0.name ?? "") • Até \(max(1, $0.maxFlavors ?? 1)) sabor(es)" }
|
||||
) {
|
||||
ForEach(sizes) { size in
|
||||
radioRow(
|
||||
title: size.name ?? "Tamanho",
|
||||
subtitle: "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)",
|
||||
isSelected: selectedSizeId == size.id
|
||||
) {
|
||||
selectedSizeId = size.id
|
||||
applyAutoSelections()
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedStep = nextStep(after: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
var stepDoughs: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("2. Massa")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
accordionSection(
|
||||
step: 1,
|
||||
label: "Massa",
|
||||
summary: doughs.count <= 1
|
||||
? (doughs.first?.name ?? "Tradicional")
|
||||
: doughs.first(where: { $0.id == selectedDoughId })?.name
|
||||
) {
|
||||
if doughs.count <= 1 {
|
||||
Text(doughs.first?.name ?? "Massa tradicional")
|
||||
.font(AppTypography.body)
|
||||
@@ -36,21 +47,24 @@ extension PizzaProductDetailSheet {
|
||||
isSelected: selectedDoughId == dough.id
|
||||
) {
|
||||
selectedDoughId = dough.id
|
||||
applyAutoSelections()
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedStep = nextStep(after: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
var stepCrusts: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("3. Borda")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
accordionSection(
|
||||
step: 2,
|
||||
label: "Borda",
|
||||
summary: crusts.count <= 1
|
||||
? crustDescription(crusts.first)
|
||||
: crusts.first(where: { $0.id == selectedCrustId }).map { crustDescription($0) }
|
||||
) {
|
||||
if crusts.count <= 1 {
|
||||
Text(crustDescription(crusts.first))
|
||||
.font(AppTypography.body)
|
||||
@@ -59,38 +73,40 @@ extension PizzaProductDetailSheet {
|
||||
ForEach(crusts) { crust in
|
||||
radioRow(
|
||||
title: crust.name ?? "Borda",
|
||||
subtitle: crust.priceModifier ?? 0 > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil,
|
||||
subtitle: (crust.priceModifier ?? 0) > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil,
|
||||
isSelected: selectedCrustId == crust.id
|
||||
) {
|
||||
selectedCrustId = crust.id
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedStep = 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
var stepFlavors: some View {
|
||||
let flavorItems = flavors
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
Text("4. Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
accordionSection(
|
||||
step: 3,
|
||||
label: "Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))",
|
||||
summary: selectedFlavorIds.isEmpty ? nil
|
||||
: selectedFlavorProducts.map(\.name).joined(separator: ", ")
|
||||
) {
|
||||
Text("Toque no sabor para escolher adicionais.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
ForEach(0..<flavorItems.count, id: \.self) { index in
|
||||
let flavor = flavorItems[index]
|
||||
ForEach(flavors) { flavor in
|
||||
let isSelected = selectedFlavorIds.contains(flavor.id)
|
||||
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
|
||||
let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed
|
||||
let disableSwitch = isSelected == false && maxReached
|
||||
|
||||
HStack(spacing: 10) {
|
||||
AsyncStoreImage(imageURL: resolveImageURL(flavor.image))
|
||||
.frame(width: 52, height: 52)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(flavor.name)
|
||||
.font(AppTypography.body)
|
||||
@@ -101,21 +117,15 @@ extension PizzaProductDetailSheet {
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: Binding(
|
||||
get: { isSelected },
|
||||
set: { value in
|
||||
if value {
|
||||
addFlavor(flavor.id)
|
||||
} else {
|
||||
removeFlavor(flavor.id)
|
||||
}
|
||||
if value { addFlavor(flavor.id) } else { removeFlavor(flavor.id) }
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.disabled(disableSwitch)
|
||||
.disabled(!isSelected && maxReached)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.appContentShape(Rectangle())
|
||||
@@ -126,23 +136,90 @@ extension PizzaProductDetailSheet {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
// MARK: - Accordion container
|
||||
|
||||
func accordionSection(
|
||||
step: Int,
|
||||
label: String,
|
||||
summary: String?,
|
||||
@ViewBuilder content: () -> some View
|
||||
) -> some View {
|
||||
let isExpanded = expandedStep == step
|
||||
let isDone = summary != nil
|
||||
|
||||
return VStack(spacing: 0) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
expandedStep = isExpanded ? -1 : step
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isDone || isExpanded ? AppColors.primary : AppColors.textMuted.opacity(0.25))
|
||||
.frame(width: 26, height: 26)
|
||||
if isDone && !isExpanded {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
Text("\(step + 1)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let summary, !isExpanded {
|
||||
Text(summary)
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: 140, alignment: .trailing)
|
||||
}
|
||||
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
Divider().padding(.horizontal, 14)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
content()
|
||||
}
|
||||
.padding(14)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
func sizeRow(_ size: StorePizzaSize) -> some View {
|
||||
let title = size.name ?? "Tamanho"
|
||||
let subtitle = "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)"
|
||||
let selected = selectedSizeId == size.id
|
||||
// MARK: - Helpers
|
||||
|
||||
return radioRow(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
isSelected: selected
|
||||
) {
|
||||
selectedSizeId = size.id
|
||||
func nextStep(after step: Int) -> Int {
|
||||
if step == 0 {
|
||||
if doughs.count > 1 { return 1 }
|
||||
if crusts.count > 1 { return 2 }
|
||||
return 3
|
||||
}
|
||||
if step == 1 {
|
||||
if crusts.count > 1 { return 2 }
|
||||
return 3
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func radioRow(title: String, subtitle: String?, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
@@ -158,7 +235,6 @@ extension PizzaProductDetailSheet {
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(AppTypography.body)
|
||||
@@ -169,7 +245,6 @@ extension PizzaProductDetailSheet {
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.appContentShape(Rectangle())
|
||||
@@ -181,40 +256,29 @@ extension PizzaProductDetailSheet {
|
||||
guard let crust else { return "Sem borda especial" }
|
||||
let name = crust.name ?? "Borda"
|
||||
let modifier = crust.priceModifier ?? 0
|
||||
if modifier > 0 {
|
||||
return "\(name) (+ \(formatCurrency(modifier)))"
|
||||
}
|
||||
return name
|
||||
return modifier > 0 ? "\(name) (+ \(formatCurrency(modifier)))" : name
|
||||
}
|
||||
|
||||
func applyAutoSelections() {
|
||||
if selectedSizeId != nil {
|
||||
if doughs.count == 1 {
|
||||
selectedDoughId = doughs.first?.id
|
||||
} else if doughs.isEmpty {
|
||||
selectedDoughId = "__none__"
|
||||
if doughs.count == 1 { selectedDoughId = doughs.first?.id }
|
||||
else if doughs.isEmpty { selectedDoughId = "__none__" }
|
||||
}
|
||||
}
|
||||
|
||||
if isDoughReady {
|
||||
if crusts.count == 1 {
|
||||
selectedCrustId = crusts.first?.id
|
||||
} else if crusts.isEmpty {
|
||||
selectedCrustId = "__none__"
|
||||
}
|
||||
if crusts.count == 1 { selectedCrustId = crusts.first?.id }
|
||||
else if crusts.isEmpty { selectedCrustId = "__none__" }
|
||||
}
|
||||
}
|
||||
|
||||
func trimFlavorSelectionByLimit() {
|
||||
let limit = maxFlavorsAllowed
|
||||
guard selectedFlavorIds.count > limit else { return }
|
||||
let sorted = selectedFlavorIds.sorted()
|
||||
selectedFlavorIds = Set(sorted.prefix(limit))
|
||||
selectedFlavorIds = Set(selectedFlavorIds.sorted().prefix(limit))
|
||||
}
|
||||
|
||||
func addFlavor(_ flavorId: String) {
|
||||
if selectedFlavorIds.contains(flavorId) { return }
|
||||
if selectedFlavorIds.count >= maxFlavorsAllowed { return }
|
||||
guard !selectedFlavorIds.contains(flavorId),
|
||||
selectedFlavorIds.count < maxFlavorsAllowed else { return }
|
||||
selectedFlavorIds.insert(flavorId)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ struct PizzaProductDetailSheet: View {
|
||||
@State var flavorAddonQuantities: [String: [String: Int]] = [:]
|
||||
@State var selectedFlavorForAddons: StoreCatalogProduct? = nil
|
||||
@State var quantity: Int = 1
|
||||
@State var expandedStep: Int = 0
|
||||
|
||||
var flavors: [StoreCatalogProduct] {
|
||||
category.products
|
||||
@@ -45,7 +46,7 @@ struct PizzaProductDetailSheet: View {
|
||||
return resolveImageURL(firstImage)
|
||||
}
|
||||
|
||||
private var selectedSize: StorePizzaSize? {
|
||||
var selectedSize: StorePizzaSize? {
|
||||
guard let selectedSizeId else { return nil }
|
||||
return sizes.first(where: { $0.id == selectedSizeId })
|
||||
}
|
||||
@@ -76,7 +77,7 @@ struct PizzaProductDetailSheet: View {
|
||||
isCrustReady
|
||||
}
|
||||
|
||||
private var selectedFlavorProducts: [StoreCatalogProduct] {
|
||||
var selectedFlavorProducts: [StoreCatalogProduct] {
|
||||
flavors
|
||||
.filter { selectedFlavorIds.contains($0.id) }
|
||||
.sorted { $0.name < $1.name }
|
||||
@@ -102,12 +103,12 @@ struct PizzaProductDetailSheet: View {
|
||||
}
|
||||
|
||||
private var basePizzaPrice: Double {
|
||||
selectedFlavorProducts
|
||||
.map { flavor in
|
||||
let prices = selectedFlavorProducts.map { flavor in
|
||||
guard let selectedSizeId else { return flavor.price ?? 0 }
|
||||
return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0
|
||||
}
|
||||
.max() ?? 0
|
||||
guard prices.isEmpty == false else { return 0 }
|
||||
return prices.reduce(0, +) / Double(prices.count)
|
||||
}
|
||||
|
||||
private var unitPrice: Double {
|
||||
@@ -179,6 +180,34 @@ struct PizzaProductDetailSheet: View {
|
||||
return chunks.isEmpty ? nil : chunks.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private var pizzaChoices: [String] {
|
||||
var choices: [String] = []
|
||||
if let sizeName = selectedSize?.name {
|
||||
let sizePrice = basePizzaPrice
|
||||
if sizePrice > 0 {
|
||||
choices.append("Tamanho: \(sizeName) (+\(formatCurrency(sizePrice)))")
|
||||
} else {
|
||||
choices.append("Tamanho: \(sizeName)")
|
||||
}
|
||||
}
|
||||
if let crustName = selectedCrust?.name, crustName.isEmpty == false {
|
||||
let mod = crustPriceModifier
|
||||
if mod > 0 {
|
||||
choices.append("Borda: \(crustName) (+\(formatCurrency(mod)))")
|
||||
} else {
|
||||
choices.append("Borda: \(crustName)")
|
||||
}
|
||||
}
|
||||
if let doughName = selectedDoughName, doughName.isEmpty == false {
|
||||
choices.append("Massa: \(doughName)")
|
||||
}
|
||||
let flavorCount = selectedFlavorProducts.count
|
||||
for flavor in selectedFlavorProducts {
|
||||
choices.append(flavorCount > 1 ? "Meio \(flavor.name)" : flavor.name)
|
||||
}
|
||||
return choices
|
||||
}
|
||||
|
||||
private var addButtonTitle: String {
|
||||
if canConfirm == false {
|
||||
return "Selecione as opções"
|
||||
@@ -189,11 +218,20 @@ struct PizzaProductDetailSheet: View {
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
AsyncStoreImage(imageURL: representativeImage)
|
||||
screenHeader
|
||||
Rectangle()
|
||||
.fill(AppColors.brandSoft)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 220)
|
||||
.overlay(
|
||||
Image("placeholder-pizza")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.clipped()
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
|
||||
Text("Pizza de varios sabores")
|
||||
Text("Escolha seu sabor")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
@@ -206,18 +244,9 @@ struct PizzaProductDetailSheet: View {
|
||||
.foregroundStyle(AppColors.primary)
|
||||
|
||||
stepSizes
|
||||
|
||||
if selectedSizeId != nil {
|
||||
stepDoughs
|
||||
}
|
||||
|
||||
if isDoughReady {
|
||||
stepCrusts
|
||||
}
|
||||
|
||||
if canShowFlavors {
|
||||
stepFlavors
|
||||
}
|
||||
if selectedSizeId != nil { stepDoughs }
|
||||
if isDoughReady { stepCrusts }
|
||||
if canShowFlavors { stepFlavors }
|
||||
}
|
||||
.padding(20)
|
||||
.padding(.bottom, 90)
|
||||
@@ -262,9 +291,10 @@ struct PizzaProductDetailSheet: View {
|
||||
id: cartItemId,
|
||||
productId: selectedFlavorProducts.first?.id ?? category.id,
|
||||
storeId: storeId,
|
||||
name: "Pizza de varios sabores",
|
||||
name: "Escolha seu sabor",
|
||||
imageURL: representativeImage,
|
||||
details: selectedDetailsText,
|
||||
choices: pizzaChoices.isEmpty ? nil : pizzaChoices,
|
||||
addons: selectedAddonsPayload,
|
||||
quantity: quantity,
|
||||
unitPrice: unitPrice
|
||||
@@ -291,12 +321,8 @@ struct PizzaProductDetailSheet: View {
|
||||
}
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
.navigationTitle("Monte sua pizza")
|
||||
.appInlineNavigationTitle()
|
||||
.appTopBarTrailingToolbar {
|
||||
Button("Fechar") { dismiss() }
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.onAppear {
|
||||
applyAutoSelections()
|
||||
let existing = currentQuantityForItemId(cartItemId)
|
||||
@@ -314,4 +340,25 @@ struct PizzaProductDetailSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Monte sua pizza")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ struct ProductDetailSheet: View {
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
screenHeader
|
||||
AsyncStoreImage(imageURL: imageURL)
|
||||
.frame(height: 220)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
@@ -221,12 +222,8 @@ struct ProductDetailSheet: View {
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||
.navigationTitle("Detalhes")
|
||||
.appInlineNavigationTitle()
|
||||
.appTopBarTrailingToolbar {
|
||||
Button("Fechar") { dismiss() }
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.onAppear {
|
||||
let existing = currentQuantityForItemId(cartItemId)
|
||||
quantity = existing > 0 ? existing : 1
|
||||
@@ -246,6 +243,27 @@ struct ProductDetailSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Detalhes")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatCurrency(_ value: Double) -> String {
|
||||
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ struct ProfileView: View {
|
||||
@State var openAddressesOnboarding = false
|
||||
@State var onboardingMessage: String? = nil
|
||||
@State var showLogoutAlert = false
|
||||
@State private var openOrders = false
|
||||
let tabBarClearance: CGFloat = 120
|
||||
|
||||
var body: some View {
|
||||
@@ -28,7 +29,7 @@ struct ProfileView: View {
|
||||
|
||||
VStack(spacing: 14) {
|
||||
NavigationLink {
|
||||
OrdersView()
|
||||
OrdersView(appState: $appState)
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos")
|
||||
}
|
||||
@@ -41,12 +42,12 @@ struct ProfileView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// NavigationLink {
|
||||
// Text("Meus Cartões")
|
||||
// } label: {
|
||||
// ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões")
|
||||
// }
|
||||
// .buttonStyle(.plain)
|
||||
NavigationLink {
|
||||
SavedCardsView(appState: $appState)
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
NavigationLink {
|
||||
MyReviewsView()
|
||||
@@ -55,12 +56,14 @@ struct ProfileView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// NavigationLink {
|
||||
// Text("Cupons de Desconto")
|
||||
// } label: {
|
||||
// ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO")
|
||||
// }
|
||||
// .buttonStyle(.plain)
|
||||
if appState.featureFlags.isEnabled("at.cupons") {
|
||||
NavigationLink {
|
||||
Text("Cupons de Desconto")
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// NavigationLink {
|
||||
// Text("Ajuda")
|
||||
@@ -120,6 +123,15 @@ struct ProfileView: View {
|
||||
AddressesView(message: onboardingMessage, appState: $appState)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $openOrders) {
|
||||
OrdersView(appState: $appState)
|
||||
}
|
||||
.onChange(of: appState.shouldNavigateToOrders) { _, val in
|
||||
if val {
|
||||
appState.shouldNavigateToOrders = false
|
||||
openOrders = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
|
||||
@@ -24,6 +24,8 @@ struct MyReviewsView: View {
|
||||
@State var isLoading = false
|
||||
@State var loadError: String? = nil
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
init(initialOrder: ReviewDraft? = nil) {
|
||||
self.initialOrder = initialOrder
|
||||
}
|
||||
@@ -31,6 +33,7 @@ struct MyReviewsView: View {
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 12) {
|
||||
screenHeader
|
||||
if isLoading && reviews.isEmpty && pendingReviews.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -66,8 +69,8 @@ struct MyReviewsView: View {
|
||||
.padding(.bottom, UIDevice.bottomNotch + 30)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Minhas Avaliações")
|
||||
.appInlineNavigationTitle()
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.navigationDestination(item: $selectedDraft) { draft in
|
||||
OrderReviewView(draft: draft) {
|
||||
Task { await loadReviewsFromBackend(forceRefresh: true) }
|
||||
@@ -86,6 +89,27 @@ struct MyReviewsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Minhas Avaliações")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text("Você não tem avaliações nem pendências no momento.")
|
||||
@@ -434,6 +458,7 @@ struct OrderReviewView: View {
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
screenHeader
|
||||
topSection
|
||||
orderItemsSection
|
||||
if existingReview != nil {
|
||||
@@ -450,8 +475,8 @@ struct OrderReviewView: View {
|
||||
.padding(.bottom, UIDevice.bottomNotch + 140)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Avaliar Pedido")
|
||||
.appInlineNavigationTitle()
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.onAppear {
|
||||
if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) {
|
||||
existingReview = cachedReview
|
||||
@@ -488,6 +513,27 @@ struct OrderReviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Avaliar Pedido")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var newReviewContent: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
starsSection
|
||||
@@ -802,6 +848,7 @@ struct OrderReviewView: View {
|
||||
.frame(height: 110)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
|
||||
244
pedi-foods/Sources/PediFoods/Views/Main/SavedCardsView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ struct ScrollOffsetReader: View {
|
||||
)
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in
|
||||
if baseline == nil { baseline = minY }
|
||||
let offset = max(0, (baseline ?? 0) - minY)
|
||||
let offset = (baseline ?? 0) - minY
|
||||
if abs(offsetY - offset) > 0.5 {
|
||||
offsetY = offset
|
||||
}
|
||||
@@ -47,6 +47,7 @@ struct ScrollOffsetReader: View {
|
||||
|
||||
struct AsyncStoreImage: View {
|
||||
let imageURL: String?
|
||||
var fallbackImageName: String = "placeholder-product"
|
||||
|
||||
var body: some View {
|
||||
CachedRemoteImage(imageURL: imageURL) {
|
||||
@@ -58,7 +59,7 @@ struct AsyncStoreImage: View {
|
||||
}
|
||||
|
||||
private var fallback: some View {
|
||||
Image("placeholder-product")
|
||||
Image(fallbackImageName)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
}
|
||||
|
||||
@@ -26,15 +26,18 @@ extension StoreDetailView {
|
||||
var heroSection: some View {
|
||||
ZStack(alignment: .top) {
|
||||
AsyncStoreImage(imageURL: resolvedURL(storeCoverURL))
|
||||
.frame(height: topSectionHeight)
|
||||
.overlay(
|
||||
.frame(height: topSectionHeight + stretchAmount)
|
||||
.offset(y: -stretchAmount)
|
||||
.ignoresSafeArea(.container, edges: .top)
|
||||
|
||||
LinearGradient(
|
||||
colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.frame(height: topSectionHeight)
|
||||
.ignoresSafeArea(.container, edges: .top)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
@@ -266,7 +269,15 @@ extension StoreDetailView {
|
||||
Spacer()
|
||||
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if item.isPizzaSummary {
|
||||
Image("placeholder-pizza")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
AsyncStoreImage(imageURL: resolvedURL(item.imageURL))
|
||||
}
|
||||
}
|
||||
.frame(width: 92, height: 92)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
|
||||
|
||||
@@ -286,7 +286,7 @@ extension StoreDetailView {
|
||||
StoreCatalogListItem(
|
||||
id: "\(category.id)::pizza-summary",
|
||||
product: first,
|
||||
title: "Pizza de varios sabores",
|
||||
title: "Escolha seu sabor",
|
||||
description: "Escolha o tamanho da sua fome",
|
||||
imageURL: representativeImage ?? first.image,
|
||||
isPizzaSummary: true,
|
||||
|
||||
@@ -38,6 +38,7 @@ struct StoreDetailView: View {
|
||||
@State var scrollOffset: CGFloat = 0
|
||||
|
||||
var isCategoryTabsPinned: Bool { scrollOffset >= topSectionHeight }
|
||||
var stretchAmount: CGFloat { max(0, -scrollOffset) }
|
||||
|
||||
let cardTopInset: CGFloat = 180
|
||||
let summaryCardBaseHeight: CGFloat = 212
|
||||
@@ -45,17 +46,24 @@ struct StoreDetailView: View {
|
||||
let coverVisibleUntilY: CGFloat = 253
|
||||
let storeLogoSize: CGFloat = 84
|
||||
|
||||
var safeAreaTop: CGFloat {
|
||||
#if canImport(UIKit)
|
||||
return UIDevice.appSafeAreaTop
|
||||
#else
|
||||
return 0
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack(alignment: .top) {
|
||||
ScrollView(showsIndicators: false) {
|
||||
ScrollOffsetReader(offsetY: $scrollOffset)
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
LazyVStack(spacing: 0) {
|
||||
topSection
|
||||
Section {
|
||||
categoryTabs(proxy: proxy, isPinned: false)
|
||||
.opacity(isCategoryTabsPinned ? 0 : 1)
|
||||
sectionedProducts
|
||||
} header: {
|
||||
categoryTabs(proxy: proxy, isPinned: isCategoryTabsPinned)
|
||||
}
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
|
||||
@@ -64,6 +72,13 @@ struct StoreDetailView: View {
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.background(AppColors.backgroundLight)
|
||||
|
||||
categoryTabs(proxy: proxy, isPinned: true)
|
||||
.opacity(isCategoryTabsPinned ? 1 : 0)
|
||||
.allowsHitTesting(isCategoryTabsPinned)
|
||||
.zIndex(10)
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.saturation(isStoreOpen ? 1 : 0)
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
|
||||
@@ -11,6 +11,7 @@ struct UserProfileView: View {
|
||||
@State var name: String = ""
|
||||
@State var email: String = ""
|
||||
@State var phone: String = ""
|
||||
@State var cpf: String = ""
|
||||
@State var profilePicture: String = ""
|
||||
@State var isSaving = false
|
||||
|
||||
@@ -21,6 +22,7 @@ struct UserProfileView: View {
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 22) {
|
||||
screenHeader
|
||||
avatarSection
|
||||
formSection
|
||||
saveButton
|
||||
@@ -30,8 +32,8 @@ struct UserProfileView: View {
|
||||
.padding(.bottom, UIDevice.bottomNotch + 24)
|
||||
}
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Meu Perfil")
|
||||
.appInlineNavigationTitle()
|
||||
.appHiddenNavigationBar()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.onAppear {
|
||||
hydrateFromAppState()
|
||||
}
|
||||
@@ -42,6 +44,27 @@ struct UserProfileView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Meu Perfil")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var avatarSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
Circle()
|
||||
@@ -97,6 +120,14 @@ struct UserProfileView: View {
|
||||
phone = masked
|
||||
}
|
||||
}
|
||||
|
||||
textFieldSection(title: "CPF", placeholder: "000.000.000-00", text: $cpf)
|
||||
.keyboardType(.numberPad)
|
||||
.onChange(of: cpf) { _, newValue in
|
||||
let digits = newValue.filter(\.isNumber)
|
||||
let masked = formatCPF(digits)
|
||||
if masked != newValue { cpf = masked }
|
||||
}
|
||||
.appNoAutoCap()
|
||||
}
|
||||
.padding(16)
|
||||
@@ -148,6 +179,15 @@ struct UserProfileView: View {
|
||||
email = appState.profile.email
|
||||
phone = formatPhoneForDisplay(appState.profile.phone)
|
||||
profilePicture = appState.profile.profilePicture
|
||||
cpf = formatCPF(appState.profile.cpf.filter(\.isNumber))
|
||||
}
|
||||
|
||||
private func formatCPF(_ digits: String) -> String {
|
||||
let d = String(digits.prefix(11))
|
||||
if d.count <= 3 { return d }
|
||||
if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" }
|
||||
if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" }
|
||||
return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -158,6 +198,7 @@ struct UserProfileView: View {
|
||||
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||
let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let newPhoto = cleanPhoto.hasPrefix("data:") ? cleanPhoto : nil
|
||||
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
@@ -167,7 +208,7 @@ struct UserProfileView: View {
|
||||
name: cleanName,
|
||||
email: cleanEmail,
|
||||
phoneNumber: normalizedPhone,
|
||||
profilePicture: cleanPhoto.isEmpty ? nil : cleanPhoto
|
||||
profilePicture: newPhoto
|
||||
)
|
||||
|
||||
if response.error {
|
||||
@@ -180,12 +221,38 @@ struct UserProfileView: View {
|
||||
return
|
||||
}
|
||||
|
||||
let customer = response.result
|
||||
appState.profile.id = customer?.id ?? appState.profile.id
|
||||
appState.profile.name = customer?.name ?? cleanName
|
||||
appState.profile.email = customer?.email ?? cleanEmail
|
||||
appState.profile.phone = customer?.phoneNumber ?? normalizedPhone
|
||||
appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto
|
||||
appState.profile.name = cleanName
|
||||
appState.profile.email = cleanEmail
|
||||
appState.profile.phone = normalizedPhone
|
||||
if let pictureUrl = response.profilePictureUrl, pictureUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
appState.profile.profilePicture = ImageSourceResolver.resolve(pictureUrl) ?? pictureUrl
|
||||
} else if cleanPhoto.isEmpty == false {
|
||||
appState.profile.profilePicture = cleanPhoto
|
||||
}
|
||||
|
||||
let cleanCpf = cpf.filter(\.isNumber)
|
||||
if cleanCpf.count == 11 {
|
||||
guard isValidCPF(cleanCpf) else {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "CPF inválido. Verifique e tente novamente.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
return
|
||||
}
|
||||
let cpfResponse = try await ApiService().updateProfileCpf(cpf: cleanCpf)
|
||||
if cpfResponse.error {
|
||||
SnackbarCenter.shared.show(
|
||||
title: cpfResponse.message ?? "Não foi possível atualizar o CPF.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
return
|
||||
}
|
||||
appState.profile.cpf = cleanCpf
|
||||
}
|
||||
SessionStateStore.setActiveUserKey(
|
||||
SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email)
|
||||
)
|
||||
@@ -241,14 +308,29 @@ struct UserProfileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func isValidCPF(_ digits: String) -> Bool {
|
||||
guard digits.count == 11, digits.unicodeScalars.allSatisfy({ CharacterSet.decimalDigits.contains($0) }) else { return false }
|
||||
guard Set(digits).count > 1 else { return false }
|
||||
func checkDigit(_ d: String, _ length: Int) -> Bool {
|
||||
let sum = d.prefix(length).enumerated().reduce(0) { acc, pair in
|
||||
acc + (Int(String(pair.element)) ?? 0) * (length + 1 - pair.offset)
|
||||
}
|
||||
let rem = (sum * 10) % 11
|
||||
let expected = rem == 10 ? 0 : rem
|
||||
return Int(String(d[d.index(d.startIndex, offsetBy: length)])) == expected
|
||||
}
|
||||
return checkDigit(digits, 9) && checkDigit(digits, 10)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@MainActor
|
||||
private func applySelectedPhoto(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { return }
|
||||
guard let image = UIImage(data: data),
|
||||
let jpegData = image.jpegData(compressionQuality: 0.82) else { return }
|
||||
guard let image = UIImage(data: data) else { return }
|
||||
let resized = resizedIfNeeded(image, maxSide: 600)
|
||||
guard let jpegData = resized.jpegData(compressionQuality: 0.8) else { return }
|
||||
profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(
|
||||
@@ -259,5 +341,17 @@ struct UserProfileView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func resizedIfNeeded(_ image: UIImage, maxSide: CGFloat) -> UIImage {
|
||||
let w = image.size.width
|
||||
let h = image.size.height
|
||||
guard w > maxSide || h > maxSide else { return image }
|
||||
let scale = maxSide / max(w, h)
|
||||
let newSize = CGSize(width: w * scale, height: h * scale)
|
||||
let renderer = UIGraphicsImageRenderer(size: newSize)
|
||||
return renderer.image { _ in
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
BIN
sim-home.png
|
Before Width: | Height: | Size: 134 KiB |