feat(swift-sdk): Phase 3.3 - Broadcast and Survey clients
- BLBroadcastClient.swift: In-app message fetch, read/dismiss, click tracking, polling - BLSurveyClient.swift: Survey fetch, start/submit/complete, offline cache, polling
This commit is contained in:
parent
4def08d74e
commit
b96503dc2d
152
packages/swift-platform-sdk/Sources/BLBroadcastClient.swift
Normal file
152
packages/swift-platform-sdk/Sources/BLBroadcastClient.swift
Normal file
@ -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<Void, Never>?
|
||||||
|
|
||||||
|
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?
|
||||||
|
}
|
||||||
368
packages/swift-platform-sdk/Sources/BLSurveyClient.swift
Normal file
368
packages/swift-platform-sdk/Sources/BLSurveyClient.swift
Normal file
@ -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<Void, Never>?
|
||||||
|
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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user