This commit is contained in:
Daniel Arantes Loverde
2026-06-05 16:32:08 -03:00
parent e62bd60e9a
commit 18818f909a
27 changed files with 1440 additions and 137 deletions

BIN
logo/alelocard_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
logo/amexcard_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
logo/hipercard_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
logo/mastercard_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
logo/sodexo_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
logo/visacard_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -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"

View File

@@ -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)
}
}
}

View File

@@ -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?

View File

@@ -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)
}
}

View File

@@ -155,28 +155,17 @@ final class ApiService {
return envelope
}
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
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<CustomerProfile> = try await sendEnvelope(req)
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
} else {
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
}

View File

@@ -9,6 +9,7 @@ struct AppState {
var featureFlags = FeatureFlagsState()
var homeFilters = HomeFiltersState()
var activeModal: AppModal? = nil
var shouldNavigateToOrders: Bool = false
}
enum FeatureFlagValue: Codable, Equatable {

View File

@@ -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()
}
}
}
}

View File

@@ -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<String>,
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))"
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -16,7 +16,7 @@ struct MainTabView: View {
}
case .cart:
NavigationStack {
CartView(appState: $appState)
CartView(appState: $appState, selectedTab: $selectedTab)
}
case .profile:
NavigationStack {

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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: ",")
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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 {