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:
@@ -156,6 +156,7 @@ struct CheckoutView: View {
|
||||
.navigationDestination(item: $pixPaymentContext) { context in
|
||||
PaymentPixView(
|
||||
context: context,
|
||||
appState: $appState,
|
||||
onPaymentConfirmed: {
|
||||
appState.cart.clear()
|
||||
SessionStateStore.clearPendingCartOrder()
|
||||
@@ -560,9 +561,19 @@ struct PixPaymentContext: Identifiable, Hashable {
|
||||
let id: String
|
||||
let orderId: String
|
||||
let shortId: String?
|
||||
let storeId: String
|
||||
let copyPaste: String
|
||||
let qrCodeImageBase64: 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 {
|
||||
@@ -625,6 +636,7 @@ struct CardPaymentContext: Identifiable, Hashable {
|
||||
|
||||
struct PaymentPixView: View {
|
||||
let context: PixPaymentContext
|
||||
@Binding var appState: AppState
|
||||
var onPaymentConfirmed: (() -> Void)? = nil
|
||||
var onOpenTracking: (() -> Void)? = nil
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -633,9 +645,20 @@ struct PaymentPixView: View {
|
||||
@State var hasOpenedTracking = false
|
||||
@State var hasShownPixExpiredSnackbar = false
|
||||
@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? {
|
||||
guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
guard let raw = currentContext.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
raw.isEmpty == false else { return nil }
|
||||
if raw.lowercased().hasPrefix("data:image") { return raw }
|
||||
return "data:image/png;base64,\(raw)"
|
||||
@@ -683,33 +706,53 @@ struct PaymentPixView: View {
|
||||
.frame(height: 20)
|
||||
.overlay(
|
||||
VStack(spacing: 8) {
|
||||
Text(context.copyPaste)
|
||||
Text(currentContext.copyPaste)
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(0)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 10)
|
||||
|
||||
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
)
|
||||
|
||||
if let expirationDate = context.expirationDate, expirationDate.isEmpty == false {
|
||||
|
||||
if let expirationDate = currentContext.expirationDate, expirationDate.isEmpty == false {
|
||||
Text(expirationLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted)
|
||||
}
|
||||
|
||||
PrimaryButton(title: "Copiar Código PIX") {
|
||||
if isPixExpired {
|
||||
showPixExpiredSnackbar()
|
||||
return
|
||||
|
||||
HStack(spacing: 10) {
|
||||
PrimaryButton(title: "Copiar Código PIX") {
|
||||
if isPixExpired {
|
||||
showPixExpiredSnackbar()
|
||||
return
|
||||
}
|
||||
copyToClipboard(currentContext.copyPaste)
|
||||
SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0)
|
||||
}
|
||||
copyToClipboard(context.copyPaste)
|
||||
SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0)
|
||||
.disabled(isPixExpired)
|
||||
.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)
|
||||
}
|
||||
.disabled(isPixExpired)
|
||||
.opacity(isPixExpired ? 0.5 : 1.0)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
.padding(20)
|
||||
@@ -717,6 +760,26 @@ struct PaymentPixView: View {
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Pagamento via PIX")
|
||||
.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 {
|
||||
tracker.onOrderUpdated = { updated in
|
||||
latestOrder = updated
|
||||
@@ -725,7 +788,7 @@ struct PaymentPixView: View {
|
||||
openTrackingOnce()
|
||||
}
|
||||
}
|
||||
tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt)
|
||||
tracker.start(orderId: currentContext.orderId, jwt: DefaultTokenStore().jwt)
|
||||
}
|
||||
.task {
|
||||
while Task.isCancelled == false {
|
||||
@@ -752,8 +815,16 @@ struct PaymentPixView: View {
|
||||
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? {
|
||||
let raw = (context.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let raw = (currentContext.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard raw.isEmpty == false else { return nil }
|
||||
|
||||
let iso = ISO8601DateFormatter()
|
||||
@@ -1257,3 +1328,347 @@ struct PaymentCardView: View {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user