Fix
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
270
pedi-foods/Sources/PediFoods/Services/ApiReviewModels.swift
Normal file
270
pedi-foods/Sources/PediFoods/Services/ApiReviewModels.swift
Normal 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 = []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,22 +93,25 @@ extension HomeView {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !forceRefresh {
|
// If user selected/saved an address, always trust its coordinates.
|
||||||
if let lat = appState.address.latitude, let lng = appState.address.longitude {
|
// This avoids overriding the chosen city with current device GPS.
|
||||||
return (lat, lng)
|
if let lat = appState.address.latitude, let lng = appState.address.longitude {
|
||||||
}
|
return (lat, lng)
|
||||||
if let cached = LocationService.shared.cachedLocation() {
|
|
||||||
appState.address.latitude = cached.0
|
|
||||||
appState.address.longitude = cached.1
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let coordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3)
|
|
||||||
if let coordinate {
|
if !forceRefresh, let cached = LocationService.shared.cachedLocation() {
|
||||||
appState.address.latitude = coordinate.0
|
appState.address.latitude = cached.0
|
||||||
appState.address.longitude = coordinate.1
|
appState.address.longitude = cached.1
|
||||||
|
return cached
|
||||||
}
|
}
|
||||||
return coordinate
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
return deviceCoordinate
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasConfiguredAddress() -> Bool {
|
func hasConfiguredAddress() -> Bool {
|
||||||
|
|||||||
@@ -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) {
|
||||||
Circle()
|
Button {
|
||||||
.fill(AppColors.brandSoft)
|
selectedTab = .profile
|
||||||
.frame(width: 40, height: 40)
|
} label: {
|
||||||
.overlay(
|
Circle()
|
||||||
Image(systemName: "person.fill")
|
.fill(AppColors.brandSoft)
|
||||||
.foregroundStyle(AppColors.brandDark)
|
.frame(width: 40, height: 40)
|
||||||
)
|
.overlay {
|
||||||
|
if let profilePictureURL {
|
||||||
|
AsyncStoreImage(imageURL: profilePictureURL)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.clipShape(Circle())
|
||||||
|
} else {
|
||||||
|
Image(systemName: "person.fill")
|
||||||
|
.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: "/")
|
private var profilePictureURL: String? {
|
||||||
let lower = normalized.lowercased()
|
resolveStoreMediaURL(appState.profile.profilePicture)
|
||||||
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)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,7 +29,11 @@ struct OrderTrackingView: View {
|
|||||||
statusBanner
|
statusBanner
|
||||||
timelineSection
|
timelineSection
|
||||||
placeholderCard
|
placeholderCard
|
||||||
contactButton
|
if shouldShowReviewButton {
|
||||||
|
reviewButton
|
||||||
|
} else {
|
||||||
|
contactButton
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, 14)
|
.padding(.top, 14)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
header
|
NavigationLink {
|
||||||
|
UserProfileView(appState: $appState)
|
||||||
|
} label: {
|
||||||
|
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(
|
||||||
Text(profileInitials)
|
Group {
|
||||||
.font(.system(size: 30, weight: .bold))
|
if let picture = profilePictureURL {
|
||||||
.foregroundStyle(AppColors.textInverse)
|
AsyncStoreImage(imageURL: picture)
|
||||||
|
.frame(width: 92, height: 92)
|
||||||
|
.clipShape(Circle())
|
||||||
|
} else {
|
||||||
|
Text(profileInitials)
|
||||||
|
.font(.system(size: 30, weight: .bold))
|
||||||
|
.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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
269
pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift
Normal file
269
pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user