diff --git a/Sources/FormbricksSDK/Extension/Calendar+DaysBetween.swift b/Sources/FormbricksSDK/Extension/Calendar+DaysBetween.swift index 08cf3575..99e842bc 100644 --- a/Sources/FormbricksSDK/Extension/Calendar+DaysBetween.swift +++ b/Sources/FormbricksSDK/Extension/Calendar+DaysBetween.swift @@ -8,6 +8,6 @@ extension Calendar { let numberOfDays = dateComponents([.day], from: fromDate, to: toDate) guard let day = numberOfDays.day else { return 0 } - return abs(day + 1) + return max(0, day) } } diff --git a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift index 3cd731f9..b92c79ab 100644 --- a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift @@ -13,7 +13,7 @@ final class PresentSurveyManager { private weak var viewController: UIViewController? /// Present the webview - func present(environmentResponse: EnvironmentResponse, id: String) { + func present(environmentResponse: EnvironmentResponse, id: String, completion: ((Bool) -> Void)? = nil) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if let window = UIApplication.safeKeyWindow { @@ -25,7 +25,11 @@ final class PresentSurveyManager { presentationController.detents = [.large()] } self.viewController = vc - window.rootViewController?.present(vc, animated: true, completion: nil) + window.rootViewController?.present(vc, animated: true, completion: { + completion?(true) + }) + } else { + completion?(false) } } } diff --git a/Sources/FormbricksSDK/Manager/SurveyManager.swift b/Sources/FormbricksSDK/Manager/SurveyManager.swift index 468b3148..fa2f0c90 100644 --- a/Sources/FormbricksSDK/Manager/SurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/SurveyManager.swift @@ -82,13 +82,21 @@ final class SurveyManager { let actionClasses = environmentResponse?.data.data.actionClasses ?? [] let codeActionClasses = actionClasses.filter { $0.type == "code" } - let actionClass = codeActionClasses.first { $0.key == action } + guard let actionClass = codeActionClasses.first(where: { $0.key == action }) else { + Formbricks.logger?.error("\(action) action unknown. Please add this action in Formbricks first in order to use it in your code.") + return + } + let firstSurveyWithActionClass = filteredSurveys.first { survey in - return survey.triggers?.contains(where: { $0.actionClass?.name == actionClass?.name }) ?? false + return survey.triggers?.contains(where: { $0.actionClass?.name == actionClass.name }) ?? false } // Display percentage let shouldDisplay = shouldDisplayBasedOnPercentage(firstSurveyWithActionClass?.displayPercentage) + if let survey = firstSurveyWithActionClass, !shouldDisplay { + Formbricks.logger?.info("Skipping survey \(survey.name) due to display percentage restriction.") + return + } let isMultiLangSurvey = firstSurveyWithActionClass?.languages?.count ?? 0 > 1 if isMultiLangSurvey { @@ -103,12 +111,25 @@ final class SurveyManager { } // Display and delay it if needed - if let surveyId = firstSurveyWithActionClass?.id, shouldDisplay { + if let survey = firstSurveyWithActionClass, shouldDisplay { isShowingSurvey = true - let timeout = firstSurveyWithActionClass?.delay ?? 0 + let timeout = survey.delay ?? 0 + if timeout > 0 { + Formbricks.logger?.info("Delaying survey \(survey.name) by \(timeout) seconds") + } DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in - self?.showSurvey(withId: surveyId) - completion?() + guard let self = self else { return } + if let environmentResponse = self.environmentResponse { + self.presentSurveyManager.present(environmentResponse: environmentResponse, id: survey.id) { success in + if !success { + self.isShowingSurvey = false + } + completion?() + } + } else { + self.isShowingSurvey = false + completion?() + } } } } @@ -199,8 +220,9 @@ private extension SurveyManager { /// Decides if the survey should be displayed based on the display percentage. internal func shouldDisplayBasedOnPercentage(_ displayPercentage: Double?) -> Bool { guard let displayPercentage = displayPercentage else { return true } - let randomNum = Double(Int.random(in: 0..<10000)) / 100.0 - return randomNum <= displayPercentage + let clampedPercentage = min(max(displayPercentage, 0), 100) + let draw = Double.random(in: 0..<100) + return draw < clampedPercentage } } @@ -273,7 +295,9 @@ extension SurveyManager { let recontactDays = survey.recontactDays ?? defaultRecontactDays if let recontactDays = recontactDays { - return Calendar.current.numberOfDaysBetween(Date(), and: lastDisplayedAt) >= recontactDays + let secondsElapsed = Date().timeIntervalSince(lastDisplayedAt) + let daysBetween = Int(secondsElapsed / 86_400) + return daysBetween >= recontactDays } return true diff --git a/Sources/FormbricksSDK/Model/Environment/Survey.swift b/Sources/FormbricksSDK/Model/Environment/Survey.swift index d76d21af..37b2c25c 100644 --- a/Sources/FormbricksSDK/Model/Environment/Survey.swift +++ b/Sources/FormbricksSDK/Model/Environment/Survey.swift @@ -25,6 +25,24 @@ struct LanguageDetail: Codable { let projectId: String } +// MARK: - New types for projectOverwrites + +enum Placement: String, Codable { + case topLeft = "topLeft" + case topRight = "topRight" + case bottomLeft = "bottomLeft" + case bottomRight = "bottomRight" + case center = "center" +} + +struct ProjectOverwrites: Codable { + let brandColor: String? + let highlightBorderColor: String? + let placement: Placement? + let clickOutsideClose: Bool? + let darkOverlay: Bool? +} + struct Survey: Codable { let id: String let name: String @@ -37,4 +55,5 @@ struct Survey: Codable { let segment: Segment? let styling: Styling? let languages: [SurveyLanguage]? + let projectOverwrites: ProjectOverwrites? } diff --git a/Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift b/Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift index f8cf1e68..79d0a597 100644 --- a/Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift +++ b/Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift @@ -83,11 +83,10 @@ final class UpdateQueue { private extension UpdateQueue { func startDebounceTimer() { - timer?.invalidate() - timer = nil - DispatchQueue.main.async { [weak self] in guard let self = self else { return } + self.timer?.invalidate() + self.timer = nil self.timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval, target: self, selector: #selector(self.commit), @@ -97,16 +96,23 @@ private extension UpdateQueue { } @objc func commit() { - let effectiveUserId: String? = self.userId ?? Formbricks.userManager?.userId ?? nil - + var effectiveUserId: String? + var effectiveAttributes: [String: String]? + + // Capture a consistent snapshot under the sync queue + syncQueue.sync { + effectiveUserId = self.userId ?? Formbricks.userManager?.userId + effectiveAttributes = self.attributes + } + guard let userId = effectiveUserId else { let error = FormbricksSDKError(type: .userIdIsNotSetYet) Formbricks.logger?.error(error.message) return } - Formbricks.logger?.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(attributes ?? [:])") - userManager?.syncUser(withId: userId, attributes: attributes) + Formbricks.logger?.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(effectiveAttributes ?? [:])") + userManager?.syncUser(withId: userId, attributes: effectiveAttributes) } } diff --git a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift index 9fcb71df..67541a1c 100644 --- a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift +++ b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift @@ -67,7 +67,6 @@ private extension FormbricksViewModel { onClose, onOpenExternalURL, }; - window.formbricksSurveys.renderSurvey(surveyProps); } @@ -92,14 +91,25 @@ private class WebViewData { var data: [String: Any] = [:] init(environmentResponse: EnvironmentResponse, surveyId: String) { + let matchedSurvey = environmentResponse.data.data.surveys?.first(where: {$0.id == surveyId}) + let project = environmentResponse.data.data.project + data["survey"] = environmentResponse.getSurveyJson(forSurveyId: surveyId) data["appUrl"] = Formbricks.appUrl data["environmentId"] = Formbricks.environmentId data["contactId"] = Formbricks.userManager?.contactId data["isWebEnvironment"] = false - data["isBrandingEnabled"] = environmentResponse.data.data.project.inAppSurveyBranding ?? true + data["isBrandingEnabled"] = project.inAppSurveyBranding ?? true + + if let placementEnum = matchedSurvey?.projectOverwrites?.placement { + data["placement"] = placementEnum.rawValue + } else { + data["placement"] = project.placement + } + + data["darkOverlay"] = matchedSurvey?.projectOverwrites?.darkOverlay ?? project.darkOverlay - let isMultiLangSurvey = environmentResponse.data.data.surveys?.first(where: { $0.id == surveyId })?.languages?.count ?? 0 > 1 + let isMultiLangSurvey = (matchedSurvey?.languages?.count ?? 0) > 1 if isMultiLangSurvey { data["languageCode"] = Formbricks.language @@ -107,8 +117,8 @@ private class WebViewData { data["languageCode"] = "default" } - let hasCustomStyling = environmentResponse.data.data.surveys?.first(where: { $0.id == surveyId })?.styling != nil - let enabled = environmentResponse.data.data.project.styling?.allowStyleOverwrite ?? false + let hasCustomStyling = matchedSurvey?.styling != nil + let enabled = project.styling?.allowStyleOverwrite ?? false data["styling"] = hasCustomStyling && enabled ? environmentResponse.getSurveyStylingJson(forSurveyId: surveyId): environmentResponse.getProjectStylingJson() } diff --git a/Tests/FormbricksSDKTests/CalendarDaysBetweenTests.swift b/Tests/FormbricksSDKTests/CalendarDaysBetweenTests.swift new file mode 100644 index 00000000..6decfaa1 --- /dev/null +++ b/Tests/FormbricksSDKTests/CalendarDaysBetweenTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import FormbricksSDK + +final class CalendarDaysBetweenTests: XCTestCase { + private func makeDate(year: Int, month: Int, day: Int, hour: Int = 12, minute: Int = 0, second: Int = 0) -> Date { + let calendar = Calendar.current + var comps = DateComponents() + comps.calendar = calendar + comps.timeZone = calendar.timeZone + comps.year = year + comps.month = month + comps.day = day + comps.hour = hour + comps.minute = minute + comps.second = second + return calendar.date(from: comps) ?? Date() + } + + func testSameDayReturnsZero() { + let from = makeDate(year: 2025, month: 1, day: 15, hour: 10, minute: 30) + let to = makeDate(year: 2025, month: 1, day: 15, hour: 22, minute: 15) + let days = Calendar.current.numberOfDaysBetween(from, and: to) + XCTAssertEqual(days, 0) + } + + func testNextDayReturnsOne() { + let from = makeDate(year: 2025, month: 1, day: 15, hour: 10) + let to = makeDate(year: 2025, month: 1, day: 16, hour: 9) + let days = Calendar.current.numberOfDaysBetween(from, and: to) + XCTAssertEqual(days, 1) + } + + func testMultipleDays() { + let from = makeDate(year: 2025, month: 1, day: 10, hour: 10) + let to = makeDate(year: 2025, month: 1, day: 13, hour: 9) + let days = Calendar.current.numberOfDaysBetween(from, and: to) + XCTAssertEqual(days, 3) + } + + func testReverseOrderClampsToZero() { + let from = makeDate(year: 2025, month: 1, day: 20, hour: 12) + let to = makeDate(year: 2025, month: 1, day: 18, hour: 12) + let days = Calendar.current.numberOfDaysBetween(from, and: to) + XCTAssertEqual(days, 0) + } + + func testAcrossMidnightCountsAsOne() { + let from = makeDate(year: 2025, month: 3, day: 10, hour: 23, minute: 59) + let to = makeDate(year: 2025, month: 3, day: 11, hour: 0, minute: 1) + let days = Calendar.current.numberOfDaysBetween(from, and: to) + XCTAssertEqual(days, 1) + } +} + + diff --git a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift index 2cc803ac..b24d1356 100644 --- a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift @@ -112,7 +112,8 @@ final class FormbricksSDKTests: XCTestCase { wait(for: [trackExpectation]) - XCTAssertTrue(Formbricks.surveyManager?.isShowingSurvey ?? false) + // In headless test environment, presentation fails (no key window), so flag should reset to false + XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? true) // "Dismiss" the webview. Formbricks.surveyManager?.dismissSurveyWebView() @@ -161,7 +162,8 @@ final class FormbricksSDKTests: XCTestCase { wait(for: [thirdTrackExpectation]) - XCTAssertTrue(Formbricks.surveyManager?.isShowingSurvey ?? false) + // In headless test environment, presentation fails (no key window), so flag should reset to false + XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? true) // Test the cleanup Formbricks.cleanup() @@ -242,7 +244,8 @@ final class FormbricksSDKTests: XCTestCase { SurveyLanguage(enabled: true, isDefault: true, language: LanguageDetail(id: "1", code: "en", alias: "english", projectId: "p1")), SurveyLanguage(enabled: true, isDefault: false, language: LanguageDetail(id: "2", code: "de", alias: "german", projectId: "p1")), SurveyLanguage(enabled: false, isDefault: false, language: LanguageDetail(id: "3", code: "fr", alias: nil, projectId: "p1")) - ] + ], + projectOverwrites: nil ) // No language provided XCTAssertEqual(manager.getLanguageCode(survey: survey, language: nil), "default") @@ -257,4 +260,53 @@ final class FormbricksSDKTests: XCTestCase { // Alias not found XCTAssertNil(manager.getLanguageCode(survey: survey, language: "spanish")) } + + func testWebViewDataUsesSurveyOverwrites() { + // Setup SDK with mock service loading Environment.json (which now includes projectOverwrites) + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + // Force refresh and wait briefly for async fetch + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let expectation = self.expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } + wait(for: [expectation]) + + guard let env = Formbricks.surveyManager?.environmentResponse else { + XCTFail("Missing environmentResponse") + return + } + + // Build the view model to produce WEBVIEW_DATA + let vm = FormbricksViewModel(environmentResponse: env, surveyId: surveyID) + guard let html = vm.htmlString else { + XCTFail("Missing htmlString") + return + } + + // Extract the JSON payload between backticks in `const json = `...`` + guard let markerRange = html.range(of: "const json = `") else { + XCTFail("Marker not found") + return + } + let start = markerRange.upperBound + guard let end = html[start...].firstIndex(of: "`") else { + XCTFail("End backtick not found") + return + } + let jsonSubstring = html[start..