From f74002d9f06e4e5dae2123a65bee514657bd1413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matu=CC=81s=CC=8C=20Tomlein?= Date: Fri, 7 Jul 2023 15:42:23 +0200 Subject: [PATCH 1/4] Expose property for retrieving payload in ConsentDocument that was removed in v5 (close #804) --- Sources/Snowplow/Events/ConsentDocument.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Snowplow/Events/ConsentDocument.swift b/Sources/Snowplow/Events/ConsentDocument.swift index e1b1fe011..94c1d22ac 100644 --- a/Sources/Snowplow/Events/ConsentDocument.swift +++ b/Sources/Snowplow/Events/ConsentDocument.swift @@ -42,7 +42,8 @@ public class ConsentDocument: NSObject { } /// Returns the payload. - var payload: SelfDescribingJson { + @objc + public var payload: SelfDescribingJson { var event: [String : String] = [:] event[kSPCdId] = documentId event[kSPCdVersion] = version From e04bcc092187b834d3dea998da7f0264dd6b0b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matu=CC=81s=CC=8C=20Tomlein?= Date: Fri, 7 Jul 2023 15:46:12 +0200 Subject: [PATCH 2/4] Increase interval for updating platform context properties from 0.1s to 1s (close #798) --- Sources/Core/Subject/PlatformContext.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Core/Subject/PlatformContext.swift b/Sources/Core/Subject/PlatformContext.swift index b684bdd7a..db59ecd39 100644 --- a/Sources/Core/Subject/PlatformContext.swift +++ b/Sources/Core/Subject/PlatformContext.swift @@ -20,7 +20,7 @@ import UIKit /// Manages a dictionary (Payload) with platform context. Some properties for mobile platforms are updated on fetch in set intervals. class PlatformContext { private var platformDict: Payload = Payload() - private var mobileDictUpdateFrequency: TimeInterval = 0.1 + private var mobileDictUpdateFrequency: TimeInterval = 1.0 private var networkDictUpdateFrequency: TimeInterval = 10.0 private var lastUpdatedEphemeralMobileDict: TimeInterval = 0.0 private var lastUpdatedEphemeralNetworkDict: TimeInterval = 0.0 @@ -37,7 +37,7 @@ class PlatformContext { /// - deviceInfoMonitor: Device monitor for fetching platform information /// - Returns: a PlatformContext object init(platformContextProperties: [PlatformContextProperty]? = nil, - mobileDictUpdateFrequency: TimeInterval = 0.1, + mobileDictUpdateFrequency: TimeInterval = 1.0, networkDictUpdateFrequency: TimeInterval = 10.0, deviceInfoMonitor: DeviceInfoMonitor = DeviceInfoMonitor()) { self.platformContextProperties = platformContextProperties From 57878b1c1c4cad745d975251f416f2aa3e7b649a Mon Sep 17 00:00:00 2001 From: Miranda Wilson Date: Wed, 12 Jul 2023 09:13:07 +0100 Subject: [PATCH 3/4] Add Snowplow ecommerce events and entities (close #800) PR #800 * Add Product and Promotion entities * Add Cart entity * Add ecomm events * Start adding ecommerce Screen/User entities * Add objc annotation to events and entities * Add event tests * Add screen and user entities * Deprecate old Ecommerce events * Hide internal event method * Add schemas to API docs * Fix method link in API docs * Prevent unused result warning * Remove deprecated annotation from PageView * Address review comments * Actually attach the entities * Address review comments * Update tests * Add TransactionError event * Remove github conflict markup --- Examples | 2 +- .../Tracker/EcommerceControllerImpl.swift | 37 ++ Sources/Core/Tracker/ServiceProvider.swift | 9 +- .../Tracker/ServiceProviderProtocol.swift | 1 + Sources/Core/Tracker/Tracker.swift | 5 +- .../Core/Tracker/TrackerControllerImpl.swift | 4 + Sources/Core/TrackerConstants.swift | 10 + .../Controllers/TrackerController.swift | 3 + .../Ecommerce/EcommerceController.swift | 39 ++ .../Ecommerce/Entities/CartEntity.swift | 56 +++ .../Entities/EcommerceScreenEntity.swift | 56 +++ .../Entities/EcommerceUserEntity.swift | 71 +++ .../Ecommerce/Entities/ProductEntity.swift | 187 ++++++++ .../Ecommerce/Entities/PromotionEntity.swift | 115 +++++ .../Entities/TransactionEntity.swift | 159 +++++++ .../Ecommerce/Events/AddToCartEvent.swift | 54 +++ .../Ecommerce/Events/CheckoutStepEvent.swift | 180 ++++++++ .../Events/ProductListClickEvent.swift | 48 ++ .../Events/ProductListViewEvent.swift | 54 +++ .../Ecommerce/Events/ProductViewEvent.swift | 41 ++ .../Events/PromotionClickEvent.swift | 41 ++ .../Ecommerce/Events/PromotionViewEvent.swift | 41 ++ .../Ecommerce/Events/RefundEvent.swift | 95 ++++ .../Events/RemoveFromCartEvent.swift | 54 +++ .../Events/TransactionErrorEvent.swift | 133 ++++++ .../Ecommerce/Events/TransactionEvent.swift | 58 +++ Sources/Snowplow/Events/Ecommerce.swift | 2 + Sources/Snowplow/Events/EcommerceItem.swift | 2 + Sources/Snowplow/Events/EventBase.swift | 28 +- Sources/Snowplow/Snowplow.swift | 2 +- Tests/Ecommerce/TestEcommerceController.swift | 149 +++++++ Tests/Ecommerce/TestEcommerceEntities.swift | 87 ++++ Tests/Ecommerce/TestEcommerceEvents.swift | 415 ++++++++++++++++++ 33 files changed, 2230 insertions(+), 8 deletions(-) create mode 100644 Sources/Core/Tracker/EcommerceControllerImpl.swift create mode 100644 Sources/Snowplow/Ecommerce/EcommerceController.swift create mode 100644 Sources/Snowplow/Ecommerce/Entities/CartEntity.swift create mode 100644 Sources/Snowplow/Ecommerce/Entities/EcommerceScreenEntity.swift create mode 100644 Sources/Snowplow/Ecommerce/Entities/EcommerceUserEntity.swift create mode 100644 Sources/Snowplow/Ecommerce/Entities/ProductEntity.swift create mode 100644 Sources/Snowplow/Ecommerce/Entities/PromotionEntity.swift create mode 100644 Sources/Snowplow/Ecommerce/Entities/TransactionEntity.swift create mode 100644 Sources/Snowplow/Ecommerce/Events/AddToCartEvent.swift create mode 100644 Sources/Snowplow/Ecommerce/Events/CheckoutStepEvent.swift create mode 100644 Sources/Snowplow/Ecommerce/Events/ProductListClickEvent.swift create mode 100644 Sources/Snowplow/Ecommerce/Events/ProductListViewEvent.swift create mode 100644 Sources/Snowplow/Ecommerce/Events/ProductViewEvent.swift create mode 100644 Sources/Snowplow/Ecommerce/Events/PromotionClickEvent.swift create mode 100644 Sources/Snowplow/Ecommerce/Events/PromotionViewEvent.swift create mode 100644 Sources/Snowplow/Ecommerce/Events/RefundEvent.swift create mode 100644 Sources/Snowplow/Ecommerce/Events/RemoveFromCartEvent.swift create mode 100644 Sources/Snowplow/Ecommerce/Events/TransactionErrorEvent.swift create mode 100644 Sources/Snowplow/Ecommerce/Events/TransactionEvent.swift create mode 100644 Tests/Ecommerce/TestEcommerceController.swift create mode 100644 Tests/Ecommerce/TestEcommerceEntities.swift create mode 100644 Tests/Ecommerce/TestEcommerceEvents.swift diff --git a/Examples b/Examples index b39e19df8..40b17aa76 160000 --- a/Examples +++ b/Examples @@ -1 +1 @@ -Subproject commit b39e19df83ad985db970041708923e0839ffecb0 +Subproject commit 40b17aa768f47e9f17f3eba13257ffa5ce9f1554 diff --git a/Sources/Core/Tracker/EcommerceControllerImpl.swift b/Sources/Core/Tracker/EcommerceControllerImpl.swift new file mode 100644 index 000000000..16d4b2e77 --- /dev/null +++ b/Sources/Core/Tracker/EcommerceControllerImpl.swift @@ -0,0 +1,37 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +class EcommerceControllerImpl: Controller, EcommerceController { + + func setEcommerceScreen(_ screen: EcommerceScreenEntity) { + let plugin = PluginConfiguration(identifier: "ecommercePageTypePluginInternal") + _ = plugin.entities { _ in [screen.entity] } + serviceProvider.addPlugin(plugin: plugin) + } + + func setEcommerceUser(_ user: EcommerceUserEntity) { + let plugin = PluginConfiguration(identifier: "ecommerceUserPluginInternal") + _ = plugin.entities { _ in [user.entity] } + serviceProvider.addPlugin(plugin: plugin) + } + + func removeEcommerceScreen() { + serviceProvider.removePlugin(identifier: "ecommercePageTypePluginInternal") + } + + func removeEcommerceUser() { + serviceProvider.removePlugin(identifier: "ecommerceUserPluginInternal") + } +} diff --git a/Sources/Core/Tracker/ServiceProvider.swift b/Sources/Core/Tracker/ServiceProvider.swift index a2689fc2c..f3fd9a44a 100644 --- a/Sources/Core/Tracker/ServiceProvider.swift +++ b/Sources/Core/Tracker/ServiceProvider.swift @@ -109,7 +109,14 @@ class ServiceProvider: NSObject, ServiceProviderProtocol { return mediaController } - // Configurations + private var _ecommerceController: EcommerceController? + var ecommerceController: EcommerceController { + if let controller = _ecommerceController { return controller } + let ecommerceController = EcommerceControllerImpl(serviceProvider: self) + _ecommerceController = ecommerceController + return ecommerceController + } + private(set) var networkConfiguration = NetworkConfiguration() private(set) var trackerConfiguration = TrackerConfiguration() private(set) var emitterConfiguration = EmitterConfiguration() diff --git a/Sources/Core/Tracker/ServiceProviderProtocol.swift b/Sources/Core/Tracker/ServiceProviderProtocol.swift index 01aaaed32..6435d79a7 100644 --- a/Sources/Core/Tracker/ServiceProviderProtocol.swift +++ b/Sources/Core/Tracker/ServiceProviderProtocol.swift @@ -34,6 +34,7 @@ protocol ServiceProviderProtocol: AnyObject { var subjectConfiguration: SubjectConfiguration { get } var sessionConfiguration: SessionConfiguration { get } var gdprConfiguration: GDPRConfiguration { get } + var ecommerceController: EcommerceController { get } var pluginConfigurations: [PluginIdentifiable] { get } func addPlugin(plugin: PluginIdentifiable) func removePlugin(identifier: String) diff --git a/Sources/Core/Tracker/Tracker.swift b/Sources/Core/Tracker/Tracker.swift index 0fdb943ce..22be574e9 100644 --- a/Sources/Core/Tracker/Tracker.swift +++ b/Sources/Core/Tracker/Tracker.swift @@ -471,11 +471,12 @@ class Tracker: NSObject { setApplicationInstallEventTimestamp(event) addBasicProperties(to: payload, event: event) addStateMachinePayloadValues(event: event) - event.wrapProperties(to: payload, base64Encoded: base64Encoded) - + // Context entities addBasicContexts(event: event) addStateMachineEntities(event: event) + + event.wrapProperties(to: payload, base64Encoded: base64Encoded) event.wrapContexts(to: payload, base64Encoded: base64Encoded) // Decide whether to track the event or not diff --git a/Sources/Core/Tracker/TrackerControllerImpl.swift b/Sources/Core/Tracker/TrackerControllerImpl.swift index 7e45b29b5..afc0a5f90 100644 --- a/Sources/Core/Tracker/TrackerControllerImpl.swift +++ b/Sources/Core/Tracker/TrackerControllerImpl.swift @@ -53,6 +53,10 @@ class TrackerControllerImpl: Controller, TrackerController { var media: MediaController { return serviceProvider.mediaController } + + var ecommerce: EcommerceController { + return serviceProvider.ecommerceController + } // MARK: - Control methods diff --git a/Sources/Core/TrackerConstants.swift b/Sources/Core/TrackerConstants.swift index 1197f62e9..796b17790 100644 --- a/Sources/Core/TrackerConstants.swift +++ b/Sources/Core/TrackerConstants.swift @@ -60,6 +60,16 @@ let kSPErrorSchema = "iglu:com.snowplowanalytics.snowplow/application_error/json let kSPApplicationInstallSchema = "iglu:com.snowplowanalytics.mobile/application_install/jsonschema/1-0-0" let kSPGdprContextSchema = "iglu:com.snowplowanalytics.snowplow/gdpr/jsonschema/1-0-0" let kSPDiagnosticErrorSchema = "iglu:com.snowplowanalytics.snowplow/diagnostic_error/jsonschema/1-0-0" +let ecommerceActionSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/snowplow_ecommerce_action/jsonschema/1-0-2" +let ecommerceProductSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/product/jsonschema/1-0-0" +let ecommerceCartSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/cart/jsonschema/1-0-0" +let ecommerceTransactionSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/transaction/jsonschema/1-0-0" +let ecommerceTransactionErrorSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/transaction_error/jsonschema/1-0-0" +let ecommerceCheckoutStepSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/checkout_step/jsonschema/1-0-0" +let ecommercePromotionSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/promotion/jsonschema/1-0-0" +let ecommerceRefundSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/refund/jsonschema/1-0-0" +let ecommerceUserSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/user/jsonschema/1-0-0" +let ecommercePageSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/page/jsonschema/1-0-0" // --- Event Keys let kSPEventPageView = "pv" diff --git a/Sources/Snowplow/Controllers/TrackerController.swift b/Sources/Snowplow/Controllers/TrackerController.swift index 869c916f8..3c5fcdf4c 100644 --- a/Sources/Snowplow/Controllers/TrackerController.swift +++ b/Sources/Snowplow/Controllers/TrackerController.swift @@ -56,6 +56,9 @@ public protocol TrackerController: TrackerConfigurationProtocol { /// Media controller for managing media tracking instances and tracking media events. @objc var media: MediaController { get } + /// Ecommerce controller for managing ecommerce entity addition. + @objc + var ecommerce: EcommerceController { get } /// Track the event. /// The tracker will take care to process and send the event assigning `event_id` and `device_timestamp`. /// - Parameter event: The event to track. diff --git a/Sources/Snowplow/Ecommerce/EcommerceController.swift b/Sources/Snowplow/Ecommerce/EcommerceController.swift new file mode 100644 index 000000000..df9707cf0 --- /dev/null +++ b/Sources/Snowplow/Ecommerce/EcommerceController.swift @@ -0,0 +1,39 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** + Controller for managing Ecommerce entities. + */ +@objc(SPEcommController) +public protocol EcommerceController { + + /// Add an ecommerce Screen/Page entity to all subsequent events. + /// - Parameter screen: A EcommScreenEntity. + @objc + func setEcommerceScreen(_ screen: EcommerceScreenEntity) + + /// Add an ecommerce User entity to all subsequent events. + /// - Parameter user: A EcommUserEntity. + @objc + func setEcommerceUser(_ user: EcommerceUserEntity) + + /// Stop adding a Screen/Page entity to events. + @objc + func removeEcommerceScreen() + + /// Stop adding a User entity to events. + @objc + func removeEcommerceUser() +} diff --git a/Sources/Snowplow/Ecommerce/Entities/CartEntity.swift b/Sources/Snowplow/Ecommerce/Entities/CartEntity.swift new file mode 100644 index 000000000..408fdb941 --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Entities/CartEntity.swift @@ -0,0 +1,56 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** + Provided to certain Ecommerce events. The Cart properties will be sent with the event as a Cart entity. + Entity schema: `iglu:com.snowplowanalytics.snowplow.ecommerce/cart/jsonschema/1-0-0` + */ +@objc(SPCartEntity) +public class CartEntity: NSObject { + /// The total value of the cart after this interaction. + @objc + public var totalValue: Decimal + + /// The currency used for this cart (ISO 4217). + @objc + public var currency: String + + /// The unique ID representing this cart. + @objc + public var cartId: String? + + internal var entity: SelfDescribingJson { + var data: [String : Any] = [ + "total_value": totalValue, + "currency": currency + ] + if let cartId = cartId { data["cart_id"] = cartId } + + return SelfDescribingJson(schema: ecommerceCartSchema, andData: data) + } + + /// - Parameter totalValue: The total value of the cart after this interaction. + /// - Parameter currency: The currency used for this cart (ISO 4217). + /// - Parameter cartId: The unique ID representing this cart. + @objc + public init( + totalValue: Decimal, + currency: String, + cartId: String? = nil) { + self.totalValue = totalValue + self.currency = currency + self.cartId = cartId + } +} diff --git a/Sources/Snowplow/Ecommerce/Entities/EcommerceScreenEntity.swift b/Sources/Snowplow/Ecommerce/Entities/EcommerceScreenEntity.swift new file mode 100644 index 000000000..521c6e78a --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Entities/EcommerceScreenEntity.swift @@ -0,0 +1,56 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** + Attach Ecommerce Screen (Page) details to events. It is designed to help with grouping insights by + screen/page type, e.g. Product description, Product list, Home. + Entity schema: `iglu:com.snowplowanalytics.snowplow.ecommerce/page/jsonschema/1-0-0` + */ +@objc(SPEcommerceScreenEntity) +public class EcommerceScreenEntity: NSObject { + /// The type of screen that was visited, e.g. homepage, product details, cart, checkout, etc. + @objc + public var type: String + + /// The language that the screen is based in. + @objc + public var language: String? + + /// The locale version of the app that is running. + @objc + public var locale: String? + + internal var entity: SelfDescribingJson { + var data: [String : Any] = ["type": type] + if let language = language { data["language"] = language } + if let locale = locale { data["locale"] = locale } + + return SelfDescribingJson(schema: ecommercePageSchema, andData: data) + } + + /// - Parameter type: The type of screen that was visited, e.g. homepage, product details, cart, checkout, etc. + /// - Parameter language: The language that the screen is based in. + /// - Parameter locale: The locale version of the app that is running. + @objc + public init( + type: String, + language: String? = nil, + locale: String? = nil + ) { + self.type = type + self.language = language + self.locale = locale + } +} diff --git a/Sources/Snowplow/Ecommerce/Entities/EcommerceUserEntity.swift b/Sources/Snowplow/Ecommerce/Entities/EcommerceUserEntity.swift new file mode 100644 index 000000000..daaaf11a9 --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Entities/EcommerceUserEntity.swift @@ -0,0 +1,71 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** + Attach Ecommerce User details to events. It is designed to help in modeling guest/non-guest account activity. + Entity schema: `iglu:com.snowplowanalytics.snowplow.ecommerce/user/jsonschema/1-0-0` + */ +@objc(SPEcommerceUserEntity) +public class EcommerceUserEntity: NSObject { + /// The user ID. + @objc + public var id: String + + /// Whether or not the user is a guest. + public var isGuest: Bool? + + /// The user's email address. + @objc + public var email: String? + + internal var entity: SelfDescribingJson { + var data: [String : Any] = ["id": id] + if let isGuest = isGuest { data["is_guest"] = isGuest } + if let email = email { data["email"] = email } + + return SelfDescribingJson(schema: ecommerceUserSchema, andData: data) + } + + /// - Parameter id: The user ID. + /// - Parameter isGuest: Whether or not the user is a guest. + /// - Parameter email: The user's email address. + public init( + id: String, + isGuest: Bool? = nil, + email: String? = nil + ) { + self.id = id + self.isGuest = isGuest + self.email = email + } + + /// - Parameter id: The user ID. + /// - Parameter email: The user's email address. + @objc + public init( + id: String, + email: String? = nil + ) { + self.id = id + self.email = email + } + + /// Whether or not the user is a guest. + @objc + public func isGuest(_ isGuest: Bool) -> Self { + self.isGuest = isGuest + return self + } +} diff --git a/Sources/Snowplow/Ecommerce/Entities/ProductEntity.swift b/Sources/Snowplow/Ecommerce/Entities/ProductEntity.swift new file mode 100644 index 000000000..016d61ac7 --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Entities/ProductEntity.swift @@ -0,0 +1,187 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** + Provided to certain Ecommerce events. The Product properties will be sent with the event as a Product entity. + Entity schema: `iglu:com.snowplowanalytics.snowplow.ecommerce/product/jsonschema/1-0-0` + */ +@objc(SPProductEntity) +public class ProductEntity: NSObject { + /// The SKU or product ID. + @objc + public var id: String + + /// The category the product belongs to. Use a consistent separator to express multiple levels. E.g. Woman/Shoes/Sneakers. + @objc + public var category: String + + /// The currency in which the product is being priced (ISO 4217). + @objc + public var currency: String + + /// The price of the product at the current time. + @objc + public var price: Decimal + + /// The recommended or list price of a product. + public var listPrice: Decimal? + + /// The name or title of the product. + @objc + public var name: String? + + /// The quantity of the product taking part in the action. Used for Cart events. + public var quantity: Int? + + /// The size of the product. + @objc + public var size: String? + + /// The variant of the product. + @objc + public var variant: String? + + /// The brand of the product. + @objc + public var brand: String? + + /// The inventory status of the product (e.g. in stock, out of stock, preorder, backorder, etc). + @objc + public var inventoryStatus: String? + + /// The position the product was presented in a list of products (search results, product list page, etc). + public var position: Int? + + /// Identifier, name, or url for the creative presented on the associated promotion. + @objc + public var creativeId: String? + + internal var entity: SelfDescribingJson { + var data: [String : Any] = [ + "id": id, + "category": category, + "currency": currency, + "price": price + ] + if let listPrice = listPrice { data["list_price"] = listPrice } + if let name = name { data["name"] = name } + if let quantity = quantity { data["quantity"] = quantity } + if let size = size { data["size"] = size } + if let variant = variant { data["variant"] = variant } + if let brand = brand { data["brand"] = brand } + if let inventoryStatus = inventoryStatus { data["inventory_status"] = inventoryStatus } + if let position = position { data["position"] = position } + if let creativeId = creativeId { data["creative_id"] = creativeId } + + return SelfDescribingJson(schema: ecommerceProductSchema, andData: data) + } + + /// - Parameter id: The SKU or product ID. + /// - Parameter category: The category the product belongs to. Use a consistent separator to express multiple levels. E.g. Woman/Shoes/Sneakers. + /// - Parameter currency: The currency in which the product is being priced (ISO 4217). + /// - Parameter price: The price of the product at the current time. + /// - Parameter listPrice: The recommended or list price of a product. + /// - Parameter name: The name or title of the product. + /// - Parameter quantity: The quantity of the product taking part in the action. Used for Cart events. + /// - Parameter size: The size of the product. + /// - Parameter variant: The variant of the product. + /// - Parameter brand: The brand of the product. + /// - Parameter inventoryStatus: The inventory status of the product (e.g. in stock, out of stock, preorder, backorder, etc). + /// - Parameter position: The position the product was presented in a list of products (search results, product list page, etc). + /// - Parameter creativeId: Identifier, name, or url for the creative presented on the associated promotion. + public init( + id: String, + category: String, + currency: String, + price: Decimal, + listPrice: Decimal? = nil, + name: String? = nil, + quantity: Int? = nil, + size: String? = nil, + variant: String? = nil, + brand: String? = nil, + inventoryStatus: String? = nil, + position: Int? = nil, + creativeId: String? = nil) { + self.id = id + self.category = category + self.currency = currency + self.price = price + self.listPrice = listPrice + self.name = name + self.quantity = quantity + self.size = size + self.variant = variant + self.brand = brand + self.inventoryStatus = inventoryStatus + self.position = position + self.creativeId = creativeId + } + + /// - Parameter id: The SKU or product ID. + /// - Parameter category: The category the product belongs to. Use a consistent separator to express multiple levels. E.g. Woman/Shoes/Sneakers. + /// - Parameter currency: The currency in which the product is being priced (ISO 4217). + /// - Parameter price: The price of the product at the current time. + /// - Parameter name: The name or title of the product. + /// - Parameter size: The size of the product. + /// - Parameter variant: The variant of the product. + /// - Parameter brand: The brand of the product. + /// - Parameter inventoryStatus: The inventory status of the product (e.g. in stock, out of stock, preorder, backorder, etc). + /// - Parameter creativeId: Identifier, name, or url for the creative presented on the associated promotion. + @objc + public init( + id: String, + category: String, + currency: String, + price: Decimal, + name: String? = nil, + size: String? = nil, + variant: String? = nil, + brand: String? = nil, + inventoryStatus: String? = nil, + creativeId: String? = nil) { + self.id = id + self.category = category + self.currency = currency + self.price = price + self.name = name + self.size = size + self.variant = variant + self.brand = brand + self.inventoryStatus = inventoryStatus + self.creativeId = creativeId + } + + /// The recommended or list price of a product. + @objc + public func listPrice(_ listPrice: Decimal) -> Self { + self.listPrice = listPrice + return self + } + + /// The quantity of the product taking part in the action. Used for Cart events. + @objc + public func quantity(_ quantity: Int) -> Self { + self.quantity = quantity + return self + } + + /// The position the product was presented in a list of products (search results, product list page, etc). + @objc + public func position(_ position: Int) -> Self { + self.position = position + return self + } +} diff --git a/Sources/Snowplow/Ecommerce/Entities/PromotionEntity.swift b/Sources/Snowplow/Ecommerce/Entities/PromotionEntity.swift new file mode 100644 index 000000000..d862e593e --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Entities/PromotionEntity.swift @@ -0,0 +1,115 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** + Provided to certain Ecommerce events. The Promotion properties will be sent with the event as a Promotion entity. + Entity schema: `iglu:com.snowplowanalytics.snowplow.ecommerce/promotion/jsonschema/1-0-0` + */ +@objc(SPPromotionEntity) +public class PromotionEntity: NSObject { + /// The ID of the promotion. + @objc + public var id: String + + /// The name of the promotion. + @objc + public var name: String? + + /// List of SKUs or product IDs showcased in the promotion. + @objc + public var productIds: [String]? + + /// The position the promotion was presented in a list of promotions such as a banner or slider, e.g. 2. + public var position: Int? + + /// Identifier, name, or url for the creative presented on the promotion. + @objc + public var creativeId: String? + + /// Type of the promotion delivery mechanism. E.g. popup, banner, intra-content. + @objc + public var type: String? + + /// The UI slot in which the promotional content was added to. + @objc + public var slot: String? + + internal var entity: SelfDescribingJson { + var data: [String : Any] = [ + "id": id + ] + if let name = name { data["name"] = name } + if let productIds = productIds { data["product_ids"] = productIds } + if let position = position { data["position"] = position } + if let creativeId = creativeId { data["creative_id"] = creativeId } + if let type = type { data["type"] = type } + if let slot = slot { data["slot"] = slot } + + return SelfDescribingJson(schema: ecommercePromotionSchema, andData: data) + } + + /// - Parameter id: The ID of the promotion. + /// - Parameter name: The name of the promotion. + /// - Parameter productIds: List of SKUs or product IDs showcased in the promotion. + /// - Parameter position: The position the promotion was presented in a list of promotions such as a banner or slider, e.g. 2. + /// - Parameter creativeId: Identifier, name, or url for the creative presented on the promotion. + /// - Parameter type: Type of the promotion delivery mechanism. E.g. popup, banner, intra-content. + /// - Parameter slot: The UI slot in which the promotional content was added to. + public init( + id: String, + name: String? = nil, + productIds: [String]? = nil, + position: Int? = nil, + creativeId: String? = nil, + type: String? = nil, + slot: String? = nil) { + self.id = id + self.name = name + self.productIds = productIds + self.position = position + self.creativeId = creativeId + self.type = type + self.slot = slot + } + + /// - Parameter id: The ID of the promotion. + /// - Parameter name: The name of the promotion. + /// - Parameter productIds: List of SKUs or product IDs showcased in the promotion. + /// - Parameter creativeId: Identifier, name, or url for the creative presented on the promotion. + /// - Parameter type: Type of the promotion delivery mechanism. E.g. popup, banner, intra-content. + /// - Parameter slot: The UI slot in which the promotional content was added to. + @objc + public init( + id: String, + name: String? = nil, + productIds: [String]? = nil, + creativeId: String? = nil, + type: String? = nil, + slot: String? = nil) { + self.id = id + self.name = name + self.productIds = productIds + self.creativeId = creativeId + self.type = type + self.slot = slot + } + + /// The position the promotion was presented in a list of promotions such as a banner or slider, e.g. 2. + @objc + public func position(_ position: Int) -> Self { + self.position = position + return self + } +} diff --git a/Sources/Snowplow/Ecommerce/Entities/TransactionEntity.swift b/Sources/Snowplow/Ecommerce/Entities/TransactionEntity.swift new file mode 100644 index 000000000..e0f9ceedb --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Entities/TransactionEntity.swift @@ -0,0 +1,159 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** + Provided to certain Ecommerce events. The Transaction properties will be sent with the event as a Transaction entity. + Entity schema: `iglu:com.snowplowanalytics.snowplow.ecommerce/transaction/jsonschema/1-0-0` + */ +@objc(SPTransactionEntity) +public class TransactionEntity: NSObject { + /// The ID of the transaction. + @objc + public var transactionId: String + + /// The total value of the transaction. + @objc + public var revenue: Decimal + + /// The currency used for the transaction (ISO 4217). + @objc + public var currency: String + + /// The payment method used for the transaction. + @objc + public var paymentMethod: String + + /// Total quantity of items in the transaction. + @objc + public var totalQuantity: Int + + /// Total amount of tax on the transaction. + public var tax: Decimal? + + /// Total cost of shipping on the transaction. + public var shipping: Decimal? + + /// Discount code used. + @objc + public var discountCode: String? + + /// Discount amount taken off. + public var discountAmount: Decimal? + + /// Whether the transaction is a credit order or not. + public var creditOrder: Bool? + + internal var entity: SelfDescribingJson { + var data: [String : Any] = [ + "transaction_id": transactionId, + "revenue": revenue, + "currency": currency, + "payment_method": paymentMethod, + "total_quantity": totalQuantity + ] + if let tax = tax { data["tax"] = tax } + if let shipping = shipping { data["shipping"] = shipping } + if let discountCode = discountCode { data["discount_code"] = discountCode } + if let discountAmount = discountAmount { data["discount_amount"] = discountAmount } + if let creditOrder = creditOrder { data["credit_order"] = creditOrder } + + return SelfDescribingJson(schema: ecommerceTransactionSchema, andData: data) + } + + /// - Parameter transactionId: The ID of the transaction. + /// - Parameter revenue: The total value of the transaction. + /// - Parameter currency: The currency used (ISO 4217). + /// - Parameter paymentMethod: The payment method used. + /// - Parameter totalQuantity: Total quantity of items in the transaction. + /// - Parameter tax: Total amount of tax on the transaction. + /// - Parameter shipping: Total cost of shipping on the transaction. + /// - Parameter discountCode: Discount code used. + /// - Parameter discountAmount: Discount amount taken off. + /// - Parameter creditOrder: Whether it is a credit order or not. + public init( + transactionId: String, + revenue: Decimal, + currency: String, + paymentMethod: String, + totalQuantity: Int, + tax: Decimal? = nil, + shipping: Decimal? = nil, + discountCode: String? = nil, + discountAmount: Decimal? = nil, + creditOrder: Bool? = nil + ) { + self.transactionId = transactionId + self.revenue = revenue + self.currency = currency + self.paymentMethod = paymentMethod + self.totalQuantity = totalQuantity + self.tax = tax + self.shipping = shipping + self.discountCode = discountCode + self.discountAmount = discountAmount + self.creditOrder = creditOrder + } + + /// - Parameter transactionId: The ID of the transaction. + /// - Parameter revenue: The total value of the transaction. + /// - Parameter currency: The currency used (ISO 4217). + /// - Parameter paymentMethod: The payment method used. + /// - Parameter totalQuantity: Total quantity of items in the transaction. + /// - Parameter discountCode: Discount code used. + @objc + public init( + transactionId: String, + revenue: Decimal, + currency: String, + paymentMethod: String, + totalQuantity: Int, + discountCode: String? = nil + ) { + self.transactionId = transactionId + self.revenue = revenue + self.currency = currency + self.paymentMethod = paymentMethod + self.totalQuantity = totalQuantity + self.discountCode = discountCode + } + + /// Total amount of tax on the transaction. + @objc + public func tax(_ tax: Decimal) -> Self { + self.tax = tax + return self + } + + /// Total cost of shipping on the transaction. + @objc + public func shipping(_ shipping: Decimal) -> Self { + self.shipping = shipping + return self + } + + /// Discount amount taken off. + @objc + public func discountAmount(_ discountAmount: Decimal) -> Self { + self.discountAmount = discountAmount + return self + } + + /// Whether the transaction is a credit order or not. + @objc + public func creditOrder(_ creditOrder: Bool) -> Self { + self.creditOrder = creditOrder + return self + } +} diff --git a/Sources/Snowplow/Ecommerce/Events/AddToCartEvent.swift b/Sources/Snowplow/Ecommerce/Events/AddToCartEvent.swift new file mode 100644 index 000000000..a7430ed8c --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Events/AddToCartEvent.swift @@ -0,0 +1,54 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** Track a product or products being added to cart. */ +@objc(SPAddToCartEvent) +public class AddToCartEvent: SelfDescribingAbstract { + /// List of product(s) that were added to the cart. + @objc + public var products: [ProductEntity] + + /// State of the cart after the addition. + @objc + public var cart: CartEntity + + override var schema: String { + return ecommerceActionSchema + } + + override var payload: [String : Any] { + let data: [String: Any] = ["type": "add_to_cart"] + return data + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { + var entities = [SelfDescribingJson]() + for product in products { + entities.append(product.entity) + } + entities.append(cart.entity) + return entities + } + } + + /// - Parameter products: List of product(s) that were added to the cart. + /// - Parameter cart: State of the cart after this addition. + @objc + public init(products: [ProductEntity], cart: CartEntity) { + self.products = products + self.cart = cart + } +} diff --git a/Sources/Snowplow/Ecommerce/Events/CheckoutStepEvent.swift b/Sources/Snowplow/Ecommerce/Events/CheckoutStepEvent.swift new file mode 100644 index 000000000..ee756b713 --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Events/CheckoutStepEvent.swift @@ -0,0 +1,180 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/// Track a checkout step. +/// Entity schema: `iglu:com.snowplowanalytics.snowplow.ecommerce/checkout_step/jsonschema/1-0-0` +@objc(SPCheckoutStepEvent) +public class CheckoutStepEvent: SelfDescribingAbstract { + /// Checkout step index. + @objc + public var step: Int + + /// Shipping address postcode. + @objc + public var shippingPostcode: String? + + /// Billing address postcode. + @objc + public var billingPostcode: String? + + /// Full shipping address. + @objc + public var shippingFullAddress: String? + + /// Full billing address. + @objc + public var billingFullAddress: String? + + /// Can be used to discern delivery providers DHL, PostNL etc. + @objc + public var deliveryProvider: String? + + /// E.g. store pickup, standard delivery, express delivery, international. + @objc + public var deliveryMethod: String? + + /// Coupon applied at checkout. + @objc + public var couponCode: String? + + /// Type of account used on checkout, e.g. existing user, guest. + @objc + public var accountType: String? + + /// Any kind of payment method the user selected to proceed. Card, PayPal, Alipay etc. + @objc + public var paymentMethod: String? + + /// E.g. invoice or receipt + @objc + public var proofOfPayment: String? + + /// If opted in to marketing campaigns to the email address. + public var marketingOptIn: Bool? + + override var schema: String { + return ecommerceActionSchema + } + + override var payload: [String : Any] { + let data: [String: Any] = ["type": "checkout_step"] + return data + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { + var data: [String : Any] = ["step": step] + + if let shippingPostcode = shippingPostcode { data["shipping_postcode"] = shippingPostcode } + if let billingPostcode = billingPostcode { data["billing_postcode"] = billingPostcode } + if let shippingFullAddress = shippingFullAddress { data["shipping_full_address"] = shippingFullAddress } + if let billingFullAddress = billingFullAddress { data["billing_full_address"] = billingFullAddress } + if let deliveryProvider = deliveryProvider { data["delivery_provider"] = deliveryProvider } + if let deliveryMethod = deliveryMethod { data["delivery_method"] = deliveryMethod } + if let couponCode = couponCode { data["coupon_code"] = couponCode } + if let accountType = accountType { data["account_type"] = accountType } + if let paymentMethod = paymentMethod { data["payment_method"] = paymentMethod } + if let proofOfPayment = proofOfPayment { data["proof_of_payment"] = proofOfPayment } + if let marketingOptIn = marketingOptIn { data["marketing_opt_in"] = marketingOptIn } + + return [SelfDescribingJson(schema: ecommerceCheckoutStepSchema, andData: data)] + } + } + + /// - Parameter step: Checkout step index. + /// - Parameter shippingPostcode: Shipping address postcode. + /// - Parameter billingPostcode: Billing address postcode. + /// - Parameter shippingFullAddress: Full shipping address. + /// - Parameter billingFullAddress: Full billing address. + /// - Parameter deliveryProvider: Can be used to discern delivery providers e.g. DHL, PostNL etc. + /// - Parameter deliveryMethod: Store pickup, standard delivery, express delivery, international, etc. + /// - Parameter couponCode: Coupon applied at checkout. + /// - Parameter accountType: Type of account used on checkout, e.g. existing user, guest. + /// - Parameter paymentMethod: Any kind of payment method the user selected to proceed. Card, PayPal, Alipay etc. + /// - Parameter proofOfPayment: E.g. invoice or receipt. + /// - Parameter marketingOptIn: If opted in to marketing campaigns to the email address. + public init( + step: Int, + shippingPostcode: String? = nil, + billingPostcode: String? = nil, + shippingFullAddress: String? = nil, + billingFullAddress: String? = nil, + deliveryProvider: String? = nil, + deliveryMethod: String? = nil, + couponCode: String? = nil, + accountType: String? = nil, + paymentMethod: String? = nil, + proofOfPayment: String? = nil, + marketingOptIn: Bool? = nil + ) { + self.step = step + self.shippingPostcode = shippingPostcode + self.billingPostcode = billingPostcode + self.shippingFullAddress = shippingFullAddress + self.billingFullAddress = billingFullAddress + self.deliveryProvider = deliveryProvider + self.deliveryMethod = deliveryMethod + self.couponCode = couponCode + self.accountType = accountType + self.paymentMethod = paymentMethod + self.proofOfPayment = proofOfPayment + self.marketingOptIn = marketingOptIn + } + + /// - Parameter step: Checkout step index. + /// - Parameter shippingPostcode: Shipping address postcode. + /// - Parameter billingPostcode: Billing address postcode. + /// - Parameter shippingFullAddress: Full shipping address. + /// - Parameter billingFullAddress: Full billing address. + /// - Parameter deliveryProvider: Can be used to discern delivery providers e.g. DHL, PostNL etc. + /// - Parameter deliveryMethod: Store pickup, standard delivery, express delivery, international, etc. + /// - Parameter couponCode: Coupon applied at checkout. + /// - Parameter accountType: Type of account used on checkout, e.g. existing user, guest. + /// - Parameter paymentMethod: Any kind of payment method the user selected to proceed. Card, PayPal, Alipay etc. + /// - Parameter proofOfPayment: E.g. invoice or receipt. + @objc + public init( + step: Int, + shippingPostcode: String? = nil, + billingPostcode: String? = nil, + shippingFullAddress: String? = nil, + billingFullAddress: String? = nil, + deliveryProvider: String? = nil, + deliveryMethod: String? = nil, + couponCode: String? = nil, + accountType: String? = nil, + paymentMethod: String? = nil, + proofOfPayment: String? = nil + ) { + self.step = step + self.shippingPostcode = shippingPostcode + self.billingPostcode = billingPostcode + self.shippingFullAddress = shippingFullAddress + self.billingFullAddress = billingFullAddress + self.deliveryProvider = deliveryProvider + self.deliveryMethod = deliveryMethod + self.couponCode = couponCode + self.accountType = accountType + self.paymentMethod = paymentMethod + self.proofOfPayment = proofOfPayment + } + + /// If opted in to marketing campaigns to the email address. + @objc + public func marketingOptIn(_ marketingOptIn: Bool) -> Self { + self.marketingOptIn = marketingOptIn + return self + } +} diff --git a/Sources/Snowplow/Ecommerce/Events/ProductListClickEvent.swift b/Sources/Snowplow/Ecommerce/Events/ProductListClickEvent.swift new file mode 100644 index 000000000..3e2a6e065 --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Events/ProductListClickEvent.swift @@ -0,0 +1,48 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** Track a product list click or selection event. */ +@objc(SPProductListClickEvent) +public class ProductListClickEvent: SelfDescribingAbstract { + /// Information about the product that was selected. + @objc + public var product: ProductEntity + + /// The list name. + @objc + public var name: String? + + override var schema: String { + return ecommerceActionSchema + } + + override var payload: [String : Any] { + var data: [String: Any] = ["type": "list_click"] + if let name = name { data["name"] = name } + return data + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { [product.entity] } + } + + /// - Parameter promotion: Information about the product that was selected. + /// - Parameter name: The list name. + @objc + public init(product: ProductEntity, name: String? = nil) { + self.product = product + self.name = name + } +} diff --git a/Sources/Snowplow/Ecommerce/Events/ProductListViewEvent.swift b/Sources/Snowplow/Ecommerce/Events/ProductListViewEvent.swift new file mode 100644 index 000000000..1c8088af2 --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Events/ProductListViewEvent.swift @@ -0,0 +1,54 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** Track a product list view. */ +@objc(SPProductListViewEvent) +public class ProductListViewEvent: SelfDescribingAbstract { + /// List of products viewed. + @objc + public var products: [ProductEntity] + + /// The list name. + @objc + public var name: String? + + override var schema: String { + return ecommerceActionSchema + } + + override var payload: [String : Any] { + var data: [String: Any] = ["type": "list_view"] + if let name = name { data["name"] = name } + return data + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { + var entities = [SelfDescribingJson]() + for product in products { + entities.append(product.entity) + } + return entities + } + } + + /// - Parameter promotion: List of products viewed. + /// - Parameter name: The list name. + @objc + public init(products: [ProductEntity], name: String? = nil) { + self.products = products + self.name = name + } +} diff --git a/Sources/Snowplow/Ecommerce/Events/ProductViewEvent.swift b/Sources/Snowplow/Ecommerce/Events/ProductViewEvent.swift new file mode 100644 index 000000000..c1c4f67ec --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Events/ProductViewEvent.swift @@ -0,0 +1,41 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** Track a product view/detail. */ +@objc(SPProductViewEvent) +public class ProductViewEvent: SelfDescribingAbstract { + /// The product that was viewed in a product detail page. + @objc + public var product: ProductEntity + + override var schema: String { + return ecommerceActionSchema + } + + override var payload: [String : Any] { + let data: [String: Any] = ["type": "product_view"] + return data + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { [product.entity] } + } + + /// - Parameter product: The product that was viewed in a product detail page. + @objc + public init(product: ProductEntity) { + self.product = product + } +} diff --git a/Sources/Snowplow/Ecommerce/Events/PromotionClickEvent.swift b/Sources/Snowplow/Ecommerce/Events/PromotionClickEvent.swift new file mode 100644 index 000000000..f0114ebdb --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Events/PromotionClickEvent.swift @@ -0,0 +1,41 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** Track a promotion click or selection. */ +@objc(SPPromotionClickEvent) +public class PromotionClickEvent: SelfDescribingAbstract { + /// The promotion selected. + @objc + public var promotion: PromotionEntity + + override var schema: String { + return ecommerceActionSchema + } + + override var payload: [String : Any] { + let data: [String: Any] = ["type": "promo_click"] + return data + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { [promotion.entity] } + } + + /// - Parameter promotion: The promotion selected. + @objc + public init(promotion: PromotionEntity) { + self.promotion = promotion + } +} diff --git a/Sources/Snowplow/Ecommerce/Events/PromotionViewEvent.swift b/Sources/Snowplow/Ecommerce/Events/PromotionViewEvent.swift new file mode 100644 index 000000000..ac4071062 --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Events/PromotionViewEvent.swift @@ -0,0 +1,41 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** Track a promotion view. */ +@objc(SPPromotionViewEvent) +public class PromotionViewEvent: SelfDescribingAbstract { + /// The promotion selected. + @objc + public var promotion: PromotionEntity + + override var schema: String { + return ecommerceActionSchema + } + + override var payload: [String : Any] { + let data: [String: Any] = ["type": "promo_view"] + return data + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { [promotion.entity] } + } + + /// - Parameter promotion: The promotion viewed. + @objc + public init(promotion: PromotionEntity) { + self.promotion = promotion + } +} diff --git a/Sources/Snowplow/Ecommerce/Events/RefundEvent.swift b/Sources/Snowplow/Ecommerce/Events/RefundEvent.swift new file mode 100644 index 000000000..736742542 --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Events/RefundEvent.swift @@ -0,0 +1,95 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** + Track a refund event. Use the same transaction ID as for the original Transaction event. + Provide a list of products to specify certain products to be refunded, otherwise the whole transaction + will be marked as refunded. + Entity schema: `iglu:com.snowplowanalytics.snowplow.ecommerce/refund/jsonschema/1-0-0` + */ +@objc(SPRefundEvent) +public class RefundEvent: SelfDescribingAbstract { + /// The ID of the relevant transaction. + @objc + public var transactionId: String + + /// The monetary amount refunded. + @objc + public var refundAmount: Decimal + + /// The currency in which the product is being priced (ISO 4217). + @objc + public var currency: String + + /// Reason for refunding the whole or part of the transaction. + @objc + public var refundReason: String? + + /// Products in the transaction. + @objc + public var products: [ProductEntity]? + + override var schema: String { + return ecommerceActionSchema + } + + override var payload: [String : Any] { + let data: [String: Any] = ["type": "refund"] + return data + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { + var entities = [SelfDescribingJson]() + + var data: [String : Any] = [ + "transaction_id": transactionId, + "refund_amount": refundAmount, + "currency": currency + ] + if let refundReason = refundReason { data["refund_reason"] = refundReason } + let refundEntity = SelfDescribingJson(schema: ecommerceRefundSchema, andData: data) + + entities.append(refundEntity) + if let products = products { + for product in products { + entities.append(product.entity) + } + } + + return entities + } + } + + /// - Parameter transactionId: The ID of the relevant transaction. + /// - Parameter currency: The currency in which the product(s) are being priced (ISO 4217). + /// - Parameter refundAmount: The monetary amount refunded. + /// - Parameter refundReason: Reason for refunding the whole or part of the transaction. + /// - Parameter products: The products to be refunded. + @objc + public init( + transactionId: String, + refundAmount: Decimal, + currency: String, + refundReason: String? = nil, + products: [ProductEntity]? = nil + ) { + self.transactionId = transactionId + self.refundAmount = refundAmount + self.currency = currency + self.refundReason = refundReason + self.products = products + } +} diff --git a/Sources/Snowplow/Ecommerce/Events/RemoveFromCartEvent.swift b/Sources/Snowplow/Ecommerce/Events/RemoveFromCartEvent.swift new file mode 100644 index 000000000..9021cd3d0 --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Events/RemoveFromCartEvent.swift @@ -0,0 +1,54 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** Track a product or products being removed from cart. */ +@objc(SPRemoveFromCartEvent) +public class RemoveFromCartEvent: SelfDescribingAbstract { + /// List of product(s) that were removed from the cart. + @objc + public var products: [ProductEntity] + + /// State of the cart after the removal. + @objc + public var cart: CartEntity + + override var schema: String { + return ecommerceActionSchema + } + + override var payload: [String : Any] { + let data: [String: Any] = ["type": "remove_from_cart"] + return data + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { + var entities = [SelfDescribingJson]() + for product in products { + entities.append(product.entity) + } + entities.append(cart.entity) + return entities + } + } + + /// - Parameter products: List of product(s) that were removed from the cart. + /// - Parameter cart: State of the cart after this addition. + @objc + public init(products: [ProductEntity], cart: CartEntity) { + self.products = products + self.cart = cart + } +} diff --git a/Sources/Snowplow/Ecommerce/Events/TransactionErrorEvent.swift b/Sources/Snowplow/Ecommerce/Events/TransactionErrorEvent.swift new file mode 100644 index 000000000..513b73ddb --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Events/TransactionErrorEvent.swift @@ -0,0 +1,133 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +@objc +public enum ErrorType : Int { + case hard + case soft +} + +/// Track a transaction error event. +/// Entity schema: `iglu:com.snowplowanalytics.snowplow.ecommerce/transaction_error/jsonschema/1-0-0` +@objc(SPTransactionErrorEvent) +public class TransactionErrorEvent: SelfDescribingAbstract { + /// The transaction object representing the transaction that ended up in an error. + @objc + var transaction: TransactionEntity + + /// Error-identifying code for the transaction issue, e.g. E522. + @objc + var errorCode: String? + + /// Shortcode for the error that occurred in the transaction e.g. declined_by_stock_api, declined_by_payment_method, card_declined, pm_card_radarBlock. + @objc + var errorShortcode: String? + + /// Longer description for the error that occurred in the transaction. + @objc + var errorDescription: String? + + /// Type of error. Hard error types mean the customer must provide another form of payment e.g. an expired card. + /// Soft errors can be the result of temporary issues where retrying might be successful e.g. processor declined the transaction. + var errorType: ErrorType? + + /// The resolution selected for the error scenario e.g. retry_allowed, user_blacklisted, block_gateway, contact_user, default. + @objc + var resolution: String? + + override var schema: String { + return ecommerceActionSchema + } + + override var payload: [String : Any] { + let data: [String: Any] = ["type": "trns_error"] + return data + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { + var entities = [SelfDescribingJson]() + + var data: [String : Any] = [:] + + if let errorCode = errorCode { data["error_code"] = errorCode } + if let errorShortcode = errorShortcode { data["error_shortcode"] = errorShortcode } + if let errorDescription = errorDescription { data["error_description"] = errorDescription } + if let errorType = errorType { + if (errorType == .hard) { + data["error_type"] = "hard" + } else { + data["error_type"] = "soft" + } + } + if let resolution = resolution { data["resolution"] = resolution } + + entities.append(SelfDescribingJson(schema: ecommerceTransactionErrorSchema, andData: data)) + entities.append(transaction.entity) + + return entities + } + } + + /// - Parameter transaction: The transaction object representing the transaction that ended up in an error. + /// - Parameter errorCode: Error-identifying code for the transaction issue. E.g. E522 + /// - Parameter errorShortcode: Shortcode for the error that occurred in the transaction. + /// - Parameter errorDescription: Longer description for the error that occurred in the transaction. + /// - Parameter errorType: Type of error. + /// - Parameter resolution: The resolution selected for the error scenario. + public init( + transaction: TransactionEntity, + errorCode: String? = nil, + errorShortcode: String? = nil, + errorDescription: String? = nil, + errorType: ErrorType? = nil, + resolution: String? = nil + ) { + self.transaction = transaction + self.errorCode = errorCode + self.errorShortcode = errorShortcode + self.errorDescription = errorDescription + self.errorType = errorType + self.resolution = resolution + } + + /// - Parameter transaction: The transaction object representing the transaction that ended up in an error. + /// - Parameter errorCode: Error-identifying code for the transaction issue. E.g. E522 + /// - Parameter errorShortcode: Shortcode for the error that occurred in the transaction. + /// - Parameter errorDescription: Longer description for the error that occurred in the transaction. + /// - Parameter resolution: The resolution selected for the error scenario. + @objc + public init( + transaction: TransactionEntity, + errorCode: String? = nil, + errorShortcode: String? = nil, + errorDescription: String? = nil, + resolution: String? = nil + ) { + self.transaction = transaction + self.errorCode = errorCode + self.errorShortcode = errorShortcode + self.errorDescription = errorDescription + self.resolution = resolution + } + + /// Type of error. Hard error types mean the customer must provide another form of payment e.g. an expired card. + /// Soft errors can be the result of temporary issues where retrying might be successful e.g. processor declined the transaction. + @objc + public func errorType(_ errorType: ErrorType) -> Self { + self.errorType = errorType + return self + } +} diff --git a/Sources/Snowplow/Ecommerce/Events/TransactionEvent.swift b/Sources/Snowplow/Ecommerce/Events/TransactionEvent.swift new file mode 100644 index 000000000..b330c6b00 --- /dev/null +++ b/Sources/Snowplow/Ecommerce/Events/TransactionEvent.swift @@ -0,0 +1,58 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/// Track a transaction event. +/// Entity schema: `iglu:com.snowplowanalytics.snowplow.ecommerce/transaction/jsonschema/1-0-0` +@objc(SPTransactionEvent) +public class TransactionEvent: SelfDescribingAbstract { + /// The transaction involved. + @objc + public var transaction: TransactionEntity + + /// Products in the transaction. + @objc + public var products: [ProductEntity]? + + override var schema: String { + return ecommerceActionSchema + } + + override var payload: [String : Any] { + let data: [String: Any] = ["type": "transaction"] + return data + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { + var entities = [SelfDescribingJson]() + + entities.append(transaction.entity) + if let products = products { + for product in products { + entities.append(product.entity) + } + } + return entities + } + } + + /// - Parameter transaction: The transaction details. + /// - Parameter products: The product(s) included in the transaction. + @objc + public init(transaction: TransactionEntity, products: [ProductEntity]? = nil) { + self.transaction = transaction + self.products = products + } +} diff --git a/Sources/Snowplow/Events/Ecommerce.swift b/Sources/Snowplow/Events/Ecommerce.swift index 54e2724a7..8d142a94d 100644 --- a/Sources/Snowplow/Events/Ecommerce.swift +++ b/Sources/Snowplow/Events/Ecommerce.swift @@ -13,6 +13,8 @@ import Foundation +/// @deprecated Use the ecommerce package instead. +@available(*, deprecated, message: "Use the ecommerce package instead.") @objc(SPEcommerce) public class Ecommerce : PrimitiveAbstract { /// Identifier of the order. diff --git a/Sources/Snowplow/Events/EcommerceItem.swift b/Sources/Snowplow/Events/EcommerceItem.swift index 2b1fb68b8..751d2f7b7 100644 --- a/Sources/Snowplow/Events/EcommerceItem.swift +++ b/Sources/Snowplow/Events/EcommerceItem.swift @@ -13,6 +13,8 @@ import Foundation +/// @deprecated Use the ecommerce package instead. +@available(*, deprecated, message: "Use the ecommerce package instead.") @objc(SPEcommerceItem) public class EcommerceItem : PrimitiveAbstract { /// Stock Keeping Unit of the item. diff --git a/Sources/Snowplow/Events/EventBase.swift b/Sources/Snowplow/Events/EventBase.swift index b18f64c36..4b8f8ac77 100644 --- a/Sources/Snowplow/Events/EventBase.swift +++ b/Sources/Snowplow/Events/EventBase.swift @@ -20,9 +20,22 @@ public class Event: NSObject { @objc public var trueTimestamp: Date? + private var _entities: [SelfDescribingJson] = [] /// The context entities attached to the event. @objc - public var entities: [SelfDescribingJson] = [] + public var entities: [SelfDescribingJson] { + get { + if (isProcessing) { + if let entitiesForProcessing = entitiesForProcessing { + return _entities + entitiesForProcessing + } + } + return _entities + } + set { + _entities = newValue + } + } /// The context entities attached to the event. @objc @@ -32,6 +45,12 @@ public class Event: NSObject { set { entities = newValue } } + /// Used for events whose properties are added as entities, e.g. Ecommerce events + @objc + internal var entitiesForProcessing: [SelfDescribingJson]? { + get { return nil } + } + /// The payload of the event. var payload: [String : Any] { NSException( @@ -41,14 +60,17 @@ public class Event: NSObject { abort() } + private var isProcessing = false /// Hook method called just before the event processing in order to execute special operations. /// @note Internal use only - Don't use in production, it can change without notice. func beginProcessing(withTracker tracker: Tracker) { + isProcessing = true } /// Hook method called just after the event processing in order to execute special operations. /// @note Internal use only - Don't use in production, it can change without notice. func endProcessing(withTracker tracker: Tracker) { + isProcessing = false } // MARK: - Builders @@ -60,14 +82,14 @@ public class Event: NSObject { return self } - /// The context entities attached to the event. + /// Replace the context entities attached to the event with a new list of entities. @objc public func entities(_ entities: [SelfDescribingJson]) -> Self { self.entities = entities return self } - /// The context entities attached to the event. + /// Replace the context entities attached to the event with a new list of entities. @objc @available(*, deprecated, renamed: "entities") public func contexts(_ entities: [SelfDescribingJson]) -> Self { diff --git a/Sources/Snowplow/Snowplow.swift b/Sources/Snowplow/Snowplow.swift index fbd691ace..f8fec0898 100644 --- a/Sources/Snowplow/Snowplow.swift +++ b/Sources/Snowplow/Snowplow.swift @@ -18,7 +18,7 @@ import WebKit /// Entry point to instance a new Snowplow tracker. /// -/// The following example initializes a tracker instance using the ``createTracker(namespace:network:)`` method and tracks a ``SelfDescribing`` event: +/// The following example initializes a tracker instance using the ``createTracker(namespace:endpoint:method:)`` method and tracks a ``SelfDescribing`` event: /// /// ```swift /// let tracker = Snowplow.createTracker( diff --git a/Tests/Ecommerce/TestEcommerceController.swift b/Tests/Ecommerce/TestEcommerceController.swift new file mode 100644 index 000000000..b1727aae1 --- /dev/null +++ b/Tests/Ecommerce/TestEcommerceController.swift @@ -0,0 +1,149 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import XCTest +@testable import SnowplowTracker + +class TestEcommerceController: XCTestCase { + + var trackedEvents: [InspectableEvent] = [] + var tracker: TrackerController? + + override func setUp() { + tracker = createTracker() + } + + override func tearDown() { + Snowplow.removeAllTrackers() + trackedEvents.removeAll() + } + + func testAddScreenEntity() { + tracker?.ecommerce.setEcommerceScreen(EcommerceScreenEntity(type: "product", language: "EN-GB", locale: "UK")) + + _ = tracker?.track(ScreenView(name: "screenId")) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssert(trackedEvents[0].entities.contains { $0.schema == ecommercePageSchema }) + + let screenEntities = getScreenEntities(trackedEvents[0].entities) + XCTAssertEqual(1, screenEntities.count) + + var entity = screenEntities[0] + XCTAssertEqual("product", entity.data["type"] as? String) + XCTAssertEqual("EN-GB", entity.data["language"] as? String) + XCTAssertEqual("UK", entity.data["locale"] as? String) + + // replacing earlier Screen + tracker?.ecommerce.setEcommerceScreen(EcommerceScreenEntity(type: "listing", locale: "USA")) + _ = tracker?.track(ScreenView(name: "screenId2")) + waitForEventsToBeTracked() + + entity = getScreenEntities(trackedEvents[1].entities)[0] + XCTAssertEqual("listing", entity.data["type"] as? String) + XCTAssertEqual("USA", entity.data["locale"] as? String) + + // removing Screen + tracker?.ecommerce.removeEcommerceScreen() + _ = tracker?.track(ScreenView(name: "screenId3")) + waitForEventsToBeTracked() + + XCTAssertFalse(trackedEvents[2].entities.contains { $0.schema == ecommercePageSchema }) + } + + func testAddUserEntity() { + tracker?.ecommerce.setEcommerceUser(EcommerceUserEntity(id: "userId", isGuest: true, email: "email@email.com")) + + _ = tracker?.track(ScreenView(name: "screenId")) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssert(trackedEvents[0].entities.contains { $0.schema == ecommerceUserSchema }) + + let userEntities = getUserEntities(trackedEvents[0].entities) + XCTAssertEqual(1, userEntities.count) + + var entity = userEntities[0] + XCTAssertEqual("userId", entity.data["id"] as? String) + XCTAssertEqual(true, entity.data["is_guest"] as? Bool) + XCTAssertEqual("email@email.com", entity.data["email"] as? String) + + // replacing earlier User + tracker?.ecommerce.setEcommerceUser(EcommerceUserEntity(id: "newUser", isGuest: false)) + _ = tracker?.track(ScreenView(name: "screenId2")) + waitForEventsToBeTracked() + + entity = getUserEntities(trackedEvents[1].entities)[0] + XCTAssertEqual("newUser", entity.data["id"] as? String) + XCTAssertEqual(false, entity.data["is_guest"] as? Bool) + + // removing Screen + tracker?.ecommerce.removeEcommerceUser() + _ = tracker?.track(ScreenView(name: "screenId3")) + waitForEventsToBeTracked() + + XCTAssertFalse(trackedEvents[2].entities.contains { $0.schema == ecommerceUserSchema }) + } + + private func createTracker() -> TrackerController { + let networkConfig = NetworkConfiguration(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 200)) + let trackerConfig = TrackerConfiguration() + trackerConfig.installAutotracking = false + trackerConfig.lifecycleAutotracking = false + + let namespace = "testEcommerce" + String(describing: Int.random(in: 0..<100)) + let plugin = PluginConfiguration(identifier: "testPlugin" + namespace) + .afterTrack { event in + if namespace == self.tracker?.namespace { + self.trackedEvents.append(event) + } + } + + return Snowplow.createTracker(namespace: namespace, + network: networkConfig, + configurations: [trackerConfig, plugin])! + } + + private func waitForEventsToBeTracked() { + let expect = expectation(description: "Wait for events to be tracked") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { () -> Void in + expect.fulfill() + } + wait(for: [expect], timeout: 1) + } + + private func getScreenEntities(_ all: [SelfDescribingJson]?) -> [SelfDescribingJson] { + var entities: [SelfDescribingJson] = [] + if let all = all { + for entity in all { + if (entity.schema == ecommercePageSchema) { + entities.append(entity) + } + } + } + return entities + } + + private func getUserEntities(_ all: [SelfDescribingJson]?) -> [SelfDescribingJson] { + var entities: [SelfDescribingJson] = [] + if let all = all { + for entity in all { + if (entity.schema == ecommerceUserSchema) { + entities.append(entity) + } + } + } + return entities + } +} diff --git a/Tests/Ecommerce/TestEcommerceEntities.swift b/Tests/Ecommerce/TestEcommerceEntities.swift new file mode 100644 index 000000000..64fa0caf1 --- /dev/null +++ b/Tests/Ecommerce/TestEcommerceEntities.swift @@ -0,0 +1,87 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import XCTest +@testable import SnowplowTracker + +class TestEcommerceEntities: XCTestCase { + func testBuildsCartEntity() { + let cart = CartEntity( + totalValue: 1000, + currency: "USD", + cartId: "id" + ) + let entity = cart.entity + + XCTAssertEqual(ecommerceCartSchema, entity.schema) + XCTAssertEqual("id", entity.data["cart_id"] as? String) + XCTAssertEqual("USD", entity.data["currency"] as? String) + XCTAssertEqual(1000, entity.data["total_value"] as? Decimal) + } + + func testBuildsProductEntity() { + let product = ProductEntity( + id: "id", + category: "category", + currency: "GBP", + price: 123.45, + listPrice: 130, + name: "name", + quantity: 1, + size: "small", + variant: "cerise", + brand: "snowplow", + inventoryStatus: "in_stock", + position: 7, + creativeId: "creative" + ) + let entity = product.entity + + XCTAssertEqual(ecommerceProductSchema, entity.schema) + XCTAssertEqual("id", entity.data["id"] as? String) + XCTAssertEqual("category", entity.data["category"] as? String) + XCTAssertEqual("GBP", entity.data["currency"] as? String) + XCTAssertEqual(123.45, entity.data["price"] as? Decimal) + XCTAssertEqual(130, entity.data["list_price"] as? Decimal) + XCTAssertEqual("name", entity.data["name"] as? String) + XCTAssertEqual(1, entity.data["quantity"] as? Int) + XCTAssertEqual("small", entity.data["size"] as? String) + XCTAssertEqual("cerise", entity.data["variant"] as? String) + XCTAssertEqual("snowplow", entity.data["brand"] as? String) + XCTAssertEqual("in_stock", entity.data["inventory_status"] as? String) + XCTAssertEqual(7, entity.data["position"] as? Int) + XCTAssertEqual("creative", entity.data["creative_id"] as? String) + } + + func testBuildsPromotionEntity() { + let promotion = PromotionEntity( + id: "id", + name: "name", + productIds: ["product1", "product2", "product3"], + position: 5, + creativeId: "creative", + type: "animated", + slot: "sidebar" + ) + let entity = promotion.entity + + XCTAssertEqual(ecommercePromotionSchema, entity.schema) + XCTAssertEqual("id", entity.data["id"] as? String) + XCTAssertEqual("name", entity.data["name"] as? String) + XCTAssertEqual(["product1", "product2", "product3"], entity.data["product_ids"] as? [String]) + XCTAssertEqual(5, entity.data["position"] as? Int) + XCTAssertEqual("creative", entity.data["creative_id"] as? String) + XCTAssertEqual("animated", entity.data["type"] as? String) + XCTAssertEqual("sidebar", entity.data["slot"] as? String) + } +} diff --git a/Tests/Ecommerce/TestEcommerceEvents.swift b/Tests/Ecommerce/TestEcommerceEvents.swift new file mode 100644 index 000000000..f04a9c016 --- /dev/null +++ b/Tests/Ecommerce/TestEcommerceEvents.swift @@ -0,0 +1,415 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import XCTest +@testable import SnowplowTracker + +class TestEcommerceEvents: XCTestCase { + + var trackedEvents: [InspectableEvent] = [] + var tracker: TrackerController? + + override func setUp() { + tracker = createTracker() + } + + override func tearDown() { + Snowplow.removeAllTrackers() + trackedEvents.removeAll() + } + + func testAddToCart() { + let cart = CartEntity(totalValue: 500, currency: "GBP") + + let event = AddToCartEvent(products: [product1, product2], cart: cart) + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(ecommerceActionSchema, event.schema) + XCTAssertEqual("add_to_cart", event.payload["type"] as? String) + + let entities = trackedEvents[0].entities + XCTAssertEqual(2, getProductEntities(entities).count) + XCTAssertEqual(1, getCartEntities(entities).count) + } + + func testRemoveFromCart() { + let cart = CartEntity(totalValue: 500, currency: "GBP") + + let event = RemoveFromCartEvent(products: [product1], cart: cart) + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(ecommerceActionSchema, event.schema) + XCTAssertEqual("remove_from_cart", event.payload["type"] as? String) + + let entities = trackedEvents[0].entities + XCTAssertEqual(1, getProductEntities(entities).count) + XCTAssertEqual(1, getCartEntities(entities).count) + } + + func testProductListClick() { + let event = ProductListClickEvent(product: product1, name: "list_name") + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(ecommerceActionSchema, event.schema) + XCTAssertEqual("list_click", event.payload["type"] as? String) + XCTAssertEqual("list_name", event.payload["name"] as? String) + + let entities = trackedEvents[0].entities + XCTAssertEqual(1, getProductEntities(entities).count) + } + + func testProductListView() { + let event = ProductListViewEvent(products: [product1, product2], name: "list_name") + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(ecommerceActionSchema, event.schema) + XCTAssertEqual("list_view", event.payload["type"] as? String) + XCTAssertEqual("list_name", event.payload["name"] as? String) + + let entities = trackedEvents[0].entities + XCTAssertEqual(2, getProductEntities(entities).count) + } + + func testProductView() { + let event = ProductViewEvent(product: product1) + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(ecommerceActionSchema, event.schema) + XCTAssertEqual("product_view", event.payload["type"] as? String) + + let entities = trackedEvents[0].entities + XCTAssertEqual(1, getProductEntities(entities).count) + } + + func testCheckoutStep() { + let event = CheckoutStepEvent( + step: 5, + shippingPostcode: "postcode1", + billingPostcode: "postcode2", + shippingFullAddress: "address1", + billingFullAddress: "address2", + deliveryProvider: "provider", + deliveryMethod: "delivery_method", + couponCode: "coupon", + accountType: "account", + paymentMethod: "payment_method", + proofOfPayment: "proof", + marketingOptIn: false + ) + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(ecommerceActionSchema, event.schema) + XCTAssertEqual("checkout_step", event.payload["type"] as? String) + + let entities = trackedEvents[0].entities + let checkoutEntities = getCheckoutStepEntities(entities) + XCTAssertEqual(1, checkoutEntities.count) + + let entity = checkoutEntities[0] + + XCTAssertEqual(5, entity.data["step"] as? Int) + XCTAssertEqual("postcode1", entity.data["shipping_postcode"] as? String) + XCTAssertEqual("postcode2", entity.data["billing_postcode"] as? String) + XCTAssertEqual("address1", entity.data["shipping_full_address"] as? String) + XCTAssertEqual("address2", entity.data["billing_full_address"] as? String) + XCTAssertEqual("provider", entity.data["delivery_provider"] as? String) + XCTAssertEqual("delivery_method", entity.data["delivery_method"] as? String) + XCTAssertEqual("coupon", entity.data["coupon_code"] as? String) + XCTAssertEqual("account", entity.data["account_type"] as? String) + XCTAssertEqual("payment_method", entity.data["payment_method"] as? String) + XCTAssertEqual("proof", entity.data["proof_of_payment"] as? String) + XCTAssertEqual(false, entity.data["marketing_opt_in"] as? Bool) + } + + func testTransaction() { + let transaction = TransactionEntity( + transactionId: "id", + revenue: 55.55, + currency: "CAD", + paymentMethod: "cash", + totalQuantity: 2, + tax: 11.11, + shipping: 0, + discountCode: "new", + discountAmount: 0.5, + creditOrder: false + ) + let event = TransactionEvent(transaction: transaction, products: [product1, product2]) + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(ecommerceActionSchema, event.schema) + XCTAssertEqual("transaction", event.payload["type"] as? String) + + let entities = trackedEvents[0].entities + let transactionEntities = getTransactionEntities(entities) + XCTAssertEqual(1, transactionEntities.count) + XCTAssertEqual(2, getProductEntities(entities).count) + + let entity = transactionEntities[0] + + XCTAssertEqual("id", entity.data["transaction_id"] as? String) + XCTAssertEqual(55.55, entity.data["revenue"] as? Decimal) + XCTAssertEqual("CAD", entity.data["currency"] as? String) + XCTAssertEqual("cash", entity.data["payment_method"] as? String) + XCTAssertEqual(2, entity.data["total_quantity"] as? Int) + XCTAssertEqual(11.11, entity.data["tax"] as? Decimal) + XCTAssertEqual(0, entity.data["shipping"] as? Decimal) + XCTAssertEqual("new", entity.data["discount_code"] as? String) + XCTAssertEqual(0.5, entity.data["discount_amount"] as? Decimal) + XCTAssertEqual(false, entity.data["credit_order"] as? Bool) + } + + func testTransactionError() { + let transaction = TransactionEntity( + transactionId: "id", + revenue: 55.55, + currency: "CAD", + paymentMethod: "cash", + totalQuantity: 2, + tax: 11.11, + shipping: 0, + discountCode: "new", + discountAmount: 0.5, + creditOrder: false + ) + let event = TransactionErrorEvent( + transaction: transaction, + errorCode: "E123", + errorShortcode: "card_declined", + errorDescription: "card_expired", + errorType: .hard, + resolution: "user_notification" + ) + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(ecommerceActionSchema, event.schema) + XCTAssertEqual("trns_error", event.payload["type"] as? String) + + let entities = trackedEvents[0].entities + let errorEntities = getTransactionErrorEntities(entities) + XCTAssertEqual(1, errorEntities.count) + XCTAssertEqual(1, getTransactionEntities(entities).count) + + let entity = errorEntities[0] + + XCTAssertEqual("E123", entity.data["error_code"] as? String) + XCTAssertEqual("card_declined", entity.data["error_shortcode"] as? String) + XCTAssertEqual("card_expired", entity.data["error_description"] as? String) + XCTAssertEqual("hard", entity.data["error_type"] as? String) + XCTAssertEqual("user_notification", entity.data["resolution"] as? String) + } + + func testRefund() { + let event = RefundEvent( + transactionId: "id", + refundAmount: 300, + currency: "INR", + refundReason: "reason", + products: [product1] + ) + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(ecommerceActionSchema, event.schema) + XCTAssertEqual("refund", event.payload["type"] as? String) + + let entities = trackedEvents[0].entities + let refundEntities = getRefundEntities(entities) + XCTAssertEqual(1, refundEntities.count) + XCTAssertEqual(1, getProductEntities(entities).count) + + let entity = refundEntities[0] + + XCTAssertEqual("id", entity.data["transaction_id"] as? String) + XCTAssertEqual("INR", entity.data["currency"] as? String) + XCTAssertEqual(300, entity.data["refund_amount"] as? Decimal) + XCTAssertEqual("reason", entity.data["refund_reason"] as? String) + } + + func testPromotionClick() { + let event = PromotionClickEvent(promotion: PromotionEntity(id: "promo")) + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(ecommerceActionSchema, event.schema) + XCTAssertEqual("promo_click", event.payload["type"] as? String) + + let entities = trackedEvents[0].entities + XCTAssertEqual(1, getPromotionEntities(entities).count) + } + + func testPromotionView() { + let event = PromotionViewEvent(promotion: PromotionEntity(id: "promo")) + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(ecommerceActionSchema, event.schema) + XCTAssertEqual("promo_view", event.payload["type"] as? String) + + let entities = trackedEvents[0].entities + XCTAssertEqual(1, getPromotionEntities(entities).count) + } + + private let product1 = ProductEntity( + id: "id1", + category: "category1", + currency: "GBP", + price: 1.23 + ) + + let product2 = ProductEntity( + id: "id2", + category: "category2", + currency: "GBP", + price: 0.99 + ) + + private func createTracker() -> TrackerController { + let networkConfig = NetworkConfiguration(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 200)) + let trackerConfig = TrackerConfiguration() + trackerConfig.installAutotracking = false + trackerConfig.lifecycleAutotracking = false + + let namespace = "testEcommerce" + String(describing: Int.random(in: 0..<100)) + let plugin = PluginConfiguration(identifier: "testPlugin" + namespace) + .afterTrack { event in + if namespace == self.tracker?.namespace { + self.trackedEvents.append(event) + } + } + + return Snowplow.createTracker(namespace: namespace, + network: networkConfig, + configurations: [trackerConfig, plugin])! + } + + private func waitForEventsToBeTracked() { + let expect = expectation(description: "Wait for events to be tracked") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { () -> Void in + expect.fulfill() + } + wait(for: [expect], timeout: 1) + } + + private func getProductEntities(_ all: [SelfDescribingJson]?) -> [SelfDescribingJson] { + var entities: [SelfDescribingJson] = [] + if let all = all { + for entity in all { + if (entity.schema == ecommerceProductSchema) { + entities.append(entity) + } + } + } + return entities + } + + private func getCartEntities(_ all: [SelfDescribingJson]?) -> [SelfDescribingJson] { + var entities: [SelfDescribingJson] = [] + if let all = all { + for entity in all { + if (entity.schema == ecommerceCartSchema) { + entities.append(entity) + } + } + } + return entities + } + + private func getPromotionEntities(_ all: [SelfDescribingJson]?) -> [SelfDescribingJson] { + var entities: [SelfDescribingJson] = [] + if let all = all { + for entity in all { + if (entity.schema == ecommercePromotionSchema) { + entities.append(entity) + } + } + } + return entities + } + + private func getCheckoutStepEntities(_ all: [SelfDescribingJson]?) -> [SelfDescribingJson] { + var entities: [SelfDescribingJson] = [] + if let all = all { + for entity in all { + if (entity.schema == ecommerceCheckoutStepSchema) { + entities.append(entity) + } + } + } + return entities + } + + private func getTransactionEntities(_ all: [SelfDescribingJson]?) -> [SelfDescribingJson] { + var entities: [SelfDescribingJson] = [] + if let all = all { + for entity in all { + if (entity.schema == ecommerceTransactionSchema) { + entities.append(entity) + } + } + } + return entities + } + + private func getTransactionErrorEntities(_ all: [SelfDescribingJson]?) -> [SelfDescribingJson] { + var entities: [SelfDescribingJson] = [] + if let all = all { + for entity in all { + if (entity.schema == ecommerceTransactionErrorSchema) { + entities.append(entity) + } + } + } + return entities + } + + private func getRefundEntities(_ all: [SelfDescribingJson]?) -> [SelfDescribingJson] { + var entities: [SelfDescribingJson] = [] + if let all = all { + for entity in all { + if (entity.schema == ecommerceRefundSchema) { + entities.append(entity) + } + } + } + return entities + } +} From 93a651d0c412359bc20badad3dd603a41cdf6850 Mon Sep 17 00:00:00 2001 From: Miranda Wilson Date: Wed, 12 Jul 2023 09:39:03 +0100 Subject: [PATCH 4/4] Prepare for 5.4.0 release --- CHANGELOG | 6 ++++++ Examples | 2 +- SnowplowTracker.podspec | 2 +- Sources/Core/TrackerConstants.swift | 2 +- VERSION | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index ff0d8727b..103503aee 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +Version 5.4.0 (2023-07-12) +-------------------------- +Add Snowplow ecommerce events and entities (#800) +Increase interval for updating platform context properties from 0.1s to 1s (#798) +Expose property for retrieving payload in ConsentDocument that was removed in v5 (#804) + Version 5.3.1 (2023-07-06) -------------------------- Fix incorrect date deserialization when reading the install timestamp from 1.7 version of the tracker (#801) diff --git a/Examples b/Examples index 40b17aa76..65b00e6c0 160000 --- a/Examples +++ b/Examples @@ -1 +1 @@ -Subproject commit 40b17aa768f47e9f17f3eba13257ffa5ce9f1554 +Subproject commit 65b00e6c06a9f6187514d6a3eccbd83e22ba9b65 diff --git a/SnowplowTracker.podspec b/SnowplowTracker.podspec index 58321737e..28dfb053b 100644 --- a/SnowplowTracker.podspec +++ b/SnowplowTracker.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "SnowplowTracker" - s.version = "5.3.1" + s.version = "5.4.0" s.summary = "Snowplow event tracker for iOS, macOS, tvOS, watchOS for apps and games." s.description = <<-DESC Snowplow is a mobile and event analytics platform with a difference: rather than tell our users how they should analyze their data, we deliver their event-level data in their own data warehouse, on their own Amazon Redshift or Postgres database, so they can analyze it any way they choose. Snowplow mobile is used by data-savvy games companies and app developers to better understand their users and how they engage with their games and applications. Snowplow is open source using the business-friendly Apache License, Version 2.0 and scales horizontally to many billions of events. diff --git a/Sources/Core/TrackerConstants.swift b/Sources/Core/TrackerConstants.swift index 796b17790..6504ecb09 100644 --- a/Sources/Core/TrackerConstants.swift +++ b/Sources/Core/TrackerConstants.swift @@ -14,7 +14,7 @@ import Foundation // --- Version -let kSPRawVersion = "5.3.1" +let kSPRawVersion = "5.4.0" #if os(iOS) let kSPVersion = "ios-\(kSPRawVersion)" #elseif os(tvOS) diff --git a/VERSION b/VERSION index c7cb1311a..8a30e8f94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.3.1 +5.4.0