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:
@@ -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?
|
||||||
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,33 +706,53 @@ 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)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
|
|
||||||
}
|
}
|
||||||
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
PrimaryButton(title: "Copiar Código PIX") {
|
HStack(spacing: 10) {
|
||||||
if isPixExpired {
|
PrimaryButton(title: "Copiar Código PIX") {
|
||||||
showPixExpiredSnackbar()
|
if isPixExpired {
|
||||||
return
|
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)
|
.disabled(isPixExpired)
|
||||||
SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.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)
|
||||||
}
|
}
|
||||||
.disabled(isPixExpired)
|
|
||||||
.opacity(isPixExpired ? 0.5 : 1.0)
|
|
||||||
.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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user