feat(payment): implement change payment method (PATCH /payment-method)

- ChangePaymentMethodPayload + ChangePaymentMethodResult models
- ApiService.changePaymentMethod()
- PixPaymentContext gains storeId + order data for payment switch
- PaymentPixView: Trocar button opens ChangePaymentSheet with 3 options
- ChangePaymentSheet: Novo PIX (new QR), Cartão (saved or new), Pagar na Entrega
- ChangePaymentCardSheet + ChangePaymentNewCardView with same card masks/validation
- OrdersView/OrderEntryDestinationView: threaded appState binding
This commit is contained in:
Daniel Arantes Loverde
2026-06-03 18:49:07 -03:00
parent 9525c19b6b
commit ee8745553e
6 changed files with 487 additions and 21 deletions

View File

@@ -65,3 +65,19 @@ struct CreditCardOrderPayload: Encodable {
let expiryYear: String let expiryYear: String
let ccv: String let ccv: String
} }
struct ChangePaymentMethodPayload: Encodable {
let paymentMethod: String
let clientCpfCnpj: String?
let creditCard: CreditCardOrderPayload?
let creditCardHolderInfo: SaveCardHolderInfoPayload?
let savedCardId: String?
}
struct ChangePaymentMethodResult: Decodable {
let paymentMethod: String?
let paymentLocation: String?
let paymentId: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
}

View File

@@ -451,6 +451,12 @@ final class ApiService {
return try await sendEnvelope(req) return try await sendEnvelope(req)
} }
func changePaymentMethod(storeId: String, orderId: String, payload: ChangePaymentMethodPayload) async throws -> ApiEnvelope<ChangePaymentMethodResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/orders/\(orderId)/payment-method", method: "PATCH", module: .store, requiresAuth: true, body: body)
return try await sendEnvelope(req)
}
// MARK: - Profile CPF // MARK: - Profile CPF
func updateProfileCpf(cpf: String) async throws -> ApiEnvelope<EmptyResult> { func updateProfileCpf(cpf: String) async throws -> ApiEnvelope<EmptyResult> {

View File

@@ -397,9 +397,24 @@ extension CheckoutView {
? pixFromPayment?.expirationDate : pixFromPayload?.expirationDate ? pixFromPayment?.expirationDate : pixFromPayload?.expirationDate
if let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { if let copyPaste, copyPaste.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
let itemsData = (try? JSONEncoder().encode(appState.cart.toOrderItemsPayload())).flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
let storeId = appState.cart.storeId ?? ""
pixPaymentContext = PixPaymentContext( pixPaymentContext = PixPaymentContext(
id: orderId, orderId: orderId, shortId: result.shortId, id: orderId,
copyPaste: copyPaste, qrCodeImageBase64: qrCodeImage, expirationDate: expirationDate orderId: orderId,
shortId: result.shortId,
storeId: storeId,
copyPaste: copyPaste,
qrCodeImageBase64: qrCodeImage,
expirationDate: expirationDate,
total: totalValue,
profileName: appState.profile.name,
profileEmail: appState.profile.email,
profilePhone: appState.profile.phone,
addressZip: selectedCustomerAddress?.zipCode,
addressNumber: selectedCustomerAddress?.number,
deliveryType: deliveryType.rawValue,
itemsJSON: itemsData
) )
return return
} }

View File

@@ -156,6 +156,7 @@ struct CheckoutView: View {
.navigationDestination(item: $pixPaymentContext) { context in .navigationDestination(item: $pixPaymentContext) { context in
PaymentPixView( PaymentPixView(
context: context, context: context,
appState: $appState,
onPaymentConfirmed: { onPaymentConfirmed: {
appState.cart.clear() appState.cart.clear()
SessionStateStore.clearPendingCartOrder() SessionStateStore.clearPendingCartOrder()
@@ -560,9 +561,19 @@ struct PixPaymentContext: Identifiable, Hashable {
let id: String let id: String
let orderId: String let orderId: String
let shortId: String? let shortId: String?
let storeId: String
let copyPaste: String let copyPaste: String
let qrCodeImageBase64: String? let qrCodeImageBase64: String?
let expirationDate: String? let expirationDate: String?
// Para troca de pagamento
let total: Double
let profileName: String
let profileEmail: String
let profilePhone: String
let addressZip: String?
let addressNumber: String?
let deliveryType: String
let itemsJSON: String
} }
struct OrderTrackingContext: Identifiable, Hashable { struct OrderTrackingContext: Identifiable, Hashable {
@@ -625,6 +636,7 @@ struct CardPaymentContext: Identifiable, Hashable {
struct PaymentPixView: View { struct PaymentPixView: View {
let context: PixPaymentContext let context: PixPaymentContext
@Binding var appState: AppState
var onPaymentConfirmed: (() -> Void)? = nil var onPaymentConfirmed: (() -> Void)? = nil
var onOpenTracking: (() -> Void)? = nil var onOpenTracking: (() -> Void)? = nil
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@@ -633,9 +645,20 @@ struct PaymentPixView: View {
@State var hasOpenedTracking = false @State var hasOpenedTracking = false
@State var hasShownPixExpiredSnackbar = false @State var hasShownPixExpiredSnackbar = false
@State var currentTime = Date() @State var currentTime = Date()
@State var showChangePaymentSheet = false
@State var savedCards: [SavedCard] = []
@State var currentContext: PixPaymentContext
init(context: PixPaymentContext, appState: Binding<AppState>, onPaymentConfirmed: (() -> Void)? = nil, onOpenTracking: (() -> Void)? = nil) {
self.context = context
self._appState = appState
self.onPaymentConfirmed = onPaymentConfirmed
self.onOpenTracking = onOpenTracking
_currentContext = State(initialValue: context)
}
private var qrImageSource: String? { private var qrImageSource: String? {
guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines), guard let raw = currentContext.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines),
raw.isEmpty == false else { return nil } raw.isEmpty == false else { return nil }
if raw.lowercased().hasPrefix("data:image") { return raw } if raw.lowercased().hasPrefix("data:image") { return raw }
return "data:image/png;base64,\(raw)" return "data:image/png;base64,\(raw)"
@@ -683,7 +706,7 @@ struct PaymentPixView: View {
.frame(height: 20) .frame(height: 20)
.overlay( .overlay(
VStack(spacing: 8) { VStack(spacing: 8) {
Text(context.copyPaste) Text(currentContext.copyPaste)
.font(.system(size: 12, weight: .medium, design: .monospaced)) .font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
.lineLimit(0) .lineLimit(0)
@@ -694,22 +717,42 @@ struct PaymentPixView: View {
.padding(.vertical, 14) .padding(.vertical, 14)
) )
if let expirationDate = context.expirationDate, expirationDate.isEmpty == false { if let expirationDate = currentContext.expirationDate, expirationDate.isEmpty == false {
Text(expirationLabel) Text(expirationLabel)
.font(.caption) .font(.caption)
.foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted) .foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted)
} }
HStack(spacing: 10) {
PrimaryButton(title: "Copiar Código PIX") { PrimaryButton(title: "Copiar Código PIX") {
if isPixExpired { if isPixExpired {
showPixExpiredSnackbar() showPixExpiredSnackbar()
return return
} }
copyToClipboard(context.copyPaste) copyToClipboard(currentContext.copyPaste)
SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0) SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0)
} }
.disabled(isPixExpired) .disabled(isPixExpired)
.opacity(isPixExpired ? 0.5 : 1.0) .opacity(isPixExpired ? 0.5 : 1.0)
Button {
Task { await loadSavedCards() }
showChangePaymentSheet = true
} label: {
HStack(spacing: 6) {
Text("Trocar")
.font(AppTypography.heading3)
Image(systemName: "chevron.down")
.font(.system(size: 12, weight: .semibold))
}
.foregroundStyle(AppColors.textPrimary)
.frame(height: 54)
.padding(.horizontal, 16)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
.padding(.top, 10) .padding(.top, 10)
} }
.padding(20) .padding(20)
@@ -717,6 +760,26 @@ struct PaymentPixView: View {
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Pagamento via PIX") .navigationTitle("Pagamento via PIX")
.appInlineNavigationTitle() .appInlineNavigationTitle()
.sheet(isPresented: $showChangePaymentSheet) {
ChangePaymentSheet(
pixContext: currentContext,
savedCards: savedCards,
appState: $appState,
onChanged: { newContext in
showChangePaymentSheet = false
if let newContext {
currentContext = newContext
tracker.stop()
tracker.start(orderId: newContext.orderId, jwt: DefaultTokenStore().jwt)
}
},
onConfirmed: {
showChangePaymentSheet = false
onPaymentConfirmed?()
openTrackingOnce()
}
)
}
.task { .task {
tracker.onOrderUpdated = { updated in tracker.onOrderUpdated = { updated in
latestOrder = updated latestOrder = updated
@@ -725,7 +788,7 @@ struct PaymentPixView: View {
openTrackingOnce() openTrackingOnce()
} }
} }
tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt) tracker.start(orderId: currentContext.orderId, jwt: DefaultTokenStore().jwt)
} }
.task { .task {
while Task.isCancelled == false { while Task.isCancelled == false {
@@ -752,8 +815,16 @@ struct PaymentPixView: View {
appWriteClipboardText(value) appWriteClipboardText(value)
} }
@MainActor
private func loadSavedCards() async {
guard savedCards.isEmpty else { return }
if let cards = try? await ApiService().listCards().result {
savedCards = cards
}
}
private var parsedExpirationDate: Date? { private var parsedExpirationDate: Date? {
let raw = (context.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let raw = (currentContext.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard raw.isEmpty == false else { return nil } guard raw.isEmpty == false else { return nil }
let iso = ISO8601DateFormatter() let iso = ISO8601DateFormatter()
@@ -1257,3 +1328,347 @@ struct PaymentCardView: View {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
} }
} }
// MARK: - ChangePaymentSheet
struct ChangePaymentSheet: View {
let pixContext: PixPaymentContext
let savedCards: [SavedCard]
@Binding var appState: AppState
let onChanged: (PixPaymentContext?) -> Void
let onConfirmed: () -> Void
@Environment(\.dismiss) var dismiss
@State var isSubmitting = false
@State var showCardSheet = false
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Trocar Pagamento")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(AppColors.textMuted)
.frame(width: 30, height: 30)
.background(AppColors.surface)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 16)
ScrollView(showsIndicators: false) {
VStack(spacing: 12) {
optionRow(icon: "qrcode", title: "Novo QR Code PIX", subtitle: "Gera um novo código PIX") {
Task { await changeToMethod("PIX") }
}
optionRow(icon: "creditcard.fill", title: "Cartão de Crédito", subtitle: "Débito imediato no cartão") {
showCardSheet = true
}
optionRow(icon: "banknote.fill", title: "Pagar na Entrega", subtitle: "Pague ao receber o pedido") {
Task { await changeToMethod("CASH") }
}
}
.padding(.horizontal, 20)
.padding(.bottom, 32)
}
}
.background(AppColors.backgroundLight)
.sheet(isPresented: $showCardSheet) {
let items = (try? JSONDecoder().decode([CreateOrderItemPayload].self, from: pixContext.itemsJSON.data(using: .utf8) ?? Data())) ?? []
let cardCtx = CardPaymentContext.build(storeId: pixContext.storeId, total: pixContext.total, deliveryType: pixContext.deliveryType, profile: appState.profile, address: nil, items: items)
ChangePaymentCardSheet(pixContext: pixContext, cardContext: cardCtx, savedCards: savedCards, appState: $appState) { _, _ in
showCardSheet = false
onConfirmed()
}
}
}
private func optionRow(icon: String, title: String, subtitle: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 14) {
Image(systemName: icon).font(.system(size: 20)).foregroundStyle(AppColors.primary)
.frame(width: 44, height: 44).background(AppColors.brandSoft)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
Text(title).font(AppTypography.heading3).foregroundStyle(AppColors.textPrimary)
Text(subtitle).font(.caption).foregroundStyle(AppColors.textMuted)
}
Spacer()
if isSubmitting { ProgressView().scaleEffect(0.8) }
else { Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)).foregroundStyle(AppColors.textMuted) }
}
.padding(14).background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain).disabled(isSubmitting)
}
@MainActor
private func changeToMethod(_ method: String) async {
isSubmitting = true
defer { isSubmitting = false }
let payload = ChangePaymentMethodPayload(paymentMethod: method, clientCpfCnpj: nil, creditCard: nil, creditCardHolderInfo: nil, savedCardId: nil)
do {
let response = try await ApiService().changePaymentMethod(storeId: pixContext.storeId, orderId: pixContext.orderId, payload: payload)
if response.error {
SnackbarCenter.shared.show(title: response.message ?? "Não foi possível trocar o pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.5)
return
}
guard let result = response.result else { return }
if method == "CASH" {
SnackbarCenter.shared.show(title: "Pagamento alterado. Pague ao receber.", style: .success, icon: "checkmark.circle.fill", duration: 3.0)
onConfirmed(); return
}
let pix = result.payment?.pix ?? result.paymentPayload
if let copyPaste = pix?.copyPaste, copyPaste.isEmpty == false {
let newCtx = PixPaymentContext(id: UUID().uuidString, orderId: pixContext.orderId, shortId: pixContext.shortId, storeId: pixContext.storeId, copyPaste: copyPaste, qrCodeImageBase64: pix?.qrCodeImage, expirationDate: pix?.expirationDate, total: pixContext.total, profileName: pixContext.profileName, profileEmail: pixContext.profileEmail, profilePhone: pixContext.profilePhone, addressZip: pixContext.addressZip, addressNumber: pixContext.addressNumber, deliveryType: pixContext.deliveryType, itemsJSON: pixContext.itemsJSON)
SnackbarCenter.shared.show(title: "Novo QR Code gerado.", style: .success, icon: "qrcode", duration: 2.5)
onChanged(newCtx)
}
} catch let error as NetworkError {
let msg: String
if case .httpError(_, let m) = error, let m, m.isEmpty == false { msg = m } else { msg = error.errorDescription ?? "Erro ao trocar pagamento." }
SnackbarCenter.shared.show(title: msg, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
} catch {
SnackbarCenter.shared.show(title: "Erro ao trocar pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
}
// MARK: - ChangePaymentCardSheet
struct ChangePaymentCardSheet: View {
let pixContext: PixPaymentContext
let cardContext: CardPaymentContext
let savedCards: [SavedCard]
@Binding var appState: AppState
let onConfirmed: (String, String?) -> Void
@Environment(\.dismiss) var dismiss
@State var selectedCardId: String?
@State var showNewCardForm = false
@State var isSubmitting = false
init(pixContext: PixPaymentContext, cardContext: CardPaymentContext, savedCards: [SavedCard], appState: Binding<AppState>, onConfirmed: @escaping (String, String?) -> Void) {
self.pixContext = pixContext; self.cardContext = cardContext; self.savedCards = savedCards
self._appState = appState; self.onConfirmed = onConfirmed
_selectedCardId = State(initialValue: savedCards.first(where: { $0.isDefault })?.id ?? savedCards.first?.id)
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Cartão de Crédito").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark").font(.system(size: 12, weight: .bold)).foregroundStyle(AppColors.textMuted)
.frame(width: 30, height: 30).background(AppColors.surface).clipShape(Circle())
}.buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 16)
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
if savedCards.isEmpty == false {
VStack(spacing: 0) {
ForEach(savedCards) { card in
Button { selectedCardId = card.id } label: {
HStack(spacing: 12) {
Image(systemName: "creditcard.fill").font(.system(size: 18)).foregroundStyle(AppColors.textPrimary)
.frame(width: 44, height: 44).background(AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
Text(card.displayLabel).font(AppTypography.heading3).foregroundStyle(AppColors.textPrimary)
Text("Vence \(card.expiryLabel)").font(.caption).foregroundStyle(AppColors.textMuted)
}
Spacer()
Circle().stroke(selectedCardId == card.id ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2)
.frame(width: 22, height: 22).background(Circle().fill(selectedCardId == card.id ? AppColors.tertiary : Color.clear))
}.padding(14)
}.buttonStyle(.plain)
if card.id != savedCards.last?.id { Divider().padding(.horizontal, 14) }
}
}
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
if let selectedCardId {
PrimaryButton(title: isSubmitting ? "Processando..." : "Pagar com este Cartão") {
guard isSubmitting == false else { return }
Task { await changeToSavedCard(cardId: selectedCardId) }
}.disabled(isSubmitting)
}
Text("ou").font(AppTypography.caption).foregroundStyle(AppColors.textMuted).frame(maxWidth: .infinity, alignment: .center)
}
Button { showNewCardForm = true } label: {
HStack(spacing: 10) {
Image(systemName: "plus.circle.fill").font(.system(size: 18)).foregroundStyle(AppColors.primary)
Text("Novo cartão").font(AppTypography.heading3).foregroundStyle(AppColors.primary)
Spacer()
}
.padding(14).background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}.buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.bottom, 32)
}
}
.background(AppColors.backgroundLight)
.sheet(isPresented: $showNewCardForm) {
ChangePaymentNewCardView(pixContext: pixContext, appState: $appState) { orderId, shortId in
showNewCardForm = false; onConfirmed(orderId, shortId)
}
}
}
@MainActor
private func changeToSavedCard(cardId: String) async {
isSubmitting = true; defer { isSubmitting = false }
let payload = ChangePaymentMethodPayload(paymentMethod: "CREDIT_CARD", clientCpfCnpj: nil, creditCard: nil, creditCardHolderInfo: nil, savedCardId: cardId)
do {
let response = try await ApiService().changePaymentMethod(storeId: pixContext.storeId, orderId: pixContext.orderId, payload: payload)
if response.error { SnackbarCenter.shared.show(title: response.message ?? "Pagamento recusado.", style: .error, icon: "xmark.octagon.fill", duration: 3.5); return }
onConfirmed(pixContext.orderId, pixContext.shortId)
} catch let error as NetworkError {
let msg: String
if case .httpError(_, let m) = error, let m, m.isEmpty == false { msg = m } else { msg = "Pagamento recusado." }
SnackbarCenter.shared.show(title: msg, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
} catch { SnackbarCenter.shared.show(title: "Erro ao processar.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) }
}
}
// MARK: - ChangePaymentNewCardView
struct ChangePaymentNewCardView: View {
let pixContext: PixPaymentContext
@Binding var appState: AppState
let onConfirmed: (String, String?) -> Void
@Environment(\.dismiss) var dismiss
@State var holderName: String
@State var cardNumber = ""
@State var expiry = ""
@State var cvv = ""
@State var cpf: String
@State var saveCard = false
@State var nickname = ""
@State var isSubmitting = false
init(pixContext: PixPaymentContext, appState: Binding<AppState>, onConfirmed: @escaping (String, String?) -> Void) {
self.pixContext = pixContext; self._appState = appState; self.onConfirmed = onConfirmed
_holderName = State(initialValue: appState.wrappedValue.profile.name)
_cpf = State(initialValue: appState.wrappedValue.profile.cpf)
}
var canSubmit: Bool {
holderName.trimmingCharacters(in: .whitespaces).isEmpty == false &&
cardNumber.filter(\.isNumber).count >= 13 &&
expiry.filter(\.isNumber).count == 6 &&
cvv.filter(\.isNumber).count >= 3 &&
cpf.filter(\.isNumber).count == 11
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Novo Cartão").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary)
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark").font(.system(size: 12, weight: .bold)).foregroundStyle(AppColors.textMuted)
.frame(width: 30, height: 30).background(AppColors.surface).clipShape(Circle())
}.buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 16)
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
VStack(spacing: 10) {
cardField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber).keyboardType(.numberPad)
.onChange(of: cardNumber) { _, v in
let d = String(v.filter(\.isNumber).prefix(16))
let m = stride(from: 0, to: d.count, by: 4).map { i -> String in let s = d.index(d.startIndex, offsetBy: i); let e = d.index(s, offsetBy: min(4, d.count - i)); return String(d[s..<e]) }.joined(separator: " ")
if m != v { cardNumber = m }
}
cardField("Nome no Cartão", placeholder: "Como impresso no cartão", text: $holderName)
HStack(spacing: 10) {
cardField("Validade", placeholder: "MM/AAAA", text: $expiry).keyboardType(.numberPad)
.onChange(of: expiry) { _, v in let d = String(v.filter(\.isNumber).prefix(6)); let m = d.count <= 2 ? d : "\(d.prefix(2))/\(d.dropFirst(2))"; if m != v { expiry = m } }
cardField("CVV", placeholder: "•••", text: $cvv).keyboardType(.numberPad)
.onChange(of: cvv) { _, v in let d = String(v.filter(\.isNumber).prefix(4)); if d != v { cvv = d } }
}
cardField("CPF do Titular", placeholder: "000.000.000-00", text: $cpf).keyboardType(.numberPad)
.onChange(of: cpf) { _, v in
let d = String(v.filter(\.isNumber).prefix(11))
let m: String
if d.count <= 3 { m = d } else if d.count <= 6 { m = "\(d.prefix(3)).\(d.dropFirst(3))" }
else if d.count <= 9 { m = "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" }
else { m = "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))" }
if m != v { cpf = m }
}
}
.padding(14).background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
PrimaryButton(title: isSubmitting ? "Processando..." : "Confirmar e Pagar") {
guard canSubmit, isSubmitting == false else { return }
Task { await submit() }
}.disabled(canSubmit == false || isSubmitting)
}
.padding(20)
}
}
.background(AppColors.backgroundLight)
}
@MainActor
private func submit() async {
isSubmitting = true; defer { isSubmitting = false }
let cleanCpf = cpf.filter(\.isNumber)
let cleanNumber = cardNumber.filter(\.isNumber)
let expiryDigits = expiry.filter(\.isNumber)
let expiryMonth = String(expiryDigits.prefix(2))
let yearPart = String(expiryDigits.dropFirst(2))
let expiryYear = yearPart.count == 2 ? "20\(yearPart)" : yearPart
let rawPhone = pixContext.profilePhone.filter(\.isNumber)
let phone = rawPhone.hasPrefix("55") ? rawPhone : "55\(rawPhone)"
let holderInfo = SaveCardHolderInfoPayload(name: pixContext.profileName.isEmpty ? holderName : pixContext.profileName, email: pixContext.profileEmail, cpfCnpj: cleanCpf, postalCode: (pixContext.addressZip ?? "").filter(\.isNumber), addressNumber: pixContext.addressNumber ?? "", phone: phone)
let card = CreditCardOrderPayload(holderName: holderName, number: cleanNumber, expiryMonth: expiryMonth, expiryYear: expiryYear, ccv: cvv)
let payload = ChangePaymentMethodPayload(paymentMethod: "CREDIT_CARD", clientCpfCnpj: cleanCpf, creditCard: card, creditCardHolderInfo: holderInfo, savedCardId: nil)
if cleanCpf.count == 11 {
appState.profile.cpf = cleanCpf
Task<Void, Never> { do { _ = try await ApiService().updateProfileCpf(cpf: cleanCpf) } catch {} }
}
if saveCard {
let sp = SaveCardPayload(creditCard: SaveCardCreditCardPayload(holderName: holderName, number: cleanNumber, expiryMonth: expiryMonth, expiryYear: expiryYear, ccv: cvv), creditCardHolderInfo: holderInfo, nickname: nickname.trimmingCharacters(in: .whitespaces).isEmpty ? nil : nickname, isDefault: false)
Task<Void, Never> { do { _ = try await ApiService().saveCard(payload: sp) } catch {} }
}
do {
let response = try await ApiService().changePaymentMethod(storeId: pixContext.storeId, orderId: pixContext.orderId, payload: payload)
if response.error { SnackbarCenter.shared.show(title: response.message ?? "Pagamento recusado.", style: .error, icon: "xmark.octagon.fill", duration: 4.0); return }
onConfirmed(pixContext.orderId, pixContext.shortId)
} catch let error as NetworkError {
let msg: String
if case .httpError(_, let m) = error, let m, m.isEmpty == false { msg = m } else { msg = error.errorDescription ?? "Erro ao processar." }
SnackbarCenter.shared.show(title: msg, style: .error, icon: "xmark.octagon.fill", duration: 5.0)
} catch { SnackbarCenter.shared.show(title: "Erro ao processar pagamento.", style: .error, icon: "xmark.octagon.fill", duration: 3.0) }
}
private func cardField(_ label: String, placeholder: String, text: Binding<String>) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label).font(AppTypography.caption).foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: text).appNoAutoCap()
.padding(.horizontal, 12).frame(height: 46).background(AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}

View File

@@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
struct OrdersView: View { struct OrdersView: View {
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State var isLoading = false @State var isLoading = false
@State var errorMessage: String? = nil @State var errorMessage: String? = nil
@@ -58,7 +59,8 @@ struct OrdersView: View {
initialShortId: context.shortId, initialShortId: context.shortId,
fallbackPaymentMethod: context.paymentMethod, fallbackPaymentMethod: context.paymentMethod,
fallbackTotal: context.total, fallbackTotal: context.total,
routeIntent: context.intent routeIntent: context.intent,
appState: $appState
) )
} }
} }
@@ -629,6 +631,7 @@ struct OrderEntryDestinationView: View {
let fallbackPaymentMethod: String? let fallbackPaymentMethod: String?
let fallbackTotal: Double? let fallbackTotal: Double?
let routeIntent: OrderRouteIntent let routeIntent: OrderRouteIntent
@Binding var appState: AppState
@State var isResolvingRoute = true @State var isResolvingRoute = true
@State var didResolve = false @State var didResolve = false
@@ -650,6 +653,7 @@ struct OrderEntryDestinationView: View {
} else if let pixContext { } else if let pixContext {
PaymentPixView( PaymentPixView(
context: pixContext, context: pixContext,
appState: $appState,
onPaymentConfirmed: { onPaymentConfirmed: {
orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId) orderTrackingContext = OrderTrackingContext(orderId: pixContext.orderId, shortId: pixContext.shortId)
}, },
@@ -701,15 +705,25 @@ struct OrderEntryDestinationView: View {
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.expirationDate ? pixFromPayment?.expirationDate
: pixFromPayload?.expirationDate : pixFromPayload?.expirationDate
let storeId = order.storeId ?? ""
pixContext = PixPaymentContext( pixContext = PixPaymentContext(
id: order.id, id: order.id,
orderId: order.id, orderId: order.id,
shortId: order.shortId ?? initialShortId, shortId: order.shortId ?? initialShortId,
storeId: storeId,
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? (copyPaste ?? "") ? (copyPaste ?? "")
: "Código PIX indisponível no momento. Aguarde e tente novamente.", : "Código PIX indisponível no momento. Aguarde e tente novamente.",
qrCodeImageBase64: qrCodeImage, qrCodeImageBase64: qrCodeImage,
expirationDate: expirationDate expirationDate: expirationDate,
total: order.total ?? 0,
profileName: "",
profileEmail: "",
profilePhone: "",
addressZip: nil,
addressNumber: nil,
deliveryType: order.deliveryType ?? "DELIVERY",
itemsJSON: "[]"
) )
return return
} }

View File

@@ -28,7 +28,7 @@ struct ProfileView: View {
VStack(spacing: 14) { VStack(spacing: 14) {
NavigationLink { NavigationLink {
OrdersView() OrdersView(appState: $appState)
} label: { } label: {
ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos") ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos")
} }