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

View File

@@ -2,10 +2,12 @@ import SwiftUI
struct TermsOfUseView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
screenHeader
Text("Termos de Uso")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
@@ -16,17 +18,40 @@ struct TermsOfUseView: View {
.padding(24)
}
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Termos de Uso")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
}
private var screenHeader: some View {
ZStack {
Text("Termos de Uso")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
}
struct PrivacyPolicyView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
screenHeader
Text("Política de Privacidade")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
@@ -37,7 +62,28 @@ struct PrivacyPolicyView: View {
.padding(24)
}
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Privacidade")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
}
private var screenHeader: some View {
ZStack {
Text("Privacidade")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
}

View File

@@ -0,0 +1,461 @@
import SwiftUI
struct AddCardFormView: View {
let appState: AppState
let isFirstCard: Bool
let onCardAdded: (SavedCard) -> Void
@Environment(\.dismiss) var dismiss
@State private var cardNumber = ""
@State private var holderName = ""
@State private var expiry = ""
@State private var cvv = ""
@State private var cpf = ""
@State private var nickname = ""
@State private var isDefault = false
@State private var isSaving = false
@State private var addresses: [CustomerAddress] = []
@State private var selectedAddress: CustomerAddress? = nil
@State private var isLoadingAddresses = false
@State private var showAddressPicker = false
private var detectedBrandLogo: String? {
let clean = cardNumber.filter(\.isNumber)
guard clean.isEmpty == false else { return nil }
if clean.hasPrefix("506766") || clean.hasPrefix("603389") { return "sodexo_logo" }
if clean.hasPrefix("5067") || clean.hasPrefix("5078") || clean.hasPrefix("6278") || clean.hasPrefix("6367") { return "alelocard_logo" }
if clean.hasPrefix("3841") || clean.hasPrefix("606282") || clean.hasPrefix("637095") || clean.hasPrefix("637568") { return "hipercard_logo" }
if clean.hasPrefix("34") || clean.hasPrefix("37") { return "amexcard_logo" }
if clean.hasPrefix("4") { return "visacard_logo" }
let prefix2 = Int(clean.prefix(2)) ?? 0
if (51...59).contains(prefix2) { return "mastercard_logo" }
if let p4 = Int(clean.prefix(4)), (2221...2720).contains(p4) { return "mastercard_logo" }
return nil
}
private var selectedAddressZip: String {
(selectedAddress?.zipCode ?? "").filter(\.isNumber)
}
private var canSave: Bool {
let digits = cardNumber.filter(\.isNumber)
let cpfDigits = cpf.filter(\.isNumber)
let parts = expiry.split(separator: "/")
return digits.count >= 13
&& holderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
&& parts.count == 2
&& cvv.count >= 3
&& cpfDigits.count == 11
&& selectedAddress != nil
&& selectedAddressZip.count >= 7
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
screenHeader
formSection("Dados do Cartão") {
cardNumberField
labeledField("Nome no cartão", placeholder: "Como impresso no cartão", text: $holderName, autocap: true)
HStack(spacing: 12) {
labeledField("Validade", placeholder: "MM/AA", text: $expiry, keyboard: .numberPad)
.onChange(of: expiry) { _, v in expiry = formatExpiry(v) }
labeledField("CVV", placeholder: "•••", text: $cvv, keyboard: .numberPad)
.onChange(of: cvv) { _, v in cvv = String(v.filter(\.isNumber).prefix(4)) }
}
}
formSection("Identificação do Titular") {
labeledField("CPF", placeholder: "000.000.000-00", text: $cpf, keyboard: .numberPad)
.onChange(of: cpf) { _, v in cpf = formatCPF(v.filter(\.isNumber)) }
addressPickerRow
}
formSection("Opções") {
labeledField("Apelido (opcional)", placeholder: "Ex: Cartão do Nubank", text: $nickname)
if isFirstCard == false {
Toggle(isOn: $isDefault) {
Text("Definir como principal")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
.tint(AppColors.primary)
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
Button(action: { Task { await saveCard() } }) {
Group {
if isSaving {
ProgressView().tint(Color(hex: "#0E1A06"))
} else {
Text("Salvar Cartão").font(AppTypography.heading2)
}
}
.foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56)
.background(canSave && !isSaving ? Color(hex: "#C8F06E") : Color(hex: "#C8F06E").opacity(0.45))
.clipShape(Capsule())
}
.buttonStyle(.plain)
.disabled(!canSave || isSaving)
.padding(.top, 4)
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, 40)
}
.background(AppColors.backgroundLight)
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.sheet(isPresented: $showAddressPicker) {
addressPickerSheet
}
.task { await loadData() }
}
// MARK: - Address picker row
private var addressPickerRow: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Endereço de cobrança")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
Button {
if addresses.isEmpty == false { showAddressPicker = true }
} label: {
HStack(spacing: 10) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 18))
.foregroundStyle(selectedAddress != nil ? AppColors.primary : AppColors.textMuted)
VStack(alignment: .leading, spacing: 2) {
if isLoadingAddresses {
Text("Carregando endereços...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
} else if let addr = selectedAddress {
Text(addressDisplayTitle(addr))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
if let sub = addressDisplaySubtitle(addr) {
Text(sub)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
}
} else if addresses.isEmpty {
Text("Nenhum endereço cadastrado")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
} else {
Text("Selecionar endereço")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
}
Spacer()
if addresses.isEmpty == false {
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppColors.textMuted)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
.disabled(isLoadingAddresses || addresses.isEmpty)
}
}
// MARK: - Address picker sheet
private var addressPickerSheet: some View {
NavigationStack {
ScrollView(showsIndicators: false) {
VStack(spacing: 10) {
ForEach(Array(addresses.enumerated()), id: \.offset) { _, addr in
Button {
selectedAddress = addr
showAddressPicker = false
} label: {
HStack(spacing: 12) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 22))
.foregroundStyle(isSelected(addr) ? AppColors.primary : AppColors.textMuted)
VStack(alignment: .leading, spacing: 3) {
Text(addressDisplayTitle(addr))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
if let sub = addressDisplaySubtitle(addr) {
Text(sub)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
}
}
Spacer()
if isSelected(addr) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(AppColors.primary)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
.padding(.top, 14)
.padding(.bottom, 30)
}
.background(AppColors.backgroundLight)
.navigationTitle("Endereço de cobrança")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Fechar") { showAddressPicker = false }
.foregroundStyle(AppColors.textPrimary)
}
}
}
}
// MARK: - Sub-views
private var screenHeader: some View {
ZStack {
Text("Novo Cartão")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var cardNumberField: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Número do cartão")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
HStack(spacing: 8) {
TextField("0000 0000 0000 0000", text: $cardNumber)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.keyboardType(.numberPad)
.onChange(of: cardNumber) { _, v in cardNumber = formatCardNumber(v.filter(\.isNumber)) }
let digits = cardNumber.filter(\.isNumber)
if let logo = detectedBrandLogo {
Image(logo)
.resizable()
.scaledToFit()
.frame(width: 40, height: 26)
} else if digits.count >= 4 {
Image(systemName: "creditcard")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
.frame(width: 40, height: 26)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
private func labeledField(
_ label: String,
placeholder: String,
text: Binding<String>,
keyboard: UIKeyboardType = .default,
autocap: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: text)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.keyboardType(keyboard)
.autocorrectionDisabled()
.textInputAutocapitalization(autocap ? .characters : .never)
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
private func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.padding(.leading, 2)
content()
}
}
// MARK: - Helpers
private func isSelected(_ addr: CustomerAddress) -> Bool {
guard let sel = selectedAddress else { return false }
if let id = addr.id, let selId = sel.id { return id == selId }
return addr.address == sel.address && addr.number == sel.number
}
private func addressDisplayTitle(_ addr: CustomerAddress) -> String {
let label = addr.label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if label.isEmpty == false { return label }
let street = addr.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let number = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let base = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ")
return base.isEmpty ? "Endereço" : base
}
private func addressDisplaySubtitle(_ addr: CustomerAddress) -> String? {
let parts = [
addr.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines),
addr.city?.trimmingCharacters(in: .whitespacesAndNewlines),
addr.state?.trimmingCharacters(in: .whitespacesAndNewlines)
].compactMap { v -> String? in
guard let v, v.isEmpty == false else { return nil }
return v
}
return parts.isEmpty ? nil : parts.joined(separator: ", ")
}
// MARK: - Load & Save
@MainActor
private func loadData() async {
holderName = appState.profile.name
cpf = formatCPF(appState.profile.cpf.filter(\.isNumber))
isDefault = isFirstCard
isLoadingAddresses = true
defer { isLoadingAddresses = false }
if let result = try? await ApiService().profile(forceRefresh: false).result {
let book = result.addressBook ?? []
addresses = book
selectedAddress = book.first
}
}
@MainActor
private func saveCard() async {
guard canSave, let addr = selectedAddress else { return }
isSaving = true
defer { isSaving = false }
let parts = expiry.split(separator: "/")
let month = String(parts[0])
let year: String = {
let y = String(parts[1])
return y.count == 2 ? "20\(y)" : y
}()
let cleanNumber = cardNumber.filter(\.isNumber)
let cleanCpf = cpf.filter(\.isNumber)
let zip = selectedAddressZip
let addrNumber = addr.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "0"
let creditCard = SaveCardCreditCardPayload(
holderName: holderName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased(),
number: cleanNumber,
expiryMonth: month,
expiryYear: year,
ccv: cvv
)
let holderInfo = SaveCardHolderInfoPayload(
name: holderName.trimmingCharacters(in: .whitespacesAndNewlines),
email: appState.profile.email,
cpfCnpj: cleanCpf,
postalCode: zip,
addressNumber: addrNumber.isEmpty ? "0" : addrNumber,
phone: appState.profile.phone
)
let payload = SaveCardPayload(
creditCard: creditCard,
creditCardHolderInfo: holderInfo,
nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : nickname,
isDefault: isDefault || isFirstCard
)
do {
let response = try await ApiService().saveCard(payload: payload)
if response.error == false, let result = response.result {
let newCard = SavedCard(
id: result.id,
nickname: payload.nickname,
holderName: result.holderName,
last4: result.last4,
brand: result.brand,
expiryMonth: result.expiryMonth,
expiryYear: result.expiryYear,
isDefault: result.isDefault
)
onCardAdded(newCard)
dismiss()
SnackbarCenter.shared.show(title: "Cartão salvo com sucesso.", style: .success, icon: "creditcard.fill", duration: 2.5)
} else {
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível salvar o cartão.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
} catch {
SnackbarCenter.shared.show(title: "Erro ao salvar cartão. Verifique os dados e tente novamente.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
}
}
// MARK: - Formatters
private func formatCardNumber(_ digits: String) -> String {
let d = String(digits.prefix(16))
var result = ""
for (i, c) in d.enumerated() {
if i > 0 && i % 4 == 0 { result += " " }
result.append(c)
}
return result
}
private func formatExpiry(_ value: String) -> String {
let digits = String(value.filter(\.isNumber).prefix(4))
if digits.count > 2 { return "\(digits.prefix(2))/\(digits.dropFirst(2))" }
return digits
}
private func formatCPF(_ digits: String) -> String {
let d = String(digits.prefix(11))
if d.count <= 3 { return d }
if d.count <= 6 { return "\(d.prefix(3)).\(d.dropFirst(3))" }
if d.count <= 9 { return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" }
return "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))"
}
}

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct CartView: View {
@Binding var appState: AppState
@Binding var selectedTab: MainTab
@State var openCheckout = false
@State var couponCode = ""
@State var appliedCouponCode: String? = nil
@@ -49,7 +50,7 @@ struct CartView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
.navigationDestination(isPresented: $openCheckout) {
CheckoutView(appState: $appState)
CheckoutView(appState: $appState, selectedTab: $selectedTab)
}
.task(id: deliveryFeeWatchKey) {
await refreshDeliveryFee()

View File

@@ -7,6 +7,8 @@ import AppKit
struct CheckoutView: View {
@Binding var appState: AppState
@Binding var selectedTab: MainTab
@Environment(\.dismiss) var dismiss
@State var storeInfo: StoreInfoResult? = nil
@State var errorMessage: String? = nil
@@ -98,6 +100,7 @@ struct CheckoutView: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
screenHeader
deliveryTypeSection
addressSection
orderSummarySection
@@ -120,8 +123,8 @@ struct CheckoutView: View {
.padding(.bottom, 10)
}
.background(AppColors.backgroundLight)
.navigationTitle("Finalizar Pedido")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.appBottomSafeAreaInset {
bottomBar
}
@@ -212,7 +215,31 @@ struct CheckoutView: View {
}
}
.navigationDestination(item: $orderTrackingContext) { context in
OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId)
OrderTrackingView(orderId: context.orderId, initialShortId: context.shortId, postOrderBack: {
appState.shouldNavigateToOrders = true
selectedTab = .profile
})
}
}
private var screenHeader: some View {
ZStack {
Text("Finalizar Pedido")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
@@ -676,6 +703,7 @@ struct PaymentPixView: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 14) {
screenHeader
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(AppColors.surface)
.overlay(
@@ -767,8 +795,8 @@ struct PaymentPixView: View {
.padding(20)
}
.background(AppColors.backgroundLight)
.navigationTitle("Pagamento via PIX")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.sheet(isPresented: $showChangePaymentSheet) {
ChangePaymentSheet(
pixContext: currentContext,
@@ -814,6 +842,27 @@ struct PaymentPixView: View {
}
}
private var screenHeader: some View {
ZStack {
Text("Pagamento via PIX")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private func openTrackingOnce() {
guard hasOpenedTracking == false else { return }
hasOpenedTracking = true

View File

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

View File

@@ -4,10 +4,20 @@ struct OrderDetailsView: View {
let order: PublicOrderResult
let orderId: String
let initialShortId: String?
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@State private var storeContactPhone: String? = nil
@State private var resolvedStoreLogoURL: String? = nil
@State private var showCallAlert = false
@State private var navigateToStore = false
@State private var showClearCartAlert = false
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 14) {
screenHeader
statusCard
storeCard
itemsCard
@@ -22,8 +32,44 @@ struct OrderDetailsView: View {
.padding(.bottom, UIDevice.bottomNotch)
}
.background(AppColors.backgroundLight)
.navigationTitle("Detalhes do Pedido")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.navigationDestination(isPresented: $navigateToStore) {
if let storeId = order.storeId, storeId.isEmpty == false {
StoreDetailView(
storeId: storeId,
storeName: order.storeName ?? "Loja",
storeCoverURL: nil,
storeLogoURL: order.storeLogoURL,
storeCategory: nil,
storeRating: nil,
storeDistance: nil,
storeDeliveryFee: nil,
appState: $appState
)
}
}
.alert("Ligar para a loja?", isPresented: $showCallAlert) {
Button("Ligar para \(order.storeName ?? "a loja")") {
if let phone = storeContactPhone {
openTel(phone)
}
}
Button("Cancelar", role: .cancel) {}
} message: {
Text("WhatsApp não encontrado. Deseja ligar para \(order.storeName ?? "a loja")?")
}
.task {
await loadStoreContactPhone()
}
.alert("Substituir carrinho?", isPresented: $showClearCartAlert) {
Button("Limpar e adicionar", role: .destructive) {
applyReorder(clearFirst: true)
}
Button("Cancelar", role: .cancel) {}
} message: {
Text("Seu carrinho tem itens de \(appState.cart.storeName ?? appState.cart.storeId ?? "outra loja"). Deseja limpar e adicionar itens de \(order.storeName ?? "esta loja")?")
}
.appBottomSafeAreaInset {
VStack {
reorderButton
@@ -35,6 +81,27 @@ struct OrderDetailsView: View {
}
}
private var screenHeader: some View {
ZStack {
Text("Detalhes do Pedido")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var statusCard: some View {
HStack(spacing: 14) {
Circle()
@@ -54,6 +121,26 @@ struct OrderDetailsView: View {
Text(statusDateText)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
if let reason = cancellationReasonText {
Text(reason)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 2)
}
if let addr = deliveryAddressSummary {
HStack(spacing: 4) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 11))
.foregroundStyle(AppColors.textMuted)
Text(addr)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(2)
}
.padding(.top, 2)
}
}
Spacer(minLength: 0)
@@ -64,30 +151,39 @@ struct OrderDetailsView: View {
}
private var storeCard: some View {
HStack(spacing: 12) {
AsyncStoreImage(imageURL: resolvedMediaURL(order.storeLogoURL))
.frame(width: 54, height: 54)
.clipShape(Circle())
.background(AppColors.brandSoft, in: Circle())
VStack(alignment: .leading, spacing: 4) {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Text(storeSubtitle)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Button {
if order.storeId?.isEmpty == false {
navigateToStore = true
}
} label: {
HStack(spacing: 12) {
AsyncStoreImage(imageURL: resolvedMediaURL(resolvedStoreLogoURL ?? order.storeLogoURL))
.frame(width: 54, height: 54)
.clipShape(Circle())
.background(AppColors.brandSoft, in: Circle())
Spacer(minLength: 0)
VStack(alignment: .leading, spacing: 4) {
Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Loja") : "Loja")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Text(storeSubtitle)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
Text("Ver loja")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
Spacer(minLength: 0)
if order.storeId?.isEmpty == false {
Text("Ver loja")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
}
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.padding(16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.buttonStyle(.plain)
}
private var itemsCard: some View {
@@ -216,27 +312,28 @@ struct OrderDetailsView: View {
private var reorderButton: some View {
Button("Pedir Novamente") {
SnackbarCenter.shared.show(
title: "Recompra será integrada com o catálogo em breve.",
style: .info,
icon: "cart.badge.plus",
duration: 2.0
)
reorder()
}
.font(AppTypography.heading2)
.foregroundStyle(Color(hex: "#0E1A06"))
.frame(maxWidth: .infinity, minHeight: 56)
.background(Color(hex: "#C8F06E"))
.background(order.items.isEmpty ? Color(hex: "#C8F06E").opacity(0.45) : Color(hex: "#C8F06E"))
.clipShape(Capsule())
.buttonStyle(.plain)
.disabled(order.items.isEmpty)
}
private var helpFooter: some View {
Text("Precisa de ajuda com esse pedido?")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
Button {
handleHelpTap()
} label: {
Text("Precisa de ajuda com esse pedido?")
.font(AppTypography.body)
.foregroundStyle(Color(hex: "#A5D645"))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
}
.buttonStyle(.plain)
}
private var subtotal: Double {
@@ -296,6 +393,24 @@ struct OrderDetailsView: View {
return "Pedido #\(displayOrderTitle)"
}
private var deliveryAddressSummary: String? {
if let full = order.fullAddress, full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
return full.trimmingCharacters(in: .whitespacesAndNewlines)
}
let line1 = deliveryAddressLine
let line2 = deliveryAddressLine2
let combined = [line1, line2].filter { $0.isEmpty == false }.joined(separator: ", ")
return combined.isEmpty ? nil : combined
}
private var cancellationReasonText: String? {
guard statusTitle.contains("cancelado"),
let reason = order.cancellationReason,
reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
else { return nil }
return "Motivo: \(reason.trimmingCharacters(in: .whitespacesAndNewlines))"
}
private var statusTitle: String {
let status = normalized(order.status)
if status.contains("CANCEL") { return "Pedido cancelado" }
@@ -309,6 +424,12 @@ struct OrderDetailsView: View {
}
private var statusDateText: String {
if let event = order.timeline.first,
let date = event.date, date.isEmpty == false {
let time = event.time.flatMap { $0.isEmpty ? nil : $0 }
let combined = time.map { "\(date) às \($0)" } ?? date
return "\(statusDatePrefix) \(combined)"
}
if let formatted = formatDate(order.updatedAt ?? order.createdAt) {
return "\(statusDatePrefix) \(formatted)"
}
@@ -342,14 +463,81 @@ struct OrderDetailsView: View {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
private func reorder() {
guard order.items.isEmpty == false else { return }
let cartStoreId = appState.cart.storeId ?? appState.cart.items.first?.storeId ?? ""
let orderStoreId = order.storeId ?? ""
let cartHasDifferentStore = cartStoreId.isEmpty == false
&& orderStoreId.isEmpty == false
&& cartStoreId != orderStoreId
&& appState.cart.items.isEmpty == false
if cartHasDifferentStore {
showClearCartAlert = true
} else {
applyReorder(clearFirst: false)
}
}
private func applyReorder(clearFirst: Bool) {
if clearFirst {
appState.cart.clear()
}
let storeId = order.storeId ?? ""
if appState.cart.storeId == nil || appState.cart.storeId?.isEmpty == true {
appState.cart.storeId = storeId
appState.cart.storeName = order.storeName
}
var addedCount = 0
for item in order.items {
let name = item.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard name.isEmpty == false else { continue }
let qty = max(1, item.qty ?? 1)
let price = item.price ?? 0
let cartItem = CartItemState(
id: UUID().uuidString,
productId: item.productId ?? item.id,
storeId: storeId,
name: name,
imageURL: nil,
details: nil,
addons: [],
quantity: qty,
unitPrice: price
)
appState.cart.add(item: cartItem)
addedCount += qty
}
let label = addedCount == 1 ? "1 item adicionado ao carrinho." : "\(addedCount) itens adicionados ao carrinho."
SnackbarCenter.shared.show(title: label, style: .success, icon: "cart.badge.plus", duration: 2.5)
}
private func formatDate(_ isoValue: String?) -> String? {
guard let isoValue, isoValue.isEmpty == false else { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var date = iso.date(from: isoValue)
let optionSets: [ISO8601DateFormatter.Options] = [
[.withInternetDateTime, .withFractionalSeconds],
[.withInternetDateTime],
[.withFullDate, .withTime, .withColonSeparatorInTime],
[.withFullDate, .withTime, .withColonSeparatorInTime, .withTimeZone],
[.withFullDate]
]
var date: Date? = nil
for options in optionSets {
iso.formatOptions = options
if let d = iso.date(from: isoValue) {
date = d
break
}
}
if date == nil {
iso.formatOptions = [.withInternetDateTime]
date = iso.date(from: isoValue)
let fallback = DateFormatter()
fallback.locale = Locale(identifier: "en_US_POSIX")
for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSZ", "yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd"] {
fallback.dateFormat = fmt
if let d = fallback.date(from: isoValue) { date = d; break }
}
}
guard let date else { return nil }
@@ -359,6 +547,64 @@ struct OrderDetailsView: View {
return formatter.string(from: date)
}
@MainActor
private func loadStoreContactPhone() async {
if let inline = order.storePhone, inline.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
storeContactPhone = inline
}
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines),
storeId.isEmpty == false else { return }
do {
let response = try await ApiService().storeInfo(storeId: storeId)
if response.error == false, let result = response.result {
let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if phone.isEmpty == false {
storeContactPhone = phone
}
if let logo = result.logo, logo.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
resolvedStoreLogoURL = logo
}
}
} catch {}
}
private func handleHelpTap() {
guard let phoneRaw = storeContactPhone,
phoneRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
SnackbarCenter.shared.show(
title: "Telefone da loja indisponível.",
style: .warning,
icon: "exclamationmark.triangle.fill",
duration: 2.8
)
return
}
if let waURL = makeWhatsAppURL(from: phoneRaw) {
openURL(waURL)
} else {
showCallAlert = true
}
}
private func openTel(_ phoneRaw: String) {
let digits = phoneRaw.filter(\.isNumber)
if digits.isEmpty { return }
if let url = URL(string: "tel://\(digits)") {
openURL(url)
}
}
private func makeWhatsAppURL(from phoneRaw: String) -> URL? {
var digits = phoneRaw.filter(\.isNumber)
if digits.isEmpty { return nil }
if digits.hasPrefix("0") { digits = String(digits.drop(while: { $0 == "0" })) }
if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) {
digits = "55" + digits
}
guard digits.count >= 12 else { return nil }
return URL(string: "https://wa.me/\(digits)")
}
private func normalized(_ value: String?) -> String {
(value ?? "")
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)

View File

@@ -12,6 +12,8 @@ private struct TrackingStep: Identifiable {
struct OrderTrackingView: View {
let orderId: String
let initialShortId: String?
var postOrderBack: (() -> Void)? = nil
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@State var isLoading = true
@@ -27,6 +29,7 @@ struct OrderTrackingView: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
screenHeader
topHeader
orderTitleSection
statusBanner
@@ -43,8 +46,8 @@ struct OrderTrackingView: View {
.padding(.bottom, UIDevice.bottomNotch + 45)
}
.background(AppColors.backgroundLight)
.navigationTitle("Pedido \(displayOrderTitle)")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
Button("Fechar", role: .cancel) {}
} message: {
@@ -97,6 +100,33 @@ struct OrderTrackingView: View {
self.reviewSavedObserver = nil
}
private var screenHeader: some View {
ZStack {
Text("Pedido \(displayOrderTitle)")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: {
if let postOrderBack {
postOrderBack()
} else {
dismiss()
}
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var topHeader: some View {
HStack(spacing: 10) {
Circle()

View File

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

View File

@@ -10,6 +10,7 @@ struct PizzaFlavorAddonsSheet: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 12) {
screenHeader
Text(flavor.name)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
@@ -77,12 +78,28 @@ struct PizzaFlavorAddonsSheet: View {
.padding(20)
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Adicionais")
.appInlineNavigationTitle()
.appTopBarTrailingToolbar {
Button("Concluir") { dismiss() }
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
}
private var screenHeader: some View {
ZStack {
Text("Adicionais")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
.foregroundStyle(AppColors.primary)
Spacer()
}
}
}

View File

@@ -189,6 +189,7 @@ struct PizzaProductDetailSheet: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
screenHeader
AsyncStoreImage(imageURL: representativeImage)
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
@@ -291,12 +292,8 @@ struct PizzaProductDetailSheet: View {
}
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Monte sua pizza")
.appInlineNavigationTitle()
.appTopBarTrailingToolbar {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.onAppear {
applyAutoSelections()
let existing = currentQuantityForItemId(cartItemId)
@@ -314,4 +311,25 @@ struct PizzaProductDetailSheet: View {
}
}
private var screenHeader: some View {
ZStack {
Text("Monte sua pizza")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
}

View File

@@ -72,6 +72,7 @@ struct ProductDetailSheet: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
screenHeader
AsyncStoreImage(imageURL: imageURL)
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
@@ -221,12 +222,8 @@ struct ProductDetailSheet: View {
.background(.ultraThinMaterial)
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Detalhes")
.appInlineNavigationTitle()
.appTopBarTrailingToolbar {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.onAppear {
let existing = currentQuantityForItemId(cartItemId)
quantity = existing > 0 ? existing : 1
@@ -246,6 +243,27 @@ struct ProductDetailSheet: View {
}
}
private var screenHeader: some View {
ZStack {
Text("Detalhes")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}

View File

@@ -14,6 +14,7 @@ struct ProfileView: View {
@State var openAddressesOnboarding = false
@State var onboardingMessage: String? = nil
@State var showLogoutAlert = false
@State private var openOrders = false
let tabBarClearance: CGFloat = 120
var body: some View {
@@ -41,12 +42,12 @@ struct ProfileView: View {
}
.buttonStyle(.plain)
// NavigationLink {
// Text("Meus Cartões")
// } label: {
// ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões")
// }
// .buttonStyle(.plain)
NavigationLink {
SavedCardsView(appState: $appState)
} label: {
ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões")
}
.buttonStyle(.plain)
NavigationLink {
MyReviewsView()
@@ -120,6 +121,15 @@ struct ProfileView: View {
AddressesView(message: onboardingMessage, appState: $appState)
}
}
.navigationDestination(isPresented: $openOrders) {
OrdersView(appState: $appState)
}
.onChange(of: appState.shouldNavigateToOrders) { _, val in
if val {
appState.shouldNavigateToOrders = false
openOrders = true
}
}
}
private var header: some View {

View File

@@ -24,6 +24,8 @@ struct MyReviewsView: View {
@State var isLoading = false
@State var loadError: String? = nil
@Environment(\.dismiss) var dismiss
init(initialOrder: ReviewDraft? = nil) {
self.initialOrder = initialOrder
}
@@ -31,6 +33,7 @@ struct MyReviewsView: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 12) {
screenHeader
if isLoading && reviews.isEmpty && pendingReviews.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
@@ -66,8 +69,8 @@ struct MyReviewsView: View {
.padding(.bottom, UIDevice.bottomNotch + 30)
}
.background(AppColors.backgroundLight)
.navigationTitle("Minhas Avaliações")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.navigationDestination(item: $selectedDraft) { draft in
OrderReviewView(draft: draft) {
Task { await loadReviewsFromBackend(forceRefresh: true) }
@@ -86,6 +89,27 @@ struct MyReviewsView: View {
}
}
private var screenHeader: some View {
ZStack {
Text("Minhas Avaliações")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var emptyState: some View {
VStack(spacing: 8) {
Text("Você não tem avaliações nem pendências no momento.")
@@ -434,6 +458,7 @@ struct OrderReviewView: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) {
screenHeader
topSection
orderItemsSection
if existingReview != nil {
@@ -450,8 +475,8 @@ struct OrderReviewView: View {
.padding(.bottom, UIDevice.bottomNotch + 140)
}
.background(AppColors.backgroundLight)
.navigationTitle("Avaliar Pedido")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.onAppear {
if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) {
existingReview = cachedReview
@@ -488,6 +513,27 @@ struct OrderReviewView: View {
}
}
private var screenHeader: some View {
ZStack {
Text("Avaliar Pedido")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var newReviewContent: some View {
VStack(alignment: .leading, spacing: 24) {
starsSection

View File

@@ -0,0 +1,244 @@
import SwiftUI
struct SavedCardsView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@State private var cards: [SavedCard] = []
@State private var isLoading = false
@State private var errorMessage: String? = nil
@State private var openSwipeRowId: String? = nil
@State private var deletingCardId: String? = nil
@State private var showAddCard = false
private var canDelete: Bool { cards.count > 1 }
private let tabBarClearance: CGFloat = 96
var body: some View {
ZStack {
AppColors.backgroundLight.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
screenHeader
VStack(spacing: 12) {
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 32)
} else if let errorMessage {
Text(errorMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.multilineTextAlignment(.center)
.padding(.top, 32)
} else if cards.isEmpty {
Text("Nenhum cartão cadastrado")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 32)
} else {
ForEach(cards) { card in
if canDelete {
SwipeToDeleteAddressRow(
rowId: card.id,
openRowId: $openSwipeRowId,
isDeleting: deletingCardId == card.id,
onDelete: { deleteCard(card) }
) {
cardRow(card)
.appContentShape(Rectangle())
.onTapGesture {
if openSwipeRowId == card.id { openSwipeRowId = nil }
}
}
.id(card.id)
.opacity(deletingCardId == card.id ? 0.6 : 1.0)
.disabled(deletingCardId != nil)
} else {
cardRow(card)
}
}
}
}
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, tabBarClearance + 20)
}
VStack {
Spacer()
addCardButton
.padding(.bottom, tabBarClearance)
}
}
.navigationBarBackButtonHidden(true)
.appHiddenNavigationBar()
.sheet(isPresented: $showAddCard) {
NavigationStack {
AddCardFormView(appState: appState, isFirstCard: cards.isEmpty) { newCard in
cards.append(newCard)
}
}
}
.task { await loadCards() }
}
private var screenHeader: some View {
ZStack {
Text("Meus Cartões")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var addCardButton: some View {
ZStack(alignment: .bottom) {
Rectangle()
.fill(AppColors.backgroundLight)
.frame(height: 136)
Button(action: { showAddCard = true }) {
HStack(spacing: 12) {
Image(systemName: "creditcard.fill")
.font(.system(size: 20))
Text("Adicionar novo cartão")
.font(AppTypography.heading3)
}
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
.buttonStyle(.plain)
.padding(.horizontal, 20)
.padding(.bottom, 14)
}
}
private func cardRow(_ card: SavedCard) -> some View {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(AppColors.backgroundLight)
.frame(width: 52, height: 52)
if let logo = brandLogoName(for: card.brand) {
Image(logo)
.resizable()
.scaledToFit()
.frame(width: 36, height: 24)
} else {
Image(systemName: "creditcard.fill")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
}
}
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(card.displayLabel)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if card.isDefault {
Text("Principal")
.font(AppTypography.caption)
.foregroundStyle(AppColors.primary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(AppColors.brandSoft)
.clipShape(Capsule())
}
}
Text("Vence \(card.expiryLabel)")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
if !canDelete {
Text("Ao menos um cartão deve permanecer")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted.opacity(0.7))
}
}
Spacer(minLength: 0)
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private func brandLogoName(for brand: String?) -> String? {
switch brand?.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: .diacriticInsensitive, locale: .current) {
case "visa": return "visacard_logo"
case "mastercard", "master": return "mastercard_logo"
case "amex", "american express", "americanexpress": return "amexcard_logo"
case "hipercard": return "hipercard_logo"
case "alelo": return "alelocard_logo"
case "sodexo": return "sodexo_logo"
default: return nil
}
}
@MainActor
private func loadCards() async {
guard isLoading == false else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response = try await ApiService().listCards()
if response.error == false {
cards = response.result ?? []
} else {
errorMessage = response.message ?? "Erro ao carregar cartões."
}
} catch {
errorMessage = "Não foi possível carregar seus cartões."
}
}
private func deleteCard(_ card: SavedCard) {
guard canDelete, deletingCardId == nil else { return }
deletingCardId = card.id
openSwipeRowId = nil
Task {
do {
let response = try await ApiService().deleteCard(cardId: card.id)
if response.error == false {
cards.removeAll { $0.id == card.id }
} else {
SnackbarCenter.shared.show(
title: response.message ?? "Não foi possível excluir o cartão.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
}
} catch {
SnackbarCenter.shared.show(
title: "Erro ao excluir cartão.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
}
deletingCardId = nil
}
}
}

View File

@@ -22,6 +22,7 @@ struct UserProfileView: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 22) {
screenHeader
avatarSection
formSection
saveButton
@@ -31,8 +32,8 @@ struct UserProfileView: View {
.padding(.bottom, UIDevice.bottomNotch + 24)
}
.background(AppColors.backgroundLight)
.navigationTitle("Meu Perfil")
.appInlineNavigationTitle()
.appHiddenNavigationBar()
.navigationBarBackButtonHidden(true)
.onAppear {
hydrateFromAppState()
}
@@ -43,6 +44,27 @@ struct UserProfileView: View {
#endif
}
private var screenHeader: some View {
ZStack {
Text("Meu Perfil")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
HStack {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 52, height: 52)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
.buttonStyle(.plain)
Spacer()
}
}
}
private var avatarSection: some View {
VStack(spacing: 12) {
Circle()
@@ -198,12 +220,14 @@ struct UserProfileView: View {
return
}
let customer = response.result
appState.profile.id = customer?.id ?? appState.profile.id
appState.profile.name = customer?.name ?? cleanName
appState.profile.email = customer?.email ?? cleanEmail
appState.profile.phone = customer?.phoneNumber ?? normalizedPhone
appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto
appState.profile.name = cleanName
appState.profile.email = cleanEmail
appState.profile.phone = normalizedPhone
if let pictureUrl = response.profilePictureUrl, pictureUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
appState.profile.profilePicture = ImageSourceResolver.resolve(pictureUrl) ?? pictureUrl
} else if cleanPhoto.isEmpty == false {
appState.profile.profilePicture = cleanPhoto
}
let cleanCpf = cpf.filter(\.isNumber)
if cleanCpf.count == 11 {