migration

This commit is contained in:
Daniel Arantes Loverde
2026-08-11 13:22:02 -03:00
parent c4d6998595
commit 7702836fe7
231 changed files with 4329 additions and 1958 deletions

View File

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