// ── Survey Client ──────────────────────────────────────── // In-app survey client for iOS/watchOS/macOS. // Part of ByteLystPlatformSDK. import Foundation /// Survey question types. public enum BLSurveyQuestionType: String, Codable, Sendable { case singleChoice = "single_choice" case multipleChoice = "multiple_choice" case rating = "rating" case nps = "nps" case textShort = "text_short" case textLong = "text_long" case dropdown = "dropdown" case scale = "scale" case ranking = "ranking" } /// Survey status. public enum BLSurveyStatus: String, Codable, Sendable { case draft = "draft" case active = "active" case paused = "paused" case closed = "closed" } /// Represents a survey question option. public struct BLSurveyOption: Codable, Sendable, Identifiable { public let id: String public let text: String public let emoji: String? } /// Represents a survey question. public struct BLSurveyQuestion: Codable, Sendable, Identifiable { public let id: String public let type: BLSurveyQuestionType public let text: String public let description: String? public let required: Bool public let options: [BLSurveyOption]? public let minLength: Int? public let maxLength: Int? public let minValue: Int? public let maxValue: Int? } /// Represents an active survey for display. public struct BLActiveSurvey: Codable, Sendable, Identifiable { public let id: String public let title: String public let description: String? public let questions: [BLSurveyQuestion] public let incentive: BLSurveyIncentive? public let displayTrigger: BLSurveyTrigger } /// Survey incentive. public struct BLSurveyIncentive: Codable, Sendable { public let type: String public let amount: Int } /// Survey display trigger. public struct BLSurveyTrigger: Codable, Sendable { public let type: String public let seconds: Int? public let eventName: String? public let pagePattern: String? } /// Survey answer types. public enum BLSurveyAnswer: Codable, Sendable { case singleChoice(optionId: String) case multipleChoice(optionIds: [String]) case rating(value: Int) case nps(value: Int) case text(value: String) case ranking(rankedOptionIds: [String]) public var type: String { switch self { case .singleChoice: return "single_choice" case .multipleChoice: return "multiple_choice" case .rating: return "rating" case .nps: return "nps" case .text: return "text" case .ranking: return "ranking" } } } /// Represents a survey response. public struct BLSurveyResponse: Codable, Sendable { public let id: String public let surveyId: String public let userId: String public var answers: [String: BLSurveyAnswer] public var currentQuestionIndex: Int public let startedAt: String public var completedAt: String? public var isComplete: Bool public var incentiveClaimed: Bool public var incentiveClaimedAt: String? public let createdAt: String public let updatedAt: String } /// Survey client for managing in-app surveys. @available(iOS 15.0, macOS 12.0, watchOS 8.0, *) public class BLSurveyClient: ObservableObject { private let platformClient: BLPlatformClient private var pollTask: Task? private var cachedResponses: [String: BLSurveyResponse] = [:] public init(platformClient: BLPlatformClient) { self.platformClient = platformClient } /// Get active survey for the current user (if any). public func getActiveSurvey() async throws -> BLActiveSurvey? { let request = try platformClient.buildRequest(path: "/surveys/active") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw BLPlatformError.requestFailed(String(data: data, encoding: .utf8) ?? "Unknown error") } let result = try JSONDecoder().decode(ActiveSurveyResponse.self, from: data) return result.survey } /// Start a survey session. public func startSurvey(surveyId: String) async throws -> BLSurveyResponse { let request = try platformClient.buildRequest( path: "/surveys/\(surveyId)/start", method: "POST" ) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw BLPlatformError.requestFailed("Failed to start survey") } let result = try JSONDecoder().decode(StartSurveyResponse.self, from: data) // Build and cache the response let surveyResponse = BLSurveyResponse( id: result.responseId, surveyId: surveyId, userId: "", // Will be filled by server answers: result.answers, currentQuestionIndex: result.currentQuestionIndex, startedAt: result.startedAt, completedAt: nil, isComplete: false, incentiveClaimed: false, incentiveClaimedAt: nil, createdAt: result.startedAt, updatedAt: result.startedAt ) cachedResponses[surveyId] = surveyResponse return surveyResponse } /// Submit an answer to a survey question. public func submitAnswer( surveyId: String, questionId: String, answer: BLSurveyAnswer ) async throws -> BLSurveyResponse { let request = try platformClient.buildRequest( path: "/surveys/\(surveyId)/response", method: "POST", body: [ "questionId": questionId, "answer": [ "type": answer.type, "value": encodeAnswerValue(answer) ] ] ) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw BLPlatformError.requestFailed("Failed to submit answer") } let result = try JSONDecoder().decode(SubmitAnswerResponse.self, from: data) // Update cache if var cached = cachedResponses[surveyId] { cached.answers = result.answers cached.currentQuestionIndex = result.currentQuestionIndex cachedResponses[surveyId] = cached } // Return updated response return BLSurveyResponse( id: result.responseId, surveyId: surveyId, userId: "", answers: result.answers, currentQuestionIndex: result.currentQuestionIndex, startedAt: "", completedAt: nil, isComplete: false, incentiveClaimed: false, incentiveClaimedAt: nil, createdAt: "", updatedAt: Date().ISO8601Format() ) } /// Complete a survey. public func completeSurvey(surveyId: String) async throws -> SurveyCompletionResult { let request = try platformClient.buildRequest( path: "/surveys/\(surveyId)/complete", method: "POST" ) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw BLPlatformError.requestFailed("Failed to complete survey") } let result = try JSONDecoder().decode(SurveyCompletionResult.self, from: data) // Clear cache on completion cachedResponses.removeValue(forKey: surveyId) return result } /// Dismiss a survey (won't show again). public func dismissSurvey(surveyId: String) async throws { let request = try platformClient.buildRequest( path: "/surveys/\(surveyId)/dismiss", method: "POST" ) let (_, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw BLPlatformError.requestFailed("Failed to dismiss survey") } // Clear cache cachedResponses.removeValue(forKey: surveyId) } /// Get cached response for a survey. public func getCachedResponse(surveyId: String) -> BLSurveyResponse? { return cachedResponses[surveyId] } /// Start polling for eligible surveys. public func startPolling( interval: TimeInterval = 60, onUpdate: @escaping (BLActiveSurvey?) -> Void ) { stopPolling() pollTask = Task { while !Task.isCancelled { do { let survey = try await getActiveSurvey() onUpdate(survey) } catch { // Silently ignore polling errors } try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) } } } /// Stop polling for surveys. public func stopPolling() { pollTask?.cancel() pollTask = nil } } // MARK: - Survey Completion Result public struct SurveyCompletionResult: Codable, Sendable { public let success: Bool public let timeSpentSeconds: Int public let incentiveClaimed: Bool } // MARK: - Response Types private struct ActiveSurveyResponse: Codable { let survey: BLActiveSurvey? } private struct StartSurveyResponse: Codable { let responseId: String let startedAt: String let currentQuestionIndex: Int let answers: [String: BLSurveyAnswer] } private struct SubmitAnswerResponse: Codable { let responseId: String let currentQuestionIndex: Int let answers: [String: BLSurveyAnswer] } // MARK: - Helper Functions private func encodeAnswerValue(_ answer: BLSurveyAnswer) -> AnyCodable { switch answer { case .singleChoice(let optionId): return AnyCodable(optionId) case .multipleChoice(let optionIds): return AnyCodable(optionIds) case .rating(let value), .nps(let value): return AnyCodable(value) case .text(let value): return AnyCodable(value) case .ranking(let rankedOptionIds): return AnyCodable(rankedOptionIds) } } /// Type-erased codable wrapper for encoding heterogeneous types. private struct AnyCodable: Codable { private let value: Any init(_ value: Any) { self.value = value } func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() if let string = value as? String { try container.encode(string) } else if let int = value as? Int { try container.encode(int) } else if let array = value as? [String] { try container.encode(array) } else { try container.encode(String(describing: value)) } } init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let string = try? container.decode(String.self) { value = string } else if let int = try? container.decode(Int.self) { value = int } else if let array = try? container.decode([String].self) { value = array } else { value = "" } } }