@loyalytics/swan-react-native-sdk 2.1.3-beta.0 → 2.1.3-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/android/build.gradle +66 -0
  2. package/android/src/main/AndroidManifest.xml +10 -0
  3. package/android/src/main/kotlin/com/loyalytics/swan/SwanNotificationModule.kt +43 -0
  4. package/android/src/main/kotlin/com/loyalytics/swan/SwanNotificationPackage.kt +16 -0
  5. package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanNotificationActionReceiver.kt +49 -0
  6. package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanNotificationTemplate.kt +20 -0
  7. package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanTemplateRegistry.kt +47 -0
  8. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselAutoRemoteViews.kt +103 -0
  9. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselFilmstripRemoteViews.kt +132 -0
  10. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselRemoteViews.kt +129 -0
  11. package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselTemplate.kt +412 -0
  12. package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationBitmapCache.kt +70 -0
  13. package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationImageLoader.kt +97 -0
  14. package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationStateManager.kt +85 -0
  15. package/android/src/main/res/anim/swan_fade_in.xml +6 -0
  16. package/android/src/main/res/anim/swan_fade_out.xml +6 -0
  17. package/android/src/main/res/anim/swan_slide_in_right.xml +8 -0
  18. package/android/src/main/res/anim/swan_slide_out_left.xml +8 -0
  19. package/android/src/main/res/drawable/swan_ic_chevron_left.xml +11 -0
  20. package/android/src/main/res/drawable/swan_ic_chevron_right.xml +11 -0
  21. package/android/src/main/res/layout/swan_carousel_auto_expanded.xml +51 -0
  22. package/android/src/main/res/layout/swan_carousel_collapsed.xml +31 -0
  23. package/android/src/main/res/layout/swan_carousel_expanded.xml +96 -0
  24. package/android/src/main/res/layout/swan_carousel_filmstrip_expanded.xml +115 -0
  25. package/android/src/main/res/layout/swan_carousel_flipper_item.xml +7 -0
  26. package/android/src/test/kotlin/com/loyalytics/swan/templates/carousel/CarouselTemplateTest.kt +125 -0
  27. package/docs/SDK_INDUSTRY_REVIEW_REPORT.md +347 -0
  28. package/docs/Swan_Push_Notifications.postman_collection.json +330 -0
  29. package/docs/deep-link-attribution.md +281 -0
  30. package/ios/SwanNotificationContentExtension/Info.plist +40 -0
  31. package/ios/SwanNotificationContentExtension/MainInterface.storyboard +19 -0
  32. package/ios/SwanNotificationContentExtension/NotificationViewController.swift +190 -0
  33. package/ios/SwanNotificationContentExtension/SwanNotificationContentExtension.entitlements +10 -0
  34. package/ios/SwanNotificationContentExtension/common/ImageDownloader.swift +32 -0
  35. package/ios/SwanNotificationContentExtension/templates/CarouselView.swift +336 -0
  36. package/lib/commonjs/constants/ApiUrls.js.map +1 -1
  37. package/lib/commonjs/index.js +150 -35
  38. package/lib/commonjs/index.js.map +1 -1
  39. package/lib/commonjs/providers/NullPushProvider.js.map +1 -1
  40. package/lib/commonjs/services/DeviceRegistrationService.js.map +1 -1
  41. package/lib/commonjs/state/AuthStateMachine.js.map +1 -1
  42. package/lib/commonjs/state/DeviceStateMachine.js.map +1 -1
  43. package/lib/commonjs/state/PushStateMachine.js.map +1 -1
  44. package/lib/commonjs/utils/FirebaseNotificationManager.js.map +1 -1
  45. package/lib/commonjs/utils/Logger.js.map +1 -1
  46. package/lib/commonjs/utils/SharedCredentialsManager.js +28 -0
  47. package/lib/commonjs/utils/SharedCredentialsManager.js.map +1 -1
  48. package/lib/commonjs/version.js +1 -1
  49. package/lib/module/index.js +150 -35
  50. package/lib/module/index.js.map +1 -1
  51. package/lib/module/providers/NullPushProvider.js.map +1 -1
  52. package/lib/module/services/DeviceRegistrationService.js.map +1 -1
  53. package/lib/module/state/AuthStateMachine.js.map +1 -1
  54. package/lib/module/state/DeviceStateMachine.js.map +1 -1
  55. package/lib/module/state/PushStateMachine.js.map +1 -1
  56. package/lib/module/utils/FirebaseNotificationManager.js.map +1 -1
  57. package/lib/module/utils/Logger.js.map +1 -1
  58. package/lib/module/utils/SharedCredentialsManager.js +28 -0
  59. package/lib/module/utils/SharedCredentialsManager.js.map +1 -1
  60. package/lib/module/version.js +1 -1
  61. package/lib/typescript/commonjs/src/constants/ApiUrls.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/src/index.d.ts +24 -7
  63. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/src/providers/NullPushProvider.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/src/services/DeviceRegistrationService.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/src/state/AuthStateMachine.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/src/state/DeviceStateMachine.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/src/state/PushStateMachine.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/src/utils/Logger.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts +13 -0
  72. package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/src/version.d.ts +1 -1
  74. package/lib/typescript/module/src/constants/ApiUrls.d.ts.map +1 -1
  75. package/lib/typescript/module/src/index.d.ts +24 -7
  76. package/lib/typescript/module/src/index.d.ts.map +1 -1
  77. package/lib/typescript/module/src/providers/NullPushProvider.d.ts.map +1 -1
  78. package/lib/typescript/module/src/services/DeviceRegistrationService.d.ts.map +1 -1
  79. package/lib/typescript/module/src/state/AuthStateMachine.d.ts.map +1 -1
  80. package/lib/typescript/module/src/state/DeviceStateMachine.d.ts.map +1 -1
  81. package/lib/typescript/module/src/state/PushStateMachine.d.ts.map +1 -1
  82. package/lib/typescript/module/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
  83. package/lib/typescript/module/src/utils/Logger.d.ts.map +1 -1
  84. package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts +13 -0
  85. package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts.map +1 -1
  86. package/lib/typescript/module/src/version.d.ts +1 -1
  87. package/package.json +7 -10
  88. package/react-native.config.json +12 -0
  89. package/scripts/setup-ios-extension.js +100 -20
  90. package/scripts/test-carousel-push.js +266 -0
  91. package/swan-react-native-sdk.podspec +18 -0
@@ -0,0 +1,19 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="AppleSDK" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="jFH-bh-RYg">
3
+ <device id="retina6_12" orientation="portrait" appearance="light"/>
4
+ <scenes>
5
+ <scene sceneID="8ME-jv-LvP">
6
+ <objects>
7
+ <viewController id="jFH-bh-RYg" customClass="NotificationViewController" customModule="SwanNotificationContentExtension" customModuleProvider="target" sceneMemberID="viewController">
8
+ <view key="view" contentMode="scaleToFill" id="2Vz-2H-oeq">
9
+ <rect key="frame" x="0.0" y="0.0" width="393" height="300"/>
10
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
11
+ <color key="backgroundColor" systemColor="systemBackgroundColor"/>
12
+ </view>
13
+ </viewController>
14
+ <placeholder placeholderIdentifier="IBFirstResponder" id="B3F-wd-TrL" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
15
+ </objects>
16
+ <point key="canvasLocation" x="132" y="132"/>
17
+ </scene>
18
+ </scenes>
19
+ </document>
@@ -0,0 +1,190 @@
1
+ import UIKit
2
+ import UserNotifications
3
+ import UserNotificationsUI
4
+
5
+ /**
6
+ * Notification Content Extension entry point.
7
+ * Routes to the correct template view based on notification category.
8
+ *
9
+ * Supported categories:
10
+ * - swan_carousel: Swipeable product carousel
11
+ * (Future: swan_timer, swan_cta, swan_rating)
12
+ *
13
+ * Action buttons (registered by SDK via Notifee setNotificationCategories):
14
+ * - swan_prev: Navigate to previous carousel item
15
+ * - swan_next: Navigate to next carousel item
16
+ * - swan_open: Open deep link for current item
17
+ */
18
+ class NotificationViewController: UIViewController, UNNotificationContentExtension {
19
+
20
+ private var carouselView: CarouselView?
21
+
22
+ /// Action button identifiers (must match what SDK registers via setNotificationCategories)
23
+ private enum ActionID {
24
+ static let prev = "swan_prev"
25
+ static let next = "swan_next"
26
+ static let open = "swan_open"
27
+ }
28
+
29
+ required init?(coder: NSCoder) {
30
+ self.carouselView = nil
31
+ super.init(coder: coder)
32
+ }
33
+
34
+ override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
35
+ self.carouselView = nil
36
+ super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
37
+ }
38
+
39
+ override func viewDidLoad() {
40
+ super.viewDidLoad()
41
+ view.backgroundColor = .systemBackground
42
+ }
43
+
44
+ func didReceive(_ notification: UNNotification) {
45
+ let content = notification.request.content
46
+ let category = content.categoryIdentifier
47
+ let userInfo = content.userInfo
48
+
49
+ switch category {
50
+ case "swan_carousel":
51
+ setupCarousel(userInfo: userInfo)
52
+ default:
53
+ // Unknown category — show basic content
54
+ setupFallback(content: content)
55
+ }
56
+ }
57
+
58
+ func didReceive(
59
+ _ response: UNNotificationResponse,
60
+ completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption) -> Void
61
+ ) {
62
+ let userInfo = response.notification.request.content.userInfo
63
+ let category = response.notification.request.content.categoryIdentifier
64
+ let actionId = response.actionIdentifier
65
+
66
+ guard category == "swan_carousel" else {
67
+ completion(.dismissAndForwardAction)
68
+ return
69
+ }
70
+
71
+ switch actionId {
72
+ case ActionID.prev:
73
+ carouselView?.scrollToPrevious()
74
+ completion(.doNotDismiss)
75
+
76
+ case ActionID.next:
77
+ carouselView?.scrollToNext()
78
+ completion(.doNotDismiss)
79
+
80
+ case ActionID.open:
81
+ handleCarouselAction(response: response, userInfo: userInfo)
82
+ completion(.dismissAndForwardAction)
83
+
84
+ case UNNotificationDefaultActionIdentifier:
85
+ // User tapped the notification body itself
86
+ handleCarouselAction(response: response, userInfo: userInfo)
87
+ completion(.dismissAndForwardAction)
88
+
89
+ default:
90
+ completion(.dismissAndForwardAction)
91
+ }
92
+ }
93
+
94
+ // MARK: - Carousel
95
+
96
+ private func setupCarousel(userInfo: [AnyHashable: Any]) {
97
+ let carousel = CarouselView()
98
+ carousel.translatesAutoresizingMaskIntoConstraints = false
99
+ view.addSubview(carousel)
100
+
101
+ NSLayoutConstraint.activate([
102
+ carousel.topAnchor.constraint(equalTo: view.topAnchor),
103
+ carousel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
104
+ carousel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
105
+ carousel.bottomAnchor.constraint(equalTo: view.bottomAnchor),
106
+ ])
107
+
108
+ // Parse carousel items
109
+ let itemsString = userInfo["carouselItems"] as? String ?? "[]"
110
+ let modeString = userInfo["carouselMode"] as? String ?? "manual"
111
+ let intervalString = userInfo["carouselInterval"] as? String ?? "3000"
112
+ let interval = TimeInterval(intervalString) ?? 3000
113
+
114
+ let items = CarouselView.parseItems(from: itemsString)
115
+ let mode: CarouselView.CarouselMode = modeString == "auto" ? .auto : .manual
116
+ carousel.configure(with: items, mode: mode, autoScrollInterval: interval / 1000.0)
117
+
118
+ // Tap on carousel image/labels saves click data and opens the app
119
+ carousel.onItemTapped = { [weak self] in
120
+ guard let self = self, let cv = self.carouselView else { return }
121
+ let messageId = userInfo["messageId"] as? String ?? ""
122
+ self.saveClickData(
123
+ messageId: messageId,
124
+ itemIndex: cv.currentIndex,
125
+ route: cv.currentItem?.route ?? ""
126
+ )
127
+ self.extensionContext?.performNotificationDefaultAction()
128
+ }
129
+
130
+ self.carouselView = carousel
131
+
132
+ // Set preferred content size for expanded notification
133
+ let imageAspectRatio: CGFloat = 0.6 // height / width
134
+ let width = view.bounds.width > 0 ? view.bounds.width : UIScreen.main.bounds.width
135
+ preferredContentSize = CGSize(width: width, height: width * imageAspectRatio + 80)
136
+ }
137
+
138
+ private func handleCarouselAction(response: UNNotificationResponse, userInfo: [AnyHashable: Any]) {
139
+ guard let carousel = carouselView else { return }
140
+
141
+ let messageId = userInfo["messageId"] as? String ?? ""
142
+ let currentItem = carousel.currentItem
143
+
144
+ // Save click data to App Group for main app to read
145
+ saveClickData(
146
+ messageId: messageId,
147
+ itemIndex: carousel.currentIndex,
148
+ route: currentItem?.route ?? ""
149
+ )
150
+ }
151
+
152
+ // MARK: - Click Data Persistence
153
+
154
+ private func saveClickData(messageId: String, itemIndex: Int, route: String) {
155
+ let appGroupId = "group.swan.sdk.notifications"
156
+ guard let defaults = UserDefaults(suiteName: appGroupId) else { return }
157
+
158
+ let clickData: [String: Any] = [
159
+ "messageId": messageId,
160
+ "itemIndex": itemIndex,
161
+ "route": route,
162
+ "timestamp": Date().timeIntervalSince1970,
163
+ ]
164
+
165
+ // Serialize as JSON string — react-native-shared-group-preferences only reads strings
166
+ if let jsonData = try? JSONSerialization.data(withJSONObject: clickData),
167
+ let jsonString = String(data: jsonData, encoding: .utf8) {
168
+ defaults.set(jsonString, forKey: "swanTemplateClickData")
169
+ defaults.synchronize()
170
+ }
171
+ }
172
+
173
+ // MARK: - Fallback
174
+
175
+ private func setupFallback(content: UNNotificationContent) {
176
+ let label = UILabel()
177
+ label.text = content.body
178
+ label.numberOfLines = 0
179
+ label.textAlignment = .center
180
+ label.translatesAutoresizingMaskIntoConstraints = false
181
+ view.addSubview(label)
182
+
183
+ NSLayoutConstraint.activate([
184
+ label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
185
+ label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
186
+ label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
187
+ label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
188
+ ])
189
+ }
190
+ }
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>com.apple.security.application-groups</key>
6
+ <array>
7
+ <string>group.swan.sdk.notifications</string>
8
+ </array>
9
+ </dict>
10
+ </plist>
@@ -0,0 +1,32 @@
1
+ import UIKit
2
+
3
+ /**
4
+ * Shared image downloader for notification template views.
5
+ * Uses URLSession for background-safe downloading.
6
+ */
7
+ class ImageDownloader {
8
+
9
+ static func download(urlString: String, completion: @escaping (UIImage?) -> Void) {
10
+ guard let url = URL(string: urlString) else {
11
+ completion(nil)
12
+ return
13
+ }
14
+
15
+ let config = URLSessionConfiguration.default
16
+ config.timeoutIntervalForRequest = 10
17
+ config.timeoutIntervalForResource = 15
18
+ let session = URLSession(configuration: config)
19
+
20
+ session.dataTask(with: url) { data, response, error in
21
+ guard error == nil,
22
+ let data = data,
23
+ let httpResponse = response as? HTTPURLResponse,
24
+ httpResponse.statusCode == 200,
25
+ let image = UIImage(data: data) else {
26
+ completion(nil)
27
+ return
28
+ }
29
+ completion(image)
30
+ }.resume()
31
+ }
32
+ }
@@ -0,0 +1,336 @@
1
+ import UIKit
2
+
3
+ /**
4
+ * Carousel view for notification content extension.
5
+ * Displays a horizontal, swipeable collection of product images with titles.
6
+ *
7
+ * Supports:
8
+ * - Manual mode: user swipes to navigate
9
+ * - Auto mode: auto-scrolls at interval, pauses on touch
10
+ */
11
+ class CarouselView: UIView {
12
+
13
+ // MARK: - Types
14
+
15
+ enum CarouselMode {
16
+ case manual
17
+ case auto
18
+ }
19
+
20
+ struct CarouselItem {
21
+ let imageUrl: String
22
+ let title: String?
23
+ let body: String?
24
+ let route: String?
25
+ }
26
+
27
+ // MARK: - Properties
28
+
29
+ private(set) var currentIndex: Int = 0
30
+ private var items: [CarouselItem] = []
31
+ private var mode: CarouselMode = .manual
32
+ private(set) var autoScrollTimer: Timer?
33
+ private var autoScrollInterval: TimeInterval = 3.0
34
+
35
+ var currentItem: CarouselItem? {
36
+ guard currentIndex >= 0 && currentIndex < items.count else { return nil }
37
+ return items[currentIndex]
38
+ }
39
+
40
+ // MARK: - UI Elements
41
+
42
+ private lazy var collectionView: UICollectionView = {
43
+ let layout = UICollectionViewFlowLayout()
44
+ layout.scrollDirection = .horizontal
45
+ layout.minimumLineSpacing = 0
46
+ layout.minimumInteritemSpacing = 0
47
+ let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
48
+ cv.isPagingEnabled = true
49
+ cv.showsHorizontalScrollIndicator = false
50
+ cv.backgroundColor = .clear
51
+ cv.delegate = self
52
+ cv.dataSource = self
53
+ cv.register(CarouselImageCell.self, forCellWithReuseIdentifier: CarouselImageCell.reuseId)
54
+ return cv
55
+ }()
56
+
57
+ private lazy var pageControl: UIPageControl = {
58
+ let pc = UIPageControl()
59
+ pc.currentPageIndicatorTintColor = .label
60
+ pc.pageIndicatorTintColor = .systemGray3
61
+ pc.isUserInteractionEnabled = false
62
+ pc.accessibilityLabel = "Carousel page indicator"
63
+ return pc
64
+ }()
65
+
66
+ private lazy var titleLabel: UILabel = {
67
+ let label = UILabel()
68
+ label.font = .systemFont(ofSize: 14, weight: .semibold)
69
+ label.textColor = .label
70
+ label.numberOfLines = 1
71
+ return label
72
+ }()
73
+
74
+ private lazy var bodyLabel: UILabel = {
75
+ let label = UILabel()
76
+ label.font = .systemFont(ofSize: 12)
77
+ label.textColor = .secondaryLabel
78
+ label.numberOfLines = 1
79
+ return label
80
+ }()
81
+
82
+ /// Called when user taps a carousel item (image or label area)
83
+ var onItemTapped: (() -> Void)?
84
+
85
+ // Image cache
86
+ private var imageCache: [Int: UIImage] = [:]
87
+
88
+ // MARK: - Init
89
+
90
+ override init(frame: CGRect) {
91
+ super.init(frame: frame)
92
+ setupUI()
93
+ }
94
+
95
+ required init?(coder: NSCoder) {
96
+ super.init(coder: coder)
97
+ setupUI()
98
+ }
99
+
100
+ deinit {
101
+ stopAutoScroll()
102
+ }
103
+
104
+ // MARK: - Setup
105
+
106
+ private func setupUI() {
107
+ [collectionView, titleLabel, bodyLabel, pageControl].forEach {
108
+ $0.translatesAutoresizingMaskIntoConstraints = false
109
+ addSubview($0)
110
+ }
111
+
112
+ // Tap anywhere on carousel opens the app
113
+ let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
114
+ self.addGestureRecognizer(tap)
115
+
116
+ NSLayoutConstraint.activate([
117
+ collectionView.topAnchor.constraint(equalTo: topAnchor),
118
+ collectionView.leadingAnchor.constraint(equalTo: leadingAnchor),
119
+ collectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
120
+ collectionView.heightAnchor.constraint(equalTo: widthAnchor, multiplier: 0.6),
121
+
122
+ titleLabel.topAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 8),
123
+ titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
124
+ titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
125
+
126
+ bodyLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
127
+ bodyLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
128
+ bodyLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
129
+
130
+ pageControl.topAnchor.constraint(equalTo: bodyLabel.bottomAnchor, constant: 4),
131
+ pageControl.centerXAnchor.constraint(equalTo: centerXAnchor),
132
+ pageControl.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
133
+ ])
134
+ }
135
+
136
+ // MARK: - Public API
137
+
138
+ func configure(with items: [CarouselItem], mode: CarouselMode, autoScrollInterval: TimeInterval = 3.0) {
139
+ self.items = items
140
+ self.mode = mode
141
+ self.autoScrollInterval = autoScrollInterval
142
+ self.currentIndex = 0
143
+
144
+ pageControl.numberOfPages = items.count
145
+ pageControl.currentPage = 0
146
+ pageControl.isHidden = items.count <= 1
147
+
148
+ collectionView.reloadData()
149
+ updateLabels()
150
+
151
+ // Pre-download images
152
+ for (index, item) in items.enumerated() {
153
+ ImageDownloader.download(urlString: item.imageUrl) { [weak self] image in
154
+ DispatchQueue.main.async {
155
+ self?.imageCache[index] = image
156
+ if let cell = self?.collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? CarouselImageCell {
157
+ cell.imageView.image = image
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ if mode == .auto && items.count > 1 {
164
+ startAutoScroll()
165
+ }
166
+ }
167
+
168
+ func scrollToNext() {
169
+ guard items.count > 1 else { return }
170
+ let nextIndex = (currentIndex + 1) % items.count
171
+ scrollToIndex(nextIndex)
172
+ }
173
+
174
+ func scrollToPrevious() {
175
+ guard items.count > 1 else { return }
176
+ let prevIndex = (currentIndex - 1 + items.count) % items.count
177
+ scrollToIndex(prevIndex)
178
+ }
179
+
180
+ func scrollToIndex(_ index: Int) {
181
+ guard index >= 0 && index < items.count else { return }
182
+ currentIndex = index
183
+ collectionView.scrollToItem(
184
+ at: IndexPath(item: index, section: 0),
185
+ at: .centeredHorizontally,
186
+ animated: true
187
+ )
188
+ pageControl.currentPage = index
189
+ updateLabels()
190
+ }
191
+
192
+ // MARK: - Auto Scroll
193
+
194
+ private func startAutoScroll() {
195
+ stopAutoScroll()
196
+ autoScrollTimer = Timer.scheduledTimer(withTimeInterval: autoScrollInterval, repeats: true) { [weak self] _ in
197
+ self?.scrollToNext()
198
+ }
199
+ }
200
+
201
+ func stopAutoScroll() {
202
+ autoScrollTimer?.invalidate()
203
+ autoScrollTimer = nil
204
+ }
205
+
206
+ @objc private func handleTap() {
207
+ onItemTapped?()
208
+ }
209
+
210
+ // MARK: - Helpers
211
+
212
+ private func updateLabels() {
213
+ guard currentIndex < items.count else { return }
214
+ let item = items[currentIndex]
215
+ titleLabel.text = item.title
216
+ bodyLabel.text = item.body
217
+ bodyLabel.isHidden = (item.body ?? "").isEmpty
218
+ }
219
+
220
+ // MARK: - Parsing
221
+
222
+ static func parseItems(from jsonString: String) -> [CarouselItem] {
223
+ guard let data = jsonString.data(using: .utf8),
224
+ let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
225
+ return []
226
+ }
227
+
228
+ return jsonArray.compactMap { dict in
229
+ guard let imageUrl = dict["imageUrl"] as? String, !imageUrl.isEmpty else {
230
+ return nil
231
+ }
232
+ return CarouselItem(
233
+ imageUrl: imageUrl,
234
+ title: dict["title"] as? String,
235
+ body: dict["body"] as? String,
236
+ route: dict["route"] as? String
237
+ )
238
+ }.prefix(10).map { $0 }
239
+ }
240
+ }
241
+
242
+ // MARK: - UICollectionViewDataSource & Delegate
243
+
244
+ extension CarouselView: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
245
+
246
+ func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
247
+ return items.count
248
+ }
249
+
250
+ func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
251
+ let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CarouselImageCell.reuseId, for: indexPath) as! CarouselImageCell
252
+ let item = items[indexPath.item]
253
+
254
+ // Accessibility
255
+ cell.isAccessibilityElement = true
256
+ cell.accessibilityLabel = item.title ?? "Carousel image \(indexPath.item + 1) of \(items.count)"
257
+ if let body = item.body, !body.isEmpty {
258
+ cell.accessibilityValue = body
259
+ }
260
+ cell.accessibilityTraits = .image
261
+
262
+ if let cached = imageCache[indexPath.item] {
263
+ cell.imageView.image = cached
264
+ } else {
265
+ cell.imageView.image = nil
266
+ // Download if not cached
267
+ ImageDownloader.download(urlString: item.imageUrl) { [weak self, weak cell] image in
268
+ DispatchQueue.main.async {
269
+ self?.imageCache[indexPath.item] = image
270
+ cell?.imageView.image = image
271
+ }
272
+ }
273
+ }
274
+ return cell
275
+ }
276
+
277
+ func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
278
+ return collectionView.bounds.size
279
+ }
280
+
281
+ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
282
+ let page = Int(scrollView.contentOffset.x / scrollView.bounds.width)
283
+ if page != currentIndex {
284
+ currentIndex = page
285
+ pageControl.currentPage = page
286
+ updateLabels()
287
+ }
288
+
289
+ // Always restart auto-scroll after any drag settles, even if index unchanged
290
+ if mode == .auto && items.count > 1 {
291
+ startAutoScroll()
292
+ }
293
+ }
294
+
295
+ func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
296
+ // Pause auto-scroll during manual interaction
297
+ if mode == .auto {
298
+ stopAutoScroll()
299
+ }
300
+ }
301
+ }
302
+
303
+ // MARK: - CarouselImageCell
304
+
305
+ class CarouselImageCell: UICollectionViewCell {
306
+ static let reuseId = "CarouselImageCell"
307
+
308
+ let imageView: UIImageView = {
309
+ let iv = UIImageView()
310
+ iv.contentMode = .scaleAspectFill
311
+ iv.clipsToBounds = true
312
+ iv.backgroundColor = .systemGray5
313
+ return iv
314
+ }()
315
+
316
+ override init(frame: CGRect) {
317
+ super.init(frame: frame)
318
+ imageView.translatesAutoresizingMaskIntoConstraints = false
319
+ contentView.addSubview(imageView)
320
+ NSLayoutConstraint.activate([
321
+ imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
322
+ imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
323
+ imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
324
+ imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
325
+ ])
326
+ }
327
+
328
+ required init?(coder: NSCoder) {
329
+ fatalError("init(coder:) has not been implemented")
330
+ }
331
+
332
+ override func prepareForReuse() {
333
+ super.prepareForReuse()
334
+ imageView.image = nil
335
+ }
336
+ }
@@ -1 +1 @@
1
- {"version":3,"names":["WEBHOOK_BASE_URL","STAGE","PROD","WEB_SDK_BASE_URL","ECOM_SDK_BASE_URL","ECOM_ENRICH_PROFILE_URL","exports","ECOM_TRACK_EVENT_URL","ECOM_DEVICE_REGISTER_URL","ECOM_PUSH_SUBSCRIPTION_URL","WEBHOOK_MOBILE_PUSH_URL","NOTIFICATION_GET_URL","_default","default"],"sourceRoot":"../../../src","sources":["constants/ApiUrls.ts"],"mappings":";;;;;;AAAA;AACA;AACA;AACA;;AAEA,MAAMA,gBAAgB,GAAG;EACvBC,KAAK,EAAE,iCAAiC;EACxCC,IAAI,EAAE;AACR,CAAC;AAED,MAAMC,gBAAgB,GAAG;EACvBF,KAAK,EAAE,wCAAwC;EAC/CC,IAAI,EAAE;AACR,CAAC;AAED,MAAME,iBAAiB,GAAG;EACxBH,KAAK,EAAE,+BAA+B;EACtCC,IAAI,EAAE;AACR,CAAC;AAEM,MAAMG,uBAAuB,GAAAC,OAAA,CAAAD,uBAAA,GAAG;EACrCJ,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,6BAA6B;EAC9DC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMK,oBAAoB,GAAAD,OAAA,CAAAC,oBAAA,GAAG;EAClCN,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,gBAAgB;EACjDC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMM,wBAAwB,GAAAF,OAAA,CAAAE,wBAAA,GAAG;EACtCP,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,qBAAqB;EACtDC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMO,0BAA0B,GAAAH,OAAA,CAAAG,0BAAA,GAAG;EACxCR,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,2BAA2B;EAC5DC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMQ,uBAAuB,GAAAJ,OAAA,CAAAI,uBAAA,GAAG;EACrCT,KAAK,EAAE,GAAGD,gBAAgB,CAACC,KAAK,uBAAuB;EACvDC,IAAI,EAAE,GAAGF,gBAAgB,CAACE,IAAI;AAChC,CAAC;AAEM,MAAMS,oBAAoB,GAAAL,OAAA,CAAAK,oBAAA,GAAG;EAClCV,KAAK,EAAE,GAAGE,gBAAgB,CAACF,KAAK,kCAAkC;EAClEC,IAAI,EAAE,GAAGC,gBAAgB,CAACD,IAAI;AAChC,CAAC;AAAA,IAAAU,QAAA,GAAAN,OAAA,CAAAO,OAAA,GAEc;EACbR,uBAAuB;EACvBE,oBAAoB;EACpBC,wBAAwB;EACxBC,0BAA0B;EAC1BC,uBAAuB;EACvBC;AACF,CAAC","ignoreList":[]}
1
+ {"version":3,"names":["WEBHOOK_BASE_URL","STAGE","PROD","WEB_SDK_BASE_URL","ECOM_SDK_BASE_URL","ECOM_ENRICH_PROFILE_URL","exports","ECOM_TRACK_EVENT_URL","ECOM_DEVICE_REGISTER_URL","ECOM_PUSH_SUBSCRIPTION_URL","WEBHOOK_MOBILE_PUSH_URL","NOTIFICATION_GET_URL","_default","default"],"sourceRoot":"../../../src","sources":["constants/ApiUrls.ts"],"mappings":";;;;;;AAAA;AACA;AACA;AACA;;AAEA,MAAMA,gBAAgB,GAAG;EACvBC,KAAK,EAAE,iCAAiC;EACxCC,IAAI,EAAE;AACR,CAAC;AAED,MAAMC,gBAAgB,GAAG;EACvBF,KAAK,EAAE,wCAAwC;EAC/CC,IAAI,EAAE;AACR,CAAC;AAED,MAAME,iBAAiB,GAAG;EACxBH,KAAK,EAAE,+BAA+B;EACtCC,IAAI,EAAE;AACR,CAAC;AAEM,MAAMG,uBAAuB,GAAAC,OAAA,CAAAD,uBAAA,GAAG;EACrCJ,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,6BAA6B;EAC9DC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMK,oBAAoB,GAAAD,OAAA,CAAAC,oBAAA,GAAG;EAClCN,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,gBAAgB;EACjDC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMM,wBAAwB,GAAAF,OAAA,CAAAE,wBAAA,GAAG;EACtCP,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,qBAAqB;EACtDC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMO,0BAA0B,GAAAH,OAAA,CAAAG,0BAAA,GAAG;EACxCR,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,2BAA2B;EAC5DC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMQ,uBAAuB,GAAAJ,OAAA,CAAAI,uBAAA,GAAG;EACrCT,KAAK,EAAE,GAAGD,gBAAgB,CAACC,KAAK,uBAAuB;EACvDC,IAAI,EAAE,GAAGF,gBAAgB,CAACE,IAAI;AAChC,CAAC;AAEM,MAAMS,oBAAoB,GAAAL,OAAA,CAAAK,oBAAA,GAAG;EAClCV,KAAK,EAAE,GAAGE,gBAAgB,CAACF,KAAK,kCAAkC;EAClEC,IAAI,EAAE,GAAGC,gBAAgB,CAACD,IAAI;AAChC,CAAC;AAAC,IAAAU,QAAA,GAAAN,OAAA,CAAAO,OAAA,GAEa;EACbR,uBAAuB;EACvBE,oBAAoB;EACpBC,wBAAwB;EACxBC,0BAA0B;EAC1BC,uBAAuB;EACvBC;AACF,CAAC","ignoreList":[]}