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, 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))" } }