cards
This commit is contained in:
BIN
logo/alelocard_logo.png
Normal file
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
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
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
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
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
BIN
logo/visacard_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ struct AppState {
|
||||
var featureFlags = FeatureFlagsState()
|
||||
var homeFilters = HomeFiltersState()
|
||||
var activeModal: AppModal? = nil
|
||||
var shouldNavigateToOrders: Bool = false
|
||||
}
|
||||
|
||||
enum FeatureFlagValue: Codable, Equatable {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
461
pedi-foods/Sources/PediFoods/Views/Main/AddCardFormView.swift
Normal file
461
pedi-foods/Sources/PediFoods/Views/Main/AddCardFormView.swift
Normal 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))"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@ struct MainTabView: View {
|
||||
}
|
||||
case .cart:
|
||||
NavigationStack {
|
||||
CartView(appState: $appState)
|
||||
CartView(appState: $appState, selectedTab: $selectedTab)
|
||||
}
|
||||
case .profile:
|
||||
NavigationStack {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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: ",")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
244
pedi-foods/Sources/PediFoods/Views/Main/SavedCardsView.swift
Normal file
244
pedi-foods/Sources/PediFoods/Views/Main/SavedCardsView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user