Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 6.10.0

- Add Adact campaign support to `GamifyWidgetSDK`: `initialize(widgetUrl:adactUrl:)`, `openAdactCampaign(from:params:)`, `closeAdactCampaign()`, `closeWidget()`, and `buildAdactCampaignUrl(params:)`. Opens `{adactUrl}/embedded/{campaignId}` with optional `cid` and `customerIdToken` query params (same contract as the Web / Android SDKs). Adact does not use the loyalty READY→INIT handshake.

## 6.9.0

- Add `OptimoveConfigBuilder.enableOverlayMessaging(sessionLengthMinutes:)` overload to configure the overlay messaging session window in minutes (minimum 15). The existing `enableOverlayMessaging(sessionLengthHours:)` overload is unchanged.
Expand Down
2 changes: 1 addition & 1 deletion OptimoveCore.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'OptimoveCore'
s.version = '6.9.0'
s.version = '6.10.0'
s.summary = 'Official Optimove SDK for iOS. Core framework.'
s.description = 'The core framework is used to share code-base between other Optimove frameworks.'
s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS'
Expand Down
2 changes: 1 addition & 1 deletion OptimoveCore/Sources/Classes/Constants/SDKVersion.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Copyright © 2019 Optimove. All rights reserved.

public let SDKVersion = "6.9.0"
public let SDKVersion = "6.10.0"
2 changes: 1 addition & 1 deletion OptimoveNotificationServiceExtension.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'OptimoveNotificationServiceExtension'
s.version = '6.9.0'
s.version = '6.10.0'
s.summary = 'Official Optimove SDK for iOS. Notification service extension framework.'
s.description = 'The notification service extension is used for handling additional content in push notifications.'
s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS'
Expand Down
2 changes: 1 addition & 1 deletion OptimoveSDK.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'OptimoveSDK'
s.version = '6.9.0'
s.version = '6.10.0'
s.summary = 'Official Optimove SDK for iOS.'
s.description = 'The Optimove SDK framework is used for reporting events and receive push notifications.'
s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS'
Expand Down
160 changes: 146 additions & 14 deletions OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,48 @@

import UIKit

/// Entry point for the Gamify Widget SDK.
/// Entry point for the Gamify Widget SDK (loyalty + Adact).
///
/// Usage:
/// Usage (loyalty):
/// GamifyWidgetSDK.initialize(widgetUrl: "https://your-widget.example.com")
/// GamifyWidgetSDK.open(from: viewController, userId: "u123")
///
/// Usage (Adact — `adactUrl` from onboarding / retrieval service):
/// GamifyWidgetSDK.initialize(widgetUrl: widgetUrl, adactUrl: "https://adact-campaign.example.com/")
/// GamifyWidgetSDK.openAdactCampaign(from: viewController, params: OpenAdactParams(campaignId: 179, cid: "cid", token: "token"))
public final class GamifyWidgetSDK {

internal static var widgetUrl: String = ""
internal static var adactUrl: String?

private weak static var loyaltyViewController: GamifyWidgetViewController?
private weak static var adactViewController: GamifyWidgetViewController?

private init() {}

/// Configure the widget URL before opening.
public static func initialize(widgetUrl: String) {
ensureMain { initialize_onMain(widgetUrl: widgetUrl) }
/// Configure the loyalty widget URL (and optional Adact host) before opening.
///
/// - Parameters:
/// - widgetUrl: Loyalty widget base URL (may be empty if only Adact is used).
/// - adactUrl: Adact campaign host from config (trailing slash optional);
/// region-correct URL is supplied by onboarding / retrieval service.
public static func initialize(widgetUrl: String, adactUrl: String? = nil) {
ensureMain { initialize_onMain(widgetUrl: widgetUrl, adactUrl: adactUrl) }
}

private static func initialize_onMain(widgetUrl: String) {
private static func initialize_onMain(widgetUrl: String, adactUrl: String?) {
assertOnMainThread()
self.widgetUrl = widgetUrl
self.adactUrl = Self.normalizeBaseUrl(adactUrl)
}

/// Present the widget in a modal sheet.
/// Returns the configured Adact host with a trailing slash, or empty when unset.
public static func getAdactUrl() -> String {
guard let adactUrl = adactUrl, !adactUrl.isEmpty else { return "" }
return adactUrl + "/"
}

/// Present the loyalty widget in a modal sheet.
///
/// - Parameters:
/// - viewController: The presenting UIViewController.
Expand All @@ -43,15 +63,119 @@ public final class GamifyWidgetSDK {
token: String? = nil
) {
assertOnMainThread()
guard !widgetUrl.isEmpty, URL(string: widgetUrl) != nil else {
guard !widgetUrl.isEmpty, let url = URL(string: widgetUrl), url.scheme?.lowercased() == "https" else {
Logger.error("GamifyWidgetSDK.open called with an invalid widgetUrl.")
return
}
let vc = GamifyWidgetViewController(
widgetUrl: widgetUrl,
userId: userId,
token: token
)
if loyaltyViewController != nil {
return
}

// Dismiss Adact first (if open), then present — avoids UIKit "presentation in progress" races.
dismissOverlay(adactViewController, clearing: { adactViewController = nil }) {
let vc = GamifyWidgetViewController(
widgetUrl: widgetUrl,
userId: userId,
token: token,
enableInitHandshake: true
)
present(vc, from: viewController)
loyaltyViewController = vc
}
}

/// Opens an Adact embedded campaign overlay.
/// Does not perform the loyalty READY→INIT handshake; identity is passed via URL query params.
public static func openAdactCampaign(
from viewController: UIViewController,
params: OpenAdactParams
) {
ensureMain { openAdactCampaign_onMain(from: viewController, params: params) }
}

private static func openAdactCampaign_onMain(
from viewController: UIViewController,
params: OpenAdactParams
) {
assertOnMainThread()
let campaignUrl = buildAdactCampaignUrl(params: params)
guard !campaignUrl.isEmpty else {
Logger.error("GamifyWidgetSDK.openAdactCampaign called with invalid adactUrl or campaignId.")
return
}
if adactViewController != nil {
return
}

dismissOverlay(loyaltyViewController, clearing: { loyaltyViewController = nil }) {
let vc = GamifyWidgetViewController(
widgetUrl: campaignUrl,
userId: nil,
token: nil,
enableInitHandshake: false
)
present(vc, from: viewController)
adactViewController = vc
}
}

/// Builds `{adactUrl}/embedded/{campaignId}?cid=&customerIdToken=`.
/// Returns empty string when `adactUrl` or `campaignId` is missing, or URL is not HTTPS.
public static func buildAdactCampaignUrl(params: OpenAdactParams) -> String {
guard let adactUrl = adactUrl, !adactUrl.isEmpty, let campaignId = params.campaignId else {
return ""
}

var components = URLComponents(string: "\(adactUrl)/embedded/\(campaignId)")
guard let scheme = components?.scheme?.lowercased(), scheme == "https" else {
return ""
}

var items: [URLQueryItem] = []
if let cid = params.cid, !cid.isEmpty {
items.append(URLQueryItem(name: "cid", value: cid))
}
if let token = params.token, !token.isEmpty {
items.append(URLQueryItem(name: "customerIdToken", value: token))
}
if !items.isEmpty {
components?.queryItems = items
}
return components?.url?.absoluteString ?? ""
}

public static func closeWidget() {
ensureMain { closeWidget_onMain() }
}

private static func closeWidget_onMain() {
assertOnMainThread()
dismissOverlay(loyaltyViewController, clearing: { loyaltyViewController = nil })
}

public static func closeAdactCampaign() {
ensureMain { closeAdactCampaign_onMain() }
}

private static func closeAdactCampaign_onMain() {
assertOnMainThread()
dismissOverlay(adactViewController, clearing: { adactViewController = nil })
}

private static func dismissOverlay(
_ overlay: GamifyWidgetViewController?,
clearing: () -> Void,
completion: (() -> Void)? = nil
) {
guard let overlay = overlay else {
completion?()
return
}
clearing()
overlay.dismiss(animated: true, completion: completion)
}

private static func present(_ vc: GamifyWidgetViewController, from presenter: UIViewController) {
if #available(iOS 15.0, *) {
if let sheet = vc.sheetPresentationController {
sheet.detents = [.large()]
Expand All @@ -60,7 +184,15 @@ public final class GamifyWidgetSDK {
} else {
vc.modalPresentationStyle = .pageSheet
}
viewController.present(vc, animated: true)
presenter.present(vc, animated: true)
}

internal static func normalizeBaseUrl(_ url: String?) -> String? {
guard let url = url, !url.isEmpty else { return nil }
if url.hasSuffix("/") {
return String(url.dropLast())
}
return url
}

private static func ensureMain(_ work: @escaping () -> Void) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ final class GamifyWidgetViewController: UIViewController {
private let widgetUrl: String
private let userId: String?
private let token: String?
private let enableInitHandshake: Bool

private var webView: WKWebView!
private var activityIndicator: UIActivityIndicatorView!

init(widgetUrl: String, userId: String?, token: String?) {
/// - Parameter enableInitHandshake: Loyalty widgets use READY→INIT. Adact campaigns pass
/// identity via URL query params and set this to `false`.
init(widgetUrl: String, userId: String?, token: String?, enableInitHandshake: Bool = true) {
self.widgetUrl = widgetUrl
self.userId = userId
self.token = token
self.enableInitHandshake = enableInitHandshake
super.init(nibName: nil, bundle: nil)
}

Expand Down Expand Up @@ -102,6 +106,18 @@ final class GamifyWidgetViewController: UIViewController {
private func dismissSelf() {
dismiss(animated: true)
}

private func parseMessageBody(_ body: Any) -> [String: Any]? {
if let dict = body as? [String: Any] {
return dict
}
if let string = body as? String,
let data = string.data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return dict
}
return nil
}
}

extension GamifyWidgetViewController: WKScriptMessageHandler {
Expand All @@ -111,10 +127,17 @@ extension GamifyWidgetViewController: WKScriptMessageHandler {
return
}
guard message.name == BridgeMessage.receiveMessage,
let body = message.body as? [String: Any],
let body = parseMessageBody(message.body),
let type = body["type"] as? String else { return }
if type == "READY" {

switch type {
case "READY":
guard enableInitHandshake else { return }
DispatchQueue.main.async { self.sendInit() }
case "CLOSE":
DispatchQueue.main.async { self.dismissSelf() }
default:
break
}
}
}
Expand Down
19 changes: 19 additions & 0 deletions OptimoveSDK/Sources/Classes/GamifyWidget/OpenAdactParams.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright © 2026 Optimove. All rights reserved.

import Foundation

/// Parameters for opening an Adact embedded campaign.
///
/// Maps to the Web / Android SDK `openAdactCampaign` contract:
/// `{adactUrl}/embedded/{campaignId}?cid=&customerIdToken=`
public struct OpenAdactParams {
public var campaignId: Int?
public var cid: String?
public var token: String?

public init(campaignId: Int? = nil, cid: String? = nil, token: String? = nil) {
self.campaignId = campaignId
self.cid = cid
self.token = token
}
}
Loading
Loading