fix(checkout): display snackbar on top of sheet modals

This commit is contained in:
Daniel Arantes Loverde
2026-06-04 16:17:34 -03:00
parent 39ddf9c86c
commit eae9613ed5

View File

@@ -929,89 +929,93 @@ struct CardSelectionSheet: View {
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Selecionar 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(alignment: .leading, spacing: 16) {
if savedCards.isEmpty == false {
VStack(alignment: .leading, spacing: 0) {
ForEach(savedCards) { card in
savedCardRow(card)
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 \(formatCurrency(cardContext.total))") {
guard isSubmitting == false else { return }
onSavedCardConfirmed(selectedCardId)
}
.disabled(isSubmitting)
}
Text("ou")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.frame(maxWidth: .infinity, alignment: .center)
}
ZStack(alignment: .top) {
VStack(spacing: 0) {
// Header
HStack {
Text("Selecionar Cartão")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Button {
showNewCardSheet = true
dismiss()
} label: {
HStack(spacing: 10) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 18))
.foregroundStyle(AppColors.primary)
Text("Adicionar novo cartão")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
Spacer()
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
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(.bottom, 32)
}
}
.background(AppColors.backgroundLight)
.sheet(isPresented: $showNewCardSheet) {
PaymentCardView(
context: cardContext,
appState: $appState,
onOrderCreated: { orderId, shortId in
showNewCardSheet = false
onOrderCreated(orderId, shortId)
.padding(.top, 20)
.padding(.bottom, 16)
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
if savedCards.isEmpty == false {
VStack(alignment: .leading, spacing: 0) {
ForEach(savedCards) { card in
savedCardRow(card)
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 \(formatCurrency(cardContext.total))") {
guard isSubmitting == false else { return }
onSavedCardConfirmed(selectedCardId)
}
.disabled(isSubmitting)
}
Text("ou")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.frame(maxWidth: .infinity, alignment: .center)
}
Button {
showNewCardSheet = true
} label: {
HStack(spacing: 10) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 18))
.foregroundStyle(AppColors.primary)
Text("Adicionar 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: $showNewCardSheet) {
PaymentCardView(
context: cardContext,
appState: $appState,
onOrderCreated: { orderId, shortId in
showNewCardSheet = false
onOrderCreated(orderId, shortId)
}
)
}
SnackbarOverlay(center: SnackbarCenter.shared)
}
}
@@ -1133,140 +1137,144 @@ struct PaymentCardView: View {
}
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(alignment: .leading, spacing: 16) {
// Total
ZStack(alignment: .top) {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Total do Pedido")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
Text(formatCurrency(context.total))
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
}
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(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 16)
// Dados do cartão
VStack(alignment: .leading, spacing: 0) {
sectionHeader("DADOS DO CARTÃO")
VStack(spacing: 10) {
labeledField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber, brandLogo: detectedBrandLogo)
.keyboardType(.numberPad)
.onChange(of: cardNumber) { _, v in
let d = String(v.filter(\.isNumber).prefix(16))
let masked = stride(from: 0, to: d.count, by: 4)
.map { i -> String in
let start = d.index(d.startIndex, offsetBy: i)
let end = d.index(start, offsetBy: min(4, d.count - i))
return String(d[start..<end])
}.joined(separator: " ")
if masked != v { cardNumber = masked }
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
// Total
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Total do Pedido")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
.textCase(nil)
Text(formatCurrency(context.total))
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
}
Spacer()
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
labeledField("Nome no Cartão", placeholder: "Como impresso no cartão", text: $holderName)
// Dados do cartão
VStack(alignment: .leading, spacing: 0) {
sectionHeader("DADOS DO CARTÃO")
VStack(spacing: 10) {
labeledField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber, brandLogo: detectedBrandLogo)
.keyboardType(.numberPad)
.onChange(of: cardNumber) { _, v in
let d = String(v.filter(\.isNumber).prefix(16))
let masked = stride(from: 0, to: d.count, by: 4)
.map { i -> String in
let start = d.index(d.startIndex, offsetBy: i)
let end = d.index(start, offsetBy: min(4, d.count - i))
return String(d[start..<end])
}.joined(separator: " ")
if masked != v { cardNumber = masked }
}
HStack(spacing: 10) {
labeledField("Validade", placeholder: "MM/AAAA", text: $expiry)
.keyboardType(.numberPad)
.onChange(of: expiry) { _, v in
let d = String(v.filter(\.isNumber).prefix(6))
let masked: String
if d.count <= 2 { masked = d }
else { masked = "\(d.prefix(2))/\(d.dropFirst(2))" }
if masked != v { expiry = masked }
labeledField("Nome no Cartão", placeholder: "Como impresso no cartão", text: $holderName)
HStack(spacing: 10) {
labeledField("Validade", placeholder: "MM/AAAA", text: $expiry)
.keyboardType(.numberPad)
.onChange(of: expiry) { _, v in
let d = String(v.filter(\.isNumber).prefix(6))
let masked: String
if d.count <= 2 { masked = d }
else { masked = "\(d.prefix(2))/\(d.dropFirst(2))" }
if masked != v { expiry = masked }
}
labeledField("CVV", placeholder: "•••", text: $cvv)
.keyboardType(.numberPad)
.onChange(of: cvv) { _, v in
let d = String(v.filter(\.isNumber).prefix(4))
if d != v { cvv = d }
}
}
labeledField("CVV", placeholder: "•••", text: $cvv)
.keyboardType(.numberPad)
.onChange(of: cvv) { _, v in
let d = String(v.filter(\.isNumber).prefix(4))
if d != v { cvv = d }
}
}
labeledField("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 masked: String
if d.count <= 3 { masked = d }
else if d.count <= 6 { masked = "\(d.prefix(3)).\(d.dropFirst(3))" }
else if d.count <= 9 { masked = "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" }
else { masked = "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))" }
if masked != v { cpf = masked }
labeledField("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 masked: String
if d.count <= 3 { masked = d }
else if d.count <= 6 { masked = "\(d.prefix(3)).\(d.dropFirst(3))" }
else if d.count <= 9 { masked = "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6))" }
else { masked = "\(d.prefix(3)).\(d.dropFirst(3).prefix(3)).\(d.dropFirst(6).prefix(3))-\(d.dropFirst(9))" }
if masked != v { cpf = masked }
}
}
}
.padding(14)
}
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
// Salvar cartão
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Salvar cartão")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text("Pague mais rápido nas próximas compras")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
Spacer()
Toggle("", isOn: $saveCard)
.labelsHidden()
.tint(AppColors.primary)
}
.padding(14)
if saveCard {
Divider().padding(.horizontal, 14)
labeledField("Apelido do cartão (opcional)", placeholder: "Ex: Meu Visa", text: $nickname)
.padding(14)
}
}
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.animation(.easeInOut(duration: 0.2), value: saveCard)
}
.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 submitOrder() }
// Salvar cartão
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Salvar cartão")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text("Pague mais rápido nas próximas compras")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
Spacer()
Toggle("", isOn: $saveCard)
.labelsHidden()
.tint(AppColors.primary)
}
.padding(14)
if saveCard {
Divider().padding(.horizontal, 14)
labeledField("Apelido do cartão (opcional)", placeholder: "Ex: Meu Visa", text: $nickname)
.padding(14)
}
}
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.animation(.easeInOut(duration: 0.2), value: saveCard)
PrimaryButton(title: isSubmitting ? "Processando..." : "Confirmar e Pagar") {
guard canSubmit, isSubmitting == false else { return }
Task { await submitOrder() }
}
.disabled(canSubmit == false || isSubmitting)
}
.padding(20)
}
.disabled(canSubmit == false || isSubmitting)
}
.padding(20)
.background(AppColors.backgroundLight)
SnackbarOverlay(center: SnackbarCenter.shared)
}
.background(AppColors.backgroundLight)
} // VStack
.background(AppColors.backgroundLight)
}
@MainActor
@@ -1527,68 +1535,72 @@ struct ChangePaymentCardSheet: View {
}
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))
ZStack(alignment: .top) {
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(.bottom, 32)
.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)
.background(AppColors.backgroundLight)
.sheet(isPresented: $showNewCardForm) {
ChangePaymentNewCardView(pixContext: pixContext, appState: $appState) { orderId, shortId in
showNewCardForm = false; onConfirmed(orderId, shortId)
}
}
SnackbarOverlay(center: SnackbarCenter.shared)
}
}
@@ -1688,55 +1700,59 @@ struct ChangePaymentNewCardView: View {
}
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, brandLogo: detectedBrandLogo).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)
ZStack(alignment: .top) {
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, brandLogo: detectedBrandLogo).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)
}
.padding(20)
}
.background(AppColors.backgroundLight)
SnackbarOverlay(center: SnackbarCenter.shared)
}
.background(AppColors.backgroundLight)
}
@MainActor