cards
This commit is contained in:
461
pedi-foods/Sources/PediFoods/Views/Main/AddCardFormView.swift
Normal file
461
pedi-foods/Sources/PediFoods/Views/Main/AddCardFormView.swift
Normal 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))"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user