This commit is contained in:
Daniel Arantes Loverde
2026-03-05 10:10:27 -03:00
parent a8e3631177
commit 1407f0b9de
27 changed files with 1493 additions and 84 deletions

View File

@@ -565,3 +565,220 @@ Se o socket cair, o app deve continuar consultando o status até estado final.
- próximos 2min: a cada `5s` - próximos 2min: a cada `5s`
- depois: a cada `10s` - depois: a cada `10s`
- parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela. - parar em status final (`COMPLETED`, `CANCELED`, `REFUNDED`) ou ao sair da tela.
### 8. Catálogo Oficial de Tags de Review
### 8.1 Matriz Completa de Endpoints (Reviews + Tags)
**Público/App (Customer):**
- `GET /api/public/reviews/tags` → catálogo oficial de tags e regras de validação.
- `POST /api/public/orders/:orderId/review` → cria/atualiza avaliação do pedido concluído.
- `GET /api/public/store/:storeId/reviews` → lista reviews públicos da loja.
**Store/Backoffice (analytics e operação):**
- `GET /api/store/:storeId/reviews` → listagem detalhada + métricas de reviews da loja.
- `POST /api/store/:storeId/reviews/:reviewId/reply` → resposta oficial da loja para avaliação.
- `POST /api/store/:storeId/reviews/:reviewId/dispute` → contestação da avaliação.
- `GET /api/store/reviews/app/nps` → NPS do app (geral/por filtros).
- `GET /api/store/reviews/app/nps/platform` → NPS por plataforma (`ios`, `android`, `web`).
- `GET /api/store/reviews/observability/overview` → funil técnico (sucesso/rejeição/erro).
- `GET /api/store/reviews/observability/alerts` → alertas operacionais (ex.: pico de reviews negativas).
**Observação de base URL:**
- No app, consumir sempre via domínio/API oficial do ecossistema (PediFoods/Atomenta), mantendo o Atomenta como orquestrador.
O app deve buscar este endpoint para renderizar as tags válidas e enviar somente os `id` retornados.
**Endpoint:**
- `GET /api/public/reviews/tags`
**Headers:**
- `Accept: application/json`
**Response (resumo):**
```json
{
"error": false,
"result": {
"version": "2026-03-01",
"order": {
"positive": [{ "id": "flavor", "label": "Sabor" }],
"improvement": [{ "id": "wrong_items", "label": "Itens errados" }],
"rules": {
"positiveAllowedWhenRateGte": 5,
"improvementAllowedWhenRateLte": 4
}
},
"delivery": {
"sentiments": [
{ "id": "positive", "allowedTags": ["politeness", "on_time"] },
{ "id": "negative", "allowedTags": ["delay", "rude"] }
],
"positive": [{ "id": "politeness", "label": "Educação" }],
"negative": [{ "id": "delay", "label": "Atraso" }]
},
"app": {
"nps": { "min": 0, "max": 10 },
"platforms": ["ios", "android", "web"]
}
}
}
```
**Regra de integração:**
- Não hardcodear tags no app; usar o catálogo do backend.
- Enviar no POST de review apenas os `id` retornados em cada bloco.
### 9. Enviar Avaliação do Pedido
Permite o customer enviar review da loja a partir de um pedido finalizado.
**Endpoint:**
- `POST /api/public/orders/:orderId/review`
**Headers:**
- `Authorization: Bearer <Users_JWT_Token>` (obrigatório)
- `Accept: application/json`
- `Content-Type: application/json`
**Body:**
```json
{
"orderRate": 5,
"orderComment": "Pedido chegou certinho e bem embalado.",
"orderPositiveTags": ["flavor", "temperature"],
"orderImprovementTags": [],
"deliverySentiment": "positive",
"deliveryPositiveTags": ["on_time", "politeness"],
"deliveryNegativeTags": [],
"appNps": 10,
"platform": "ios",
"rate": 5,
"message": "Pedido chegou certinho e bem embalado."
}
```
**Regras de negócio atuais:**
- Só aceita review para pedido com status `COMPLETED`.
- O `orderId` deve existir.
- `storeId`, `userId`, `clientName` e `orderId` do review são derivados do pedido no backend (não enviar no body).
- Campos obrigatórios para integração nova: `orderRate`, `deliverySentiment`, `appNps`, `platform`.
- Compatibilidade: `rate/message` ainda são aceitos como fallback.
- App deve manter o rascunho local do formulário em caso de erro HTTP para permitir reenvio sem redigitar.
**Response (sucesso):**
```json
{
"error": false,
"result": {
"id": "rev_abc123",
"storeId": "store_1772117366848_wmqw4",
"userId": "cust_uuid_001",
"clientName": "Customer 002",
"rate": 5,
"message": "Pedido chegou certinho e bem embalado.",
"orderRate": 5,
"orderComment": "Pedido chegou certinho e bem embalado.",
"deliveryFeedback": "positive",
"platform": "ios",
"editableUntil": "2026-03-06T17:15:00.000Z",
"storeReplyUntil": "2026-03-06T17:15:00.000Z",
"reviewWindowExpiresAt": "2026-03-06T16:10:00.000Z",
"orderId": "ffdc47b4-6a55-4713-aea9-45169e4cf0d7",
"date": "2026-03-01T17:15:00.000Z"
}
}
```
**Erros esperados:**
- `400` quando `orderId` não for enviado.
- `400` quando o pedido não existir ou ainda não estiver concluído (`Apenas pedidos concluídos podem ser avaliados`).
- `400` `REVIEW_WINDOW_EXPIRED` quando passou da janela inicial de 5 dias para criar review.
- `400` `REVIEW_EDIT_WINDOW_EXPIRED` quando tentar alterar review após 5 dias da criação.
- `400` `INVALID_REVIEW_PAYLOAD` / `INVALID_REVIEW_TAGS` / `INVALID_DELIVERY_SENTIMENT_TAGS` para payload inválido.
- `401` se o token JWT for inválido/ausente.
- `500` em falha interna (`Erro ao enviar avaliação`).
**Exemplos de validação (cenários):**
Exemplo válido (nota alta + entrega positiva):
```json
{
"orderRate": 5,
"orderComment": "Perfeito.",
"orderPositiveTags": ["flavor", "temperature"],
"orderImprovementTags": [],
"deliverySentiment": "positive",
"deliveryPositiveTags": ["on_time", "politeness"],
"deliveryNegativeTags": [],
"appNps": 10,
"platform": "android"
}
```
Exemplo inválido (nota 5 com `orderImprovementTags`):
```json
{
"orderRate": 5,
"orderImprovementTags": ["temperature"],
"deliverySentiment": "positive",
"appNps": 8,
"platform": "ios"
}
```
Retorno esperado: `400 INVALID_REVIEW_TAGS`.
Exemplo inválido (entrega positiva com tags negativas):
```json
{
"orderRate": 4,
"orderImprovementTags": ["temperature"],
"deliverySentiment": "positive",
"deliveryNegativeTags": ["delay"],
"appNps": 7,
"platform": "web"
}
```
Retorno esperado: `400 INVALID_DELIVERY_SENTIMENT_TAGS`.
### 10. Analytics de Reviews (Store/API)
Para dashboard da loja e análise de produto, usar:
- `GET /api/store/:storeId/reviews`
- filtros opcionais: `startDate`, `endDate`, `minRate`, `maxRate`, `deliverySentiment`, `platform`
- retorno inclui métricas de estrelas, blocos de pedido/entrega, janelas (`editableUntil`, `storeReplyUntil`) e `appNps` agregado.
- `POST /api/store/:storeId/reviews/:reviewId/reply`
- body obrigatório: `reply` (texto da resposta da loja).
- regra: respeita janela de resposta (`storeReplyUntil`).
- `POST /api/store/:storeId/reviews/:reviewId/dispute`
- body obrigatório: `reason` (motivo da contestação).
- `GET /api/store/reviews/app/nps`
- filtros opcionais: `startDate`, `endDate`, `storeId`, `platform`
- retorno: volume NPS, média e score.
- `GET /api/store/reviews/app/nps/platform`
- filtros opcionais: `startDate`, `endDate`, `storeId`
- retorno: NPS separado em `ios`, `android`, `web`.
- `GET /api/store/reviews/observability/overview`
- filtros opcionais: `startDate`, `endDate`, `storeId`
- retorno: tentativas, sucesso/rejeição/erro, taxa de sucesso e conversão por pedidos concluídos.
- `GET /api/store/reviews/observability/alerts`
- filtro opcional: `storeId`
- retorno: alertas de pico, ex.: `NEGATIVE_REVIEWS_SPIKE`.
**Compatibilidade Store:**
- 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.

View File

@@ -139,10 +139,7 @@ final class CachedRemoteImageLoader: ObservableObject {
} }
private static func normalizeImageSource(_ value: String?) -> String? { private static func normalizeImageSource(_ value: String?) -> String? {
guard var normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines), ImageSourceResolver.resolve(value)
normalized.isEmpty == false else { return nil }
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
return normalized
} }
private static func dataURLCacheKey(_ source: String) -> String { private static func dataURLCacheKey(_ source: String) -> String {

View File

@@ -127,6 +127,7 @@ struct ContentView: View {
appState.profile.name = customer.name appState.profile.name = customer.name
appState.profile.email = customer.email appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? "" appState.profile.phone = customer.phoneNumber ?? ""
appState.profile.profilePicture = customer.profilePicture ?? ""
SessionStateStore.setActiveUserKey( SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
) )
@@ -143,13 +144,49 @@ struct ContentView: View {
return return
} }
let preferredAddress = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first let preferredAddress = resolvePreferredAddress(from: addresses, current: appState.address)
if let preferredAddress { if let preferredAddress {
applyAddress(preferredAddress) applyAddress(preferredAddress)
SessionStateStore.saveAddress(appState.address) SessionStateStore.saveAddress(appState.address)
} }
} }
private func resolvePreferredAddress(from addresses: [CustomerAddress], current: AddressState) -> CustomerAddress? {
guard addresses.isEmpty == false else { return nil }
if let selectedId = current.selectedId?.trimmingCharacters(in: .whitespacesAndNewlines),
selectedId.isEmpty == false,
let byId = addresses.first(where: { ($0.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == selectedId }) {
return byId
}
let normalizedDisplay = current.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
if normalizedDisplay.isEmpty == false && normalizedDisplay != "defina seu endereco",
let byLabel = addresses.first(where: {
(($0.label ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()) == normalizedDisplay
}) {
return byLabel
}
if let lat = current.latitude, let lng = current.longitude,
let byCoordinate = addresses.first(where: { address in
guard let addrLat = address.latLong?.first,
let addrLng = address.latLong?.dropFirst().first else { return false }
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
}) {
return byCoordinate
}
return addresses.first
}
@MainActor @MainActor
private func applyAddress(_ address: CustomerAddress) { private func applyAddress(_ address: CustomerAddress) {
appState.address.selectedId = address.id appState.address.selectedId = address.id

View File

@@ -36,6 +36,7 @@ enum AppTypography {
// Avoid applying dynamic weight on custom font descriptors to prevent // Avoid applying dynamic weight on custom font descriptors to prevent
// SwiftUI runtime warnings on some platforms/toolchains. // SwiftUI runtime warnings on some platforms/toolchains.
static let heading1 = Font.custom(fontFamily, size: 28) static let heading1 = Font.custom(fontFamily, size: 28)
static let heading25 = Font.custom(fontFamily, size: 25)
static let heading2 = Font.custom(fontFamily, size: 20) static let heading2 = Font.custom(fontFamily, size: 20)
static let heading3 = Font.custom(fontFamily, size: 16) static let heading3 = Font.custom(fontFamily, size: 16)
static let body = Font.custom(fontFamily, size: 16) static let body = Font.custom(fontFamily, size: 16)

View File

@@ -3,6 +3,10 @@
"strings" : { "strings" : {
"" : { "" : {
},
" " : {
"comment" : "A placeholder text used to maintain vertical spacing in the UI.",
"isCommentAutoGenerated" : true
}, },
"..." : { "..." : {
@@ -51,6 +55,18 @@
}, },
"%lldx" : { "%lldx" : {
},
"%lldx %@" : {
"comment" : "A line that shows the quantity of an ordered item and its name. The first argument is the quantity of the item. The second argument is the name of the item.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lldx %2$@"
}
}
}
}, },
"+ %@" : { "+ %@" : {
"comment" : "A small, secondary text label displaying the price of an item.", "comment" : "A small, secondary text label displaying the price of an item.",
@@ -115,6 +131,10 @@
"comment" : "A heading for the additional options available for a product.", "comment" : "A heading for the additional options available for a product.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Adicionar fotos" : {
"comment" : "A label for adding photos in the order review section.",
"isCommentAutoGenerated" : true
},
"Adicionar novo endereço" : { "Adicionar novo endereço" : {
"comment" : "A button label that translates to \"Add new address\" in English.", "comment" : "A button label that translates to \"Add new address\" in English.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -184,6 +204,28 @@
"comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.", "comment" : "A description below the fields in the \"Editar endereço\" form, instructing the user to update their address details.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Avaliação" : {
"comment" : "A label describing a review section.",
"isCommentAutoGenerated" : true
},
"Avaliar pedido" : {
"comment" : "A button that allows users to rate and review their orders.",
"isCommentAutoGenerated" : true
},
"Avaliar Pedido" : {
},
"AVALIAR PEDIDO" : {
},
"Avaliar pedido #%@" : {
"comment" : "A button that lets the user review a previously placed order. The placeholder `#\\(initialOrder.shortId ?? initialOrder.orderId)` will be replaced with the actual order number when displayed.",
"isCommentAutoGenerated" : true
},
"Avalie o Pedi Foods também" : {
"comment" : "A heading that asks users to review Pedi Foods.",
"isCommentAutoGenerated" : true
},
"Boas-vindas!" : { "Boas-vindas!" : {
"comment" : "A welcome message displayed in the login view.", "comment" : "A welcome message displayed in the login view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -257,6 +299,14 @@
"comment" : "A button that allows the user to paste their OTP code directly into the field.", "comment" : "A button that allows the user to paste their OTP code directly into the field.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Comentário" : {
"comment" : "A label for the comment section in the order review view.",
"isCommentAutoGenerated" : true
},
"Comprar novamente" : {
"comment" : "A button that allows a user to purchase an order again.",
"isCommentAutoGenerated" : true
},
"Concluir" : { "Concluir" : {
"comment" : "The text for a button that confirms and closes a sheet.", "comment" : "The text for a button that confirms and closes a sheet.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -273,6 +323,10 @@
"comment" : "The text on a button that takes the user to contact support.", "comment" : "The text on a button that takes the user to contact support.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Conte-nos se gostou ou não." : {
"comment" : "A prompt asking users to share their opinion about the delivery.",
"isCommentAutoGenerated" : true
},
"Conteúdo da política de privacidade..." : { "Conteúdo da política de privacidade..." : {
"comment" : "A placeholder text describing the content of the privacy policy.", "comment" : "A placeholder text describing the content of the privacy policy.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -365,16 +419,20 @@
"comment" : "A label describing the delivery location section.", "comment" : "A label describing the delivery location section.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Descubra lorem ipsum ba bla" : {
"comment" : "A welcome message displayed on the login screen.",
"isCommentAutoGenerated" : true
},
"Detalhes" : { "Detalhes" : {
"comment" : "The title of the navigation bar at the top of the product detail sheet.", "comment" : "The title of the navigation bar at the top of the product detail sheet.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Do que você gostou?" : {
"comment" : "A prompt asking users to mention what they liked about a review.",
"isCommentAutoGenerated" : true
},
"Editar endereço" : { "Editar endereço" : {
},
"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 ENTREGA" : { "ENDEREÇO DE ENTREGA" : {
"comment" : "A label displayed above the address section when delivery is selected.", "comment" : "A label displayed above the address section when delivery is selected.",
@@ -387,6 +445,9 @@
"Entrega" : { "Entrega" : {
"comment" : "A text describing delivery mode.", "comment" : "A text describing delivery mode.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Enviar Avaliação" : {
}, },
"Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : { "Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : {
"comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.", "comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.",
@@ -396,6 +457,10 @@
"comment" : "A description under the QR code that instructs the user on how to pay using their bank app.", "comment" : "A description under the QR code that instructs the user on how to pay using their bank app.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Escolha de 1 a 5 estrelas para classificar." : {
"comment" : "A description under the star rating section of the order review view.",
"isCommentAutoGenerated" : true
},
"Escolha o tamanho da sua fome" : { "Escolha o tamanho da sua fome" : {
"comment" : "A label displayed below the pizza size selection.", "comment" : "A label displayed below the pizza size selection.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -581,6 +646,10 @@
"comment" : "The title of the user's cart screen.", "comment" : "The title of the user's cart screen.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Meu Perfil" : {
"comment" : "The title of the user profile view.",
"isCommentAutoGenerated" : true
},
"Meus Endereços" : { "Meus Endereços" : {
"comment" : "A label displayed at the top of the view.", "comment" : "A label displayed at the top of the view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -650,6 +719,10 @@
}, },
"Nenhum pedido encontrado." : { "Nenhum pedido encontrado." : {
},
"Nota do pedido" : {
"comment" : "A title for the rating of a review.",
"isCommentAutoGenerated" : true
}, },
"Notes" : { "Notes" : {
"comment" : "Item editor form label for the Notes field", "comment" : "Item editor form label for the Notes field",
@@ -689,12 +762,32 @@
"comment" : "A text that appears at the bottom of the screen, inviting users to create an account.", "comment" : "A text that appears at the bottom of the screen, inviting users to create an account.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"O que pode melhorar?" : {
"comment" : "A label for the section where users can select tags they think could improve an order.",
"isCommentAutoGenerated" : true
},
"O que podemos melhorar? Conte-nos sua experiência..." : {
"comment" : "A placeholder text displayed in the text editor of the review draft view, when the text editor is empty.",
"isCommentAutoGenerated" : true
},
"O que vai querer \npedir hoje?" : { "O que vai querer \npedir hoje?" : {
"comment" : "A title displayed above the search bar in the home view.", "comment" : "A title displayed above the search bar in the home view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"O que vai querer pedir hoje?\n " : { "O que vai querer pedir hoje?\n " : {
},
"O que você achou do pedido?" : {
"comment" : "A question prompt asking users to rate their experience with the order.",
"isCommentAutoGenerated" : true
},
"O que você achou do pedido? Conte-nos sua experiência..." : {
"comment" : "A placeholder text displayed in the text editor of the order review view, instructing the user to leave a comment about their experience with the order.",
"isCommentAutoGenerated" : true
},
"Obrigatório" : {
"comment" : "A label indicating a required field.",
"isCommentAutoGenerated" : true
}, },
"Pagamento" : { "Pagamento" : {
"comment" : "The title of the screen where a user can enter and save payment information.", "comment" : "The title of the screen where a user can enter and save payment information.",
@@ -710,6 +803,16 @@
}, },
"Pedido #%@" : { "Pedido #%@" : {
},
"Pedido #%@ • %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Pedido #%1$@ • %2$@"
}
}
}
}, },
"Pedido %@" : { "Pedido %@" : {
@@ -778,6 +881,10 @@
}, },
"Progresso do Pedido" : { "Progresso do Pedido" : {
},
"Reavaliar" : {
"comment" : "The text for a button that allows a user to re-evaluate an order.",
"isCommentAutoGenerated" : true
}, },
"Receber Código" : { "Receber Código" : {
"comment" : "A button label that says \"Receive Code\".", "comment" : "A button label that says \"Receive Code\".",
@@ -787,6 +894,14 @@
"comment" : "A button that allows a user to request a new OTP code.", "comment" : "A button that allows a user to request a new OTP code.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Remover" : {
"comment" : "A button that deletes the user's profile picture.",
"isCommentAutoGenerated" : true
},
"Resposta da loja" : {
"comment" : "A heading for the reply from the store in an order review.",
"isCommentAutoGenerated" : true
},
"RESTAURANT" : { "RESTAURANT" : {
}, },
@@ -811,6 +926,14 @@
}, },
"Sair da conta?" : { "Sair da conta?" : {
},
"Salvando..." : {
"comment" : "The title of a button while it is \"saving\" (e.g. waiting for a network request to complete).",
"isCommentAutoGenerated" : true
},
"Salvar Alterações" : {
"comment" : "The text on a button that saves changes to a user's profile.",
"isCommentAutoGenerated" : true
}, },
"Save" : { "Save" : {
"comment" : "Button title indicating that the current contents should be saved", "comment" : "Button title indicating that the current contents should be saved",
@@ -894,6 +1017,10 @@
"comment" : "The text for a button that confirms an action. In this case, it confirms the user's choice to continue with the order despite the address not being served by the store.", "comment" : "The text for a button that confirms an action. In this case, it confirms the user's choice to continue with the order despite the address not being served by the store.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque." : {
"comment" : "A tagline displayed in the login view.",
"isCommentAutoGenerated" : true
},
"System" : { "System" : {
"comment" : "Menu item indicating that the appearance should be in the default system mode", "comment" : "Menu item indicating that the appearance should be in the default system mode",
"extractionState" : "stale", "extractionState" : "stale",
@@ -989,6 +1116,14 @@
"comment" : "A title for an alert that prompts the user to switch stores.", "comment" : "A title for an alert that prompts the user to switch stores.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Trocar Foto" : {
"comment" : "A button label that allows a user to change their profile picture.",
"isCommentAutoGenerated" : true
},
"UPLOAD" : {
"comment" : "A button label that says \"UPLOAD\".",
"isCommentAutoGenerated" : true
},
"Ver motivo do cancelamento" : { "Ver motivo do cancelamento" : {
}, },
@@ -1039,6 +1174,13 @@
} }
} }
} }
},
"Você ainda não avaliou nenhum pedido." : {
"comment" : "A message displayed when a user has not reviewed any orders yet.",
"isCommentAutoGenerated" : true
},
"Você gostou da entrega?" : {
}, },
"Welcome" : { "Welcome" : {
"comment" : "Tab bar item title for the Welcome tab", "comment" : "Tab bar item title for the Welcome tab",

View File

@@ -84,12 +84,8 @@ final class ApiClient {
func send<T: Decodable>(_ request: ApiRequest) async throws -> T { func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
// NOTE: // NOTE:
// /api/customer/login is strict about body fields (email/phoneNumber/otp). // /api/customer/login is strict about body fields (email/phoneNumber/otp).
// On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is. // On iOS, route this endpoint through URLSession to preserve raw JSON body behavior.
// Also route order-related polling/listing through URLSession to avoid if request.path == "/api/customer/login" {
// intermittent cancellation observed with the shared API bridge.
if request.path == "/api/customer/login" ||
request.path == "/api/app/orders" ||
request.path.hasPrefix("/api/public/orders/") {
if let body = request.body, let bodyText = String(data: body, encoding: .utf8) { if let body = request.body, let bodyText = String(data: body, encoding: .utf8) {
print("[ApiClient] /api/customer/login body: \(bodyText)") print("[ApiClient] /api/customer/login body: \(bodyText)")
} }

View File

@@ -14,6 +14,37 @@ struct CustomerProfileUpdatePayload: Encodable {
} }
} }
struct CustomerIdentityUpdatePayload: Encodable {
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)
if let profilePicture, profilePicture.isEmpty == false {
try container.encode(profilePicture, forKey: .profilePicture)
try container.encode(profilePicture, forKey: .profile_picture)
}
}
}
struct CustomerAddressPayload: Encodable { struct CustomerAddressPayload: Encodable {
let label: String? let label: String?
let address: String? let address: String?

View File

@@ -114,6 +114,9 @@ struct StoreSummary: Decodable {
struct StoreInfoResult: Decodable { struct StoreInfoResult: Decodable {
let isOpen: Bool? let isOpen: Bool?
let statusLabel: String? let statusLabel: String?
let fantasyName: String?
let logo: String?
let cover: String?
let deliveryTime: String? let deliveryTime: String?
let minOrder: Double? let minOrder: Double?
let address: StoreAddressInfo? let address: StoreAddressInfo?
@@ -122,6 +125,9 @@ struct StoreInfoResult: Decodable {
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case isOpen case isOpen
case statusLabel case statusLabel
case fantasyName
case logo
case cover
case deliveryTime case deliveryTime
case minOrder case minOrder
case address case address
@@ -132,6 +138,9 @@ struct StoreInfoResult: Decodable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
isOpen = try? container.decode(Bool.self, forKey: .isOpen) isOpen = try? container.decode(Bool.self, forKey: .isOpen)
statusLabel = try? container.decode(String.self, forKey: .statusLabel) statusLabel = try? container.decode(String.self, forKey: .statusLabel)
fantasyName = try? container.decode(String.self, forKey: .fantasyName)
logo = try? container.decode(String.self, forKey: .logo)
cover = try? container.decode(String.self, forKey: .cover)
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime) deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder]) minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder])
address = try? container.decode(StoreAddressInfo.self, forKey: .address) address = try? container.decode(StoreAddressInfo.self, forKey: .address)

View File

@@ -0,0 +1,270 @@
import Foundation
enum ReviewPlatform: String, Encodable {
case ios
case android
case web
static var current: ReviewPlatform {
#if os(iOS)
return .ios
#elseif os(Android)
return .android
#else
return .web
#endif
}
}
struct SubmitOrderReviewPayload: Encodable {
let rate: Int
let message: String
let orderRate: Int
let orderComment: String
let orderPositiveTags: [String]
let orderImprovementTags: [String]
let deliverySentiment: String
let deliveryPositiveTags: [String]
let deliveryNegativeTags: [String]
let appNps: Int
let platform: String
}
struct ReviewTagItem: Decodable, Hashable, Identifiable {
let id: String
let label: String
}
struct ReviewOrderTagRules: Decodable {
let positiveAllowedWhenRateGte: Int?
let improvementAllowedWhenRateLte: Int?
}
struct ReviewOrderTagsCatalog: Decodable {
let positive: [ReviewTagItem]
let improvement: [ReviewTagItem]
let rules: ReviewOrderTagRules?
}
struct ReviewDeliverySentimentRule: Decodable {
let id: String
let allowedTags: [String]
}
struct ReviewDeliveryTagsCatalog: Decodable {
let sentiments: [ReviewDeliverySentimentRule]
let positive: [ReviewTagItem]
let negative: [ReviewTagItem]
}
struct ReviewNpsCatalog: Decodable {
let min: Int?
let max: Int?
}
struct ReviewAppTagsCatalog: Decodable {
let nps: ReviewNpsCatalog?
let platforms: [String]?
}
struct ReviewTagsCatalog: Decodable {
let version: String?
let order: ReviewOrderTagsCatalog?
let delivery: ReviewDeliveryTagsCatalog?
let app: ReviewAppTagsCatalog?
}
struct SubmitOrderReviewResult: Decodable {
let id: String?
let storeId: String?
let userId: String?
let clientName: String?
let rate: Int?
let message: String?
let orderRate: Int?
let orderComment: String?
let deliverySentiment: String?
let orderPositiveTags: [String]?
let orderImprovementTags: [String]?
let deliveryPositiveTags: [String]?
let deliveryNegativeTags: [String]?
let appNps: Int?
let platform: String?
let orderId: String?
let date: String?
let editableUntil: String?
let storeReplyUntil: String?
let reviewWindowExpiresAt: String?
let storeReplyMessage: String?
let storeReplyAt: String?
enum CodingKeys: String, CodingKey {
case id
case storeId
case userId
case clientName
case rate
case message
case orderRate
case orderComment
case deliverySentiment
case deliveryFeedback
case itemFeedback
case improvementFeedback
case orderPositiveTags
case orderImprovementTags
case deliveryPositiveTags
case deliveryNegativeTags
case appNps
case app_nps
case platform
case orderId
case date
case editableUntil
case storeReplyUntil
case reviewWindowExpiresAt
case storeReply
case store_response
case storeResponse
case reply
case storeReplyMessage
case storeReplyAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId])
userId = ApiService.decodeFlexibleString(from: container, keys: [.userId])
clientName = ApiService.decodeFlexibleString(from: container, keys: [.clientName])
rate = ApiService.decodeFlexibleInt(from: container, keys: [.rate])
message = ApiService.decodeFlexibleString(from: container, keys: [.message])
orderRate = ApiService.decodeFlexibleInt(from: container, keys: [.orderRate, .rate])
orderComment = ApiService.decodeFlexibleString(from: container, keys: [.orderComment, .message])
deliverySentiment = ApiService.decodeFlexibleString(from: container, keys: [.deliverySentiment, .deliveryFeedback])
orderPositiveTags = Self.decodeStringList(from: container, keys: [.orderPositiveTags, .itemFeedback])
orderImprovementTags = Self.decodeStringList(from: container, keys: [.orderImprovementTags, .improvementFeedback])
deliveryPositiveTags = (try? container.decode([String].self, forKey: .deliveryPositiveTags)) ?? nil
deliveryNegativeTags = (try? container.decode([String].self, forKey: .deliveryNegativeTags)) ?? nil
appNps = Self.decodeNps(from: container, keys: [.appNps, .app_nps])
platform = ApiService.decodeFlexibleString(from: container, keys: [.platform])
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
date = ApiService.decodeFlexibleString(from: container, keys: [.date])
editableUntil = ApiService.decodeFlexibleString(from: container, keys: [.editableUntil])
storeReplyUntil = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyUntil])
reviewWindowExpiresAt = ApiService.decodeFlexibleString(from: container, keys: [.reviewWindowExpiresAt])
storeReplyMessage = Self.decodeReplyMessage(from: container)
storeReplyAt = Self.decodeReplyDate(from: container)
}
private static func decodeStringList(
from container: KeyedDecodingContainer<CodingKeys>,
keys: [CodingKeys]
) -> [String]? {
for key in keys {
if let list = try? container.decode([String].self, forKey: key) {
return list
}
if let single = try? container.decode(String.self, forKey: key) {
let normalized = single.trimmingCharacters(in: .whitespacesAndNewlines)
if normalized.isEmpty == false {
return [normalized]
}
}
}
return nil
}
private static func decodeNps(
from container: KeyedDecodingContainer<CodingKeys>,
keys: [CodingKeys]
) -> Int? {
for key in keys {
if let value = try? container.decode(Int.self, forKey: key) {
return value
}
if let value = try? container.decode(Double.self, forKey: key) {
return Int(value.rounded())
}
if let raw = try? container.decode(String.self, forKey: key) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { continue }
if let asInt = Int(trimmed) {
return asInt
}
let normalized = trimmed.replacingOccurrences(of: ",", with: ".")
if let asDouble = Double(normalized) {
return Int(asDouble.rounded())
}
}
}
return nil
}
private static func decodeReplyMessage(from container: KeyedDecodingContainer<CodingKeys>) -> String? {
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyMessage]) {
return value
}
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReply, .store_response, .storeResponse, .reply]) {
return value
}
for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] {
if let object = try? container.decode([String: String].self, forKey: key) {
let candidates = ["message", "text", "reply", "content", "body"]
for candidate in candidates {
let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty == false { return value }
}
}
}
return nil
}
private static func decodeReplyDate(from container: KeyedDecodingContainer<CodingKeys>) -> String? {
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyAt]) {
return value
}
for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] {
if let object = try? container.decode([String: String].self, forKey: key) {
let candidates = ["date", "createdAt", "updatedAt", "repliedAt", "replyAt"]
for candidate in candidates {
let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty == false { return value }
}
}
}
return nil
}
}
struct PublicStoreReviewsResult: Decodable {
let reviews: [SubmitOrderReviewResult]
enum CodingKeys: String, CodingKey {
case reviews
case data
case items
}
init(from decoder: Decoder) throws {
if let list = try? [SubmitOrderReviewResult](from: decoder) {
reviews = list
return
}
let container = try decoder.container(keyedBy: CodingKeys.self)
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .reviews) {
reviews = list
return
}
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .data) {
reviews = list
return
}
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .items) {
reviews = list
return
}
reviews = []
}
}

View File

@@ -148,6 +148,31 @@ final class ApiService {
return envelope 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
}
let payload = CustomerIdentityUpdatePayload(
name: name,
email: email,
phoneNumber: phoneNumber,
profilePicture: profilePicture,
addressBook: (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:))
)
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 {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
}
return envelope
}
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> { func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
return try await saveCustomerAddress(address, replacingAddressId: nil) return try await saveCustomerAddress(address, replacingAddressId: nil)
} }
@@ -297,7 +322,13 @@ final class ApiService {
return cached return cached
} }
let req = ApiRequest(path: "/api/app/orders", method: "GET", module: .app, requiresAuth: true) let req = ApiRequest(
path: "/api/app/orders",
method: "GET",
module: .app,
requiresAuth: true,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req) let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req)
if envelope.error == false { if envelope.error == false {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours) AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
@@ -306,7 +337,46 @@ final class ApiService {
} }
func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> { func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> {
let req = ApiRequest(path: "/api/public/orders/\(orderId)", method: "GET", module: .none, requiresAuth: true) let req = ApiRequest(
path: "/api/public/orders/\(orderId)",
method: "GET",
module: .none,
requiresAuth: true,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
return try await sendEnvelope(req)
}
func submitOrderReview(orderId: String, payload: SubmitOrderReviewPayload) async throws -> ApiEnvelope<SubmitOrderReviewResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(
path: "/api/public/orders/\(orderId)/review",
method: "POST",
module: .none,
requiresAuth: true,
body: body
)
return try await sendEnvelope(req)
}
func reviewTagsCatalog() async throws -> ApiEnvelope<ReviewTagsCatalog> {
let req = ApiRequest(
path: "/api/public/reviews/tags",
method: "GET",
module: .none,
requiresAuth: false
)
return try await sendEnvelope(req)
}
func publicStoreReviews(storeId: String) async throws -> ApiEnvelope<PublicStoreReviewsResult> {
let req = ApiRequest(
path: "/api/public/store/\(storeId)/reviews",
method: "GET",
module: .none,
requiresAuth: false,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
return try await sendEnvelope(req) return try await sendEnvelope(req)
} }
} }

View File

@@ -12,6 +12,10 @@ enum AppCacheTTL {
static let homeStores: TimeInterval = 5 * 60 static let homeStores: TimeInterval = 5 * 60
} }
enum AppCacheKey {
static let homeStoresLatestSnapshot = "home-stores.latest.snapshot"
}
final class AppContentCache: @unchecked Sendable { final class AppContentCache: @unchecked Sendable {
static let shared = AppContentCache() static let shared = AppContentCache()

View File

@@ -0,0 +1,44 @@
import Foundation
enum ImageSourceResolver {
static func resolve(_ raw: String?) -> String? {
guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
normalized.isEmpty == false else { return nil }
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
let lower = normalized.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") {
return normalized
}
// if let base64DataURL = normalizedBase64DataURL(normalized) {
// return base64DataURL
// }
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)"
return "\(base)\(path)"
}
private static func normalizedBase64DataURL(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return nil }
let payload: String
if let marker = trimmed.range(of: "base64,", options: [.caseInsensitive]) {
payload = String(trimmed[marker.upperBound...])
} else {
payload = trimmed
}
let sanitized = payload
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: " ", with: "")
guard sanitized.count >= 64 else { return nil }
guard Data(base64Encoded: sanitized, options: [.ignoreUnknownCharacters]) != nil else { return nil }
return "data:image/png;base64,\(sanitized)"
}
}

View File

@@ -76,13 +76,18 @@ final class OrderRealtimeTracker {
@discardableResult @discardableResult
private func fetchLatest(orderId: String) async -> PublicOrderResult? { private func fetchLatest(orderId: String) async -> PublicOrderResult? {
do { do {
logger.debug("OrderTracking poll request orderId=\(orderId, privacy: .public)")
let response = try await ApiService().publicOrder(orderId: orderId) let response = try await ApiService().publicOrder(orderId: orderId)
guard response.error == false, let order = response.result else { return nil } guard response.error == false, let order = response.result else {
SessionStateStore.saveTrackedOrder(order) logger.error("OrderTracking poll API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
return nil
}
clearPendingCartIfNeeded(for: order) clearPendingCartIfNeeded(for: order)
logger.info("OrderTracking poll success orderId=\(orderId, privacy: .public) status=\((order.status ?? "nil"), privacy: .public) paymentStatus=\((order.paymentStatus ?? "nil"), privacy: .public)")
onOrderUpdated?(order) onOrderUpdated?(order)
return order return order
} catch { } catch {
logger.error("OrderTracking poll failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
return nil return nil
} }
} }

View File

@@ -3,4 +3,5 @@ import Foundation
extension Notification.Name { extension Notification.Name {
static let sessionExpired = Notification.Name("SessionExpiredNotification") static let sessionExpired = Notification.Name("SessionExpiredNotification")
static let cartDidReset = Notification.Name("CartDidResetNotification") static let cartDidReset = Notification.Name("CartDidResetNotification")
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
} }

View File

@@ -37,6 +37,48 @@ private struct PersistedTrackedOrdersState: Codable {
let orders: [PublicOrderResult] let orders: [PublicOrderResult]
} }
struct OrderReviewRecord: Codable, Identifiable, Hashable {
var id: String { orderId }
let orderId: String
let storeId: String?
let shortId: String?
let storeName: String?
let storeLogoURL: String?
let createdAt: String?
let submittedAt: String
let rating: Int
let comment: String
let orderPositiveTags: [String]?
let orderImprovementTags: [String]?
let deliverySentiment: String?
let deliveryPositiveTags: [String]?
let deliveryNegativeTags: [String]?
let appNps: Int?
let platform: String?
let editableUntil: String?
let storeReplyUntil: String?
let reviewWindowExpiresAt: String?
let storeReplyMessage: String?
let storeReplyAt: String?
}
struct OrderReviewDraftState: Codable, Hashable {
var orderId: String
var orderRate: Int
var orderComment: String
var orderPositiveTags: [String]
var orderImprovementTags: [String]
var deliverySentiment: String
var deliveryPositiveTags: [String]
var deliveryNegativeTags: [String]
var appNps: Int
var platform: String
}
private struct PersistedOrderReviewsState: Codable {
let reviews: [OrderReviewRecord]
}
enum SessionStateStore { enum SessionStateStore {
private static let legacyAddressKey = "session.address.state.v1" private static let legacyAddressKey = "session.address.state.v1"
private static let addressKeyPrefix = "session.address.state.v2." private static let addressKeyPrefix = "session.address.state.v2."
@@ -44,6 +86,8 @@ enum SessionStateStore {
private static let cartKeyPrefix = "session.cart.state.v1." private static let cartKeyPrefix = "session.cart.state.v1."
private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1." private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1."
private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1." private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
private static let orderReviewsKeyPrefix = "session.orders.reviews.v1."
private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1."
static func makeUserKey(profileId: String?, email: String?) -> String? { static func makeUserKey(profileId: String?, email: String?) -> String? {
let id = (profileId ?? "") let id = (profileId ?? "")
@@ -261,6 +305,23 @@ enum SessionStateStore {
return pendingCartOrderKeyPrefix + safe return pendingCartOrderKeyPrefix + safe
} }
private static func orderReviewsStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return orderReviewsKeyPrefix + safe
}
private static func orderReviewDraftStorageKey(for orderId: String, userKey: String?) -> String {
let scope = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: " ", with: "_")
let id = orderId
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return "\(orderReviewDraftKeyPrefix)\(scope).\(id)"
}
static func loadTrackedOrders() -> [PublicOrderResult] { static func loadTrackedOrders() -> [PublicOrderResult] {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let key = trackedOrdersStorageKey(for: nil) let key = trackedOrdersStorageKey(for: nil)
@@ -315,4 +376,66 @@ enum SessionStateStore {
static func clearPendingCartOrder() { static func clearPendingCartOrder() {
UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil)) UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
} }
static func loadOrderReviews() -> [OrderReviewRecord] {
let key = orderReviewsStorageKey(for: nil)
guard let data = UserDefaults.standard.data(forKey: key),
let decoded = try? JSONDecoder().decode(PersistedOrderReviewsState.self, from: data) else {
return []
}
return decoded.reviews.sorted { lhs, rhs in
lhs.submittedAt > rhs.submittedAt
}
}
static func loadOrderReview(orderId: String) -> OrderReviewRecord? {
let normalized = orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard normalized.isEmpty == false else { return nil }
return loadOrderReviews().first { review in
review.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
}
}
static func hasOrderReview(orderId: String) -> Bool {
loadOrderReview(orderId: orderId) != nil
}
static func saveOrderReview(_ review: OrderReviewRecord) {
let cleanId = review.orderId.trimmingCharacters(in: .whitespacesAndNewlines)
guard cleanId.isEmpty == false else { return }
var reviews = loadOrderReviews()
if let index = reviews.firstIndex(where: {
$0.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == cleanId.lowercased()
}) {
reviews[index] = review
} else {
reviews.insert(review, at: 0)
}
let payload = PersistedOrderReviewsState(reviews: reviews)
guard let data = try? JSONEncoder().encode(payload) else { return }
UserDefaults.standard.set(data, forKey: orderReviewsStorageKey(for: nil))
}
static func loadOrderReviewDraft(orderId: String) -> OrderReviewDraftState? {
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
guard let data = UserDefaults.standard.data(forKey: key),
let draft = try? JSONDecoder().decode(OrderReviewDraftState.self, from: data) else {
return nil
}
return draft
}
static func saveOrderReviewDraft(_ draft: OrderReviewDraftState) {
let key = orderReviewDraftStorageKey(for: draft.orderId, userKey: nil)
guard let data = try? JSONEncoder().encode(draft) else { return }
UserDefaults.standard.set(data, forKey: key)
}
static func clearOrderReviewDraft(orderId: String) {
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
UserDefaults.standard.removeObject(forKey: key)
}
} }

View File

@@ -26,6 +26,7 @@ struct ProfileState {
var name: String = "" var name: String = ""
var email: String = "" var email: String = ""
var phone: String = "" var phone: String = ""
var profilePicture: String = ""
} }
struct AddressState { struct AddressState {

View File

@@ -55,8 +55,8 @@ struct LoginView: View {
.scaledToFit() .scaledToFit()
.frame(height: 180) .frame(height: 180)
Text("Descubra lorem ipsum ba bla") Text("Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque.")
.font(AppTypography.heading1) .font(AppTypography.heading25)
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary) .foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -261,6 +261,7 @@ struct OtpView: View {
appState.profile.name = customer.name appState.profile.name = customer.name
appState.profile.email = customer.email appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? "" appState.profile.phone = customer.phoneNumber ?? ""
appState.profile.profilePicture = customer.profilePicture ?? ""
SessionStateStore.setActiveUserKey( SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
) )

View File

@@ -208,7 +208,7 @@ struct AddressesView: View {
} }
private func applyPreferredAddress(from updatedAddresses: [CustomerAddress]) { private func applyPreferredAddress(from updatedAddresses: [CustomerAddress]) {
let selected = updatedAddresses.first(where: { $0.id == appState.address.selectedId }) ?? updatedAddresses.first let selected = resolvePreferredAddress(from: updatedAddresses)
appState.address.selectedId = selected?.id appState.address.selectedId = selected?.id
appState.address.display = selected?.label?.isEmpty == false ? (selected?.label ?? "Defina seu endereco") : "Defina seu endereco" appState.address.display = selected?.label?.isEmpty == false ? (selected?.label ?? "Defina seu endereco") : "Defina seu endereco"
@@ -314,6 +314,7 @@ struct AddressesView: View {
appState.profile.name = customer.name appState.profile.name = customer.name
appState.profile.email = customer.email appState.profile.email = customer.email
appState.profile.phone = customer.phoneNumber ?? appState.profile.phone appState.profile.phone = customer.phoneNumber ?? appState.profile.phone
appState.profile.profilePicture = customer.profilePicture ?? ""
SessionStateStore.setActiveUserKey( SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
) )
@@ -321,7 +322,7 @@ struct AddressesView: View {
} else { } else {
addresses = [] addresses = []
} }
if let selected = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first { if let selected = resolvePreferredAddress(from: addresses) {
appState.address.selectedId = selected.id appState.address.selectedId = selected.id
appState.address.display = selected.label ?? "Defina seu endereco" appState.address.display = selected.label ?? "Defina seu endereco"
if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
@@ -336,4 +337,39 @@ struct AddressesView: View {
isLoading = false isLoading = false
} }
private func resolvePreferredAddress(from list: [CustomerAddress]) -> CustomerAddress? {
guard list.isEmpty == false else { return nil }
let selectedId = appState.address.selectedId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if selectedId.isEmpty == false,
let byId = list.first(where: { ($0.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == selectedId }) {
return byId
}
let normalizedDisplay = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
if normalizedDisplay.isEmpty == false && normalizedDisplay != "defina seu endereco",
let byLabel = list.first(where: {
(($0.label ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()) == normalizedDisplay
}) {
return byLabel
}
if let lat = appState.address.latitude, let lng = appState.address.longitude,
let byCoordinate = list.first(where: { address in
guard let addrLat = address.latLong?.first,
let addrLng = address.latLong?.dropFirst().first else { return false }
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
}) {
return byCoordinate
}
return list.first
}
} }

View File

@@ -61,6 +61,7 @@ extension CheckoutView {
if let phoneNumber = customer.phoneNumber, phoneNumber.isEmpty == false { if let phoneNumber = customer.phoneNumber, phoneNumber.isEmpty == false {
appState.profile.phone = phoneNumber appState.profile.phone = phoneNumber
} }
appState.profile.profilePicture = customer.profilePicture ?? ""
SessionStateStore.setActiveUserKey( SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
) )
@@ -84,6 +85,15 @@ extension CheckoutView {
} }
} }
if selectedCustomerAddress == nil,
let lat = appState.address.latitude, let lng = appState.address.longitude {
selectedCustomerAddress = addresses.first { address in
guard let addrLat = address.latLong?.first,
let addrLng = address.latLong?.dropFirst().first else { return false }
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
}
}
if selectedCustomerAddress == nil { if selectedCustomerAddress == nil {
selectedCustomerAddress = addresses.first selectedCustomerAddress = addresses.first
} }

View File

@@ -93,22 +93,25 @@ extension HomeView {
return nil return nil
} }
if !forceRefresh { // If user selected/saved an address, always trust its coordinates.
// This avoids overriding the chosen city with current device GPS.
if let lat = appState.address.latitude, let lng = appState.address.longitude { if let lat = appState.address.latitude, let lng = appState.address.longitude {
return (lat, lng) return (lat, lng)
} }
if let cached = LocationService.shared.cachedLocation() {
if !forceRefresh, let cached = LocationService.shared.cachedLocation() {
appState.address.latitude = cached.0 appState.address.latitude = cached.0
appState.address.longitude = cached.1 appState.address.longitude = cached.1
return cached return cached
} }
// Fallback to device location only when no address coordinates are available.
let deviceCoordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3)
if let deviceCoordinate {
appState.address.latitude = deviceCoordinate.0
appState.address.longitude = deviceCoordinate.1
} }
let coordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3) return deviceCoordinate
if let coordinate {
appState.address.latitude = coordinate.0
appState.address.longitude = coordinate.1
}
return coordinate
} }
func hasConfiguredAddress() -> Bool { func hasConfiguredAddress() -> Bool {

View File

@@ -7,6 +7,7 @@ import UIKit
struct HomeView: View { struct HomeView: View {
@Binding var appState: AppState @Binding var appState: AppState
@Binding var selectedTab: MainTab
@State var searchText = "" @State var searchText = ""
@State var selectedCategory = "all" @State var selectedCategory = "all"
@State var categories: [CategoryModel] = [ @State var categories: [CategoryModel] = [
@@ -205,13 +206,24 @@ struct HomeView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Spacer().frame(height: 20) Spacer().frame(height: 20)
HStack(alignment: .center, spacing: 12) { HStack(alignment: .center, spacing: 12) {
Button {
selectedTab = .profile
} label: {
Circle() Circle()
.fill(AppColors.brandSoft) .fill(AppColors.brandSoft)
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.overlay( .overlay {
if let profilePictureURL {
AsyncStoreImage(imageURL: profilePictureURL)
.frame(width: 36, height: 36)
.clipShape(Circle())
} else {
Image(systemName: "person.fill") Image(systemName: "person.fill")
.foregroundStyle(AppColors.brandDark) .foregroundStyle(AppColors.brandDark)
) }
}
}
.buttonStyle(.plain)
VStack(alignment: .center, spacing: 4) { VStack(alignment: .center, spacing: 4) {
Text("DELIVERY LOCATION") Text("DELIVERY LOCATION")
@@ -376,18 +388,11 @@ struct HomeView: View {
} }
private func resolveStoreMediaURL(_ raw: String?) -> String? { private func resolveStoreMediaURL(_ raw: String?) -> String? {
guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines), ImageSourceResolver.resolve(raw)
normalized.isEmpty == false else { return nil }
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
let lower = normalized.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") {
return normalized
} }
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) private var profilePictureURL: String? {
let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)" resolveStoreMediaURL(appState.profile.profilePicture)
return "\(base)\(path)"
} }
private func formatDistance(_ distance: Double?) -> String { private func formatDistance(_ distance: Double?) -> String {
@@ -430,6 +435,15 @@ struct HomeView: View {
let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) { let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) {
isLoadingStores = false isLoadingStores = false
stores = cachedStores stores = cachedStores
#if os(iOS)
for store in cachedStores {
printLog(
title: "LOGO HOME",
msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")"
)
}
#endif
AppContentCache.shared.set(cachedStores, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
if refreshCategories || (category == nil && categories.count <= 1) { if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(withFallbackStores: cachedStores, forceRefresh: forceLocationRefresh) await loadHomeCategories(withFallbackStores: cachedStores, forceRefresh: forceLocationRefresh)
if categories.contains(where: { $0.id == selectedCategory }) == false { if categories.contains(where: { $0.id == selectedCategory }) == false {
@@ -453,7 +467,16 @@ struct HomeView: View {
} }
let results = response.result ?? [] let results = response.result ?? []
stores = results stores = results
#if os(iOS)
for store in results {
printLog(
title: "LOGO HOME",
msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")"
)
}
#endif
AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores) AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores)
AppContentCache.shared.set(results, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
if refreshCategories || (category == nil && categories.count <= 1) { if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(withFallbackStores: results, forceRefresh: forceLocationRefresh) await loadHomeCategories(withFallbackStores: results, forceRefresh: forceLocationRefresh)
if categories.contains(where: { $0.id == selectedCategory }) == false { if categories.contains(where: { $0.id == selectedCategory }) == false {
@@ -488,7 +511,9 @@ struct HomeView: View {
let display = appState.address.display let display = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() .lowercased()
return "\(selected)|\(display)" let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil"
let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil"
return "\(selected)|\(display)|\(lat)|\(lng)"
} }
private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String { private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String {

View File

@@ -12,7 +12,7 @@ struct MainTabView: View {
switch selectedTab { switch selectedTab {
case .home: case .home:
NavigationStack { NavigationStack {
HomeView(appState: $appState) HomeView(appState: $appState, selectedTab: $selectedTab)
} }
case .cart: case .cart:
NavigationStack { NavigationStack {

View File

@@ -18,6 +18,8 @@ struct OrderTrackingView: View {
@State var order: PublicOrderResult? = nil @State var order: PublicOrderResult? = nil
@State var tracker = OrderRealtimeTracker() @State var tracker = OrderRealtimeTracker()
@State var showCancellationReason = false @State var showCancellationReason = false
@State var reviewDraft: ReviewDraft? = nil
@State var didSaveReviewForCurrentOrder = false
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
@@ -27,8 +29,12 @@ struct OrderTrackingView: View {
statusBanner statusBanner
timelineSection timelineSection
placeholderCard placeholderCard
if shouldShowReviewButton {
reviewButton
} else {
contactButton contactButton
} }
}
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 14) .padding(.top, 14)
.padding(.bottom, UIDevice.bottomNotch + 45) .padding(.bottom, UIDevice.bottomNotch + 45)
@@ -53,6 +59,16 @@ struct OrderTrackingView: View {
.onDisappear { .onDisappear {
tracker.stop() tracker.stop()
} }
.navigationDestination(item: $reviewDraft) { draft in
MyReviewsView(initialOrder: draft)
}
.onReceive(NotificationCenter.default.publisher(for: .orderReviewDidSave)) { payload in
guard let orderIdFromEvent = payload.userInfo?["orderId"] as? String else { return }
let currentOrderId = (order?.id ?? orderId).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if orderIdFromEvent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == currentOrderId {
didSaveReviewForCurrentOrder = true
}
}
} }
private var topHeader: some View { private var topHeader: some View {
@@ -217,6 +233,19 @@ struct OrderTrackingView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
private var reviewButton: some View {
Button("AVALIAR PEDIDO") {
guard let reviewTargetDraft else { return }
reviewDraft = reviewTargetDraft
}
.font(AppTypography.heading2)
.foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56)
.background(Color(hex: "#7CF02A"))
.clipShape(Capsule())
.buttonStyle(.plain)
}
private func statusBadge(title: String, fg: Color, bg: Color, icon: String) -> some View { private func statusBadge(title: String, fg: Color, bg: Color, icon: String) -> some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: icon) Image(systemName: icon)
@@ -444,12 +473,43 @@ struct OrderTrackingView: View {
return customerOtpSubtitle return customerOtpSubtitle
} }
let label = (eventLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let label = (eventLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if label.isEmpty == false { if shouldUseTimelineEventLabel(label, fallback: fallback) {
return label return label
} }
return fallback return fallback
} }
private func shouldUseTimelineEventLabel(_ label: String, fallback: String) -> Bool {
guard label.isEmpty == false else { return false }
let foldedLabel = label
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
let foldedFallback = fallback
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
if foldedLabel == foldedFallback { return false }
let englishHints = [
"order",
"confirmed",
"in progress",
"progress",
"delivery",
"delivered",
"ready",
"sent",
"out for",
"began"
]
if englishHints.contains(where: { foldedLabel.contains($0) }) {
return false
}
return true
}
private var customerOtpSubtitle: String { private var customerOtpSubtitle: String {
if let otp = customerOtpCode { if let otp = customerOtpCode {
return "Código para o entregador: \(otp)" return "Código para o entregador: \(otp)"
@@ -548,6 +608,57 @@ struct OrderTrackingView: View {
return isCanceled == false && isWaitingPayment == false && stepIndex >= terminalIndex return isCanceled == false && isWaitingPayment == false && stepIndex >= terminalIndex
} }
private var shouldShowReviewButton: Bool {
guard isCompletedOrder else { return false }
guard isCanceled == false else { return false }
guard let reviewTargetDraft else { return false }
if didSaveReviewForCurrentOrder { return false }
if hasPersistedReviewForCurrentOrder { return false }
return order?.review == nil
}
private var hasPersistedReviewForCurrentOrder: Bool {
reviewIdCandidates.contains { candidate in
SessionStateStore.hasOrderReview(orderId: candidate)
}
}
private var reviewIdCandidates: [String] {
let values = [
orderId,
order?.id,
order?.realId,
order?.shortId
]
var unique: [String] = []
var seen = Set<String>()
for raw in values {
let normalized = (raw ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard normalized.isEmpty == false, seen.contains(normalized) == false else { continue }
seen.insert(normalized)
unique.append(normalized)
}
return unique
}
private var reviewTargetDraft: ReviewDraft? {
let idFromOrder = (order?.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let id = idFromOrder.isEmpty ? orderId : idFromOrder
guard id.isEmpty == false else { return nil }
return ReviewDraft(
orderId: id,
storeId: order?.storeId,
shortId: order?.shortId ?? initialShortId,
storeName: order?.storeName,
storeLogoURL: order?.storeLogoURL,
createdAt: order?.createdAt,
total: order?.total
)
}
private var cancellationReasonText: String { private var cancellationReasonText: String {
let value = (order?.cancellationReason ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let value = (order?.cancellationReason ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? "Sem detalhe informado." : value return value.isEmpty ? "Sem detalhe informado." : value
@@ -590,7 +701,7 @@ struct OrderTrackingView: View {
case 1: case 1:
return "tracking-preparing" return "tracking-preparing"
case 2: case 2:
return isPickup ? "tracking-ready" : "tracking-preparing" return "tracking-ready"
case 3: case 3:
return "tracking-delivering" return "tracking-delivering"
default: default:
@@ -636,24 +747,21 @@ struct OrderTrackingView: View {
@MainActor @MainActor
private func loadInitialOrder() async { private func loadInitialOrder() async {
if let cached = SessionStateStore.loadTrackedOrder(orderId: orderId) { logger.info("OrderTracking initial fetch orderId=\(orderId, privacy: .public)")
order = cached
isLoading = false
}
do { do {
let response = try await ApiService().publicOrder(orderId: orderId) let response = try await ApiService().publicOrder(orderId: orderId)
if response.error { if response.error {
errorMessage = response.message ?? "Não foi possível carregar o pedido." errorMessage = response.message ?? "Não foi possível carregar o pedido."
logger.error("OrderTracking initial fetch API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
} else if let result = response.result { } else if let result = response.result {
order = result order = result
SessionStateStore.saveTrackedOrder(result)
errorMessage = nil errorMessage = nil
logger.info("OrderTracking initial fetch success orderId=\(orderId, privacy: .public) status=\((result.status ?? "nil"), privacy: .public) paymentStatus=\((result.paymentStatus ?? "nil"), privacy: .public)")
} }
} catch { } catch {
if order == nil {
errorMessage = "Não foi possível carregar o pedido." errorMessage = "Não foi possível carregar o pedido."
} logger.error("OrderTracking initial fetch failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
} }
isLoading = false isLoading = false

View File

@@ -17,7 +17,12 @@ struct ProfileView: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 18) { VStack(spacing: 18) {
NavigationLink {
UserProfileView(appState: $appState)
} label: {
header header
}
.buttonStyle(.plain)
VStack(spacing: 14) { VStack(spacing: 14) {
NavigationLink { NavigationLink {
@@ -42,7 +47,7 @@ struct ProfileView: View {
// .buttonStyle(.plain) // .buttonStyle(.plain)
NavigationLink { NavigationLink {
Text("Minhas Avaliações") MyReviewsView()
} label: { } label: {
ProfileMenuRow(icon: "star.fill", title: "Minhas Avaliações") ProfileMenuRow(icon: "star.fill", title: "Minhas Avaliações")
} }
@@ -122,9 +127,17 @@ struct ProfileView: View {
.fill(Color.white.opacity(0.18)) .fill(Color.white.opacity(0.18))
.frame(width: 96, height: 96) .frame(width: 96, height: 96)
.overlay( .overlay(
Group {
if let picture = profilePictureURL {
AsyncStoreImage(imageURL: picture)
.frame(width: 92, height: 92)
.clipShape(Circle())
} else {
Text(profileInitials) Text(profileInitials)
.font(.system(size: 30, weight: .bold)) .font(.system(size: 30, weight: .bold))
.foregroundStyle(AppColors.textInverse) .foregroundStyle(AppColors.textInverse)
}
}
) )
Circle() Circle()
@@ -189,6 +202,12 @@ struct ProfileView: View {
return joined.isEmpty ? "AS" : joined.uppercased() return joined.isEmpty ? "AS" : joined.uppercased()
} }
private var profilePictureURL: String? {
let raw = appState.profile.profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
guard raw.isEmpty == false else { return nil }
return ImageSourceResolver.resolve(raw)
}
private func logout() { private func logout() {
tokenStore.clear() tokenStore.clear()
SessionStateStore.clearActiveUser() SessionStateStore.clearActiveUser()

View File

@@ -143,17 +143,7 @@ extension StoreDetailView {
} }
func resolvedURL(_ raw: String?) -> String? { func resolvedURL(_ raw: String?) -> String? {
guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines), ImageSourceResolver.resolve(raw)
normalized.isEmpty == false else { return nil }
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
let lower = normalized.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") {
return normalized
}
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)"
return "\(base)\(path)"
} }
func formatCurrency(_ value: Double?) -> String { func formatCurrency(_ value: Double?) -> String {

View File

@@ -0,0 +1,269 @@
import SwiftUI
#if canImport(PhotosUI) && os(iOS)
import PhotosUI
#endif
#if canImport(UIKit)
import UIKit
#endif
struct UserProfileView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@State var name: String = ""
@State var email: String = ""
@State var phone: String = ""
@State var profilePicture: String = ""
@State var isSaving = false
#if canImport(PhotosUI) && os(iOS)
@State var selectedPhotoItem: PhotosPickerItem?
#endif
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 22) {
avatarSection
formSection
saveButton
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, UIDevice.bottomNotch + 24)
}
.background(AppColors.backgroundLight)
.navigationTitle("Meu Perfil")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
hydrateFromAppState()
}
#if canImport(PhotosUI) && os(iOS)
.onChange(of: selectedPhotoItem) { _, newItem in
Task { await applySelectedPhoto(newItem) }
}
#endif
}
private var avatarSection: some View {
VStack(spacing: 12) {
Circle()
.fill(AppColors.brandSoft)
.frame(width: 110, height: 110)
.overlay {
if let imageSource = resolvedProfilePicture {
AsyncStoreImage(imageURL: imageSource)
.frame(width: 104, height: 104)
.clipShape(Circle())
} else {
Text(initials)
.font(.system(size: 32, weight: .bold))
.foregroundStyle(AppColors.primary)
}
}
HStack(spacing: 10) {
#if canImport(PhotosUI) && os(iOS)
PhotosPicker(selection: $selectedPhotoItem, matching: .images, photoLibrary: .shared()) {
Text("Trocar Foto")
.font(AppTypography.caption)
.foregroundStyle(AppColors.primary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(AppColors.surface)
.clipShape(Capsule())
}
#endif
Button("Remover") {
profilePicture = ""
}
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.buttonStyle(.plain)
.disabled(resolvedProfilePicture == nil)
}
}
.frame(maxWidth: .infinity)
}
private var formSection: some View {
VStack(alignment: .leading, spacing: 14) {
textFieldSection(title: "Nome", placeholder: "Seu nome completo", text: $name)
textFieldSection(title: "E-mail", placeholder: "seu@email.com", text: $email)
.appNoAutoCap()
textFieldSection(title: "Telefone", placeholder: "(00) 00000-0000", text: $phone)
.onChange(of: phone) { _, newValue in
let masked = formatPhoneBR(displayPhoneDigits(newValue))
if masked != newValue {
phone = masked
}
}
.appNoAutoCap()
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var saveButton: some View {
Button(isSaving ? "Salvando..." : "Salvar Alterações") {
Task { await saveProfile() }
}
.font(AppTypography.button)
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: .infinity, minHeight: 56)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.buttonStyle(.plain)
.disabled(isSaving || canSave == false)
.opacity((isSaving || canSave == false) ? 0.6 : 1.0)
}
private var resolvedProfilePicture: String? {
let trimmed = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return nil }
return ImageSourceResolver.resolve(trimmed)
}
private var initials: String {
let parts = name
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: " ")
.prefix(2)
let letters = parts.compactMap { $0.first }.map(String.init).joined()
return letters.isEmpty ? "PF" : letters.uppercased()
}
private var canSave: Bool {
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPhone = normalizePhoneNumberForAPI(phone)
return cleanName.isEmpty == false
&& cleanEmail.isEmpty == false
&& cleanEmail.contains("@")
&& normalizedPhone.isEmpty == false
}
private func hydrateFromAppState() {
name = appState.profile.name
email = appState.profile.email
phone = formatPhoneForDisplay(appState.profile.phone)
profilePicture = appState.profile.profilePicture
}
@MainActor
private func saveProfile() async {
guard canSave else { return }
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPhone = normalizePhoneNumberForAPI(phone)
let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
isSaving = true
defer { isSaving = false }
do {
let response = try await ApiService().updateCustomerProfile(
name: cleanName,
email: cleanEmail,
phoneNumber: normalizedPhone,
profilePicture: cleanPhoto.isEmpty ? nil : cleanPhoto
)
if response.error {
SnackbarCenter.shared.show(
title: response.message ?? "Não foi possível atualizar seu perfil.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
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
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email)
)
SnackbarCenter.shared.show(
title: "Perfil atualizado com sucesso.",
style: .success,
icon: "checkmark.circle.fill",
duration: 2.0
)
dismiss()
} catch {
SnackbarCenter.shared.show(
title: "Não foi possível atualizar seu perfil.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
}
}
private func formatPhoneForDisplay(_ raw: String) -> String {
let digits = displayPhoneDigits(raw)
if digits.isEmpty { return "" }
return formatPhoneBR(digits)
}
private func displayPhoneDigits(_ raw: String) -> String {
var digits = raw.filter(\.isNumber)
if digits.hasPrefix("55"), digits.count > 11 {
digits = String(digits.dropFirst(2))
}
return String(digits.prefix(11))
}
private func textFieldSection(title: String, placeholder: String, text: Binding<String>) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: text)
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 12)
.frame(height: 50)
.background(AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(AppColors.secondary.opacity(0.2), lineWidth: 1)
)
}
}
#if canImport(PhotosUI) && 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 }
#if canImport(UIKit)
guard let image = UIImage(data: data),
let jpegData = image.jpegData(compressionQuality: 0.82) else { return }
profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
#else
profilePicture = "data:image/jpeg;base64,\(data.base64EncodedString())"
#endif
} catch {
SnackbarCenter.shared.show(
title: "Não foi possível carregar a foto selecionada.",
style: .warning,
icon: "photo",
duration: 2.5
)
}
}
#endif
}