diff --git a/packages/swift-platform-sdk/Sources/BLBroadcastClient.swift b/packages/swift-platform-sdk/Sources/BLBroadcastClient.swift new file mode 100644 index 00000000..22899536 --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLBroadcastClient.swift @@ -0,0 +1,152 @@ +// ── Broadcast Client ───────────────────────────────────── +// In-app broadcast message client for iOS/watchOS/macOS. +// Part of ByteLystPlatformSDK. + +import Foundation + +/// In-app message priority levels. +public enum BLBroadcastPriority: String, Codable, Sendable { + case low = "low" + case normal = "normal" + case high = "high" + case urgent = "urgent" +} + +/// In-app message display styles. +public enum BLBroadcastStyle: String, Codable, Sendable { + case banner = "banner" + case modal = "modal" + case toast = "toast" + case fullscreen = "fullscreen" +} + +/// In-app message status. +public enum BLBroadcastStatus: String, Codable, Sendable { + case unread = "unread" + case read = "read" + case dismissed = "dismissed" +} + +/// Represents an in-app broadcast message. +public struct BLInAppMessage: Codable, Sendable, Identifiable { + public let id: String + public let userId: String + public let productId: String + public let broadcastId: String + public let title: String + public let body: String + public let bodyMarkdown: String? + public let ctaText: String? + public let ctaUrl: String? + public let priority: BLBroadcastPriority + public let style: BLBroadcastStyle + public let dismissible: Bool + public let expiresAt: String? + public let status: BLBroadcastStatus + public let createdAt: String + public let updatedAt: String +} + +/// Broadcast client for fetching and managing in-app messages. +public actor BLBroadcastClient { + private let platformClient: BLPlatformClient + private var pollTask: Task? + + public init(platformClient: BLPlatformClient) { + self.platformClient = platformClient + } + + /// List active in-app messages for the current user. + public func listMessages() async throws -> [BLInAppMessage] { + let request = try platformClient.buildRequest(path: "/broadcasts") + 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(MessagesResponse.self, from: data) + return result.messages + } + + /// Mark a message as read. + public func markRead(messageId: String) async throws { + let request = try platformClient.buildRequest( + path: "/broadcasts/\(messageId)/read", + 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 mark message as read") + } + } + + /// Mark a message as dismissed. + public func markDismissed(messageId: String) async throws { + let request = try platformClient.buildRequest( + path: "/broadcasts/\(messageId)/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 message") + } + } + + /// Track a CTA click and get the redirect URL. + public func trackClick(messageId: String) async throws -> String? { + let request = try platformClient.buildRequest( + path: "/broadcasts/\(messageId)/click", + 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 track click") + } + + let result = try JSONDecoder().decode(ClickResponse.self, from: data) + return result.redirectUrl + } + + /// Start polling for new messages. + public func startPolling(interval: TimeInterval = 60, onUpdate: @escaping ([BLInAppMessage]) -> Void) { + stopPolling() + + pollTask = Task { + while !Task.isCancelled { + do { + let messages = try await listMessages() + onUpdate(messages) + } catch { + // Silently ignore polling errors + } + + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } + } + + /// Stop polling for messages. + public func stopPolling() { + pollTask?.cancel() + pollTask = nil + } +} + +// MARK: - Response Types + +private struct MessagesResponse: Codable { + let messages: [BLInAppMessage] +} + +private struct ClickResponse: Codable { + let success: Bool + let redirectUrl: String? +} diff --git a/packages/swift-platform-sdk/Sources/BLSurveyClient.swift b/packages/swift-platform-sdk/Sources/BLSurveyClient.swift new file mode 100644 index 00000000..89952687 --- /dev/null +++ b/packages/swift-platform-sdk/Sources/BLSurveyClient.swift @@ -0,0 +1,368 @@ +// ── 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. +public actor BLSurveyClient { + 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 = "" + } + } +}