diff --git a/logo/alelocard_logo.png b/logo/alelocard_logo.png new file mode 100644 index 0000000..9201194 Binary files /dev/null and b/logo/alelocard_logo.png differ diff --git a/logo/amexcard_logo.png b/logo/amexcard_logo.png new file mode 100644 index 0000000..42edc89 Binary files /dev/null and b/logo/amexcard_logo.png differ diff --git a/logo/hipercard_logo.png b/logo/hipercard_logo.png new file mode 100644 index 0000000..a184c60 Binary files /dev/null and b/logo/hipercard_logo.png differ diff --git a/logo/mastercard_logo.png b/logo/mastercard_logo.png new file mode 100644 index 0000000..fefd01b Binary files /dev/null and b/logo/mastercard_logo.png differ diff --git a/logo/sodexo_logo.png b/logo/sodexo_logo.png new file mode 100644 index 0000000..1aefb69 Binary files /dev/null and b/logo/sodexo_logo.png differ diff --git a/logo/visacard_logo.png b/logo/visacard_logo.png new file mode 100644 index 0000000..279c267 Binary files /dev/null and b/logo/visacard_logo.png differ diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 29f9fe1..143f19f 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -78,6 +78,9 @@ "+ %@" : { "comment" : "A small, secondary text label displaying the price of an item.", "isCommentAutoGenerated" : true + }, + "0000 0000 0000 0000" : { + }, "1. Tamanho" : { "comment" : "A heading for the size selection step in the pizza product detail sheet.", @@ -179,6 +182,9 @@ "Alterar" : { "comment" : "A button label that translates to \"Change\" in English.", "isCommentAutoGenerated" : true + }, + "Ao menos um cartão deve permanecer" : { + }, "Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)" : { "comment" : "A text that informs the user that registering implies acceptance of the app's terms of use and privacy policy. The text is clickable and navigates to the respective views when tapped.", @@ -299,6 +305,9 @@ }, "Carregando cardápio..." : { + }, + "Carregando endereços..." : { + }, "Carregando pedido..." : { @@ -334,10 +343,6 @@ "comment" : "A label for the comment section in the order review view.", "isCommentAutoGenerated" : true }, - "Concluir" : { - "comment" : "The text for a button that confirms and closes a sheet.", - "isCommentAutoGenerated" : true - }, "Conexão segura e criptografada" : { "comment" : "A description below the \"Verificar e Entrar\" button, emphasizing the security of the login process.", "isCommentAutoGenerated" : true @@ -437,6 +442,9 @@ } } } + }, + "Definir como principal" : { + }, "Desconto" : { "comment" : "A label for a discount applied to an order.", @@ -462,6 +470,9 @@ "Em uma escala de 0 a 10, qual é a chance de você indicar o Pedi Foods para um amigo?" : { "comment" : "A description under the NPS scale question in the order review view.", "isCommentAutoGenerated" : true + }, + "Endereço de cobrança" : { + }, "ENDEREÇO DE ENTREGA" : { "comment" : "A label displayed above the address section when delivery is selected.", @@ -628,6 +639,12 @@ "Já tem uma conta?" : { "comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.", "isCommentAutoGenerated" : true + }, + "Ligar para %@" : { + + }, + "Ligar para a loja?" : { + }, "Light" : { "comment" : "Menu item indicating that the appearance should be in light mode", @@ -666,6 +683,9 @@ "Limpar carrinho e adicionar" : { "comment" : "A button that clears the current cart and adds the selected product.", "isCommentAutoGenerated" : true + }, + "Limpar e adicionar" : { + }, "Loja não informou métodos presenciais." : { @@ -684,6 +704,9 @@ "Meu Perfil" : { "comment" : "The title of the user profile view.", "isCommentAutoGenerated" : true + }, + "Meus Cartões" : { + }, "Meus Endereços" : { "comment" : "A label displayed at the top of the view.", @@ -746,6 +769,9 @@ }, "Não tem conta ainda?" : { + }, + "Nenhum cartão cadastrado" : { + }, "Nenhum endereço cadastrado" : { "comment" : "A message displayed when a user has no addresses saved.", @@ -801,6 +827,9 @@ "Novo por aqui?" : { "comment" : "A text that appears at the bottom of the screen, inviting users to create an account.", "isCommentAutoGenerated" : true + }, + "Número do cartão" : { + }, "O que pode melhorar?" : { "comment" : "A label for the section where users can select tags they think could improve an order.", @@ -929,6 +958,9 @@ "Preencha os dados abaixo para começar." : { "comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.", "isCommentAutoGenerated" : true + }, + "Principal" : { + }, "PRINCIPAL" : { "comment" : "A label indicating that a particular address is the user's primary address.", @@ -999,6 +1031,9 @@ }, "Salvar cartão" : { + }, + "Salvar Cartão" : { + }, "Save" : { "comment" : "Button title indicating that the current contents should be saved", @@ -1032,6 +1067,9 @@ }, "Selecionar Cartão" : { + }, + "Selecionar endereço" : { + }, "Sem adicionais" : { @@ -1070,6 +1108,16 @@ "comment" : "A message indicating that the user's cart is empty.", "isCommentAutoGenerated" : true }, + "Seu carrinho tem itens de %@. Deseja limpar e adicionar itens de %@?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Seu carrinho tem itens de %1$@. Deseja limpar e adicionar itens de %2$@?" + } + } + } + }, "Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?" : { "comment" : "A message displayed when the user attempts to add a product to their cart from a different store. Asks if the user wants to clear their current cart before adding the new product.", "isCommentAutoGenerated" : true @@ -1088,6 +1136,9 @@ "Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque." : { "comment" : "A tagline displayed in the login view.", "isCommentAutoGenerated" : true + }, + "Substituir carrinho?" : { + }, "Subtotal" : { @@ -1309,6 +1360,9 @@ } } } + }, + "WhatsApp não encontrado. Deseja ligar para %@?" : { + } }, "version" : "1.1" diff --git a/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift index a8ff063..fc119c8 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift @@ -15,32 +15,25 @@ struct CustomerProfileUpdatePayload: Encodable { } struct CustomerIdentityUpdatePayload: Encodable { - let name: String - let email: String - let phoneNumber: String + let name: String? + let email: String? + let phoneNumber: String? let profilePicture: String? - let addressBook: [CustomerAddressPayload] enum CodingKeys: String, CodingKey { case name case email case phoneNumber - case phone case profilePicture - case profile_picture - case addressBook = "address_book" } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(email, forKey: .email) - try container.encode(phoneNumber, forKey: .phoneNumber) - try container.encode(phoneNumber, forKey: .phone) - try container.encode(addressBook, forKey: .addressBook) + try container.encodeIfPresent(name, forKey: .name) + try container.encodeIfPresent(email, forKey: .email) + try container.encodeIfPresent(phoneNumber, forKey: .phoneNumber) if let profilePicture, profilePicture.isEmpty == false { try container.encode(profilePicture, forKey: .profilePicture) - try container.encode(profilePicture, forKey: .profile_picture) } } } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift index 4e207c3..6c6e077 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift @@ -4,6 +4,13 @@ import Foundation struct EmptyResult: Decodable {} +struct ProfilePatchEnvelope: Decodable { + let error: Bool + let code: String? + let message: String? + let profilePictureUrl: String? +} + struct RegistrationResult: Decodable { let id: String? let name: String? diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift index d60ad5c..bba14fc 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift @@ -40,6 +40,10 @@ struct AppOrderSummary: Decodable, Identifiable { case storeLogo case store_logo case logo + case storeImage + case store_image + case storeImageUrl + case logoUrl case date case createdAt case updatedAt @@ -62,7 +66,7 @@ struct AppOrderSummary: Decodable, Identifiable { deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType]) storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone]) - storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo]) + storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl]) let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date]) createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate @@ -97,6 +101,7 @@ struct PublicOrderResult: Codable, Identifiable { let customerOtp: String? let confirmOtp: String? let cancellationReason: String? + let fullAddress: String? let deliveryAddress: PublicOrderDeliveryAddress? let review: PublicOrderReview? let items: [PublicOrderItem] @@ -133,12 +138,17 @@ struct PublicOrderResult: Codable, Identifiable { case storeLogo case store_logo case logo + case storeImage + case store_image + case storeImageUrl + case logoUrl case createdAt case updatedAt case otp case customerOtp case confirmOtp case cancellationReason + case fullAddress case address case deliveryAddress case delivery_address @@ -148,6 +158,8 @@ struct PublicOrderResult: Codable, Identifiable { case orderReview case items case timeline + case history + case orderedAt } init( @@ -178,6 +190,7 @@ struct PublicOrderResult: Codable, Identifiable { customerOtp: String? = nil, confirmOtp: String? = nil, cancellationReason: String? = nil, + fullAddress: String? = nil, deliveryAddress: PublicOrderDeliveryAddress? = nil, review: PublicOrderReview? = nil, items: [PublicOrderItem] = [], @@ -210,6 +223,7 @@ struct PublicOrderResult: Codable, Identifiable { self.customerOtp = customerOtp self.confirmOtp = confirmOtp self.cancellationReason = cancellationReason + self.fullAddress = fullAddress self.deliveryAddress = deliveryAddress self.review = review self.items = items @@ -248,13 +262,14 @@ struct PublicOrderResult: Codable, Identifiable { total = ApiService.decodeFlexibleDouble(from: container, keys: [.total]) storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone]) - storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo]) - createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) + storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo, .storeImage, .store_image, .storeImageUrl, .logoUrl]) + createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt, .orderedAt]) updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) otp = ApiService.decodeFlexibleString(from: container, keys: [.otp]) customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp]) confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp]) cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason]) + fullAddress = ApiService.decodeFlexibleString(from: container, keys: [.fullAddress]) deliveryAddress = (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .address)) ?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .deliveryAddress)) ?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .delivery_address)) @@ -263,7 +278,9 @@ struct PublicOrderResult: Codable, Identifiable { review = (try? container.decode(PublicOrderReview.self, forKey: .review)) ?? (try? container.decode(PublicOrderReview.self, forKey: .orderReview)) items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? [] - timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) ?? [] + timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) + ?? (try? container.decode([PublicOrderTimelineEvent].self, forKey: .history)) + ?? [] } func encode(to encoder: Encoder) throws { @@ -294,6 +311,7 @@ struct PublicOrderResult: Codable, Identifiable { try container.encodeIfPresent(customerOtp, forKey: .customerOtp) try container.encodeIfPresent(confirmOtp, forKey: .confirmOtp) try container.encodeIfPresent(cancellationReason, forKey: .cancellationReason) + try container.encodeIfPresent(fullAddress, forKey: .fullAddress) try container.encodeIfPresent(deliveryAddress, forKey: .address) try container.encodeIfPresent(review, forKey: .review) try container.encode(items, forKey: .items) @@ -537,20 +555,23 @@ struct PublicOrderReview: Codable { struct PublicOrderItem: Codable, Identifiable { let id: String + let productId: String? let name: String? let qty: Int? let price: Double? enum CodingKeys: String, CodingKey { case id + case productId case name case qty case quantity case price } - init(id: String = UUID().uuidString, name: String?, qty: Int?, price: Double?) { + init(id: String = UUID().uuidString, productId: String? = nil, name: String?, qty: Int?, price: Double?) { self.id = id + self.productId = productId self.name = name self.qty = qty self.price = price @@ -559,6 +580,7 @@ struct PublicOrderItem: Codable, Identifiable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + productId = try? container.decode(String.self, forKey: .productId) name = try? container.decode(String.self, forKey: .name) qty = ApiService.decodeFlexibleInt(from: container, keys: [.qty, .quantity]) price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) @@ -567,6 +589,7 @@ struct PublicOrderItem: Codable, Identifiable { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) + try container.encodeIfPresent(productId, forKey: .productId) try container.encodeIfPresent(name, forKey: .name) try container.encodeIfPresent(qty, forKey: .qty) try container.encodeIfPresent(price, forKey: .price) @@ -581,6 +604,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { let completed: Bool? let message: String? let time: String? + let date: String? enum CodingKeys: String, CodingKey { case id @@ -589,7 +613,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { case active case completed case message + case event case time + case date case createdAt case updatedAt } @@ -601,7 +627,8 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { active: Bool? = nil, completed: Bool? = nil, message: String?, - time: String? + time: String?, + date: String? = nil ) { self.id = id self.status = status @@ -610,6 +637,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { self.completed = completed self.message = message self.time = time + self.date = date } init(from decoder: Decoder) throws { @@ -619,10 +647,12 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { label = try? container.decode(String.self, forKey: .label) active = try? container.decode(Bool.self, forKey: .active) completed = try? container.decode(Bool.self, forKey: .completed) - message = try? container.decode(String.self, forKey: .message) + message = (try? container.decode(String.self, forKey: .message)) + ?? (try? container.decode(String.self, forKey: .event)) time = (try? container.decode(String.self, forKey: .time)) ?? (try? container.decode(String.self, forKey: .createdAt)) ?? (try? container.decode(String.self, forKey: .updatedAt)) + date = try? container.decode(String.self, forKey: .date) } func encode(to encoder: Encoder) throws { @@ -634,6 +664,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable { try container.encodeIfPresent(completed, forKey: .completed) try container.encodeIfPresent(message, forKey: .message) try container.encodeIfPresent(time, forKey: .time) + try container.encodeIfPresent(date, forKey: .date) } } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index 0d90e42..eec5989 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -155,28 +155,17 @@ 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 - } - + func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ProfilePatchEnvelope { let payload = CustomerIdentityUpdatePayload( - name: name, - email: email, - phoneNumber: phoneNumber, - profilePicture: profilePicture, - addressBook: (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:)) + name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name, + email: email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : email, + phoneNumber: phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : phoneNumber, + profilePicture: profilePicture ) let body = try JSONEncoder().encode(payload) - let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) - let envelope: ApiEnvelope = try await sendEnvelope(req) - let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())" - if envelope.error == false, envelope.result != nil { - AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) - } else { - invalidateFavoritesCache() - } + let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body) + let envelope: ProfilePatchEnvelope = try await send(req) + AppContentCache.shared.invalidate(prefix: profileCachePrefix) return envelope } diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index 961b9bd..fab59f6 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -9,6 +9,7 @@ struct AppState { var featureFlags = FeatureFlagsState() var homeFilters = HomeFiltersState() var activeModal: AppModal? = nil + var shouldNavigateToOrders: Bool = false } enum FeatureFlagValue: Codable, Equatable { diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift index ed4d739..9c40302 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift @@ -2,10 +2,12 @@ import SwiftUI struct TermsOfUseView: View { @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { + screenHeader Text("Termos de Uso") .font(AppTypography.heading1) .foregroundStyle(AppColors.textPrimary) @@ -16,17 +18,40 @@ struct TermsOfUseView: View { .padding(24) } .background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()) - .navigationTitle("Termos de Uso") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + } + + private var screenHeader: some View { + ZStack { + Text("Termos de Uso") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } } } struct PrivacyPolicyView: View { @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) var dismiss var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { + screenHeader Text("Política de Privacidade") .font(AppTypography.heading1) .foregroundStyle(AppColors.textPrimary) @@ -37,7 +62,28 @@ struct PrivacyPolicyView: View { .padding(24) } .background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()) - .navigationTitle("Privacidade") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + } + + private var screenHeader: some View { + ZStack { + Text("Privacidade") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddCardFormView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddCardFormView.swift new file mode 100644 index 0000000..f2ba942 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/AddCardFormView.swift @@ -0,0 +1,461 @@ +import SwiftUI + +struct AddCardFormView: View { + let appState: AppState + let isFirstCard: Bool + let onCardAdded: (SavedCard) -> Void + + @Environment(\.dismiss) var dismiss + + @State private var cardNumber = "" + @State private var holderName = "" + @State private var expiry = "" + @State private var cvv = "" + @State private var cpf = "" + @State private var nickname = "" + @State private var isDefault = false + @State private var isSaving = false + + @State private var addresses: [CustomerAddress] = [] + @State private var selectedAddress: CustomerAddress? = nil + @State private var isLoadingAddresses = false + @State private var showAddressPicker = false + + private var detectedBrandLogo: String? { + let clean = cardNumber.filter(\.isNumber) + guard clean.isEmpty == false else { return nil } + if clean.hasPrefix("506766") || clean.hasPrefix("603389") { return "sodexo_logo" } + if clean.hasPrefix("5067") || clean.hasPrefix("5078") || clean.hasPrefix("6278") || clean.hasPrefix("6367") { return "alelocard_logo" } + if clean.hasPrefix("3841") || clean.hasPrefix("606282") || clean.hasPrefix("637095") || clean.hasPrefix("637568") { return "hipercard_logo" } + if clean.hasPrefix("34") || clean.hasPrefix("37") { return "amexcard_logo" } + if clean.hasPrefix("4") { return "visacard_logo" } + let prefix2 = Int(clean.prefix(2)) ?? 0 + if (51...59).contains(prefix2) { return "mastercard_logo" } + if let p4 = Int(clean.prefix(4)), (2221...2720).contains(p4) { return "mastercard_logo" } + return nil + } + + private var selectedAddressZip: String { + (selectedAddress?.zipCode ?? "").filter(\.isNumber) + } + + private var canSave: Bool { + let digits = cardNumber.filter(\.isNumber) + let cpfDigits = cpf.filter(\.isNumber) + let parts = expiry.split(separator: "/") + return digits.count >= 13 + && holderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + && parts.count == 2 + && cvv.count >= 3 + && cpfDigits.count == 11 + && selectedAddress != nil + && selectedAddressZip.count >= 7 + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + screenHeader + + formSection("Dados do Cartão") { + cardNumberField + labeledField("Nome no cartão", placeholder: "Como impresso no cartão", text: $holderName, autocap: true) + HStack(spacing: 12) { + labeledField("Validade", placeholder: "MM/AA", text: $expiry, keyboard: .numberPad) + .onChange(of: expiry) { _, v in expiry = formatExpiry(v) } + labeledField("CVV", placeholder: "•••", text: $cvv, keyboard: .numberPad) + .onChange(of: cvv) { _, v in cvv = String(v.filter(\.isNumber).prefix(4)) } + } + } + + formSection("Identificação do Titular") { + labeledField("CPF", placeholder: "000.000.000-00", text: $cpf, keyboard: .numberPad) + .onChange(of: cpf) { _, v in cpf = formatCPF(v.filter(\.isNumber)) } + addressPickerRow + } + + formSection("Opções") { + labeledField("Apelido (opcional)", placeholder: "Ex: Cartão do Nubank", text: $nickname) + if isFirstCard == false { + Toggle(isOn: $isDefault) { + Text("Definir como principal") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + } + .tint(AppColors.primary) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + Button(action: { Task { await saveCard() } }) { + Group { + if isSaving { + ProgressView().tint(Color(hex: "#0E1A06")) + } else { + Text("Salvar Cartão").font(AppTypography.heading2) + } + } + .foregroundStyle(Color(hex: "#0E1A06")) + .frame(maxWidth: .infinity, minHeight: 56) + .background(canSave && !isSaving ? Color(hex: "#C8F06E") : Color(hex: "#C8F06E").opacity(0.45)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(!canSave || isSaving) + .padding(.top, 4) + } + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, 40) + } + .background(AppColors.backgroundLight) + .navigationBarBackButtonHidden(true) + .appHiddenNavigationBar() + .sheet(isPresented: $showAddressPicker) { + addressPickerSheet + } + .task { await loadData() } + } + + // MARK: - Address picker row + + private var addressPickerRow: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Endereço de cobrança") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + + Button { + if addresses.isEmpty == false { showAddressPicker = true } + } label: { + HStack(spacing: 10) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(selectedAddress != nil ? AppColors.primary : AppColors.textMuted) + + VStack(alignment: .leading, spacing: 2) { + if isLoadingAddresses { + Text("Carregando endereços...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } else if let addr = selectedAddress { + Text(addressDisplayTitle(addr)) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + if let sub = addressDisplaySubtitle(addr) { + Text(sub) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + } + } else if addresses.isEmpty { + Text("Nenhum endereço cadastrado") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } else { + Text("Selecionar endereço") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + } + + Spacer() + + if addresses.isEmpty == false { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppColors.textMuted) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(isLoadingAddresses || addresses.isEmpty) + } + } + + // MARK: - Address picker sheet + + private var addressPickerSheet: some View { + NavigationStack { + ScrollView(showsIndicators: false) { + VStack(spacing: 10) { + ForEach(Array(addresses.enumerated()), id: \.offset) { _, addr in + Button { + selectedAddress = addr + showAddressPicker = false + } label: { + HStack(spacing: 12) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(isSelected(addr) ? AppColors.primary : AppColors.textMuted) + + VStack(alignment: .leading, spacing: 3) { + Text(addressDisplayTitle(addr)) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(1) + if let sub = addressDisplaySubtitle(addr) { + Text(sub) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(1) + } + } + + Spacer() + + if isSelected(addr) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(AppColors.primary) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, 30) + } + .background(AppColors.backgroundLight) + .navigationTitle("Endereço de cobrança") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Fechar") { showAddressPicker = false } + .foregroundStyle(AppColors.textPrimary) + } + } + } + } + + // MARK: - Sub-views + + private var screenHeader: some View { + ZStack { + Text("Novo Cartão") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + + private var cardNumberField: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Número do cartão") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + HStack(spacing: 8) { + TextField("0000 0000 0000 0000", text: $cardNumber) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .keyboardType(.numberPad) + .onChange(of: cardNumber) { _, v in cardNumber = formatCardNumber(v.filter(\.isNumber)) } + let digits = cardNumber.filter(\.isNumber) + if let logo = detectedBrandLogo { + Image(logo) + .resizable() + .scaledToFit() + .frame(width: 40, height: 26) + } else if digits.count >= 4 { + Image(systemName: "creditcard") + .font(.system(size: 22)) + .foregroundStyle(AppColors.textMuted) + .frame(width: 40, height: 26) + } + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private func labeledField( + _ label: String, + placeholder: String, + text: Binding, + keyboard: UIKeyboardType = .default, + autocap: Bool = false + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + TextField(placeholder, text: text) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .keyboardType(keyboard) + .autocorrectionDisabled() + .textInputAutocapitalization(autocap ? .characters : .never) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + } + + private func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .padding(.leading, 2) + content() + } + } + + // MARK: - Helpers + + private func isSelected(_ addr: CustomerAddress) -> Bool { + guard let sel = selectedAddress else { return false } + if let id = addr.id, let selId = sel.id { return id == selId } + return addr.address == sel.address && addr.number == sel.number + } + + private func addressDisplayTitle(_ addr: CustomerAddress) -> String { + let label = addr.label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if label.isEmpty == false { return label } + let street = addr.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let number = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let base = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ") + return base.isEmpty ? "Endereço" : base + } + + private func addressDisplaySubtitle(_ addr: CustomerAddress) -> String? { + let parts = [ + addr.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines), + addr.city?.trimmingCharacters(in: .whitespacesAndNewlines), + addr.state?.trimmingCharacters(in: .whitespacesAndNewlines) + ].compactMap { v -> String? in + guard let v, v.isEmpty == false else { return nil } + return v + } + return parts.isEmpty ? nil : parts.joined(separator: ", ") + } + + // MARK: - Load & Save + + @MainActor + private func loadData() async { + holderName = appState.profile.name + cpf = formatCPF(appState.profile.cpf.filter(\.isNumber)) + isDefault = isFirstCard + + isLoadingAddresses = true + defer { isLoadingAddresses = false } + if let result = try? await ApiService().profile(forceRefresh: false).result { + let book = result.addressBook ?? [] + addresses = book + selectedAddress = book.first + } + } + + @MainActor + private func saveCard() async { + guard canSave, let addr = selectedAddress else { return } + isSaving = true + defer { isSaving = false } + + let parts = expiry.split(separator: "/") + let month = String(parts[0]) + let year: String = { + let y = String(parts[1]) + return y.count == 2 ? "20\(y)" : y + }() + let cleanNumber = cardNumber.filter(\.isNumber) + let cleanCpf = cpf.filter(\.isNumber) + let zip = selectedAddressZip + let addrNumber = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "0" + + let creditCard = SaveCardCreditCardPayload( + holderName: holderName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased(), + number: cleanNumber, + expiryMonth: month, + expiryYear: year, + ccv: cvv + ) + let holderInfo = SaveCardHolderInfoPayload( + name: holderName.trimmingCharacters(in: .whitespacesAndNewlines), + email: appState.profile.email, + cpfCnpj: cleanCpf, + postalCode: zip, + addressNumber: addrNumber.isEmpty ? "0" : addrNumber, + phone: appState.profile.phone + ) + let payload = SaveCardPayload( + creditCard: creditCard, + creditCardHolderInfo: holderInfo, + nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : nickname, + isDefault: isDefault || isFirstCard + ) + + do { + let response = try await ApiService().saveCard(payload: payload) + if response.error == false, let result = response.result { + let newCard = SavedCard( + id: result.id, + nickname: payload.nickname, + holderName: result.holderName, + last4: result.last4, + brand: result.brand, + expiryMonth: result.expiryMonth, + expiryYear: result.expiryYear, + isDefault: result.isDefault + ) + onCardAdded(newCard) + dismiss() + SnackbarCenter.shared.show(title: "Cartão salvo com sucesso.", style: .success, icon: "creditcard.fill", duration: 2.5) + } else { + SnackbarCenter.shared.show(title: response.message ?? "Não foi possível salvar o cartão.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + } catch { + SnackbarCenter.shared.show(title: "Erro ao salvar cartão. Verifique os dados e tente novamente.", style: .error, icon: "xmark.octagon.fill", duration: 3.5) + } + } + + // MARK: - Formatters + + private func formatCardNumber(_ digits: String) -> String { + let d = String(digits.prefix(16)) + var result = "" + for (i, c) in d.enumerated() { + if i > 0 && i % 4 == 0 { result += " " } + result.append(c) + } + return result + } + + private func formatExpiry(_ value: String) -> String { + let digits = String(value.filter(\.isNumber).prefix(4)) + if digits.count > 2 { return "\(digits.prefix(2))/\(digits.dropFirst(2))" } + return digits + } + + private func formatCPF(_ digits: String) -> String { + let d = String(digits.prefix(11)) + if d.count <= 3 { return d } + if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" } + if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" } + return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))" + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift index 701c1c9..5ccb722 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift @@ -3,6 +3,7 @@ import SwiftUI struct CartView: View { @Binding var appState: AppState + @Binding var selectedTab: MainTab @State var openCheckout = false @State var couponCode = "" @State var appliedCouponCode: String? = nil @@ -49,7 +50,7 @@ struct CartView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AppColors.backgroundLight) .navigationDestination(isPresented: $openCheckout) { - CheckoutView(appState: $appState) + CheckoutView(appState: $appState, selectedTab: $selectedTab) } .task(id: deliveryFeeWatchKey) { await refreshDeliveryFee() diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift index fef09e7..47552e3 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -7,6 +7,8 @@ import AppKit struct CheckoutView: View { @Binding var appState: AppState + @Binding var selectedTab: MainTab + @Environment(\.dismiss) var dismiss @State var storeInfo: StoreInfoResult? = nil @State var errorMessage: String? = nil @@ -98,6 +100,7 @@ struct CheckoutView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 18) { + screenHeader deliveryTypeSection addressSection orderSummarySection @@ -120,8 +123,8 @@ struct CheckoutView: View { .padding(.bottom, 10) } .background(AppColors.backgroundLight) - .navigationTitle("Finalizar Pedido") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .appBottomSafeAreaInset { bottomBar } @@ -212,7 +215,31 @@ struct CheckoutView: View { } } .navigationDestination(item: $orderTrackingContext) { context in - OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId) + OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId, postOrderBack: { + appState.shouldNavigateToOrders = true + selectedTab = .profile + }) + } + } + + private var screenHeader: some View { + ZStack { + Text("Finalizar Pedido") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } } } @@ -676,6 +703,7 @@ struct PaymentPixView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 14) { + screenHeader RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) .fill(AppColors.surface) .overlay( @@ -767,8 +795,8 @@ struct PaymentPixView: View { .padding(20) } .background(AppColors.backgroundLight) - .navigationTitle("Pagamento via PIX") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .sheet(isPresented: $showChangePaymentSheet) { ChangePaymentSheet( pixContext: currentContext, @@ -814,6 +842,27 @@ struct PaymentPixView: View { } } + private var screenHeader: some View { + ZStack { + Text("Pagamento via PIX") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private func openTrackingOnce() { guard hasOpenedTracking == false else { return } hasOpenedTracking = true diff --git a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift index 36073e2..3fffbf8 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift @@ -16,7 +16,7 @@ struct MainTabView: View { } case .cart: NavigationStack { - CartView(appState: $appState) + CartView(appState: $appState, selectedTab: $selectedTab) } case .profile: NavigationStack { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift index 10cd7b4..15e4413 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift @@ -4,10 +4,20 @@ struct OrderDetailsView: View { let order: PublicOrderResult let orderId: String let initialShortId: String? + @Binding var appState: AppState + + @Environment(\.dismiss) var dismiss + @Environment(\.openURL) var openURL + @State private var storeContactPhone: String? = nil + @State private var resolvedStoreLogoURL: String? = nil + @State private var showCallAlert = false + @State private var navigateToStore = false + @State private var showClearCartAlert = false var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 14) { + screenHeader statusCard storeCard itemsCard @@ -22,8 +32,44 @@ struct OrderDetailsView: View { .padding(.bottom, UIDevice.bottomNotch) } .background(AppColors.backgroundLight) - .navigationTitle("Detalhes do Pedido") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + .navigationDestination(isPresented: $navigateToStore) { + if let storeId = order.storeId, storeId.isEmpty == false { + StoreDetailView( + storeId: storeId, + storeName: order.storeName ?? "Loja", + storeCoverURL: nil, + storeLogoURL: order.storeLogoURL, + storeCategory: nil, + storeRating: nil, + storeDistance: nil, + storeDeliveryFee: nil, + appState: $appState + ) + } + } + .alert("Ligar para a loja?", isPresented: $showCallAlert) { + Button("Ligar para \(order.storeName ?? "a loja")") { + if let phone = storeContactPhone { + openTel(phone) + } + } + Button("Cancelar", role: .cancel) {} + } message: { + Text("WhatsApp não encontrado. Deseja ligar para \(order.storeName ?? "a loja")?") + } + .task { + await loadStoreContactPhone() + } + .alert("Substituir carrinho?", isPresented: $showClearCartAlert) { + Button("Limpar e adicionar", role: .destructive) { + applyReorder(clearFirst: true) + } + Button("Cancelar", role: .cancel) {} + } message: { + Text("Seu carrinho tem itens de \(appState.cart.storeName ?? appState.cart.storeId ?? "outra loja"). Deseja limpar e adicionar itens de \(order.storeName ?? "esta loja")?") + } .appBottomSafeAreaInset { VStack { reorderButton @@ -35,6 +81,27 @@ struct OrderDetailsView: View { } } + private var screenHeader: some View { + ZStack { + Text("Detalhes do Pedido") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private var statusCard: some View { HStack(spacing: 14) { Circle() @@ -54,6 +121,26 @@ struct OrderDetailsView: View { Text(statusDateText) .font(AppTypography.body) .foregroundStyle(AppColors.textMuted) + + if let reason = cancellationReasonText { + Text(reason) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 2) + } + + if let addr = deliveryAddressSummary { + HStack(spacing: 4) { + Image(systemName: "mappin.circle.fill") + .font(.system(size: 11)) + .foregroundStyle(AppColors.textMuted) + Text(addr) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + .lineLimit(2) + } + .padding(.top, 2) + } } Spacer(minLength: 0) @@ -64,30 +151,39 @@ struct OrderDetailsView: View { } private var storeCard: some View { - HStack(spacing: 12) { - AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL)) - .frame(width: 54, height: 54) - .clipShape(Circle()) - .background(AppColors.brandSoft, in: Circle()) - - VStack(alignment: .leading, spacing: 4) { - Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja") - .font(AppTypography.heading2) - .foregroundStyle(AppColors.textPrimary) - Text(storeSubtitle) - .font(AppTypography.body) - .foregroundStyle(AppColors.textMuted) + Button { + if order.storeId?.isEmpty == false { + navigateToStore = true } + } label: { + HStack(spacing: 12) { + AsyncStoreImage(imageURL: resolvedMediaURL(resolvedStoreLogoURL ?? order.storeLogoURL)) + .frame(width: 54, height: 54) + .clipShape(Circle()) + .background(AppColors.brandSoft, in: Circle()) - Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 4) { + Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + Text(storeSubtitle) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } - Text("Ver loja") - .font(AppTypography.body) - .foregroundStyle(Color(hex: "#A5D645")) + Spacer(minLength: 0) + + if order.storeId?.isEmpty == false { + Text("Ver loja") + .font(AppTypography.body) + .foregroundStyle(Color(hex: "#A5D645")) + } + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } - .padding(16) - .background(AppColors.surface) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .buttonStyle(.plain) } private var itemsCard: some View { @@ -216,27 +312,28 @@ struct OrderDetailsView: View { private var reorderButton: some View { Button("Pedir Novamente") { - SnackbarCenter.shared.show( - title: "Recompra será integrada com o catálogo em breve.", - style: .info, - icon: "cart.badge.plus", - duration: 2.0 - ) + reorder() } .font(AppTypography.heading2) .foregroundStyle(Color(hex: "#0E1A06")) .frame(maxWidth: .infinity, minHeight: 56) - .background(Color(hex: "#C8F06E")) + .background(order.items.isEmpty ? Color(hex: "#C8F06E").opacity(0.45) : Color(hex: "#C8F06E")) .clipShape(Capsule()) .buttonStyle(.plain) + .disabled(order.items.isEmpty) } private var helpFooter: some View { - Text("Precisa de ajuda com esse pedido?") - .font(AppTypography.body) - .foregroundStyle(Color(hex: "#A5D645")) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 8) + Button { + handleHelpTap() + } label: { + Text("Precisa de ajuda com esse pedido?") + .font(AppTypography.body) + .foregroundStyle(Color(hex: "#A5D645")) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 8) + } + .buttonStyle(.plain) } private var subtotal: Double { @@ -296,6 +393,24 @@ struct OrderDetailsView: View { return "Pedido #\(displayOrderTitle)" } + private var deliveryAddressSummary: String? { + if let full = order.fullAddress, full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + return full.trimmingCharacters(in: .whitespacesAndNewlines) + } + let line1 = deliveryAddressLine + let line2 = deliveryAddressLine2 + let combined = [line1, line2].filter { $0.isEmpty == false }.joined(separator: ", ") + return combined.isEmpty ? nil : combined + } + + private var cancellationReasonText: String? { + guard statusTitle.contains("cancelado"), + let reason = order.cancellationReason, + reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + else { return nil } + return "Motivo: \(reason.trimmingCharacters(in: .whitespacesAndNewlines))" + } + private var statusTitle: String { let status = normalized(order.status) if status.contains("CANCEL") { return "Pedido cancelado" } @@ -309,6 +424,12 @@ struct OrderDetailsView: View { } private var statusDateText: String { + if let event = order.timeline.first, + let date = event.date, date.isEmpty == false { + let time = event.time.flatMap { $0.isEmpty ? nil : $0 } + let combined = time.map { "\(date) às \($0)" } ?? date + return "\(statusDatePrefix) \(combined)" + } if let formatted = formatDate(order.updatedAt ?? order.createdAt) { return "\(statusDatePrefix) \(formatted)" } @@ -342,14 +463,81 @@ struct OrderDetailsView: View { String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } + private func reorder() { + guard order.items.isEmpty == false else { return } + let cartStoreId = appState.cart.storeId ?? appState.cart.items.first?.storeId ?? "" + let orderStoreId = order.storeId ?? "" + let cartHasDifferentStore = cartStoreId.isEmpty == false + && orderStoreId.isEmpty == false + && cartStoreId != orderStoreId + && appState.cart.items.isEmpty == false + if cartHasDifferentStore { + showClearCartAlert = true + } else { + applyReorder(clearFirst: false) + } + } + + private func applyReorder(clearFirst: Bool) { + if clearFirst { + appState.cart.clear() + } + let storeId = order.storeId ?? "" + if appState.cart.storeId == nil || appState.cart.storeId?.isEmpty == true { + appState.cart.storeId = storeId + appState.cart.storeName = order.storeName + } + var addedCount = 0 + for item in order.items { + let name = item.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard name.isEmpty == false else { continue } + let qty = max(1, item.qty ?? 1) + let price = item.price ?? 0 + let cartItem = CartItemState( + id: UUID().uuidString, + productId: item.productId ?? item.id, + storeId: storeId, + name: name, + imageURL: nil, + details: nil, + addons: [], + quantity: qty, + unitPrice: price + ) + appState.cart.add(item: cartItem) + addedCount += qty + } + let label = addedCount == 1 ? "1 item adicionado ao carrinho." : "\(addedCount) itens adicionados ao carrinho." + SnackbarCenter.shared.show(title: label, style: .success, icon: "cart.badge.plus", duration: 2.5) + } + private func formatDate(_ isoValue: String?) -> String? { guard let isoValue, isoValue.isEmpty == false else { return nil } + let iso = ISO8601DateFormatter() - iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - var date = iso.date(from: isoValue) + let optionSets: [ISO8601DateFormatter.Options] = [ + [.withInternetDateTime, .withFractionalSeconds], + [.withInternetDateTime], + [.withFullDate, .withTime, .withColonSeparatorInTime], + [.withFullDate, .withTime, .withColonSeparatorInTime, .withTimeZone], + [.withFullDate] + ] + var date: Date? = nil + for options in optionSets { + iso.formatOptions = options + if let d = iso.date(from: isoValue) { + date = d + break + } + } if date == nil { - iso.formatOptions = [.withInternetDateTime] - date = iso.date(from: isoValue) + let fallback = DateFormatter() + fallback.locale = Locale(identifier: "en_US_POSIX") + for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSZ", "yyyy-MM-dd'T'HH:mm:ssZ", + "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd"] { + fallback.dateFormat = fmt + if let d = fallback.date(from: isoValue) { date = d; break } + } } guard let date else { return nil } @@ -359,6 +547,64 @@ struct OrderDetailsView: View { return formatter.string(from: date) } + @MainActor + private func loadStoreContactPhone() async { + if let inline = order.storePhone, inline.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + storeContactPhone = inline + } + guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), + storeId.isEmpty == false else { return } + do { + let response = try await ApiService().storeInfo(storeId: storeId) + if response.error == false, let result = response.result { + let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if phone.isEmpty == false { + storeContactPhone = phone + } + if let logo = result.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + resolvedStoreLogoURL = logo + } + } + } catch {} + } + + private func handleHelpTap() { + guard let phoneRaw = storeContactPhone, + phoneRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + SnackbarCenter.shared.show( + title: "Telefone da loja indisponível.", + style: .warning, + icon: "exclamationmark.triangle.fill", + duration: 2.8 + ) + return + } + if let waURL = makeWhatsAppURL(from: phoneRaw) { + openURL(waURL) + } else { + showCallAlert = true + } + } + + private func openTel(_ phoneRaw: String) { + let digits = phoneRaw.filter(\.isNumber) + if digits.isEmpty { return } + if let url = URL(string: "tel://\(digits)") { + openURL(url) + } + } + + private func makeWhatsAppURL(from phoneRaw: String) -> URL? { + var digits = phoneRaw.filter(\.isNumber) + if digits.isEmpty { return nil } + if digits.hasPrefix("0") { digits = String(digits.drop(while: { $0 == "0" })) } + if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) { + digits = "55" + digits + } + guard digits.count >= 12 else { return nil } + return URL(string: "https://wa.me/\(digits)") + } + private func normalized(_ value: String?) -> String { (value ?? "") .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift index 70006c6..5a4fcfc 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift @@ -12,6 +12,8 @@ private struct TrackingStep: Identifiable { struct OrderTrackingView: View { let orderId: String let initialShortId: String? + var postOrderBack: (() -> Void)? = nil + @Environment(\.dismiss) var dismiss @Environment(\.openURL) var openURL @State var isLoading = true @@ -27,6 +29,7 @@ struct OrderTrackingView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 16) { + screenHeader topHeader orderTitleSection statusBanner @@ -43,8 +46,8 @@ struct OrderTrackingView: View { .padding(.bottom, UIDevice.bottomNotch + 45) } .background(AppColors.backgroundLight) - .navigationTitle("Pedido \(displayOrderTitle)") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .alert("Motivo do cancelamento", isPresented: $showCancellationReason) { Button("Fechar", role: .cancel) {} } message: { @@ -97,6 +100,33 @@ struct OrderTrackingView: View { self.reviewSavedObserver = nil } + private var screenHeader: some View { + ZStack { + Text("Pedido \(displayOrderTitle)") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { + if let postOrderBack { + postOrderBack() + } else { + dismiss() + } + }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private var topHeader: some View { HStack(spacing: 10) { Circle() diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift index bf1c9ec..32bd5c6 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift @@ -9,6 +9,8 @@ struct OrdersView: View { @State var hasLoadedOnce = false @State var storeRatingByStoreId: [String: Double] = [:] @State var storeRatingByStoreName: [String: Double] = [:] + @State var storeLogoByStoreId: [String: String] = [:] + @State var storeLogoByStoreName: [String: String] = [:] @State var selectedOrderRoute: OrderRouteContext? = nil var body: some View { @@ -106,7 +108,7 @@ struct OrdersView: View { return VStack(alignment: .leading, spacing: 14) { HStack(spacing: 12) { - AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL)) + AsyncStoreImage(imageURL: resolvedMediaURL(storeLogoURL(for: order))) .frame(width: 80, height: 80) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) @@ -466,20 +468,25 @@ struct OrdersView: View { var byId: [String: Double] = [:] var byName: [String: Double] = [:] + var logoById: [String: String] = [:] + var logoByName: [String: String] = [:] for store in storeList { - guard let rating = store.rating, rating > 0 else { continue } let storeId = normalizedOrderId(store.id) - if storeId.isEmpty == false { - byId[storeId] = rating - } let nameKey = normalizedStoreName(store.name) - if nameKey.isEmpty == false { - byName[nameKey] = rating + if let rating = store.rating, rating > 0 { + if storeId.isEmpty == false { byId[storeId] = rating } + if nameKey.isEmpty == false { byName[nameKey] = rating } + } + if let logo = store.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + if storeId.isEmpty == false { logoById[storeId] = logo } + if nameKey.isEmpty == false { logoByName[nameKey] = logo } } } storeRatingByStoreId = byId storeRatingByStoreName = byName + storeLogoByStoreId = logoById + storeLogoByStoreName = logoByName } private func storeRating(for order: AppOrderSummary) -> Double? { @@ -494,6 +501,17 @@ struct OrdersView: View { return nil } + private func storeLogoURL(for order: AppOrderSummary) -> String? { + if let url = order.storeLogoURL, url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + return url + } + let storeId = normalizedOrderId(order.storeId) + if storeId.isEmpty == false, let logo = storeLogoByStoreId[storeId] { return logo } + let nameKey = normalizedStoreName(order.storeName) + if nameKey.isEmpty == false, let logo = storeLogoByStoreName[nameKey] { return logo } + return nil + } + private func resolvedMediaURL(_ raw: String?) -> String? { ImageSourceResolver.resolve(raw) } @@ -662,7 +680,7 @@ struct OrderEntryDestinationView: View { } ) } else if let orderDetails { - OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId) + OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId, appState: $appState) } else { OrderTrackingView(orderId: orderId, initialShortId: initialShortId) } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift index bc27f6b..377cfef 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift @@ -10,6 +10,7 @@ struct PizzaFlavorAddonsSheet: View { var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 12) { + screenHeader Text(flavor.name) .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) @@ -77,12 +78,28 @@ struct PizzaFlavorAddonsSheet: View { .padding(20) } .background(AppColors.backgroundLight.ignoresSafeArea()) - .navigationTitle("Adicionais") - .appInlineNavigationTitle() - .appTopBarTrailingToolbar { - Button("Concluir") { dismiss() } + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) + } + + private var screenHeader: some View { + ZStack { + Text("Adicionais") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } .buttonStyle(.plain) - .foregroundStyle(AppColors.primary) + Spacer() + } } } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift index c6852bf..0ee5760 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet.swift @@ -189,6 +189,7 @@ struct PizzaProductDetailSheet: View { var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { + screenHeader AsyncStoreImage(imageURL: representativeImage) .frame(height: 220) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) @@ -291,12 +292,8 @@ struct PizzaProductDetailSheet: View { } } .background(AppColors.backgroundLight.ignoresSafeArea()) - .navigationTitle("Monte sua pizza") - .appInlineNavigationTitle() - .appTopBarTrailingToolbar { - Button("Fechar") { dismiss() } - .foregroundStyle(AppColors.primary) - } + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .onAppear { applyAutoSelections() let existing = currentQuantityForItemId(cartItemId) @@ -314,4 +311,25 @@ struct PizzaProductDetailSheet: View { } } + private var screenHeader: some View { + ZStack { + Text("Monte sua pizza") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift index 8af117d..08c6e22 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift @@ -72,6 +72,7 @@ struct ProductDetailSheet: View { var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { + screenHeader AsyncStoreImage(imageURL: imageURL) .frame(height: 220) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) @@ -221,12 +222,8 @@ struct ProductDetailSheet: View { .background(.ultraThinMaterial) } .background(AppColors.backgroundLight.ignoresSafeArea()) - .navigationTitle("Detalhes") - .appInlineNavigationTitle() - .appTopBarTrailingToolbar { - Button("Fechar") { dismiss() } - .foregroundStyle(AppColors.primary) - } + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .onAppear { let existing = currentQuantityForItemId(cartItemId) quantity = existing > 0 ? existing : 1 @@ -246,6 +243,27 @@ struct ProductDetailSheet: View { } } + private var screenHeader: some View { + ZStack { + Text("Detalhes") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private func formatCurrency(_ value: Double) -> String { String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index 4aa2b04..1348e34 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -14,6 +14,7 @@ struct ProfileView: View { @State var openAddressesOnboarding = false @State var onboardingMessage: String? = nil @State var showLogoutAlert = false + @State private var openOrders = false let tabBarClearance: CGFloat = 120 var body: some View { @@ -41,12 +42,12 @@ struct ProfileView: View { } .buttonStyle(.plain) -// NavigationLink { -// Text("Meus Cartões") -// } label: { -// ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões") -// } -// .buttonStyle(.plain) + NavigationLink { + SavedCardsView(appState: $appState) + } label: { + ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões") + } + .buttonStyle(.plain) NavigationLink { MyReviewsView() @@ -120,6 +121,15 @@ struct ProfileView: View { AddressesView(message: onboardingMessage, appState: $appState) } } + .navigationDestination(isPresented: $openOrders) { + OrdersView(appState: $appState) + } + .onChange(of: appState.shouldNavigateToOrders) { _, val in + if val { + appState.shouldNavigateToOrders = false + openOrders = true + } + } } private var header: some View { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift index 54d9725..e1e3fb1 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift @@ -24,6 +24,8 @@ struct MyReviewsView: View { @State var isLoading = false @State var loadError: String? = nil + @Environment(\.dismiss) var dismiss + init(initialOrder: ReviewDraft? = nil) { self.initialOrder = initialOrder } @@ -31,6 +33,7 @@ struct MyReviewsView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 12) { + screenHeader if isLoading && reviews.isEmpty && pendingReviews.isEmpty { ProgressView() .frame(maxWidth: .infinity) @@ -66,8 +69,8 @@ struct MyReviewsView: View { .padding(.bottom, UIDevice.bottomNotch + 30) } .background(AppColors.backgroundLight) - .navigationTitle("Minhas Avaliações") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .navigationDestination(item: $selectedDraft) { draft in OrderReviewView(draft: draft) { Task { await loadReviewsFromBackend(forceRefresh: true) } @@ -86,6 +89,27 @@ struct MyReviewsView: View { } } + private var screenHeader: some View { + ZStack { + Text("Minhas Avaliações") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private var emptyState: some View { VStack(spacing: 8) { Text("Você não tem avaliações nem pendências no momento.") @@ -434,6 +458,7 @@ struct OrderReviewView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 24) { + screenHeader topSection orderItemsSection if existingReview != nil { @@ -450,8 +475,8 @@ struct OrderReviewView: View { .padding(.bottom, UIDevice.bottomNotch + 140) } .background(AppColors.backgroundLight) - .navigationTitle("Avaliar Pedido") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .onAppear { if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) { existingReview = cachedReview @@ -488,6 +513,27 @@ struct OrderReviewView: View { } } + private var screenHeader: some View { + ZStack { + Text("Avaliar Pedido") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private var newReviewContent: some View { VStack(alignment: .leading, spacing: 24) { starsSection diff --git a/pedi-foods/Sources/PediFoods/Views/Main/SavedCardsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/SavedCardsView.swift new file mode 100644 index 0000000..f5c6c64 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/SavedCardsView.swift @@ -0,0 +1,244 @@ +import SwiftUI + +struct SavedCardsView: View { + @Binding var appState: AppState + @Environment(\.dismiss) var dismiss + + @State private var cards: [SavedCard] = [] + @State private var isLoading = false + @State private var errorMessage: String? = nil + @State private var openSwipeRowId: String? = nil + @State private var deletingCardId: String? = nil + @State private var showAddCard = false + + private var canDelete: Bool { cards.count > 1 } + private let tabBarClearance: CGFloat = 96 + + var body: some View { + ZStack { + AppColors.backgroundLight.ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + screenHeader + + VStack(spacing: 12) { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 32) + } else if let errorMessage { + Text(errorMessage) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + .padding(.top, 32) + } else if cards.isEmpty { + Text("Nenhum cartão cadastrado") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .padding(.top, 32) + } else { + ForEach(cards) { card in + if canDelete { + SwipeToDeleteAddressRow( + rowId: card.id, + openRowId: $openSwipeRowId, + isDeleting: deletingCardId == card.id, + onDelete: { deleteCard(card) } + ) { + cardRow(card) + .appContentShape(Rectangle()) + .onTapGesture { + if openSwipeRowId == card.id { openSwipeRowId = nil } + } + } + .id(card.id) + .opacity(deletingCardId == card.id ? 0.6 : 1.0) + .disabled(deletingCardId != nil) + } else { + cardRow(card) + } + } + } + } + } + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, tabBarClearance + 20) + } + + VStack { + Spacer() + addCardButton + .padding(.bottom, tabBarClearance) + } + } + .navigationBarBackButtonHidden(true) + .appHiddenNavigationBar() + .sheet(isPresented: $showAddCard) { + NavigationStack { + AddCardFormView(appState: appState, isFirstCard: cards.isEmpty) { newCard in + cards.append(newCard) + } + } + } + .task { await loadCards() } + } + + private var screenHeader: some View { + ZStack { + Text("Meus Cartões") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + + private var addCardButton: some View { + ZStack(alignment: .bottom) { + Rectangle() + .fill(AppColors.backgroundLight) + .frame(height: 136) + Button(action: { showAddCard = true }) { + HStack(spacing: 12) { + Image(systemName: "creditcard.fill") + .font(.system(size: 20)) + Text("Adicionar novo cartão") + .font(AppTypography.heading3) + } + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + .padding(.bottom, 14) + } + } + + private func cardRow(_ card: SavedCard) -> some View { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(AppColors.backgroundLight) + .frame(width: 52, height: 52) + if let logo = brandLogoName(for: card.brand) { + Image(logo) + .resizable() + .scaledToFit() + .frame(width: 36, height: 24) + } else { + Image(systemName: "creditcard.fill") + .font(.system(size: 22)) + .foregroundStyle(AppColors.textMuted) + } + } + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(card.displayLabel) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + if card.isDefault { + Text("Principal") + .font(AppTypography.caption) + .foregroundStyle(AppColors.primary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(AppColors.brandSoft) + .clipShape(Capsule()) + } + } + Text("Vence \(card.expiryLabel)") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + if !canDelete { + Text("Ao menos um cartão deve permanecer") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted.opacity(0.7)) + } + } + + Spacer(minLength: 0) + } + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private func brandLogoName(for brand: String?) -> String? { + switch brand?.lowercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: .diacriticInsensitive, locale: .current) { + case "visa": return "visacard_logo" + case "mastercard", "master": return "mastercard_logo" + case "amex", "american express", "americanexpress": return "amexcard_logo" + case "hipercard": return "hipercard_logo" + case "alelo": return "alelocard_logo" + case "sodexo": return "sodexo_logo" + default: return nil + } + } + + @MainActor + private func loadCards() async { + guard isLoading == false else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let response = try await ApiService().listCards() + if response.error == false { + cards = response.result ?? [] + } else { + errorMessage = response.message ?? "Erro ao carregar cartões." + } + } catch { + errorMessage = "Não foi possível carregar seus cartões." + } + } + + private func deleteCard(_ card: SavedCard) { + guard canDelete, deletingCardId == nil else { return } + deletingCardId = card.id + openSwipeRowId = nil + Task { + do { + let response = try await ApiService().deleteCard(cardId: card.id) + if response.error == false { + cards.removeAll { $0.id == card.id } + } else { + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível excluir o cartão.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + } catch { + SnackbarCenter.shared.show( + title: "Erro ao excluir cartão.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + deletingCardId = nil + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift index 697ac46..a778f68 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift @@ -22,6 +22,7 @@ struct UserProfileView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 22) { + screenHeader avatarSection formSection saveButton @@ -31,8 +32,8 @@ struct UserProfileView: View { .padding(.bottom, UIDevice.bottomNotch + 24) } .background(AppColors.backgroundLight) - .navigationTitle("Meu Perfil") - .appInlineNavigationTitle() + .appHiddenNavigationBar() + .navigationBarBackButtonHidden(true) .onAppear { hydrateFromAppState() } @@ -43,6 +44,27 @@ struct UserProfileView: View { #endif } + private var screenHeader: some View { + ZStack { + Text("Meu Perfil") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppColors.textPrimary) + .frame(width: 52, height: 52) + .background(AppColors.surface) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 8, y: 2) + } + .buttonStyle(.plain) + Spacer() + } + } + } + private var avatarSection: some View { VStack(spacing: 12) { Circle() @@ -198,12 +220,14 @@ struct UserProfileView: View { return } - let customer = response.result - appState.profile.id = customer?.id ?? appState.profile.id - appState.profile.name = customer?.name ?? cleanName - appState.profile.email = customer?.email ?? cleanEmail - appState.profile.phone = customer?.phoneNumber ?? normalizedPhone - appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto + appState.profile.name = cleanName + appState.profile.email = cleanEmail + appState.profile.phone = normalizedPhone + if let pictureUrl = response.profilePictureUrl, pictureUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + appState.profile.profilePicture = ImageSourceResolver.resolve(pictureUrl) ?? pictureUrl + } else if cleanPhoto.isEmpty == false { + appState.profile.profilePicture = cleanPhoto + } let cleanCpf = cpf.filter(\.isNumber) if cleanCpf.count == 11 {