From 18818f909a3292f2055a07739acfde4e3b0f54d5 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Fri, 5 Jun 2026 16:32:08 -0300 Subject: [PATCH] cards --- logo/alelocard_logo.png | Bin 0 -> 3749 bytes logo/amexcard_logo.png | Bin 0 -> 3763 bytes logo/hipercard_logo.png | Bin 0 -> 5334 bytes logo/mastercard_logo.png | Bin 0 -> 8479 bytes logo/sodexo_logo.png | Bin 0 -> 7773 bytes logo/visacard_logo.png | Bin 0 -> 4191 bytes .../PediFoods/Resources/Localizable.xcstrings | 62 ++- .../Services/ApiCustomerPayloadModels.swift | 19 +- .../PediFoods/Services/ApiModels.swift | 7 + .../Services/ApiOrderTrackingModels.swift | 45 +- .../PediFoods/Services/ApiService.swift | 27 +- .../Sources/PediFoods/State/AppState.swift | 1 + .../PediFoods/Views/Auth/LegalViews.swift | 54 +- .../Views/Main/AddCardFormView.swift | 461 ++++++++++++++++++ .../PediFoods/Views/Main/CartView.swift | 3 +- .../PediFoods/Views/Main/CheckoutView.swift | 59 ++- .../PediFoods/Views/Main/MainTabView.swift | 2 +- .../Views/Main/OrderDetailsView.swift | 322 ++++++++++-- .../Views/Main/OrderTrackingView.swift | 34 +- .../PediFoods/Views/Main/OrdersView.swift | 34 +- .../Views/Main/PizzaFlavorAddonsSheet.swift | 27 +- .../Views/Main/PizzaProductDetailSheet.swift | 30 +- .../Views/Main/ProductDetailSheet.swift | 30 +- .../PediFoods/Views/Main/ProfileView.swift | 22 +- .../PediFoods/Views/Main/ReviewsView.swift | 54 +- .../PediFoods/Views/Main/SavedCardsView.swift | 244 +++++++++ .../Views/Main/UserProfileView.swift | 40 +- 27 files changed, 1440 insertions(+), 137 deletions(-) create mode 100644 logo/alelocard_logo.png create mode 100644 logo/amexcard_logo.png create mode 100644 logo/hipercard_logo.png create mode 100644 logo/mastercard_logo.png create mode 100644 logo/sodexo_logo.png create mode 100644 logo/visacard_logo.png create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/AddCardFormView.swift create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/SavedCardsView.swift diff --git a/logo/alelocard_logo.png b/logo/alelocard_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9201194d5d02d2380b64a2f6aa528e40bdcf50a6 GIT binary patch literal 3749 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 literal 0 HcmV?d00001 diff --git a/logo/hipercard_logo.png b/logo/hipercard_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a184c60998567bf631f579416471ae50e75aa9de GIT binary patch literal 5334 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 p1jlg^Ns>m7A3v^p> 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 {