payfrit-works-ios/PayfritWorks/Services/APIService.swift
Schwifty ece36cb484 feat: add customer rating dialog on task completion
When a worker completes a service point task and the API requires a
rating, a dialog now appears with 4 yes/no questions matching Android:
- Was the customer prepared?
- Was the scope clear?
- Was the customer respectful?
- Would you serve them again?

The rating is submitted with the task completion request via the
workerRating payload. Also handles rating_required during beacon
auto-complete by dismissing the countdown and showing the dialog.

Files changed:
- RatingDialog.swift (new) — rating dialog UI with toggle chips
- APIService.swift — added workerRating param + ratingRequired error
- TaskDetailScreen.swift — rating flow in completeTask + auto-complete
- project.pbxproj — added RatingDialog.swift to Xcode project
2026-03-22 12:30:18 +00:00

948 lines
35 KiB
Swift

import Foundation
import os.log
private let logger = Logger(subsystem: "com.payfrit.works", category: "API")
// MARK: - Dev Flag
/// Master flag: flip to `false` for production builds.
/// Controls API endpoint, dev banner, and magic OTPs.
let IS_DEV = false
// MARK: - API Errors
enum APIError: LocalizedError {
case invalidURL
case noData
case decodingError(String)
case serverError(String)
case unauthorized
case networkError(String)
case ratingRequired(customerUserId: Int)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL"
case .noData: return "No data received"
case .decodingError(let msg): return "Decoding error: \(msg)"
case .serverError(let msg): return msg
case .unauthorized: return "Unauthorized"
case .networkError(let msg): return msg
case .ratingRequired: return "Rating required"
}
}
}
// MARK: - Login Response
struct LoginResponse {
let userId: Int
let userFirstName: String
let token: String
let photoUrl: String
}
// MARK: - OTP Response
struct SendOtpResponse {
let uuid: String
let message: String
}
// MARK: - Chat Messages Result
struct ChatMessagesResult {
let messages: [ChatMessage]
let chatClosed: Bool
}
// MARK: - API Service
actor APIService {
static let shared = APIService()
private static let devBaseURL = "https://dev.payfrit.com/api"
private static let prodBaseURL = "https://biz.payfrit.com/api"
private var userToken: String?
private var userId: Int?
private var businessId: Int = 0
var baseURL: String { IS_DEV ? Self.devBaseURL : Self.prodBaseURL }
// MARK: - Configuration
func setAuth(token: String?, userId: Int?) {
self.userToken = token
self.userId = userId
}
func setBusinessId(_ id: Int) {
self.businessId = id
}
func getToken() -> String? { userToken }
func getUserId() -> Int? { userId }
func getBusinessId() -> Int { businessId }
// MARK: - Core Request
private func postJSON(_ path: String, payload: [String: Any]) async throws -> [String: Any] {
let urlString = baseURL + (path.hasPrefix("/") ? path : "/\(path)")
guard let url = URL(string: urlString) else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
if let token = userToken, !token.isEmpty {
request.setValue(token, forHTTPHeaderField: "X-User-Token")
}
if businessId > 0 {
request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID")
}
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 401 { throw APIError.unauthorized }
guard (200...299).contains(httpResponse.statusCode) else {
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
}
}
// Try to parse JSON, handling CFML prefix junk
if let json = tryDecodeJSON(data) {
return json
}
throw APIError.decodingError("Non-JSON response")
}
private func getJSON(_ path: String) async throws -> [String: Any] {
let urlString = baseURL + (path.hasPrefix("/") ? path : "/\(path)")
guard let url = URL(string: urlString) else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "GET"
if let token = userToken, !token.isEmpty {
request.setValue(token, forHTTPHeaderField: "X-User-Token")
}
if businessId > 0 {
request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID")
}
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 401 { throw APIError.unauthorized }
guard (200...299).contains(httpResponse.statusCode) else {
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
}
}
if let json = tryDecodeJSON(data) {
return json
}
throw APIError.decodingError("Non-JSON response")
}
private func tryDecodeJSON(_ data: Data) -> [String: Any]? {
// Try direct parse
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return json
}
// Try extracting JSON from mixed response
guard let body = String(data: data, encoding: .utf8),
let start = body.firstIndex(of: "{"),
let end = body.lastIndex(of: "}") else { return nil }
let jsonStr = String(body[start...end])
guard let jsonData = jsonStr.data(using: .utf8) else { return nil }
return try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
}
private func ok(_ json: [String: Any]) -> Bool {
if let b = json["OK"] as? Bool { return b }
if let b = json["ok"] as? Bool { return b }
if let i = json["OK"] as? Int { return i == 1 }
if let s = json["OK"] as? String { return s == "true" || s == "1" || s == "YES" }
return false
}
private func err(_ json: [String: Any]) -> String {
(json["ERROR"] as? String) ?? (json["error"] as? String)
?? (json["Error"] as? String) ?? (json["message"] as? String) ?? ""
}
/// All expected PascalCase keys our models use.
nonisolated private static let expectedKeys: [String] = [
"TaskID", "TaskBusinessID", "TaskCategoryID", "TaskTypeID", "TaskTitle",
"TaskDetails", "TaskCreatedOn", "TaskStatusID", "TaskSourceType", "TaskSourceID",
"TaskCategoryName", "TaskCategoryColor",
"EmployeeID", "BusinessID", "BusinessName", "BusinessAddress", "BusinessCity",
"EmployeeStatusID", "PendingTaskCount",
"OrderID", "OrderRemarks", "OrderSubmittedOn",
"ServicePointID", "ServicePointName", "ServicePointTypeID",
"DeliveryAddress", "DeliveryLat", "DeliveryLng",
"CustomerUserID", "CustomerFirstName", "CustomerLastName",
"CustomerPhone", "CustomerPhotoUrl", "BeaconUUID",
"LineItems", "TableMembers",
"LineItemID", "ItemName", "Quantity", "PriceCents", "Remark",
"IsModifier", "ParentLineItemID",
"UserID", "FirstName", "LastName", "IsHost",
"MessageID", "SenderUserID", "SenderType", "SenderName", "Text", "MessageText",
"CreatedOn", "IsRead",
"OK", "ERROR", "TASKS", "BUSINESSES", "MESSAGES", "TASK",
"UserFirstName", "Token", "PhotoUrl", "UserPhotoUrl",
"TIER", "STRIPE", "ACTIVATION",
"HasAccount", "PayoutsEnabled", "SetupIncomplete",
"BalanceCents", "CapCents", "RemainingCents", "IsComplete", "ProgressPercent",
"ENTRIES", "TOTALS", "ID",
"GrossEarningsCents", "ActivationWithheldCents", "NetTransferCents", "Status", "CreatedAt",
"TotalGrossCents", "TotalWithheldCents", "TotalNetCents",
"AccountID", "ACCOUNT_ID", "URL", "url",
"CHAT_CLOSED", "chat_closed", "PayCents",
"TaskTypeName", "OrderTotal", "CashReceivedCents"
]
/// Build a lookup: stripped key (no underscores, lowercased) expected PascalCase key.
/// This handles ALL CAPS, underscore_separated, camelCase, PascalCase any format.
nonisolated private static let keyLookup: [String: String] = {
var map: [String: String] = [:]
for k in expectedKeys {
// Strip underscores and lowercase for fuzzy matching
let stripped = k.replacingOccurrences(of: "_", with: "").lowercased()
map[stripped] = k
// Also add exact uppercased (for direct ALL CAPS match)
map[k.uppercased()] = k
}
return map
}()
/// Normalize dictionary keys: maps ANY casing/format to expected PascalCase.
/// Strips underscores and lowercases for fuzzy matching against known keys.
nonisolated static func normalizeKeys(_ dict: [String: Any]) -> [String: Any] {
var result: [String: Any] = [:]
for (key, value) in dict {
// Try exact uppercased match first, then stripped fuzzy match
let stripped = key.replacingOccurrences(of: "_", with: "").lowercased()
let normalizedKey = keyLookup[key.uppercased()] ?? keyLookup[stripped] ?? key
if let arr = value as? [[String: Any]] {
result[normalizedKey] = arr.map { normalizeKeys($0) }
} else if let sub = value as? [String: Any] {
result[normalizedKey] = normalizeKeys(sub)
} else {
result[normalizedKey] = value
}
}
return result
}
/// Find an array value in the JSON by trying multiple key variants
nonisolated static func findArray(_ json: [String: Any], _ keys: [String]) -> [[String: Any]]? {
for key in keys {
if let arr = json[key] as? [[String: Any]] { return arr }
}
// Fallback: search all values for the first array of dicts
for (_, value) in json {
if let arr = value as? [[String: Any]], !arr.isEmpty { return arr }
}
return nil
}
// MARK: - Auth
func login(username: String, password: String) async throws -> LoginResponse {
let json = try await postJSON("/auth/login.php", payload: [
"username": username,
"password": password
])
guard ok(json) else {
let e = err(json)
if e == "bad_credentials" {
throw APIError.serverError("Invalid email/phone or password")
}
throw APIError.serverError("Login failed: \(e)")
}
let uid = (json["UserID"] as? Int)
?? Int(json["UserID"] as? String ?? "")
?? (json["UserId"] as? Int)
?? 0
let token = (json["Token"] as? String)
?? (json["token"] as? String)
?? ""
let firstName = (json["UserFirstName"] as? String)
?? (json["FirstName"] as? String)
?? (json["firstName"] as? String)
?? (json["Name"] as? String)
?? (json["name"] as? String)
?? ""
let photoUrl = (json["UserPhotoUrl"] as? String)
?? (json["PhotoUrl"] as? String)
?? (json["photoUrl"] as? String)
?? ""
self.userToken = token
self.userId = uid
return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
}
// MARK: - OTP Login
func sendLoginOtp(phone: String) async throws -> SendOtpResponse {
let json = try await postJSON("/auth/loginOTP.php", payload: [
"phone": phone
])
guard ok(json) else {
throw APIError.serverError("Failed to send code: \(err(json))")
}
let data = json["DATA"] as? [String: Any] ?? json
let uuid = (data["UUID"] as? String) ?? (data["uuid"] as? String) ?? ""
let message = (data["MESSAGE"] as? String) ?? (data["message"] as? String) ?? "OTP sent"
return SendOtpResponse(uuid: uuid, message: message)
}
func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse {
let json = try await postJSON("/auth/verifyLoginOTP.php", payload: [
"uuid": uuid,
"otp": otp
])
guard ok(json) else {
throw APIError.serverError("Invalid code")
}
let data = json["DATA"] as? [String: Any] ?? json
let uid = (data["UserID"] as? Int)
?? Int(data["UserID"] as? String ?? "")
?? (data["USERID"] as? Int)
?? 0
let token = (data["Token"] as? String)
?? (data["TOKEN"] as? String)
?? (data["token"] as? String)
?? ""
let firstName = (data["FirstName"] as? String)
?? (data["FIRSTNAME"] as? String)
?? (data["USERFIRSTNAME"] as? String)
?? ""
let photoUrl = (data["PhotoUrl"] as? String)
?? (data["PHOTOURL"] as? String)
?? ""
self.userToken = token
self.userId = uid
return LoginResponse(userId: uid, userFirstName: firstName, token: token, photoUrl: photoUrl)
}
func logout() {
userToken = nil
userId = nil
businessId = 0
}
// MARK: - Profile
struct UserProfile {
let userId: Int
let firstName: String
let lastName: String
let email: String
let phone: String
let photoUrl: String
}
func getProfile() async throws -> UserProfile {
guard let uid = userId, uid > 0 else {
throw APIError.serverError("User not logged in")
}
let json = try await postJSON("/auth/profile.php", payload: [
"UserID": uid
])
guard ok(json) else {
throw APIError.serverError("Failed to load profile: \(err(json))")
}
let data = json["DATA"] as? [String: Any] ?? json
return UserProfile(
userId: (data["UserID"] as? Int) ?? (data["USERID"] as? Int) ?? uid,
firstName: (data["FirstName"] as? String) ?? (data["FIRSTNAME"] as? String) ?? "",
lastName: (data["LastName"] as? String) ?? (data["LASTNAME"] as? String) ?? "",
email: (data["Email"] as? String) ?? (data["EMAIL"] as? String) ?? (data["EmailAddress"] as? String) ?? "",
phone: (data["Phone"] as? String) ?? (data["PHONE"] as? String) ?? (data["ContactNumber"] as? String) ?? "",
photoUrl: Self.resolvePhotoUrl((data["PhotoUrl"] as? String) ?? (data["PHOTOURL"] as? String) ?? "")
)
}
func updateProfile(firstName: String? = nil, lastName: String? = nil, email: String? = nil, phone: String? = nil) async throws {
guard let uid = userId, uid > 0 else {
throw APIError.serverError("User not logged in")
}
var payload: [String: Any] = ["UserID": uid]
if let fn = firstName { payload["FirstName"] = fn }
if let ln = lastName { payload["LastName"] = ln }
if let em = email { payload["Email"] = em }
if let ph = phone { payload["Phone"] = ph }
let json = try await postJSON("/auth/profile.php", payload: payload)
guard ok(json) else {
throw APIError.serverError("Failed to update profile: \(err(json))")
}
}
// MARK: - Businesses
func getMyBusinesses() async throws -> [Employment] {
guard let uid = userId, uid > 0 else {
throw APIError.serverError("User not logged in")
}
let json = try await postJSON("/workers/myBusinesses.php", payload: [
"UserID": uid
])
guard ok(json) else {
throw APIError.serverError("Failed to load businesses: \(err(json))")
}
guard let arr = Self.findArray(json, ["BUSINESSES", "Businesses", "businesses"]) else {
return []
}
return arr.map { Employment(json: $0) }
}
// MARK: - Tasks
func listPendingTasks(categoryId: Int? = nil) async throws -> [WorkTask] {
var payload: [String: Any] = ["BusinessID": businessId]
if let cid = categoryId, cid > 0 {
payload["CategoryID"] = cid
}
let json = try await postJSON("/tasks/listPending.php", payload: payload)
guard ok(json) else {
throw APIError.serverError("Failed to load tasks: \(err(json))")
}
guard let arr = Self.findArray(json, ["TASKS", "Tasks", "tasks"]) else { return [] }
return arr.map { WorkTask(json: $0) }
}
func acceptTask(taskId: Int) async throws {
// Flutter only sends TaskID + BusinessID (no UserID)
let json = try await postJSON("/tasks/accept.php", payload: [
"TaskID": taskId,
"BusinessID": businessId
])
guard ok(json) else {
let e = err(json)
if e == "already_accepted" {
throw APIError.serverError("This task has already been claimed by someone else.")
}
throw APIError.serverError("Failed to accept task: \(e)")
}
}
func listMyTasks(filterType: String = "active") async throws -> [WorkTask] {
var payload: [String: Any] = [
"UserID": userId ?? 0,
"FilterType": filterType
]
if businessId > 0 {
payload["BusinessID"] = businessId
}
let json = try await postJSON("/tasks/listMine.php", payload: payload)
guard ok(json) else {
throw APIError.serverError("Failed to load my tasks: \(err(json))")
}
guard let arr = Self.findArray(json, ["TASKS", "Tasks", "tasks"]) else { return [] }
return arr.map { WorkTask(json: $0) }
}
func completeTask(taskId: Int, workerRating: [String: Bool]? = nil, cashReceivedCents: Int? = nil, cancelOrder: Bool = false) async throws {
var payload: [String: Any] = [
"TaskID": taskId,
"UserID": userId ?? 0
]
if let rating = workerRating {
payload["workerRating"] = rating
}
if let cents = cashReceivedCents {
payload["CashReceivedCents"] = cents
}
if cancelOrder {
payload["CancelOrder"] = true
}
let json = try await postJSON("/tasks/complete.php", payload: payload)
guard ok(json) else {
let errorMsg = err(json)
if errorMsg == "rating_required" {
let customerUserId = json["CustomerUserID"] as? Int ?? 0
throw APIError.ratingRequired(customerUserId: customerUserId)
}
throw APIError.serverError("Failed to complete task: \(errorMsg)")
}
}
func closeChat(taskId: Int) async throws {
let payload: [String: Any] = [
"TaskID": taskId
]
let json = try await postJSON("/tasks/completeChat.php", payload: payload)
guard ok(json) else {
throw APIError.serverError("Close chat failed: \(err(json))")
}
}
func getTaskDetails(taskId: Int) async throws -> TaskDetails {
let json = try await postJSON("/tasks/getDetails.php", payload: [
"TaskID": taskId
])
guard ok(json) else {
throw APIError.serverError("Failed to load task details: \(err(json))")
}
// Find the TASK object try known keys, then fallback to first dict value
var taskJson: [String: Any]?
for key in ["TASK", "Task", "task"] {
if let d = json[key] as? [String: Any] { taskJson = d; break }
}
if taskJson == nil {
// Fallback: look for first nested dict that looks like a task
for (_, value) in json {
if let d = value as? [String: Any], d.count > 3 { taskJson = d; break }
}
}
guard let taskJson = taskJson else {
throw APIError.serverError("Invalid task details response")
}
// Debug: log ALL fields to find customer data
if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let debugFile = docs.appendingPathComponent("task_debug.txt")
var lines: [String] = ["=== ALL KEYS ==="]
for key in taskJson.keys.sorted() {
let val = taskJson[key]
if let str = val as? String {
lines.append("\(key): \(str)")
} else if let num = val as? Int {
lines.append("\(key): \(num)")
} else if val is [[String: Any]] {
lines.append("\(key): [array]")
} else {
lines.append("\(key): \(val ?? "nil")")
}
}
try? lines.joined(separator: "\n").write(to: debugFile, atomically: true, encoding: .utf8)
}
let details = TaskDetails(json: taskJson)
return details
}
// MARK: - Chat
func getChatMessages(taskId: Int, afterMessageId: Int? = nil) async throws -> ChatMessagesResult {
var payload: [String: Any] = ["TaskID": taskId]
if let after = afterMessageId, after > 0 {
payload["AfterMessageID"] = after
}
let json = try await postJSON("/chat/getMessages.php", payload: payload)
guard ok(json) else {
throw APIError.serverError("Failed to load chat messages")
}
let arr = Self.findArray(json, ["MESSAGES", "Messages", "messages"]) ?? []
let messages: [ChatMessage] = arr.map { ChatMessage(json: $0) }
let chatClosed = (json["CHAT_CLOSED"] as? Bool) == true || (json["chat_closed"] as? Bool) == true
return ChatMessagesResult(messages: messages, chatClosed: chatClosed)
}
func sendChatMessage(taskId: Int, message: String, userId: Int? = nil, senderType: String? = nil) async throws -> Int {
var payload: [String: Any] = [
"TaskID": taskId,
"Message": message
]
if let uid = userId { payload["UserID"] = uid }
if let st = senderType { payload["SenderType"] = st }
let json = try await postJSON("/chat/sendMessage.php", payload: payload)
guard ok(json) else {
throw APIError.serverError("Failed to send message")
}
return (json["MessageID"] as? Int) ?? (json["MESSAGE_ID"] as? Int) ?? 0
}
func markChatMessagesRead(taskId: Int, readerType: String) async throws {
let json = try await postJSON("/chat/markRead.php", payload: [
"TaskID": taskId,
"ReaderType": readerType
])
guard ok(json) else {
throw APIError.serverError("Failed to mark messages as read")
}
}
// MARK: - Payout / Tier Endpoints
func getTierStatus() async throws -> TierStatus {
let json = try await postJSON("/workers/tierStatus.php", payload: [
"UserID": userId ?? 0
])
guard ok(json) else {
throw APIError.serverError("Failed to load tier status: \(err(json))")
}
let data = try JSONSerialization.data(withJSONObject: json)
return (try? JSONDecoder().decode(TierStatus.self, from: data)) ?? TierStatus()
}
func createStripeAccount() async throws -> String {
let json = try await postJSON("/workers/createAccount.php", payload: [
"UserID": userId ?? 0
])
guard ok(json) else {
throw APIError.serverError("Failed to create Stripe account: \(err(json))")
}
return (json["AccountID"] as? String) ?? (json["ACCOUNT_ID"] as? String) ?? ""
}
func getOnboardingLink() async throws -> String {
let json = try await postJSON("/workers/onboardingLink.php", payload: [
"UserID": userId ?? 0
])
guard ok(json) else {
throw APIError.serverError("Failed to get onboarding link: \(err(json))")
}
return (json["URL"] as? String) ?? (json["url"] as? String) ?? ""
}
func getEarlyUnlockUrl() async throws -> String {
let json = try await postJSON("/workers/earlyUnlock.php", payload: [
"UserID": userId ?? 0
])
guard ok(json) else {
throw APIError.serverError("Failed to get unlock URL: \(err(json))")
}
return (json["URL"] as? String) ?? (json["url"] as? String) ?? ""
}
func getLedger() async throws -> LedgerResponse {
let json = try await postJSON("/workers/ledger.php", payload: [
"UserID": userId ?? 0
])
guard ok(json) else {
throw APIError.serverError("Failed to load ledger: \(err(json))")
}
let data = try JSONSerialization.data(withJSONObject: json)
return try JSONDecoder().decode(LedgerResponse.self, from: data)
}
// MARK: - Avatar
func getAvatarUrl() async throws -> String? {
let json = try await getJSON("/auth/avatar.php")
print("[Avatar] Response: \(json)")
guard ok(json) else {
print("[Avatar] Response not OK")
return nil
}
let data = json["DATA"] as? [String: Any] ?? json
print("[Avatar] Data: \(data)")
// Try all possible key variations for avatar URL
let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url",
"PhotoUrl", "PHOTOURL", "photoUrl", "photo_url", "PHOTO_URL",
"UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"]
for key in keys {
if let url = data[key] as? String, !url.isEmpty {
let resolved = Self.resolvePhotoUrl(url)
print("[Avatar] Found key '\(key)' with value: \(url) -> \(resolved)")
return resolved
}
if let url = json[key] as? String, !url.isEmpty {
let resolved = Self.resolvePhotoUrl(url)
print("[Avatar] Found key '\(key)' in json with value: \(url) -> \(resolved)")
return resolved
}
}
print("[Avatar] No avatar URL found in response")
return nil
}
/// Get avatar URL for any user by their userId
func getUserAvatarUrl(userId: Int) async throws -> String? {
// Try the avatar endpoint with UserID
let json = try await postJSON("/auth/avatar.php", payload: ["UserID": userId])
print("[Avatar] getUserAvatarUrl(\(userId)) Response: \(json)")
let data = json["DATA"] as? [String: Any] ?? json
let keys = ["AVATAR_URL", "AVATARURL", "AvatarUrl", "avatarUrl", "avatar_url",
"PhotoUrl", "PHOTOURL", "photoUrl", "photo_url", "PHOTO_URL",
"UserPhotoUrl", "USERPHOTOURL", "userPhotoUrl"]
for key in keys {
if let url = data[key] as? String, !url.isEmpty {
print("[Avatar] Found avatar for userId \(userId): \(url)")
return Self.resolvePhotoUrl(url)
}
if let url = json[key] as? String, !url.isEmpty {
print("[Avatar] Found avatar for userId \(userId): \(url)")
return Self.resolvePhotoUrl(url)
}
}
print("[Avatar] No avatar found for userId \(userId), all keys: \(json.keys.sorted())")
return nil
}
/// Construct direct avatar URL for a user (fallback pattern)
nonisolated static func directAvatarUrl(userId: Int) -> String {
let baseDomain = IS_DEV ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
return "\(baseDomain)/uploads/avatars/\(userId).jpg"
}
// MARK: - Beacon Sharding
func resolveServicePoint(uuid: String, major: Int, minor: Int) async throws -> Int {
let json = try await postJSON("/beacon-sharding/resolve_servicepoint.php", payload: [
"UUID": uuid,
"Major": major,
"Minor": minor
])
guard ok(json) else {
throw APIError.serverError("Failed to resolve service point: \(err(json))")
}
let data = json["DATA"] as? [String: Any] ?? json
if let spId = data["ServicePointID"] as? Int { return spId }
if let spId = data["SERVICEPOINTID"] as? Int { return spId }
if let spId = data["servicePointId"] as? Int { return spId }
if let spId = json["ServicePointID"] as? Int { return spId }
if let spId = json["SERVICEPOINTID"] as? Int { return spId }
throw APIError.decodingError("ServicePointID not found in response")
}
func resolveBusiness(uuid: String, major: Int) async throws -> (businessId: Int, businessName: String) {
let json = try await postJSON("/beacon-sharding/resolve_business.php", payload: [
"UUID": uuid,
"Major": major
])
guard ok(json) else {
throw APIError.serverError("Failed to resolve business: \(err(json))")
}
let data = json["DATA"] as? [String: Any] ?? json
let bizId = (data["BusinessID"] as? Int) ?? (data["BUSINESSID"] as? Int) ?? (json["BusinessID"] as? Int) ?? 0
let bizName = (data["BusinessName"] as? String) ?? (data["BUSINESSNAME"] as? String) ?? (json["BusinessName"] as? String) ?? ""
return (businessId: bizId, businessName: bizName)
}
// MARK: - Debug
/// Returns raw JSON string for a given endpoint (for debugging key issues)
func debugRawJSON(_ path: String, payload: [String: Any]) async -> String {
let urlString = baseURL + (path.hasPrefix("/") ? path : "/\(path)")
guard let url = URL(string: urlString) else { return "Invalid URL" }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
if let token = userToken, !token.isEmpty {
request.setValue(token, forHTTPHeaderField: "X-User-Token")
}
if businessId > 0 {
request.setValue(String(businessId), forHTTPHeaderField: "X-Business-ID")
}
request.httpBody = try? JSONSerialization.data(withJSONObject: payload)
guard let (data, _) = try? await URLSession.shared.data(for: request) else {
return "Network error"
}
let raw = String(data: data, encoding: .utf8) ?? "Non-UTF8"
// Return first 2000 chars
return String(raw.prefix(2000))
}
// MARK: - About Info
func getAboutInfo() async throws -> AboutInfo {
let json = try await getJSON("app/about.php")
guard ok(json) else {
throw APIError.serverError(err(json))
}
let description = parseString(json["DESCRIPTION"] ?? json["description"])
let copyright = parseString(json["COPYRIGHT"] ?? json["copyright"])
// Parse features
var features: [AboutFeature] = []
if let featuresArray = (json["FEATURES"] ?? json["features"]) as? [[String: Any]] {
for f in featuresArray {
features.append(AboutFeature(
icon: parseString(f["ICON"] ?? f["icon"]),
title: parseString(f["TITLE"] ?? f["title"]),
description: parseString(f["DESCRIPTION"] ?? f["description"])
))
}
}
// Parse contacts
var contacts: [AboutContact] = []
if let contactsArray = (json["CONTACTS"] ?? json["contacts"]) as? [[String: Any]] {
for c in contactsArray {
contacts.append(AboutContact(
icon: parseString(c["ICON"] ?? c["icon"]),
label: parseString(c["LABEL"] ?? c["label"]),
url: parseString(c["URL"] ?? c["url"])
))
}
}
return AboutInfo(
description: description,
features: features,
contacts: contacts,
copyright: copyright
)
}
// MARK: - URL Helpers
/// Resolve a photo URL if it starts with "/" prepend the base domain
nonisolated static func resolvePhotoUrl(_ rawUrl: String) -> String {
let trimmed = rawUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return "" }
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") { return trimmed }
let baseDomain = IS_DEV ? "https://dev.payfrit.com" : "https://biz.payfrit.com"
if trimmed.hasPrefix("/") { return baseDomain + trimmed }
return baseDomain + "/" + trimmed
}
// MARK: - Date Parsing
private static let iso8601Formatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let iso8601NoFrac: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
private static let simpleDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
private static let cfmlDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMMM, dd yyyy HH:mm:ss Z"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
private static let cfmlShortFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
private static let cfmlAltFormatters: [DateFormatter] = {
let formats = [
"MMM dd, yyyy HH:mm:ss",
"MM/dd/yyyy HH:mm:ss",
"yyyy-MM-dd HH:mm:ss.S",
"yyyy-MM-dd'T'HH:mm:ss.S",
"yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd'T'HH:mm:ss.SZ",
]
return formats.map { fmt in
let f = DateFormatter()
f.dateFormat = fmt
f.locale = Locale(identifier: "en_US_POSIX")
return f
}
}()
nonisolated static func parseDate(_ string: String) -> Date? {
let s = string.trimmingCharacters(in: .whitespacesAndNewlines)
if s.isEmpty { return nil }
// Try epoch (milliseconds or seconds)
if let epoch = Double(s) {
if epoch > 1_000_000_000_000 { return Date(timeIntervalSince1970: epoch / 1000) }
if epoch > 1_000_000_000 { return Date(timeIntervalSince1970: epoch) }
}
if let d = iso8601Formatter.date(from: s) { return d }
if let d = iso8601NoFrac.date(from: s) { return d }
if let d = simpleDateFormatter.date(from: s) { return d }
if let d = cfmlDateFormatter.date(from: s) { return d }
if let d = cfmlShortFormatter.date(from: s) { return d }
for formatter in cfmlAltFormatters {
if let d = formatter.date(from: s) { return d }
}
return nil
}
}
// MARK: - String Parsing Helper
/// Parse any value to a string safely
private func parseString(_ value: Any?) -> String {
guard let v = value else { return "" }
if let s = v as? String { return s }
if let n = v as? NSNumber { return n.stringValue }
if let i = v as? Int { return String(i) }
if let d = v as? Double { return String(d) }
return String(describing: v)
}
// MARK: - About Info Models
struct AboutInfo {
let description: String
let features: [AboutFeature]
let contacts: [AboutContact]
let copyright: String
}
struct AboutFeature {
let icon: String
let title: String
let description: String
}
struct AboutContact {
let icon: String
let label: String
let url: String
}