diff --git a/API_Mobile_App.md b/API_Mobile_App.md index 062e4f9..3150af2 100644 --- a/API_Mobile_App.md +++ b/API_Mobile_App.md @@ -565,3 +565,220 @@ Se o socket cair, o app deve continuar consultando o status até estado final. - próximos 2min: a cada `5s` - depois: a cada `10s` - 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 ` (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. diff --git a/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift b/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift index 25beef3..9cec3ae 100644 --- a/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift +++ b/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift @@ -139,10 +139,7 @@ final class CachedRemoteImageLoader: ObservableObject { } private static func normalizeImageSource(_ value: String?) -> String? { - guard var normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines), - normalized.isEmpty == false else { return nil } - normalized = normalized.replacingOccurrences(of: "\\/", with: "/") - return normalized + ImageSourceResolver.resolve(value) } private static func dataURLCacheKey(_ source: String) -> String { diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index 640301e..f96e72b 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -127,6 +127,7 @@ struct ContentView: View { appState.profile.name = customer.name appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? "" + appState.profile.profilePicture = customer.profilePicture ?? "" SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) @@ -143,13 +144,49 @@ struct ContentView: View { return } - let preferredAddress = addresses.first(where: { $0.id == appState.address.selectedId }) ?? addresses.first + let preferredAddress = resolvePreferredAddress(from: addresses, current: appState.address) if let preferredAddress { applyAddress(preferredAddress) 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 private func applyAddress(_ address: CustomerAddress) { appState.address.selectedId = address.id diff --git a/pedi-foods/Sources/PediFoods/DesignSystem/DesignSystem.swift b/pedi-foods/Sources/PediFoods/DesignSystem/DesignSystem.swift index 2a5fcc4..a644015 100644 --- a/pedi-foods/Sources/PediFoods/DesignSystem/DesignSystem.swift +++ b/pedi-foods/Sources/PediFoods/DesignSystem/DesignSystem.swift @@ -36,6 +36,7 @@ enum AppTypography { // Avoid applying dynamic weight on custom font descriptors to prevent // SwiftUI runtime warnings on some platforms/toolchains. 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 heading3 = Font.custom(fontFamily, size: 16) static let body = Font.custom(fontFamily, size: 16) diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index eaa759a..41be62f 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -3,6 +3,10 @@ "strings" : { "" : { + }, + " " : { + "comment" : "A placeholder text used to maintain vertical spacing in the UI.", + "isCommentAutoGenerated" : true }, "..." : { @@ -51,6 +55,18 @@ }, "%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.", @@ -115,6 +131,10 @@ "comment" : "A heading for the additional options available for a product.", "isCommentAutoGenerated" : true }, + "Adicionar fotos" : { + "comment" : "A label for adding photos in the order review section.", + "isCommentAutoGenerated" : true + }, "Adicionar novo endereço" : { "comment" : "A button label that translates to \"Add new address\" in English.", "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.", "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!" : { "comment" : "A welcome message displayed in the login view.", "isCommentAutoGenerated" : true @@ -257,6 +299,14 @@ "comment" : "A button that allows the user to paste their OTP code directly into the field.", "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" : { "comment" : "The text for a button that confirms and closes a sheet.", "isCommentAutoGenerated" : true @@ -273,6 +323,10 @@ "comment" : "The text on a button that takes the user to contact support.", "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..." : { "comment" : "A placeholder text describing the content of the privacy policy.", "isCommentAutoGenerated" : true @@ -365,16 +419,20 @@ "comment" : "A label describing the delivery location section.", "isCommentAutoGenerated" : true }, - "Descubra lorem ipsum ba bla" : { - "comment" : "A welcome message displayed on the login screen.", - "isCommentAutoGenerated" : true - }, "Detalhes" : { "comment" : "The title of the navigation bar at the top of the product detail sheet.", "isCommentAutoGenerated" : true }, + "Do que você gostou?" : { + "comment" : "A prompt asking users to mention what they liked about a review.", + "isCommentAutoGenerated" : true + }, "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" : { "comment" : "A label displayed above the address section when delivery is selected.", @@ -387,6 +445,9 @@ "Entrega" : { "comment" : "A text describing delivery mode.", "isCommentAutoGenerated" : true + }, + "Enviar Avaliação" : { + }, "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.", @@ -396,6 +457,10 @@ "comment" : "A description under the QR code that instructs the user on how to pay using their bank app.", "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" : { "comment" : "A label displayed below the pizza size selection.", "isCommentAutoGenerated" : true @@ -581,6 +646,10 @@ "comment" : "The title of the user's cart screen.", "isCommentAutoGenerated" : true }, + "Meu Perfil" : { + "comment" : "The title of the user profile view.", + "isCommentAutoGenerated" : true + }, "Meus Endereços" : { "comment" : "A label displayed at the top of the view.", "isCommentAutoGenerated" : true @@ -650,6 +719,10 @@ }, "Nenhum pedido encontrado." : { + }, + "Nota do pedido" : { + "comment" : "A title for the rating of a review.", + "isCommentAutoGenerated" : true }, "Notes" : { "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.", "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?" : { "comment" : "A title displayed above the search bar in the home view.", "isCommentAutoGenerated" : true }, "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" : { "comment" : "The title of the screen where a user can enter and save payment information.", @@ -710,6 +803,16 @@ }, "Pedido #%@" : { + }, + "Pedido #%@ • %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Pedido #%1$@ • %2$@" + } + } + } }, "Pedido %@" : { @@ -778,6 +881,10 @@ }, "Progresso do Pedido" : { + }, + "Reavaliar" : { + "comment" : "The text for a button that allows a user to re-evaluate an order.", + "isCommentAutoGenerated" : true }, "Receber Código" : { "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.", "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" : { }, @@ -811,6 +926,14 @@ }, "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" : { "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.", "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" : { "comment" : "Menu item indicating that the appearance should be in the default system mode", "extractionState" : "stale", @@ -989,6 +1116,14 @@ "comment" : "A title for an alert that prompts the user to switch stores.", "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" : { }, @@ -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" : { "comment" : "Tab bar item title for the Welcome tab", diff --git a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift index 66db1b7..2a32bfc 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift @@ -84,12 +84,8 @@ final class ApiClient { func send(_ request: ApiRequest) async throws -> T { // NOTE: // /api/customer/login is strict about body fields (email/phoneNumber/otp). - // On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is. - // Also route order-related polling/listing through URLSession to avoid - // 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/") { + // On iOS, route this endpoint through URLSession to preserve raw JSON body behavior. + if request.path == "/api/customer/login" { if let body = request.body, let bodyText = String(data: body, encoding: .utf8) { print("[ApiClient] /api/customer/login body: \(bodyText)") } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift index 77c7671..a8ff063 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift @@ -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 { let label: String? let address: String? diff --git a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift index c761ef6..c3f3843 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift @@ -114,6 +114,9 @@ struct StoreSummary: Decodable { struct StoreInfoResult: Decodable { let isOpen: Bool? let statusLabel: String? + let fantasyName: String? + let logo: String? + let cover: String? let deliveryTime: String? let minOrder: Double? let address: StoreAddressInfo? @@ -122,6 +125,9 @@ struct StoreInfoResult: Decodable { enum CodingKeys: String, CodingKey { case isOpen case statusLabel + case fantasyName + case logo + case cover case deliveryTime case minOrder case address @@ -132,6 +138,9 @@ struct StoreInfoResult: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) isOpen = try? container.decode(Bool.self, forKey: .isOpen) 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) minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder]) address = try? container.decode(StoreAddressInfo.self, forKey: .address) diff --git a/pedi-foods/Sources/PediFoods/Services/ApiReviewModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiReviewModels.swift new file mode 100644 index 0000000..1a356f0 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ApiReviewModels.swift @@ -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, + 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, + 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) -> 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) -> 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 = [] + } +} diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index 8c6883e..da0e965 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -148,6 +148,31 @@ final class ApiService { return envelope } + func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ApiEnvelope { + 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 = 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 { return try await saveCustomerAddress(address, replacingAddressId: nil) } @@ -297,7 +322,13 @@ final class ApiService { 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) if envelope.error == false { AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours) @@ -306,7 +337,46 @@ final class ApiService { } func publicOrder(orderId: String) async throws -> ApiEnvelope { - 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 { + 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 { + 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 { + 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) } } diff --git a/pedi-foods/Sources/PediFoods/Services/AppCache.swift b/pedi-foods/Sources/PediFoods/Services/AppCache.swift index d199bf0..b990e60 100644 --- a/pedi-foods/Sources/PediFoods/Services/AppCache.swift +++ b/pedi-foods/Sources/PediFoods/Services/AppCache.swift @@ -12,6 +12,10 @@ enum AppCacheTTL { static let homeStores: TimeInterval = 5 * 60 } +enum AppCacheKey { + static let homeStoresLatestSnapshot = "home-stores.latest.snapshot" +} + final class AppContentCache: @unchecked Sendable { static let shared = AppContentCache() diff --git a/pedi-foods/Sources/PediFoods/Services/ImageSourceResolver.swift b/pedi-foods/Sources/PediFoods/Services/ImageSourceResolver.swift new file mode 100644 index 0000000..54dbdfa --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Services/ImageSourceResolver.swift @@ -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)" + } +} diff --git a/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift b/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift index fd68a02..81548f9 100644 --- a/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift +++ b/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift @@ -76,13 +76,18 @@ final class OrderRealtimeTracker { @discardableResult private func fetchLatest(orderId: String) async -> PublicOrderResult? { do { + logger.debug("OrderTracking poll request orderId=\(orderId, privacy: .public)") let response = try await ApiService().publicOrder(orderId: orderId) - guard response.error == false, let order = response.result else { return nil } - SessionStateStore.saveTrackedOrder(order) + guard response.error == false, let order = response.result else { + logger.error("OrderTracking poll API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)") + return nil + } 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) return order } catch { + logger.error("OrderTracking poll failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") return nil } } diff --git a/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift index 593e531..4746314 100644 --- a/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift +++ b/pedi-foods/Sources/PediFoods/Services/SessionEvents.swift @@ -3,4 +3,5 @@ import Foundation extension Notification.Name { static let sessionExpired = Notification.Name("SessionExpiredNotification") static let cartDidReset = Notification.Name("CartDidResetNotification") + static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification") } diff --git a/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift b/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift index fd92220..b1457da 100644 --- a/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift +++ b/pedi-foods/Sources/PediFoods/Services/SessionStateStore.swift @@ -37,6 +37,48 @@ private struct PersistedTrackedOrdersState: Codable { 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 { private static let legacyAddressKey = "session.address.state.v1" 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 trackedOrdersKeyPrefix = "session.orders.tracking.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? { let id = (profileId ?? "") @@ -261,6 +305,23 @@ enum SessionStateStore { 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] { let defaults = UserDefaults.standard let key = trackedOrdersStorageKey(for: nil) @@ -315,4 +376,66 @@ enum SessionStateStore { static func clearPendingCartOrder() { 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) + } } diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index cb0264a..1b3de3d 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -26,6 +26,7 @@ struct ProfileState { var name: String = "" var email: String = "" var phone: String = "" + var profilePicture: String = "" } struct AddressState { diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift index f7be7e1..2cb2ea8 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift @@ -55,8 +55,8 @@ struct LoginView: View { .scaledToFit() .frame(height: 180) - Text("Descubra lorem ipsum ba bla") - .font(AppTypography.heading1) + Text("Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque.") + .font(AppTypography.heading25) .foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift index 32a9e22..c47c965 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift @@ -261,6 +261,7 @@ struct OtpView: View { appState.profile.name = customer.name appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? "" + appState.profile.profilePicture = customer.profilePicture ?? "" SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift index d32d6c9..f710b00 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift @@ -208,7 +208,7 @@ struct AddressesView: View { } 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.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.email = customer.email appState.profile.phone = customer.phoneNumber ?? appState.profile.phone + appState.profile.profilePicture = customer.profilePicture ?? "" SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) @@ -321,7 +322,7 @@ struct AddressesView: View { } else { 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.display = selected.label ?? "Defina seu endereco" if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first { @@ -336,4 +337,39 @@ struct AddressesView: View { 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 + } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift index 469747f..250dcb3 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -61,6 +61,7 @@ extension CheckoutView { if let phoneNumber = customer.phoneNumber, phoneNumber.isEmpty == false { appState.profile.phone = phoneNumber } + appState.profile.profilePicture = customer.profilePicture ?? "" SessionStateStore.setActiveUserKey( 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 { selectedCustomerAddress = addresses.first } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift index 02d2df3..355fa8b 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Data.swift @@ -93,22 +93,25 @@ extension HomeView { return nil } - if !forceRefresh { - 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 - } + // If user selected/saved an address, always trust its coordinates. + // This avoids overriding the chosen city with current device GPS. + if let lat = appState.address.latitude, let lng = appState.address.longitude { + return (lat, lng) } - let coordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3) - if let coordinate { - appState.address.latitude = coordinate.0 - appState.address.longitude = coordinate.1 + + if !forceRefresh, let cached = LocationService.shared.cachedLocation() { + appState.address.latitude = cached.0 + 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 { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index a823d3f..6ef068d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -7,6 +7,7 @@ import UIKit struct HomeView: View { @Binding var appState: AppState + @Binding var selectedTab: MainTab @State var searchText = "" @State var selectedCategory = "all" @State var categories: [CategoryModel] = [ @@ -205,13 +206,24 @@ struct HomeView: View { VStack(alignment: .leading, spacing: 16) { Spacer().frame(height: 20) HStack(alignment: .center, spacing: 12) { - Circle() - .fill(AppColors.brandSoft) - .frame(width: 40, height: 40) - .overlay( - Image(systemName: "person.fill") - .foregroundStyle(AppColors.brandDark) - ) + Button { + selectedTab = .profile + } label: { + Circle() + .fill(AppColors.brandSoft) + .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) { Text("DELIVERY LOCATION") @@ -376,18 +388,11 @@ struct HomeView: View { } private func resolveStoreMediaURL(_ raw: String?) -> String? { - guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines), - normalized.isEmpty == false else { return nil } + ImageSourceResolver.resolve(raw) + } - 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)" + private var profilePictureURL: String? { + resolveStoreMediaURL(appState.profile.profilePicture) } private func formatDistance(_ distance: Double?) -> String { @@ -430,6 +435,15 @@ struct HomeView: View { let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) { isLoadingStores = false 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) { await loadHomeCategories(withFallbackStores: cachedStores, forceRefresh: forceLocationRefresh) if categories.contains(where: { $0.id == selectedCategory }) == false { @@ -453,7 +467,16 @@ struct HomeView: View { } let results = response.result ?? [] 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: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours) if refreshCategories || (category == nil && categories.count <= 1) { await loadHomeCategories(withFallbackStores: results, forceRefresh: forceLocationRefresh) if categories.contains(where: { $0.id == selectedCategory }) == false { @@ -488,7 +511,9 @@ struct HomeView: View { let display = appState.address.display .trimmingCharacters(in: .whitespacesAndNewlines) .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 { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift index c2971d2..728d850 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift @@ -12,7 +12,7 @@ struct MainTabView: View { switch selectedTab { case .home: NavigationStack { - HomeView(appState: $appState) + HomeView(appState: $appState, selectedTab: $selectedTab) } case .cart: NavigationStack { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift index e52004c..38129b3 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift @@ -18,6 +18,8 @@ struct OrderTrackingView: View { @State var order: PublicOrderResult? = nil @State var tracker = OrderRealtimeTracker() @State var showCancellationReason = false + @State var reviewDraft: ReviewDraft? = nil + @State var didSaveReviewForCurrentOrder = false var body: some View { ScrollView(showsIndicators: false) { @@ -27,7 +29,11 @@ struct OrderTrackingView: View { statusBanner timelineSection placeholderCard - contactButton + if shouldShowReviewButton { + reviewButton + } else { + contactButton + } } .padding(.horizontal, 20) .padding(.top, 14) @@ -53,6 +59,16 @@ struct OrderTrackingView: View { .onDisappear { 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 { @@ -217,6 +233,19 @@ struct OrderTrackingView: View { .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 { HStack(spacing: 8) { Image(systemName: icon) @@ -444,12 +473,43 @@ struct OrderTrackingView: View { return customerOtpSubtitle } let label = (eventLabel ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if label.isEmpty == false { + if shouldUseTimelineEventLabel(label, fallback: fallback) { return label } 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 { if let otp = customerOtpCode { return "Código para o entregador: \(otp)" @@ -548,6 +608,57 @@ struct OrderTrackingView: View { 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() + 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 { let value = (order?.cancellationReason ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? "Sem detalhe informado." : value @@ -590,7 +701,7 @@ struct OrderTrackingView: View { case 1: return "tracking-preparing" case 2: - return isPickup ? "tracking-ready" : "tracking-preparing" + return "tracking-ready" case 3: return "tracking-delivering" default: @@ -636,24 +747,21 @@ struct OrderTrackingView: View { @MainActor private func loadInitialOrder() async { - if let cached = SessionStateStore.loadTrackedOrder(orderId: orderId) { - order = cached - isLoading = false - } + logger.info("OrderTracking initial fetch orderId=\(orderId, privacy: .public)") do { let response = try await ApiService().publicOrder(orderId: orderId) if response.error { 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 { order = result - SessionStateStore.saveTrackedOrder(result) 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 { - 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 diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index ad11989..c261cfe 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -17,7 +17,12 @@ struct ProfileView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 18) { - header + NavigationLink { + UserProfileView(appState: $appState) + } label: { + header + } + .buttonStyle(.plain) VStack(spacing: 14) { NavigationLink { @@ -42,7 +47,7 @@ struct ProfileView: View { // .buttonStyle(.plain) NavigationLink { - Text("Minhas Avaliações") + MyReviewsView() } label: { ProfileMenuRow(icon: "star.fill", title: "Minhas Avaliações") } @@ -122,9 +127,17 @@ struct ProfileView: View { .fill(Color.white.opacity(0.18)) .frame(width: 96, height: 96) .overlay( - Text(profileInitials) - .font(.system(size: 30, weight: .bold)) - .foregroundStyle(AppColors.textInverse) + Group { + if let picture = profilePictureURL { + AsyncStoreImage(imageURL: picture) + .frame(width: 92, height: 92) + .clipShape(Circle()) + } else { + Text(profileInitials) + .font(.system(size: 30, weight: .bold)) + .foregroundStyle(AppColors.textInverse) + } + } ) Circle() @@ -189,6 +202,12 @@ struct ProfileView: View { 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() { tokenStore.clear() SessionStateStore.clearActiveUser() diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift index 35cbbbc..05fae25 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift @@ -143,17 +143,7 @@ extension StoreDetailView { } func resolvedURL(_ 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 - } - let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)" - return "\(base)\(path)" + ImageSourceResolver.resolve(raw) } func formatCurrency(_ value: Double?) -> String { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift new file mode 100644 index 0000000..d7b49bc --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift @@ -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) -> 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 +}