import Foundation class Api { static let shared = Api() // ── DEV toggle: flip to false for production ── static let IS_DEV = true private static var BASE_URL: String { IS_DEV ? "https://dev.payfrit.com/api" : "https://biz.payfrit.com/api" } private let session: URLSession private var authToken: String? private init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 config.timeoutIntervalForResource = 30 session = URLSession(configuration: config) } func setAuthToken(_ token: String?) { authToken = token } func getAuthToken() -> String? { return authToken } private func buildRequest(endpoint: String) -> URLRequest { let url = URL(string: "\(Api.BASE_URL)\(endpoint)")! var request = URLRequest(url: url) if let token = authToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue(token, forHTTPHeaderField: "X-User-Token") } return request } private func postRequest(endpoint: String, body: [String: Any], extraHeaders: [String: String] = [:]) async throws -> [String: Any] { var request = buildRequest(endpoint: endpoint) request.httpMethod = "POST" request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) for (key, value) in extraHeaders { request.setValue(value, forHTTPHeaderField: key) } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw ApiException("Invalid response") } guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { throw ApiException("Request failed: \(httpResponse.statusCode)") } guard !data.isEmpty else { throw ApiException("Empty response") } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw ApiException("Invalid JSON response") } return json } private func getRequest(endpoint: String) async throws -> [String: Any] { var request = buildRequest(endpoint: endpoint) request.httpMethod = "GET" let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw ApiException("Invalid response") } guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { throw ApiException("Request failed: \(httpResponse.statusCode)") } guard !data.isEmpty else { throw ApiException("Empty response") } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw ApiException("Invalid JSON response") } return json } // ========================================================================= // AUTH // ========================================================================= func sendLoginOtp(phone: String) async throws -> OtpResponse { let json = try await postRequest(endpoint: "/auth/loginOTP.cfm", body: ["Phone": phone]) guard let uuid = (json["UUID"] as? String) ?? (json["uuid"] as? String), !uuid.isEmpty else { throw ApiException("Server error - please try again") } return OtpResponse(uuid: uuid) } func verifyLoginOtp(uuid: String, otp: String) async throws -> LoginResponse { let json = try await postRequest(endpoint: "/auth/verifyLoginOTP.cfm", body: [ "UUID": uuid, "OTP": otp ]) let ok = parseBool(json["OK"] ?? json["ok"]) if !ok { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Invalid code" throw ApiException(error) } return LoginResponse( userId: parseIntValue(json["UserID"] ?? json["USERID"]) ?? 0, token: ((json["Token"] ?? json["TOKEN"] ?? json["token"]) as? String) ?? "", userFirstName: ((json["UserFirstName"] ?? json["USERFIRSTNAME"]) as? String) ?? "" ) } // ========================================================================= // BUSINESSES // ========================================================================= func listBusinesses() async throws -> [Business] { let json = try await postRequest(endpoint: "/businesses/list.cfm", body: [:]) guard let businesses = (json["BUSINESSES"] ?? json["businesses"] ?? json["Items"] ?? json["ITEMS"]) as? [[String: Any]] else { return [] } return businesses.compactMap { b in guard let businessId = parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) else { return nil } let name = ((b["BusinessName"] ?? b["BUSINESSNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? "" let headerImageExtension = (b["HeaderImageExtension"] ?? b["HEADERIMAGEEXTENSION"]) as? String return Business(businessId: businessId, name: name, headerImageExtension: headerImageExtension) } } // ========================================================================= // BEACONS // ========================================================================= func listAllBeacons() async throws -> [String: Int] { let json = try await getRequest(endpoint: "/beacons/list_all.cfm") guard let items = (json["ITEMS"] ?? json["items"]) as? [[String: Any]] else { return [:] } var result: [String: Int] = [:] for item in items { guard let uuid = ((item["UUID"] ?? item["uuid"] ?? item["BeaconUUID"] ?? item["BEACONUUID"]) as? String)? .replacingOccurrences(of: "-", with: "").uppercased(), let beaconId = parseIntValue(item["BeaconID"] ?? item["BEACONID"]) else { continue } result[uuid] = beaconId } return result } func lookupBeacons(uuids: [String]) async throws -> [BeaconLookupResult] { if uuids.isEmpty { return [] } let json = try await postRequest(endpoint: "/beacons/lookup.cfm", body: ["UUIDs": uuids]) guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else { return [] } return beacons.compactMap { b in guard let uuid = ((b["UUID"] ?? b["uuid"]) as? String)? .replacingOccurrences(of: "-", with: "").uppercased() else { return nil } return BeaconLookupResult( uuid: uuid, beaconId: parseIntValue(b["BeaconID"] ?? b["BEACONID"]) ?? 0, businessId: parseIntValue(b["BusinessID"] ?? b["BUSINESSID"]) ?? 0, businessName: ((b["BusinessName"] ?? b["BUSINESSNAME"]) as? String) ?? "", servicePointName: ((b["ServicePointName"] ?? b["SERVICEPOINTNAME"]) as? String) ?? "", beaconName: ((b["BeaconName"] ?? b["BEACONNAME"] ?? b["Name"] ?? b["NAME"]) as? String) ?? "" ) } } func listBeacons(businessId: Int) async throws -> [BeaconInfo] { let json = try await postRequest( endpoint: "/beacons/list.cfm", body: ["BusinessID": businessId], extraHeaders: ["X-Business-Id": String(businessId)] ) guard let beacons = (json["BEACONS"] ?? json["beacons"]) as? [[String: Any]] else { return [] } return beacons.compactMap { b in let beaconId = parseIntValue(b["BeaconID"] ?? b["BEACONID"] ?? b["ID"]) ?? 0 let name = ((b["Name"] ?? b["NAME"] ?? b["BeaconName"] ?? b["BEACONNAME"]) as? String) ?? "" let uuid = ((b["UUID"] ?? b["uuid"]) as? String)? .replacingOccurrences(of: "-", with: "").uppercased() ?? "" let isActive = parseBool(b["IsActive"] ?? b["ISACTIVE"] ?? true) return BeaconInfo(beaconId: beaconId, name: name, uuid: uuid, isActive: isActive) } } func saveBeacon(businessId: Int, name: String, uuid: String, macAddress: String? = nil) async throws -> SavedBeacon { var params: [String: Any] = [ "BusinessID": businessId, "Name": name, "UUID": uuid ] if let mac = macAddress, !mac.isEmpty { params["MACAddress"] = mac } let json = try await postRequest( endpoint: "/beacons/save.cfm", body: params, extraHeaders: ["X-Business-Id": String(businessId)] ) if !parseBool(json["OK"] ?? json["ok"]) { throw ApiException(((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to save beacon") } let beacon = (json["BEACON"] ?? json["beacon"]) as? [String: Any] return SavedBeacon( beaconId: parseIntValue(beacon?["BeaconID"] ?? beacon?["BEACONID"] ?? beacon?["ID"]) ?? 0, name: (beacon?["Name"] ?? beacon?["NAME"]) as? String ?? name, uuid: uuid, macAddress: (beacon?["MACAddress"] ?? beacon?["MACADDRESS"]) as? String ) } func lookupByMac(macAddress: String) async throws -> MacLookupResult? { let json = try await postRequest(endpoint: "/beacons/lookupByMac.cfm", body: ["MACAddress": macAddress]) if !parseBool(json["OK"] ?? json["ok"]) { return nil } guard let beacon = (json["BEACON"] ?? json["beacon"]) as? [String: Any] else { return nil } return MacLookupResult( beaconId: parseIntValue(beacon["BeaconID"] ?? beacon["BEACONID"]) ?? 0, businessId: parseIntValue(beacon["BusinessID"] ?? beacon["BUSINESSID"]) ?? 0, businessName: ((beacon["BusinessName"] ?? beacon["BUSINESSNAME"]) as? String) ?? "", beaconName: ((beacon["BeaconName"] ?? beacon["BEACONNAME"]) as? String) ?? "", uuid: ((beacon["UUID"] ?? beacon["uuid"]) as? String) ?? "", macAddress: ((beacon["MACAddress"] ?? beacon["MACADDRESS"]) as? String) ?? "", servicePointName: ((beacon["ServicePointName"] ?? beacon["SERVICEPOINTNAME"]) as? String) ?? "" ) } func wipeBeacon(businessId: Int, beaconId: Int) async throws -> Bool { let json = try await postRequest( endpoint: "/beacons/wipe.cfm", body: ["BusinessID": businessId, "BeaconID": beaconId], extraHeaders: ["X-Business-Id": String(businessId)] ) if !parseBool(json["OK"] ?? json["ok"]) { let error = ((json["ERROR"] ?? json["error"]) as? String) ?? "Failed to wipe beacon" let message = (json["MESSAGE"] ?? json["message"]) as? String throw ApiException(message ?? error) } return true } // ========================================================================= // HELPERS // ========================================================================= private func parseBool(_ value: Any?) -> Bool { switch value { case nil: return false case let b as Bool: return b case let n as NSNumber: return n.intValue != 0 case let s as String: return ["true", "1"].contains(s.lowercased()) default: return false } } private func parseIntValue(_ value: Any?) -> Int? { if let n = value as? NSNumber { return n.intValue } if let s = value as? String, let i = Int(s) { return i } return nil } } struct ApiException: LocalizedError { let message: String init(_ message: String) { self.message = message } var errorDescription: String? { message } } // ========================================================================= // DATA MODELS // ========================================================================= struct OtpResponse { let uuid: String } struct LoginResponse { let userId: Int let token: String let userFirstName: String } struct Business: Identifiable { var id: Int { businessId } let businessId: Int let name: String let headerImageExtension: String? } struct BeaconLookupResult { let uuid: String let beaconId: Int let businessId: Int let businessName: String let servicePointName: String let beaconName: String } struct BeaconInfo { let beaconId: Int let name: String let uuid: String let isActive: Bool } struct SavedBeacon { let beaconId: Int let name: String let uuid: String let macAddress: String? } struct MacLookupResult { let beaconId: Int let businessId: Int let businessName: String let beaconName: String let uuid: String let macAddress: String let servicePointName: String }