225 lines
8.6 KiB
Swift
225 lines
8.6 KiB
Swift
import Foundation
|
|
|
|
enum ContactFieldInputRules {
|
|
private static let phoneFieldIDs: Set<String> = ["phone", "whatsapp", "signal"]
|
|
private static let strictWebLinkFieldIDs: Set<String> = ["website", "customLink", "calendly"]
|
|
private static let usernameOrLinkFieldIDs: Set<String> = [
|
|
"linkedIn", "twitter", "instagram", "facebook", "tiktok", "threads",
|
|
"youtube", "snapchat", "pinterest", "twitch", "bluesky", "mastodon", "reddit",
|
|
"github", "gitlab", "stackoverflow",
|
|
"telegram", "discord", "slack", "matrix",
|
|
"patreon", "kofi"
|
|
]
|
|
|
|
static func shouldFormatAsPhone(_ fieldTypeID: String) -> Bool {
|
|
phoneFieldIDs.contains(fieldTypeID)
|
|
}
|
|
|
|
nonisolated static func linkedInProfileURL(for value: String) -> URL? {
|
|
guard let normalized = normalizedLinkedInProfileURLString(value) else { return nil }
|
|
return URL(string: normalized)
|
|
}
|
|
|
|
static func isValid(_ value: String, for fieldTypeID: String) -> Bool {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return false }
|
|
|
|
if phoneFieldIDs.contains(fieldTypeID) {
|
|
return PhoneNumberText.isValid(trimmed)
|
|
}
|
|
|
|
switch fieldTypeID {
|
|
case "linkedIn":
|
|
return linkedInProfileURL(for: trimmed) != nil
|
|
case "email":
|
|
return EmailText.isValid(trimmed)
|
|
case "zelle":
|
|
return PhoneNumberText.isValid(trimmed) || EmailText.isValid(trimmed)
|
|
case "venmo":
|
|
return isValidVenmo(trimmed)
|
|
case "cashApp":
|
|
return isValidCashApp(trimmed)
|
|
case "paypal":
|
|
return isValidPayPal(trimmed)
|
|
default:
|
|
break
|
|
}
|
|
|
|
if strictWebLinkFieldIDs.contains(fieldTypeID) {
|
|
return WebLinkText.isValid(trimmed)
|
|
}
|
|
|
|
if usernameOrLinkFieldIDs.contains(fieldTypeID) {
|
|
return isValidUsernameOrLink(trimmed)
|
|
}
|
|
|
|
return !trimmed.isEmpty
|
|
}
|
|
|
|
static func normalizedForStorage(_ value: String, for fieldTypeID: String) -> String {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "" }
|
|
|
|
if phoneFieldIDs.contains(fieldTypeID) {
|
|
return PhoneNumberText.normalizedForStorage(trimmed)
|
|
}
|
|
|
|
switch fieldTypeID {
|
|
case "linkedIn":
|
|
return linkedInProfileURL(for: trimmed)?.absoluteString ?? trimmed
|
|
case "email":
|
|
return EmailText.normalizedForStorage(trimmed)
|
|
case "zelle":
|
|
if EmailText.isValid(trimmed) {
|
|
return EmailText.normalizedForStorage(trimmed)
|
|
}
|
|
if PhoneNumberText.isValid(trimmed) {
|
|
return PhoneNumberText.normalizedForStorage(trimmed)
|
|
}
|
|
return trimmed
|
|
case "paypal":
|
|
if EmailText.isValid(trimmed) {
|
|
return EmailText.normalizedForStorage(trimmed)
|
|
}
|
|
if WebLinkText.isValid(trimmed) {
|
|
return WebLinkText.normalizedForStorage(trimmed)
|
|
}
|
|
return trimmed
|
|
default:
|
|
break
|
|
}
|
|
|
|
if strictWebLinkFieldIDs.contains(fieldTypeID) || usernameOrLinkFieldIDs.contains(fieldTypeID) {
|
|
if WebLinkText.isValid(trimmed) {
|
|
return WebLinkText.normalizedForStorage(trimmed)
|
|
}
|
|
}
|
|
|
|
return trimmed
|
|
}
|
|
|
|
static func validationMessage(for fieldTypeID: String) -> String {
|
|
if phoneFieldIDs.contains(fieldTypeID) {
|
|
return String(localized: "Enter a valid phone number")
|
|
}
|
|
|
|
switch fieldTypeID {
|
|
case "linkedIn":
|
|
return String(localized: "Enter a valid LinkedIn profile username or link")
|
|
case "email":
|
|
return String(localized: "Enter a valid email address")
|
|
case "zelle":
|
|
return String(localized: "Enter a valid phone number or email")
|
|
case "venmo":
|
|
return String(localized: "Enter a valid Venmo username")
|
|
case "cashApp":
|
|
return String(localized: "Enter a valid Cash App cashtag")
|
|
case "paypal":
|
|
return String(localized: "Enter a valid PayPal username, email, or link")
|
|
default:
|
|
break
|
|
}
|
|
|
|
if strictWebLinkFieldIDs.contains(fieldTypeID) {
|
|
return String(localized: "Enter a valid web link")
|
|
}
|
|
|
|
if usernameOrLinkFieldIDs.contains(fieldTypeID) {
|
|
return String(localized: "Enter a valid username or link")
|
|
}
|
|
|
|
return String(localized: "Enter a value")
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Validation Helpers
|
|
|
|
private extension ContactFieldInputRules {
|
|
nonisolated static func normalizedLinkedInProfileURLString(_ value: String) -> String? {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty, !trimmed.contains(where: \.isWhitespace) else { return nil }
|
|
|
|
if let url = URL(string: trimmed),
|
|
let host = url.host?.lowercased(),
|
|
host.contains("linkedin.com") {
|
|
let pathComponents = url.path
|
|
.split(separator: "/")
|
|
.map(String.init)
|
|
guard pathComponents.count >= 2,
|
|
pathComponents[0].lowercased() == "in",
|
|
let handle = normalizedLinkedInHandle(pathComponents[1]) else {
|
|
return nil
|
|
}
|
|
return "https://www.linkedin.com/in/\(handle)/"
|
|
}
|
|
|
|
var candidate = trimmed
|
|
.replacingOccurrences(of: "https://", with: "")
|
|
.replacingOccurrences(of: "http://", with: "")
|
|
.replacingOccurrences(of: "www.", with: "")
|
|
|
|
if candidate.lowercased().hasPrefix("linkedin.com/") {
|
|
candidate = String(candidate.dropFirst("linkedin.com/".count))
|
|
guard candidate.lowercased().hasPrefix("in/") else { return nil }
|
|
candidate = String(candidate.dropFirst("in/".count))
|
|
} else if candidate.lowercased().hasPrefix("in/") {
|
|
candidate = String(candidate.dropFirst("in/".count))
|
|
}
|
|
|
|
let handleCandidate = candidate
|
|
.split(separator: "/")
|
|
.first
|
|
.map(String.init) ?? candidate
|
|
|
|
guard let handle = normalizedLinkedInHandle(handleCandidate) else { return nil }
|
|
return "https://www.linkedin.com/in/\(handle)/"
|
|
}
|
|
|
|
nonisolated static func normalizedLinkedInHandle(_ value: String) -> String? {
|
|
let handle = value.hasPrefix("@") ? String(value.dropFirst()) : value
|
|
guard !handle.isEmpty else { return nil }
|
|
|
|
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
|
guard handle.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil }
|
|
return handle
|
|
}
|
|
|
|
static func isValidUsernameOrLink(_ value: String) -> Bool {
|
|
guard !value.contains(where: \.isWhitespace) else { return false }
|
|
if WebLinkText.isValid(value) { return true }
|
|
|
|
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "@._-/:+$#"))
|
|
let hasValidScalars = value.unicodeScalars.allSatisfy { allowed.contains($0) }
|
|
let hasContent = value.contains(where: { $0.isLetter || $0.isNumber })
|
|
return hasValidScalars && hasContent && value.count >= 2
|
|
}
|
|
|
|
static func isValidVenmo(_ value: String) -> Bool {
|
|
guard !value.contains(where: \.isWhitespace) else { return false }
|
|
let username = value.hasPrefix("@") ? String(value.dropFirst()) : value
|
|
return isSimpleHandle(username, min: 2, max: 30, allowDash: true)
|
|
}
|
|
|
|
static func isValidCashApp(_ value: String) -> Bool {
|
|
guard !value.contains(where: \.isWhitespace) else { return false }
|
|
let cashtag = value.hasPrefix("$") ? String(value.dropFirst()) : value
|
|
return isSimpleHandle(cashtag, min: 1, max: 20, allowDash: false)
|
|
}
|
|
|
|
static func isValidPayPal(_ value: String) -> Bool {
|
|
guard !value.contains(where: \.isWhitespace) else { return false }
|
|
if EmailText.isValid(value) || WebLinkText.isValid(value) {
|
|
return true
|
|
}
|
|
return isSimpleHandle(value, min: 2, max: 64, allowDash: true)
|
|
}
|
|
|
|
static func isSimpleHandle(_ value: String, min: Int, max: Int, allowDash: Bool) -> Bool {
|
|
guard !value.isEmpty, (min...max).contains(value.count) else { return false }
|
|
|
|
let extraCharacters = allowDash ? "._-" : "_"
|
|
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: extraCharacters))
|
|
return value.unicodeScalars.allSatisfy { allowed.contains($0) }
|
|
}
|
|
}
|