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.", "comment" : "A small, secondary text label displaying the price of an item.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"0000 0000 0000 0000" : {
}, },
"1. Tamanho" : { "1. Tamanho" : {
"comment" : "A heading for the size selection step in the pizza product detail sheet.", "comment" : "A heading for the size selection step in the pizza product detail sheet.",
@@ -179,6 +182,9 @@
"Alterar" : { "Alterar" : {
"comment" : "A button label that translates to \"Change\" in English.", "comment" : "A button label that translates to \"Change\" in English.",
"isCommentAutoGenerated" : true "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)" : { "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.", "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 cardápio..." : {
},
"Carregando endereços..." : {
}, },
"Carregando pedido..." : { "Carregando pedido..." : {
@@ -334,10 +343,6 @@
"comment" : "A label for the comment section in the order review view.", "comment" : "A label for the comment section in the order review view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Concluir" : {
"comment" : "The text for a button that confirms and closes a sheet.",
"isCommentAutoGenerated" : true
},
"Conexão segura e criptografada" : { "Conexão segura e criptografada" : {
"comment" : "A description below the \"Verificar e Entrar\" button, emphasizing the security of the login process.", "comment" : "A description below the \"Verificar e Entrar\" button, emphasizing the security of the login process.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -437,6 +442,9 @@
} }
} }
} }
},
"Definir como principal" : {
}, },
"Desconto" : { "Desconto" : {
"comment" : "A label for a discount applied to an order.", "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?" : { "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.", "comment" : "A description under the NPS scale question in the order review view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Endereço de cobrança" : {
}, },
"ENDEREÇO DE ENTREGA" : { "ENDEREÇO DE ENTREGA" : {
"comment" : "A label displayed above the address section when delivery is selected.", "comment" : "A label displayed above the address section when delivery is selected.",
@@ -628,6 +639,12 @@
"Já tem uma conta?" : { "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.", "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 "isCommentAutoGenerated" : true
},
"Ligar para %@" : {
},
"Ligar para a loja?" : {
}, },
"Light" : { "Light" : {
"comment" : "Menu item indicating that the appearance should be in light mode", "comment" : "Menu item indicating that the appearance should be in light mode",
@@ -666,6 +683,9 @@
"Limpar carrinho e adicionar" : { "Limpar carrinho e adicionar" : {
"comment" : "A button that clears the current cart and adds the selected product.", "comment" : "A button that clears the current cart and adds the selected product.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Limpar e adicionar" : {
}, },
"Loja não informou métodos presenciais." : { "Loja não informou métodos presenciais." : {
@@ -684,6 +704,9 @@
"Meu Perfil" : { "Meu Perfil" : {
"comment" : "The title of the user profile view.", "comment" : "The title of the user profile view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Meus Cartões" : {
}, },
"Meus Endereços" : { "Meus Endereços" : {
"comment" : "A label displayed at the top of the view.", "comment" : "A label displayed at the top of the view.",
@@ -746,6 +769,9 @@
}, },
"Não tem conta ainda?" : { "Não tem conta ainda?" : {
},
"Nenhum cartão cadastrado" : {
}, },
"Nenhum endereço cadastrado" : { "Nenhum endereço cadastrado" : {
"comment" : "A message displayed when a user has no addresses saved.", "comment" : "A message displayed when a user has no addresses saved.",
@@ -801,6 +827,9 @@
"Novo por aqui?" : { "Novo por aqui?" : {
"comment" : "A text that appears at the bottom of the screen, inviting users to create an account.", "comment" : "A text that appears at the bottom of the screen, inviting users to create an account.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Número do cartão" : {
}, },
"O que pode melhorar?" : { "O que pode melhorar?" : {
"comment" : "A label for the section where users can select tags they think could improve an order.", "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." : { "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.", "comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Principal" : {
}, },
"PRINCIPAL" : { "PRINCIPAL" : {
"comment" : "A label indicating that a particular address is the user's primary address.", "comment" : "A label indicating that a particular address is the user's primary address.",
@@ -999,6 +1031,9 @@
}, },
"Salvar cartão" : { "Salvar cartão" : {
},
"Salvar Cartão" : {
}, },
"Save" : { "Save" : {
"comment" : "Button title indicating that the current contents should be saved", "comment" : "Button title indicating that the current contents should be saved",
@@ -1032,6 +1067,9 @@
}, },
"Selecionar Cartão" : { "Selecionar Cartão" : {
},
"Selecionar endereço" : {
}, },
"Sem adicionais" : { "Sem adicionais" : {
@@ -1070,6 +1108,16 @@
"comment" : "A message indicating that the user's cart is empty.", "comment" : "A message indicating that the user's cart is empty.",
"isCommentAutoGenerated" : true "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?" : { "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.", "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 "isCommentAutoGenerated" : true
@@ -1088,6 +1136,9 @@
"Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque." : { "Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque." : {
"comment" : "A tagline displayed in the login view.", "comment" : "A tagline displayed in the login view.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Substituir carrinho?" : {
}, },
"Subtotal" : { "Subtotal" : {
@@ -1309,6 +1360,9 @@
} }
} }
} }
},
"WhatsApp não encontrado. Deseja ligar para %@?" : {
} }
}, },
"version" : "1.1" "version" : "1.1"

View File

@@ -15,32 +15,25 @@ struct CustomerProfileUpdatePayload: Encodable {
} }
struct CustomerIdentityUpdatePayload: Encodable { struct CustomerIdentityUpdatePayload: Encodable {
let name: String let name: String?
let email: String let email: String?
let phoneNumber: String let phoneNumber: String?
let profilePicture: String? let profilePicture: String?
let addressBook: [CustomerAddressPayload]
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name case name
case email case email
case phoneNumber case phoneNumber
case phone
case profilePicture case profilePicture
case profile_picture
case addressBook = "address_book"
} }
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name) try container.encodeIfPresent(name, forKey: .name)
try container.encode(email, forKey: .email) try container.encodeIfPresent(email, forKey: .email)
try container.encode(phoneNumber, forKey: .phoneNumber) try container.encodeIfPresent(phoneNumber, forKey: .phoneNumber)
try container.encode(phoneNumber, forKey: .phone)
try container.encode(addressBook, forKey: .addressBook)
if let profilePicture, profilePicture.isEmpty == false { if let profilePicture, profilePicture.isEmpty == false {
try container.encode(profilePicture, forKey: .profilePicture) 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 EmptyResult: Decodable {}
struct ProfilePatchEnvelope: Decodable {
let error: Bool
let code: String?
let message: String?
let profilePictureUrl: String?
}
struct RegistrationResult: Decodable { struct RegistrationResult: Decodable {
let id: String? let id: String?
let name: String? let name: String?

View File

@@ -40,6 +40,10 @@ struct AppOrderSummary: Decodable, Identifiable {
case storeLogo case storeLogo
case store_logo case store_logo
case logo case logo
case storeImage
case store_image
case storeImageUrl
case logoUrl
case date case date
case createdAt case createdAt
case updatedAt case updatedAt
@@ -62,7 +66,7 @@ struct AppOrderSummary: Decodable, Identifiable {
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType]) deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone]) 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]) let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate
@@ -97,6 +101,7 @@ struct PublicOrderResult: Codable, Identifiable {
let customerOtp: String? let customerOtp: String?
let confirmOtp: String? let confirmOtp: String?
let cancellationReason: String? let cancellationReason: String?
let fullAddress: String?
let deliveryAddress: PublicOrderDeliveryAddress? let deliveryAddress: PublicOrderDeliveryAddress?
let review: PublicOrderReview? let review: PublicOrderReview?
let items: [PublicOrderItem] let items: [PublicOrderItem]
@@ -133,12 +138,17 @@ struct PublicOrderResult: Codable, Identifiable {
case storeLogo case storeLogo
case store_logo case store_logo
case logo case logo
case storeImage
case store_image
case storeImageUrl
case logoUrl
case createdAt case createdAt
case updatedAt case updatedAt
case otp case otp
case customerOtp case customerOtp
case confirmOtp case confirmOtp
case cancellationReason case cancellationReason
case fullAddress
case address case address
case deliveryAddress case deliveryAddress
case delivery_address case delivery_address
@@ -148,6 +158,8 @@ struct PublicOrderResult: Codable, Identifiable {
case orderReview case orderReview
case items case items
case timeline case timeline
case history
case orderedAt
} }
init( init(
@@ -178,6 +190,7 @@ struct PublicOrderResult: Codable, Identifiable {
customerOtp: String? = nil, customerOtp: String? = nil,
confirmOtp: String? = nil, confirmOtp: String? = nil,
cancellationReason: String? = nil, cancellationReason: String? = nil,
fullAddress: String? = nil,
deliveryAddress: PublicOrderDeliveryAddress? = nil, deliveryAddress: PublicOrderDeliveryAddress? = nil,
review: PublicOrderReview? = nil, review: PublicOrderReview? = nil,
items: [PublicOrderItem] = [], items: [PublicOrderItem] = [],
@@ -210,6 +223,7 @@ struct PublicOrderResult: Codable, Identifiable {
self.customerOtp = customerOtp self.customerOtp = customerOtp
self.confirmOtp = confirmOtp self.confirmOtp = confirmOtp
self.cancellationReason = cancellationReason self.cancellationReason = cancellationReason
self.fullAddress = fullAddress
self.deliveryAddress = deliveryAddress self.deliveryAddress = deliveryAddress
self.review = review self.review = review
self.items = items self.items = items
@@ -248,13 +262,14 @@ struct PublicOrderResult: Codable, Identifiable {
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total]) total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName]) storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone]) 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])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt, .orderedAt])
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
otp = ApiService.decodeFlexibleString(from: container, keys: [.otp]) otp = ApiService.decodeFlexibleString(from: container, keys: [.otp])
customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp]) customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp])
confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp]) confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp])
cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason]) cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason])
fullAddress = ApiService.decodeFlexibleString(from: container, keys: [.fullAddress])
deliveryAddress = (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .address)) deliveryAddress = (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .address))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .deliveryAddress)) ?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .deliveryAddress))
?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .delivery_address)) ?? (try? container.decode(PublicOrderDeliveryAddress.self, forKey: .delivery_address))
@@ -263,7 +278,9 @@ struct PublicOrderResult: Codable, Identifiable {
review = (try? container.decode(PublicOrderReview.self, forKey: .review)) review = (try? container.decode(PublicOrderReview.self, forKey: .review))
?? (try? container.decode(PublicOrderReview.self, forKey: .orderReview)) ?? (try? container.decode(PublicOrderReview.self, forKey: .orderReview))
items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? [] 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 { func encode(to encoder: Encoder) throws {
@@ -294,6 +311,7 @@ struct PublicOrderResult: Codable, Identifiable {
try container.encodeIfPresent(customerOtp, forKey: .customerOtp) try container.encodeIfPresent(customerOtp, forKey: .customerOtp)
try container.encodeIfPresent(confirmOtp, forKey: .confirmOtp) try container.encodeIfPresent(confirmOtp, forKey: .confirmOtp)
try container.encodeIfPresent(cancellationReason, forKey: .cancellationReason) try container.encodeIfPresent(cancellationReason, forKey: .cancellationReason)
try container.encodeIfPresent(fullAddress, forKey: .fullAddress)
try container.encodeIfPresent(deliveryAddress, forKey: .address) try container.encodeIfPresent(deliveryAddress, forKey: .address)
try container.encodeIfPresent(review, forKey: .review) try container.encodeIfPresent(review, forKey: .review)
try container.encode(items, forKey: .items) try container.encode(items, forKey: .items)
@@ -537,20 +555,23 @@ struct PublicOrderReview: Codable {
struct PublicOrderItem: Codable, Identifiable { struct PublicOrderItem: Codable, Identifiable {
let id: String let id: String
let productId: String?
let name: String? let name: String?
let qty: Int? let qty: Int?
let price: Double? let price: Double?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
case productId
case name case name
case qty case qty
case quantity case quantity
case price 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.id = id
self.productId = productId
self.name = name self.name = name
self.qty = qty self.qty = qty
self.price = price self.price = price
@@ -559,6 +580,7 @@ struct PublicOrderItem: Codable, Identifiable {
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString 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) name = try? container.decode(String.self, forKey: .name)
qty = ApiService.decodeFlexibleInt(from: container, keys: [.qty, .quantity]) qty = ApiService.decodeFlexibleInt(from: container, keys: [.qty, .quantity])
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price]) price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
@@ -567,6 +589,7 @@ struct PublicOrderItem: Codable, Identifiable {
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id) try container.encode(id, forKey: .id)
try container.encodeIfPresent(productId, forKey: .productId)
try container.encodeIfPresent(name, forKey: .name) try container.encodeIfPresent(name, forKey: .name)
try container.encodeIfPresent(qty, forKey: .qty) try container.encodeIfPresent(qty, forKey: .qty)
try container.encodeIfPresent(price, forKey: .price) try container.encodeIfPresent(price, forKey: .price)
@@ -581,6 +604,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
let completed: Bool? let completed: Bool?
let message: String? let message: String?
let time: String? let time: String?
let date: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
@@ -589,7 +613,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
case active case active
case completed case completed
case message case message
case event
case time case time
case date
case createdAt case createdAt
case updatedAt case updatedAt
} }
@@ -601,7 +627,8 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
active: Bool? = nil, active: Bool? = nil,
completed: Bool? = nil, completed: Bool? = nil,
message: String?, message: String?,
time: String? time: String?,
date: String? = nil
) { ) {
self.id = id self.id = id
self.status = status self.status = status
@@ -610,6 +637,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
self.completed = completed self.completed = completed
self.message = message self.message = message
self.time = time self.time = time
self.date = date
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
@@ -619,10 +647,12 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
label = try? container.decode(String.self, forKey: .label) label = try? container.decode(String.self, forKey: .label)
active = try? container.decode(Bool.self, forKey: .active) active = try? container.decode(Bool.self, forKey: .active)
completed = try? container.decode(Bool.self, forKey: .completed) 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)) time = (try? container.decode(String.self, forKey: .time))
?? (try? container.decode(String.self, forKey: .createdAt)) ?? (try? container.decode(String.self, forKey: .createdAt))
?? (try? container.decode(String.self, forKey: .updatedAt)) ?? (try? container.decode(String.self, forKey: .updatedAt))
date = try? container.decode(String.self, forKey: .date)
} }
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
@@ -634,6 +664,7 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
try container.encodeIfPresent(completed, forKey: .completed) try container.encodeIfPresent(completed, forKey: .completed)
try container.encodeIfPresent(message, forKey: .message) try container.encodeIfPresent(message, forKey: .message)
try container.encodeIfPresent(time, forKey: .time) try container.encodeIfPresent(time, forKey: .time)
try container.encodeIfPresent(date, forKey: .date)
} }
} }

View File

@@ -155,28 +155,17 @@ final class ApiService {
return envelope return envelope
} }
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ApiEnvelope<CustomerProfile> { func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ProfilePatchEnvelope {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerIdentityUpdatePayload( let payload = CustomerIdentityUpdatePayload(
name: name, name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : name,
email: email, email: email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : email,
phoneNumber: phoneNumber, phoneNumber: phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : phoneNumber,
profilePicture: profilePicture, profilePicture: profilePicture
addressBook: (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:))
) )
let body = try JSONEncoder().encode(payload) let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req) let envelope: ProfilePatchEnvelope = try await send(req)
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())" AppContentCache.shared.invalidate(prefix: profileCachePrefix)
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
} else {
invalidateFavoritesCache()
}
return envelope return envelope
} }

View File

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

View File

@@ -2,10 +2,12 @@ import SwiftUI
struct TermsOfUseView: View { struct TermsOfUseView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
screenHeader
Text("Termos de Uso") Text("Termos de Uso")
.font(AppTypography.heading1) .font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
@@ -16,17 +18,40 @@ struct TermsOfUseView: View {
.padding(24) .padding(24)
} }
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()) .background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Termos de Uso") .appHiddenNavigationBar()
.appInlineNavigationTitle() .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 { struct PrivacyPolicyView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
screenHeader
Text("Política de Privacidade") Text("Política de Privacidade")
.font(AppTypography.heading1) .font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
@@ -37,7 +62,28 @@ struct PrivacyPolicyView: View {
.padding(24) .padding(24)
} }
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()) .background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Privacidade") .appHiddenNavigationBar()
.appInlineNavigationTitle() .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 { struct CartView: View {
@Binding var appState: AppState @Binding var appState: AppState
@Binding var selectedTab: MainTab
@State var openCheckout = false @State var openCheckout = false
@State var couponCode = "" @State var couponCode = ""
@State var appliedCouponCode: String? = nil @State var appliedCouponCode: String? = nil
@@ -49,7 +50,7 @@ struct CartView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationDestination(isPresented: $openCheckout) { .navigationDestination(isPresented: $openCheckout) {
CheckoutView(appState: $appState) CheckoutView(appState: $appState, selectedTab: $selectedTab)
} }
.task(id: deliveryFeeWatchKey) { .task(id: deliveryFeeWatchKey) {
await refreshDeliveryFee() await refreshDeliveryFee()

View File

@@ -7,6 +7,8 @@ import AppKit
struct CheckoutView: View { struct CheckoutView: View {
@Binding var appState: AppState @Binding var appState: AppState
@Binding var selectedTab: MainTab
@Environment(\.dismiss) var dismiss
@State var storeInfo: StoreInfoResult? = nil @State var storeInfo: StoreInfoResult? = nil
@State var errorMessage: String? = nil @State var errorMessage: String? = nil
@@ -98,6 +100,7 @@ struct CheckoutView: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
screenHeader
deliveryTypeSection deliveryTypeSection
addressSection addressSection
orderSummarySection orderSummarySection
@@ -120,8 +123,8 @@ struct CheckoutView: View {
.padding(.bottom, 10) .padding(.bottom, 10)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Finalizar Pedido") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.appBottomSafeAreaInset { .appBottomSafeAreaInset {
bottomBar bottomBar
} }
@@ -212,7 +215,31 @@ struct CheckoutView: View {
} }
} }
.navigationDestination(item: $orderTrackingContext) { context in .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 { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 14) { VStack(spacing: 14) {
screenHeader
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(AppColors.surface) .fill(AppColors.surface)
.overlay( .overlay(
@@ -767,8 +795,8 @@ struct PaymentPixView: View {
.padding(20) .padding(20)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Pagamento via PIX") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.sheet(isPresented: $showChangePaymentSheet) { .sheet(isPresented: $showChangePaymentSheet) {
ChangePaymentSheet( ChangePaymentSheet(
pixContext: currentContext, 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() { private func openTrackingOnce() {
guard hasOpenedTracking == false else { return } guard hasOpenedTracking == false else { return }
hasOpenedTracking = true hasOpenedTracking = true

View File

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

View File

@@ -4,10 +4,20 @@ struct OrderDetailsView: View {
let order: PublicOrderResult let order: PublicOrderResult
let orderId: String let orderId: String
let initialShortId: 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 { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 14) { VStack(spacing: 14) {
screenHeader
statusCard statusCard
storeCard storeCard
itemsCard itemsCard
@@ -22,8 +32,44 @@ struct OrderDetailsView: View {
.padding(.bottom, UIDevice.bottomNotch) .padding(.bottom, UIDevice.bottomNotch)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Detalhes do Pedido") .appHiddenNavigationBar()
.appInlineNavigationTitle() .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 { .appBottomSafeAreaInset {
VStack { VStack {
reorderButton 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 { private var statusCard: some View {
HStack(spacing: 14) { HStack(spacing: 14) {
Circle() Circle()
@@ -54,6 +121,26 @@ struct OrderDetailsView: View {
Text(statusDateText) Text(statusDateText)
.font(AppTypography.body) .font(AppTypography.body)
.foregroundStyle(AppColors.textMuted) .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) Spacer(minLength: 0)
@@ -64,30 +151,39 @@ struct OrderDetailsView: View {
} }
private var storeCard: some View { private var storeCard: some View {
HStack(spacing: 12) { Button {
AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL)) if order.storeId?.isEmpty == false {
.frame(width: 54, height: 54) navigateToStore = true
.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)
} }
} 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") Spacer(minLength: 0)
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645")) 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) .buttonStyle(.plain)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
} }
private var itemsCard: some View { private var itemsCard: some View {
@@ -216,27 +312,28 @@ struct OrderDetailsView: View {
private var reorderButton: some View { private var reorderButton: some View {
Button("Pedir Novamente") { Button("Pedir Novamente") {
SnackbarCenter.shared.show( reorder()
title: "Recompra será integrada com o catálogo em breve.",
style: .info,
icon: "cart.badge.plus",
duration: 2.0
)
} }
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(Color(hex: "#0E1A06")) .foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56) .frame(maxWidth: .infinity, minHeight: 56)
.background(Color(hex: "#C8F06E")) .background(order.items.isEmpty ? Color(hex: "#C8F06E").opacity(0.45) : Color(hex: "#C8F06E"))
.clipShape(Capsule()) .clipShape(Capsule())
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(order.items.isEmpty)
} }
private var helpFooter: some View { private var helpFooter: some View {
Text("Precisa de ajuda com esse pedido?") Button {
.font(AppTypography.body) handleHelpTap()
.foregroundStyle(Color(hex: "#A5D645")) } label: {
.frame(maxWidth: .infinity, alignment: .center) Text("Precisa de ajuda com esse pedido?")
.padding(.vertical, 8) .font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
}
.buttonStyle(.plain)
} }
private var subtotal: Double { private var subtotal: Double {
@@ -296,6 +393,24 @@ struct OrderDetailsView: View {
return "Pedido #\(displayOrderTitle)" 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 { private var statusTitle: String {
let status = normalized(order.status) let status = normalized(order.status)
if status.contains("CANCEL") { return "Pedido cancelado" } if status.contains("CANCEL") { return "Pedido cancelado" }
@@ -309,6 +424,12 @@ struct OrderDetailsView: View {
} }
private var statusDateText: String { 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) { if let formatted = formatDate(order.updatedAt ?? order.createdAt) {
return "\(statusDatePrefix) \(formatted)" return "\(statusDatePrefix) \(formatted)"
} }
@@ -342,14 +463,81 @@ struct OrderDetailsView: View {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") 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? { private func formatDate(_ isoValue: String?) -> String? {
guard let isoValue, isoValue.isEmpty == false else { return nil } guard let isoValue, isoValue.isEmpty == false else { return nil }
let iso = ISO8601DateFormatter() let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let optionSets: [ISO8601DateFormatter.Options] = [
var date = iso.date(from: isoValue) [.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 { if date == nil {
iso.formatOptions = [.withInternetDateTime] let fallback = DateFormatter()
date = iso.date(from: isoValue) 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 } guard let date else { return nil }
@@ -359,6 +547,64 @@ struct OrderDetailsView: View {
return formatter.string(from: date) 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 { private func normalized(_ value: String?) -> String {
(value ?? "") (value ?? "")
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)

View File

@@ -12,6 +12,8 @@ private struct TrackingStep: Identifiable {
struct OrderTrackingView: View { struct OrderTrackingView: View {
let orderId: String let orderId: String
let initialShortId: String? let initialShortId: String?
var postOrderBack: (() -> Void)? = nil
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL @Environment(\.openURL) var openURL
@State var isLoading = true @State var isLoading = true
@@ -27,6 +29,7 @@ struct OrderTrackingView: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 16) { VStack(spacing: 16) {
screenHeader
topHeader topHeader
orderTitleSection orderTitleSection
statusBanner statusBanner
@@ -43,8 +46,8 @@ struct OrderTrackingView: View {
.padding(.bottom, UIDevice.bottomNotch + 45) .padding(.bottom, UIDevice.bottomNotch + 45)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Pedido \(displayOrderTitle)") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) { .alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
Button("Fechar", role: .cancel) {} Button("Fechar", role: .cancel) {}
} message: { } message: {
@@ -97,6 +100,33 @@ struct OrderTrackingView: View {
self.reviewSavedObserver = nil 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 { private var topHeader: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
Circle() Circle()

View File

@@ -9,6 +9,8 @@ struct OrdersView: View {
@State var hasLoadedOnce = false @State var hasLoadedOnce = false
@State var storeRatingByStoreId: [String: Double] = [:] @State var storeRatingByStoreId: [String: Double] = [:]
@State var storeRatingByStoreName: [String: Double] = [:] @State var storeRatingByStoreName: [String: Double] = [:]
@State var storeLogoByStoreId: [String: String] = [:]
@State var storeLogoByStoreName: [String: String] = [:]
@State var selectedOrderRoute: OrderRouteContext? = nil @State var selectedOrderRoute: OrderRouteContext? = nil
var body: some View { var body: some View {
@@ -106,7 +108,7 @@ struct OrdersView: View {
return VStack(alignment: .leading, spacing: 14) { return VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) { HStack(spacing: 12) {
AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL)) AsyncStoreImage(imageURL: resolvedMediaURL(storeLogoURL(for: order)))
.frame(width: 80, height: 80) .frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
@@ -466,20 +468,25 @@ struct OrdersView: View {
var byId: [String: Double] = [:] var byId: [String: Double] = [:]
var byName: [String: Double] = [:] var byName: [String: Double] = [:]
var logoById: [String: String] = [:]
var logoByName: [String: String] = [:]
for store in storeList { for store in storeList {
guard let rating = store.rating, rating > 0 else { continue }
let storeId = normalizedOrderId(store.id) let storeId = normalizedOrderId(store.id)
if storeId.isEmpty == false {
byId[storeId] = rating
}
let nameKey = normalizedStoreName(store.name) let nameKey = normalizedStoreName(store.name)
if nameKey.isEmpty == false { if let rating = store.rating, rating > 0 {
byName[nameKey] = rating 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 storeRatingByStoreId = byId
storeRatingByStoreName = byName storeRatingByStoreName = byName
storeLogoByStoreId = logoById
storeLogoByStoreName = logoByName
} }
private func storeRating(for order: AppOrderSummary) -> Double? { private func storeRating(for order: AppOrderSummary) -> Double? {
@@ -494,6 +501,17 @@ struct OrdersView: View {
return nil 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? { private func resolvedMediaURL(_ raw: String?) -> String? {
ImageSourceResolver.resolve(raw) ImageSourceResolver.resolve(raw)
} }
@@ -662,7 +680,7 @@ struct OrderEntryDestinationView: View {
} }
) )
} else if let orderDetails { } else if let orderDetails {
OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId) OrderDetailsView(order: orderDetails, orderId: orderId, initialShortId: initialShortId, appState: $appState)
} else { } else {
OrderTrackingView(orderId: orderId, initialShortId: initialShortId) OrderTrackingView(orderId: orderId, initialShortId: initialShortId)
} }

View File

@@ -10,6 +10,7 @@ struct PizzaFlavorAddonsSheet: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
screenHeader
Text(flavor.name) Text(flavor.name)
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
@@ -77,12 +78,28 @@ struct PizzaFlavorAddonsSheet: View {
.padding(20) .padding(20)
} }
.background(AppColors.backgroundLight.ignoresSafeArea()) .background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Adicionais") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.appTopBarTrailingToolbar { }
Button("Concluir") { dismiss() }
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) .buttonStyle(.plain)
.foregroundStyle(AppColors.primary) Spacer()
}
} }
} }

View File

@@ -189,6 +189,7 @@ struct PizzaProductDetailSheet: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
screenHeader
AsyncStoreImage(imageURL: representativeImage) AsyncStoreImage(imageURL: representativeImage)
.frame(height: 220) .frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
@@ -291,12 +292,8 @@ struct PizzaProductDetailSheet: View {
} }
} }
.background(AppColors.backgroundLight.ignoresSafeArea()) .background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Monte sua pizza") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.appTopBarTrailingToolbar {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
.onAppear { .onAppear {
applyAutoSelections() applyAutoSelections()
let existing = currentQuantityForItemId(cartItemId) 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 { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
screenHeader
AsyncStoreImage(imageURL: imageURL) AsyncStoreImage(imageURL: imageURL)
.frame(height: 220) .frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
@@ -221,12 +222,8 @@ struct ProductDetailSheet: View {
.background(.ultraThinMaterial) .background(.ultraThinMaterial)
} }
.background(AppColors.backgroundLight.ignoresSafeArea()) .background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Detalhes") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.appTopBarTrailingToolbar {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
.onAppear { .onAppear {
let existing = currentQuantityForItemId(cartItemId) let existing = currentQuantityForItemId(cartItemId)
quantity = existing > 0 ? existing : 1 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 { private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
} }

View File

@@ -14,6 +14,7 @@ struct ProfileView: View {
@State var openAddressesOnboarding = false @State var openAddressesOnboarding = false
@State var onboardingMessage: String? = nil @State var onboardingMessage: String? = nil
@State var showLogoutAlert = false @State var showLogoutAlert = false
@State private var openOrders = false
let tabBarClearance: CGFloat = 120 let tabBarClearance: CGFloat = 120
var body: some View { var body: some View {
@@ -41,12 +42,12 @@ struct ProfileView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
// NavigationLink { NavigationLink {
// Text("Meus Cartões") SavedCardsView(appState: $appState)
// } label: { } label: {
// ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões") ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões")
// } }
// .buttonStyle(.plain) .buttonStyle(.plain)
NavigationLink { NavigationLink {
MyReviewsView() MyReviewsView()
@@ -120,6 +121,15 @@ struct ProfileView: View {
AddressesView(message: onboardingMessage, appState: $appState) 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 { private var header: some View {

View File

@@ -24,6 +24,8 @@ struct MyReviewsView: View {
@State var isLoading = false @State var isLoading = false
@State var loadError: String? = nil @State var loadError: String? = nil
@Environment(\.dismiss) var dismiss
init(initialOrder: ReviewDraft? = nil) { init(initialOrder: ReviewDraft? = nil) {
self.initialOrder = initialOrder self.initialOrder = initialOrder
} }
@@ -31,6 +33,7 @@ struct MyReviewsView: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 12) { VStack(spacing: 12) {
screenHeader
if isLoading && reviews.isEmpty && pendingReviews.isEmpty { if isLoading && reviews.isEmpty && pendingReviews.isEmpty {
ProgressView() ProgressView()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -66,8 +69,8 @@ struct MyReviewsView: View {
.padding(.bottom, UIDevice.bottomNotch + 30) .padding(.bottom, UIDevice.bottomNotch + 30)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Minhas Avaliações") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.navigationDestination(item: $selectedDraft) { draft in .navigationDestination(item: $selectedDraft) { draft in
OrderReviewView(draft: draft) { OrderReviewView(draft: draft) {
Task { await loadReviewsFromBackend(forceRefresh: true) } 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 { private var emptyState: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
Text("Você não tem avaliações nem pendências no momento.") Text("Você não tem avaliações nem pendências no momento.")
@@ -434,6 +458,7 @@ struct OrderReviewView: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
screenHeader
topSection topSection
orderItemsSection orderItemsSection
if existingReview != nil { if existingReview != nil {
@@ -450,8 +475,8 @@ struct OrderReviewView: View {
.padding(.bottom, UIDevice.bottomNotch + 140) .padding(.bottom, UIDevice.bottomNotch + 140)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Avaliar Pedido") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.onAppear { .onAppear {
if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) { if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) {
existingReview = cachedReview 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 { private var newReviewContent: some View {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
starsSection 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 { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 22) { VStack(spacing: 22) {
screenHeader
avatarSection avatarSection
formSection formSection
saveButton saveButton
@@ -31,8 +32,8 @@ struct UserProfileView: View {
.padding(.bottom, UIDevice.bottomNotch + 24) .padding(.bottom, UIDevice.bottomNotch + 24)
} }
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Meu Perfil") .appHiddenNavigationBar()
.appInlineNavigationTitle() .navigationBarBackButtonHidden(true)
.onAppear { .onAppear {
hydrateFromAppState() hydrateFromAppState()
} }
@@ -43,6 +44,27 @@ struct UserProfileView: View {
#endif #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 { private var avatarSection: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
Circle() Circle()
@@ -198,12 +220,14 @@ struct UserProfileView: View {
return return
} }
let customer = response.result appState.profile.name = cleanName
appState.profile.id = customer?.id ?? appState.profile.id appState.profile.email = cleanEmail
appState.profile.name = customer?.name ?? cleanName appState.profile.phone = normalizedPhone
appState.profile.email = customer?.email ?? cleanEmail if let pictureUrl = response.profilePictureUrl, pictureUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
appState.profile.phone = customer?.phoneNumber ?? normalizedPhone appState.profile.profilePicture = ImageSourceResolver.resolve(pictureUrl) ?? pictureUrl
appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto } else if cleanPhoto.isEmpty == false {
appState.profile.profilePicture = cleanPhoto
}
let cleanCpf = cpf.filter(\.isNumber) let cleanCpf = cpf.filter(\.isNumber)
if cleanCpf.count == 11 { if cleanCpf.count == 11 {