@cshah18/sdk 4.12.0 → 4.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -452,6 +452,8 @@ class ConfigManager {
452
452
  debug,
453
453
  events: options.events,
454
454
  performance,
455
+ geo: options.geo,
456
+ device: options.device,
455
457
  };
456
458
  return this.config;
457
459
  }
@@ -819,6 +821,7 @@ class GroupListModal {
819
821
  this.currentJoinedGroupId = null;
820
822
  this.currentProductId = null;
821
823
  this.currentSessionId = null;
824
+ this.analyticsClient = null;
822
825
  this.onGroupJoined = null;
823
826
  this.onViewProgress = null;
824
827
  this.socketListenerRegistered = false;
@@ -833,6 +836,7 @@ class GroupListModal {
833
836
  /** Handle group member joined socket event and update groups list */
834
837
  this.onGroupMemberJoined = (event) => {
835
838
  const detail = event.detail || {};
839
+ console.log("eventttttt", detail);
836
840
  const productId = detail.product_id;
837
841
  const groupData = detail.group;
838
842
  // Only process if this is for the current product
@@ -865,6 +869,10 @@ class GroupListModal {
865
869
  this.apiClient = apiClient;
866
870
  this.injectStyles();
867
871
  }
872
+ /** Set the analytics client for event tracking */
873
+ setAnalyticsClient(client) {
874
+ this.analyticsClient = client;
875
+ }
868
876
  /** Set callback for when a group is joined successfully */
869
877
  setOnGroupJoined(callback) {
870
878
  this.onGroupJoined = callback;
@@ -1122,10 +1130,21 @@ class GroupListModal {
1122
1130
  buttonElement.disabled = true;
1123
1131
  const originalText = button.textContent;
1124
1132
  button.textContent = "Joining...";
1133
+ const productId = this.currentProductId || "";
1134
+ if (this.analyticsClient) {
1135
+ this.analyticsClient
1136
+ .trackJoinAttempt(productId, groupId)
1137
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
1138
+ }
1125
1139
  try {
1126
1140
  const response = await this.apiClient.joinGroup(groupId);
1127
1141
  if (response.success && response.data) {
1128
1142
  this.logger.info(`[handleJoinGroup] Successfully joined group: ${groupId}`, response.data);
1143
+ if (this.analyticsClient) {
1144
+ this.analyticsClient
1145
+ .trackJoinSuccess(productId, groupId)
1146
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
1147
+ }
1129
1148
  // Track the joined group
1130
1149
  this.currentJoinedGroupId = groupId;
1131
1150
  // Close the modal and trigger callback with the full join response data
@@ -1136,12 +1155,22 @@ class GroupListModal {
1136
1155
  }
1137
1156
  else {
1138
1157
  this.logger.error("Failed to join group: API response unsuccessful");
1158
+ if (this.analyticsClient) {
1159
+ this.analyticsClient
1160
+ .trackJoinFailure(productId, groupId)
1161
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
1162
+ }
1139
1163
  button.textContent = originalText;
1140
1164
  buttonElement.disabled = false;
1141
1165
  }
1142
1166
  }
1143
1167
  catch (error) {
1144
1168
  this.logger.error("Error joining group", error);
1169
+ if (this.analyticsClient) {
1170
+ this.analyticsClient
1171
+ .trackJoinFailure(productId, groupId, "EXCEPTION", error instanceof Error ? error.message : String(error))
1172
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
1173
+ }
1145
1174
  button.textContent = originalText;
1146
1175
  buttonElement.disabled = false;
1147
1176
  }
@@ -1162,10 +1191,21 @@ class GroupListModal {
1162
1191
  this.setJoinButtonsDisabled(true);
1163
1192
  const originalText = button.textContent;
1164
1193
  button.textContent = "Creating...";
1194
+ const productId = this.currentProductId;
1195
+ if (this.analyticsClient) {
1196
+ this.analyticsClient
1197
+ .trackGroupCreateAttempt(productId)
1198
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
1199
+ }
1165
1200
  try {
1166
1201
  const response = await this.apiClient.createAndJoinGroup(this.currentProductId);
1167
1202
  if (response.success && response.data) {
1168
1203
  this.logger.info("Successfully created and joined new group", response.data);
1204
+ if (this.analyticsClient) {
1205
+ this.analyticsClient
1206
+ .trackGroupCreateSuccess(productId, response.data.group.id)
1207
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
1208
+ }
1169
1209
  this.currentJoinedGroupId = response.data.group.id;
1170
1210
  this.close();
1171
1211
  if (this.onGroupJoined) {
@@ -1174,6 +1214,11 @@ class GroupListModal {
1174
1214
  }
1175
1215
  else {
1176
1216
  this.logger.error("Failed to create group: API response unsuccessful");
1217
+ if (this.analyticsClient) {
1218
+ this.analyticsClient
1219
+ .trackGroupCreateFailure(productId)
1220
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
1221
+ }
1177
1222
  button.textContent = originalText;
1178
1223
  buttonElement.disabled = false;
1179
1224
  this.setJoinButtonsDisabled(false);
@@ -1181,6 +1226,11 @@ class GroupListModal {
1181
1226
  }
1182
1227
  catch (error) {
1183
1228
  this.logger.error("Error creating group", error);
1229
+ if (this.analyticsClient) {
1230
+ this.analyticsClient
1231
+ .trackGroupCreateFailure(productId, "EXCEPTION", error instanceof Error ? error.message : String(error))
1232
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
1233
+ }
1184
1234
  button.textContent = originalText;
1185
1235
  buttonElement.disabled = false;
1186
1236
  this.setJoinButtonsDisabled(false);
@@ -3578,14 +3628,13 @@ class LobbyModal {
3578
3628
  document.body.appendChild(this.modalElement);
3579
3629
  // Subscribe to realtime socket events
3580
3630
  this.subscribeToSocketEvents();
3581
- // If socket is available and connected, subscribe to this group
3582
- if (this.socketManager && this.socketManager.isConnected() && this.currentGroupId) {
3583
- this.logger.info(`[LobbyModal] Socket connected, subscribing to group ${this.currentGroupId}`);
3631
+ // Subscribe to the group room for targeted events.
3632
+ // Socket.IO client buffers emits until connected, so this is safe even
3633
+ // if the handshake hasn't completed yet.
3634
+ if (this.socketManager && this.currentGroupId) {
3635
+ this.logger.info(`[LobbyModal] Subscribing to group room ${this.currentGroupId}`);
3584
3636
  this.socketManager.subscribeToGroup(this.currentGroupId);
3585
3637
  }
3586
- else if (this.socketManager && !this.socketManager.isConnected()) {
3587
- this.logger.warn("[LobbyModal] Socket manager not connected yet");
3588
- }
3589
3638
  // Start timers and animations
3590
3639
  this.startTimer();
3591
3640
  this.startActivityAnimation();
@@ -4386,10 +4435,18 @@ class WidgetRoot {
4386
4435
  /** Handle backend fulfillment notifications */
4387
4436
  this.handleGroupFulfilledEvent = (event) => {
4388
4437
  const detail = event.detail;
4389
- if (!detail || !detail.productId) {
4438
+ if (!detail || !detail.product_id) {
4439
+ return;
4440
+ }
4441
+ if (this.currentProductId && detail.product_id !== this.currentProductId) {
4390
4442
  return;
4391
4443
  }
4392
- if (this.currentProductId && detail.productId !== this.currentProductId) {
4444
+ // Only enter the fulfilled/checkout flow for users who have actively joined a group this
4445
+ // session. currentSessionId is set exclusively in the setOnGroupJoined callback (when the
4446
+ // user completes a join API call). Observers who never joined should re-fetch the primary
4447
+ // group API to display the updated group state instead.
4448
+ if (!this.currentSessionId) {
4449
+ void this.refreshGroupDataFromRealtime();
4393
4450
  return;
4394
4451
  }
4395
4452
  this.processGroupFulfilled(detail);
@@ -4446,12 +4503,21 @@ class WidgetRoot {
4446
4503
  const groupData = await this.fetchPrimaryGroup(this.currentProductId);
4447
4504
  this.currentGroupData = groupData;
4448
4505
  this.currentGroupId = (groupData === null || groupData === void 0 ? void 0 : groupData.id) || this.currentGroupId;
4449
- // If backend signals completion via counts, reflect it
4506
+ // If backend signals completion via counts, reflect it — but only for actual members.
4507
+ // Observers may see a full group returned by fetchPrimaryGroup; they should NOT enter
4508
+ // the checkout flow. currentSessionId is the membership signal (non-null = joined).
4450
4509
  if (groupData) {
4451
4510
  const participants = Number(groupData.participants_count || 0);
4452
4511
  const max = Number(groupData.max_participants || 0);
4453
- if (max > 0 && participants >= max) {
4512
+ if (max > 0 && participants >= max && this.currentSessionId) {
4454
4513
  this.groupFulfilled = true;
4514
+ // The primary group API returns offline_redemption for members of fulfilled groups.
4515
+ // Extract it here so renderFulfilledSummary shows the "Redeem In-store" link
4516
+ // (matching the UI users see on a full page reload).
4517
+ if (groupData.offline_redemption &&
4518
+ isValidOfflineRedemption(groupData.offline_redemption)) {
4519
+ this.offlineRedemption = groupData.offline_redemption;
4520
+ }
4455
4521
  }
4456
4522
  }
4457
4523
  else {
@@ -4796,6 +4862,9 @@ class WidgetRoot {
4796
4862
  getGroupListModal() {
4797
4863
  if (!this.groupListModal) {
4798
4864
  this.groupListModal = new GroupListModal(undefined, 8, this.config.debug, this.apiClient);
4865
+ if (this.analyticsClient) {
4866
+ this.groupListModal.setAnalyticsClient(this.analyticsClient);
4867
+ }
4799
4868
  // Set callback to open lobby when a group is successfully joined
4800
4869
  this.groupListModal.setOnGroupJoined((joinData) => {
4801
4870
  var _a;
@@ -5021,6 +5090,11 @@ class WidgetRoot {
5021
5090
  if (max > 0 && participants >= max) {
5022
5091
  this.groupFulfilled = true;
5023
5092
  this.logger.info("Group is already fulfilled on initial load", { participants, max });
5093
+ if (this.analyticsClient) {
5094
+ this.analyticsClient
5095
+ .trackGroupFullView(options.productId, groupData.id, max)
5096
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
5097
+ }
5024
5098
  }
5025
5099
  }
5026
5100
  }
@@ -5086,6 +5160,11 @@ class WidgetRoot {
5086
5160
  // LOADED state - render widget only if we have group data
5087
5161
  this.createWidget(rewardData, container, options);
5088
5162
  this.logger.info(`Widget rendered for product: ${options.productId}`);
5163
+ if (this.analyticsClient) {
5164
+ this.analyticsClient
5165
+ .trackCreativeView(options.productId)
5166
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
5167
+ }
5089
5168
  }
5090
5169
  else {
5091
5170
  // No group data available - hide widget
@@ -5737,7 +5816,7 @@ class WidgetRoot {
5737
5816
  * Handle CTA button click with analytics and modal opening
5738
5817
  */
5739
5818
  async handleCTAClick(productId) {
5740
- var _a, _b, _c, _d;
5819
+ var _a, _b, _c, _d, _e, _f, _g;
5741
5820
  this.logger.info(`CTA clicked for product: ${productId}`);
5742
5821
  // Track analytics event asynchronously (fire-and-forget)
5743
5822
  if (this.analyticsClient) {
@@ -5755,14 +5834,31 @@ class WidgetRoot {
5755
5834
  if (this.apiClient && this.currentGroupId) {
5756
5835
  try {
5757
5836
  this.logger.info(`Joining group: ${this.currentGroupId}`);
5837
+ if (this.analyticsClient) {
5838
+ this.analyticsClient
5839
+ .trackJoinAttempt(productId, this.currentGroupId)
5840
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
5841
+ }
5758
5842
  const joinResponse = await this.apiClient.joinGroup(this.currentGroupId);
5759
5843
  if (!joinResponse.success) {
5760
5844
  this.logger.error("Failed to join group, modal will not open", joinResponse.error);
5845
+ if (this.analyticsClient) {
5846
+ const errCode = (_c = joinResponse.error) === null || _c === void 0 ? void 0 : _c.code;
5847
+ const errMsg = (_d = joinResponse.error) === null || _d === void 0 ? void 0 : _d.message;
5848
+ this.analyticsClient
5849
+ .trackJoinFailure(productId, this.currentGroupId, errCode, errMsg)
5850
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
5851
+ }
5761
5852
  this.setButtonLoadingState(false);
5762
5853
  return; // Don't open modal on error
5763
5854
  }
5764
5855
  groupJoinData = joinResponse.data;
5765
5856
  this.logger.info("Successfully joined group", groupJoinData);
5857
+ if (this.analyticsClient && groupJoinData) {
5858
+ this.analyticsClient
5859
+ .trackJoinSuccess(productId, groupJoinData.group.id)
5860
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
5861
+ }
5766
5862
  this.offlineRedemption =
5767
5863
  groupJoinData &&
5768
5864
  groupJoinData.offline_redemption &&
@@ -5786,6 +5882,11 @@ class WidgetRoot {
5786
5882
  }
5787
5883
  catch (error) {
5788
5884
  this.logger.error("Group join failed, modal will not open", error);
5885
+ if (this.analyticsClient) {
5886
+ this.analyticsClient
5887
+ .trackJoinFailure(productId, (_e = this.currentGroupId) !== null && _e !== void 0 ? _e : undefined, "EXCEPTION", error instanceof Error ? error.message : String(error))
5888
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
5889
+ }
5789
5890
  this.setButtonLoadingState(false);
5790
5891
  return; // Don't open modal on error
5791
5892
  }
@@ -5800,7 +5901,7 @@ class WidgetRoot {
5800
5901
  const progress = Math.round((groupJoinData.group.participants_count / groupJoinData.group.max_participants) * 100);
5801
5902
  // Format discount based on reward type
5802
5903
  let discountText = "";
5803
- if ((_d = (_c = this.currentRewardData) === null || _c === void 0 ? void 0 : _c.reward) === null || _d === void 0 ? void 0 : _d.value) {
5904
+ if ((_g = (_f = this.currentRewardData) === null || _f === void 0 ? void 0 : _f.reward) === null || _g === void 0 ? void 0 : _g.value) {
5804
5905
  const rewardType = this.currentRewardData.reward.type;
5805
5906
  const rewardValue = this.currentRewardData.reward.value;
5806
5907
  if (rewardType === "percentage" || rewardType === "cashback") {
@@ -5840,6 +5941,11 @@ class WidgetRoot {
5840
5941
  shareMessage: shareMessageFromInvite,
5841
5942
  isLocked: !isGroupFulfilled,
5842
5943
  offlineRedemption: offlineRedemptionFromJoin,
5944
+ onShare: this.analyticsClient
5945
+ ? () => {
5946
+ this.analyticsClient.trackShareClick(productId, groupJoinData.group.id, "other").catch((e) => this.logger.warn("Analytics tracking failed", e));
5947
+ }
5948
+ : undefined,
5843
5949
  activities: [
5844
5950
  {
5845
5951
  emoji: "👤",
@@ -5875,6 +5981,12 @@ class WidgetRoot {
5875
5981
  },
5876
5982
  ], // Will be populated from real-time data later
5877
5983
  });
5984
+ // Track popup open after modal is launched
5985
+ if (this.analyticsClient) {
5986
+ this.analyticsClient
5987
+ .trackPopupOpen(productId, groupJoinData.group.id)
5988
+ .catch((e) => this.logger.warn("Analytics tracking failed", e));
5989
+ }
5878
5990
  // Remove loading state after modal opens
5879
5991
  this.setButtonLoadingState(false);
5880
5992
  }
@@ -5981,6 +6093,14 @@ class WidgetRoot {
5981
6093
  getProductId() {
5982
6094
  return this.currentProductId;
5983
6095
  }
6096
+ /**
6097
+ * Returns the group ID that the current user has joined via this widget, or null if the user
6098
+ * is only an observer (has not actively joined a group this session).
6099
+ * Used by the CoBuy host class to check membership before preparing checkout.
6100
+ */
6101
+ getJoinedGroupId() {
6102
+ return this.currentSessionId ? this.currentGroupId : null;
6103
+ }
5984
6104
  }
5985
6105
 
5986
6106
  /**
@@ -5988,6 +6108,7 @@ class WidgetRoot {
5988
6108
  */
5989
6109
  const API_ENDPOINTS = {
5990
6110
  // Product endpoints
6111
+ PRODUCT_CONTEXT: "/v1/sdk/products/:productId/context",
5991
6112
  PRODUCT_REWARD: "/v1/sdk/products/:productId/reward",
5992
6113
  PRODUCT_PRIMARY_GROUP: "/v1/sdk/products/:productId/group/primary",
5993
6114
  // Group endpoints
@@ -5998,6 +6119,8 @@ const API_ENDPOINTS = {
5998
6119
  GROUP_CHECKOUT_PREPARE: "/v1/sdk/groups/:groupId/checkout/prepare",
5999
6120
  GROUP_CHECKOUT_VALIDATE: "/v1/sdk/groups/:groupId/checkout/validate",
6000
6121
  GROUP_CHECKOUT_CONFIRM: "/v1/sdk/groups/:groupId/checkout/confirm",
6122
+ // Invite endpoints
6123
+ INVITE_RESOLVE: "/v1/sdk/invite/resolve",
6001
6124
  };
6002
6125
  /**
6003
6126
  * Build full API URL from base URL and endpoint path
@@ -6036,8 +6159,11 @@ class ApiClient {
6036
6159
  var _a;
6037
6160
  this.traceId = null;
6038
6161
  this.rewardCache = new Map();
6162
+ this.productContextCache = new Map();
6039
6163
  this.REWARD_CACHE_TTL = 60000; // 1 minute
6040
- this.pendingRequests = new Map();
6164
+ this.PRODUCT_CONTEXT_CACHE_TTL = 30000; // 30 seconds
6165
+ this.pendingRewardRequests = new Map();
6166
+ this.pendingContextRequests = new Map();
6041
6167
  this.baseUrl = config.baseUrl;
6042
6168
  this.authStrategy = config.authStrategy;
6043
6169
  this.sessionId = config.sessionId;
@@ -6433,6 +6559,106 @@ class ApiClient {
6433
6559
  getTraceId() {
6434
6560
  return this.traceId;
6435
6561
  }
6562
+ getCachedProductContext(productId) {
6563
+ const cached = this.productContextCache.get(productId);
6564
+ if (cached && cached.expires > Date.now()) {
6565
+ return cached.data;
6566
+ }
6567
+ if (cached) {
6568
+ this.productContextCache.delete(productId);
6569
+ }
6570
+ return null;
6571
+ }
6572
+ normalizePrimaryGroup(groupData) {
6573
+ if (!groupData) {
6574
+ return null;
6575
+ }
6576
+ const nestedGroup = groupData.group;
6577
+ if (nestedGroup && typeof nestedGroup === "object" && "id" in nestedGroup) {
6578
+ return nestedGroup;
6579
+ }
6580
+ return groupData;
6581
+ }
6582
+ buildRewardDataFromContext(productId, context) {
6583
+ var _a, _b, _c, _d, _e;
6584
+ const primaryGroup = this.normalizePrimaryGroup(context.primary_group);
6585
+ return {
6586
+ productId,
6587
+ reward: (_a = context.reward) !== null && _a !== void 0 ? _a : null,
6588
+ campaign_mode: (_c = (_b = context.campaign) === null || _b === void 0 ? void 0 : _b.campaign_mode) !== null && _c !== void 0 ? _c : "group",
6589
+ campaign_creative_id: (_d = primaryGroup === null || primaryGroup === void 0 ? void 0 : primaryGroup.campaign_creative_id) !== null && _d !== void 0 ? _d : null,
6590
+ eligibility: (_e = context.eligibility) !== null && _e !== void 0 ? _e : { isEligible: false },
6591
+ };
6592
+ }
6593
+ setProductContextCache(productId, context) {
6594
+ this.productContextCache.set(productId, {
6595
+ data: context,
6596
+ expires: Date.now() + this.PRODUCT_CONTEXT_CACHE_TTL,
6597
+ });
6598
+ this.rewardCache.set(productId, {
6599
+ data: this.buildRewardDataFromContext(productId, context),
6600
+ expires: Date.now() + this.REWARD_CACHE_TTL,
6601
+ });
6602
+ }
6603
+ /**
6604
+ * Get product context information
6605
+ *
6606
+ * Uses the consolidated bootstrap endpoint so the SDK can resolve campaign,
6607
+ * reward, primary group, and active groups in a single request.
6608
+ */
6609
+ async getProductContext(productId) {
6610
+ if (!validateProductId(productId)) {
6611
+ return {
6612
+ success: false,
6613
+ error: {
6614
+ message: "Invalid productId format. Must be a non-empty string (max 200 chars).",
6615
+ code: "INVALID_PRODUCT_ID",
6616
+ },
6617
+ };
6618
+ }
6619
+ // const cached = this.getCachedProductContext(productId);
6620
+ // if (cached) {
6621
+ // this.logger.info(`Using cached product context for product: ${productId}`);
6622
+ // return {
6623
+ // success: true,
6624
+ // data: cached,
6625
+ // };
6626
+ // }
6627
+ const cacheKey = `context:${productId}`;
6628
+ const pending = this.pendingContextRequests.get(cacheKey);
6629
+ if (pending) {
6630
+ this.logger.info(`Deduplicating product context request for: ${productId}`);
6631
+ return pending;
6632
+ }
6633
+ const promise = this.fetchProductContext(productId).then((response) => {
6634
+ if (response.success && response.data) {
6635
+ this.setProductContextCache(productId, response.data);
6636
+ }
6637
+ return response;
6638
+ });
6639
+ this.pendingContextRequests.set(cacheKey, promise);
6640
+ promise.then(() => this.pendingContextRequests.delete(cacheKey), () => this.pendingContextRequests.delete(cacheKey));
6641
+ return promise;
6642
+ }
6643
+ async fetchProductContext(productId) {
6644
+ var _a;
6645
+ const endpoint = buildApiUrl("", API_ENDPOINTS.PRODUCT_CONTEXT, { productId });
6646
+ this.logger.info(`Fetching product context for product: ${productId}`);
6647
+ const response = await this.get(endpoint);
6648
+ if (response.success && ((_a = response.data) === null || _a === void 0 ? void 0 : _a.data)) {
6649
+ return {
6650
+ success: true,
6651
+ data: response.data.data,
6652
+ };
6653
+ }
6654
+ return {
6655
+ success: false,
6656
+ error: response.error || {
6657
+ message: "Failed to fetch product context data",
6658
+ code: "PRODUCT_CONTEXT_FETCH_ERROR",
6659
+ },
6660
+ };
6661
+ }
6436
6662
  /**
6437
6663
  * Get product reward information
6438
6664
  *
@@ -6477,13 +6703,31 @@ class ApiClient {
6477
6703
  data: cached.data,
6478
6704
  };
6479
6705
  }
6706
+ const cachedContext = this.getCachedProductContext(productId);
6707
+ if (cachedContext) {
6708
+ this.logger.info(`Using cached product context reward for product: ${productId}`);
6709
+ return {
6710
+ success: true,
6711
+ data: this.buildRewardDataFromContext(productId, cachedContext),
6712
+ };
6713
+ }
6480
6714
  const cacheKey = `reward:${productId}`;
6481
- const pending = this.pendingRequests.get(cacheKey);
6715
+ const pending = this.pendingRewardRequests.get(cacheKey);
6482
6716
  if (pending) {
6483
6717
  this.logger.info(`Deduplicating request for product: ${productId}`);
6484
6718
  return pending;
6485
6719
  }
6486
- const promise = this.fetchProductReward(productId).then((response) => {
6720
+ const promise = (async () => {
6721
+ const contextResponse = await this.getProductContext(productId);
6722
+ if (contextResponse.success && contextResponse.data) {
6723
+ return {
6724
+ success: true,
6725
+ data: this.buildRewardDataFromContext(productId, contextResponse.data),
6726
+ };
6727
+ }
6728
+ this.logger.info(`Falling back to legacy reward endpoint for product: ${productId}`);
6729
+ return this.fetchProductReward(productId);
6730
+ })().then((response) => {
6487
6731
  if (response.success && response.data) {
6488
6732
  this.rewardCache.set(productId, {
6489
6733
  data: response.data,
@@ -6492,8 +6736,8 @@ class ApiClient {
6492
6736
  }
6493
6737
  return response;
6494
6738
  });
6495
- this.pendingRequests.set(cacheKey, promise);
6496
- promise.then(() => this.pendingRequests.delete(cacheKey), () => this.pendingRequests.delete(cacheKey));
6739
+ this.pendingRewardRequests.set(cacheKey, promise);
6740
+ promise.then(() => this.pendingRewardRequests.delete(cacheKey), () => this.pendingRewardRequests.delete(cacheKey));
6497
6741
  return promise;
6498
6742
  }
6499
6743
  /**
@@ -6509,6 +6753,7 @@ class ApiClient {
6509
6753
  */
6510
6754
  clearRewardCache() {
6511
6755
  this.rewardCache.clear();
6756
+ this.productContextCache.clear();
6512
6757
  }
6513
6758
  async fetchProductReward(productId) {
6514
6759
  var _a;
@@ -6556,7 +6801,7 @@ class ApiClient {
6556
6801
  * ```
6557
6802
  */
6558
6803
  async getProductPrimaryGroup(productId) {
6559
- var _a;
6804
+ var _a, _b;
6560
6805
  if (!validateProductId(productId)) {
6561
6806
  return {
6562
6807
  success: false,
@@ -6566,15 +6811,38 @@ class ApiClient {
6566
6811
  },
6567
6812
  };
6568
6813
  }
6569
- const endpoint = buildApiUrl("", API_ENDPOINTS.PRODUCT_PRIMARY_GROUP, { productId }, { allowAutoCreate: true });
6570
- this.logger.info(`Fetching primary group for product: ${productId}`);
6571
- const response = await this.get(endpoint);
6572
- if (response.success && ((_a = response.data) === null || _a === void 0 ? void 0 : _a.data)) {
6814
+ // const cachedContext = this.getCachedProductContext(productId);
6815
+ // const cachedGroup = this.normalizePrimaryGroup(cachedContext?.primary_group);
6816
+ // if (cachedGroup) {
6817
+ // this.logger.info(`Using cached primary group from product context for product: ${productId}`);
6818
+ // return {
6819
+ // success: true,
6820
+ // data: {
6821
+ // ...cachedGroup,
6822
+ // group: cachedGroup,
6823
+ // },
6824
+ // };
6825
+ // }
6826
+ const contextResponse = await this.getProductContext(productId);
6827
+ const contextGroup = this.normalizePrimaryGroup((_a = contextResponse.data) === null || _a === void 0 ? void 0 : _a.primary_group);
6828
+ if (contextResponse.success && contextGroup) {
6573
6829
  return {
6574
6830
  success: true,
6575
- data: response.data.data,
6831
+ data: Object.assign(Object.assign({}, contextGroup), { group: contextGroup }),
6576
6832
  };
6577
6833
  }
6834
+ const endpoint = buildApiUrl("", API_ENDPOINTS.PRODUCT_PRIMARY_GROUP, { productId }, { allowAutoCreate: true });
6835
+ this.logger.info(`Fetching primary group for product: ${productId}`);
6836
+ const response = await this.get(endpoint);
6837
+ if (response.success && ((_b = response.data) === null || _b === void 0 ? void 0 : _b.data)) {
6838
+ const normalizedGroup = this.normalizePrimaryGroup(response.data.data);
6839
+ if (normalizedGroup) {
6840
+ return {
6841
+ success: true,
6842
+ data: Object.assign(Object.assign({}, normalizedGroup), { group: normalizedGroup }),
6843
+ };
6844
+ }
6845
+ }
6578
6846
  return {
6579
6847
  success: false,
6580
6848
  error: response.error || {
@@ -6622,6 +6890,25 @@ class ApiClient {
6622
6890
  },
6623
6891
  };
6624
6892
  }
6893
+ const cachedContext = this.getCachedProductContext(productId);
6894
+ if (cachedContext) {
6895
+ this.logger.info(`Using cached active groups from product context for product: ${productId}`);
6896
+ return {
6897
+ success: true,
6898
+ data: {
6899
+ groups: (cachedContext.active_groups || []),
6900
+ },
6901
+ };
6902
+ }
6903
+ const contextResponse = await this.getProductContext(productId);
6904
+ if (contextResponse.success && contextResponse.data) {
6905
+ return {
6906
+ success: true,
6907
+ data: {
6908
+ groups: (contextResponse.data.active_groups || []),
6909
+ },
6910
+ };
6911
+ }
6625
6912
  const endpoint = buildApiUrl("", API_ENDPOINTS.PRODUCT_ACTIVE_GROUPS, { productId });
6626
6913
  this.logger.info(`Fetching active groups for product: ${productId}`);
6627
6914
  const response = await this.get(endpoint);
@@ -6808,6 +7095,58 @@ class ApiClient {
6808
7095
  },
6809
7096
  };
6810
7097
  }
7098
+ /**
7099
+ * Resolve an invite token to get product and group information
7100
+ *
7101
+ * Resolves an invite link token (e.g., from https://brand.cobuyza.co.za/i/QMeHD7ew)
7102
+ * to get the associated product, group, and invite metadata.
7103
+ *
7104
+ * @param {string} inviteToken - The invite token from the URL
7105
+ * @returns {Promise<ApiResponse<InviteResolveResponseData>>} Product and group information
7106
+ *
7107
+ * @description
7108
+ * Features:
7109
+ * - Resolves invite tokens to product/group details
7110
+ * - Tracks invite click counts
7111
+ * - Returns invite metadata (shared_via, click_count)
7112
+ * - Used for invite link handling in applications
7113
+ *
7114
+ * @example
7115
+ * ```typescript
7116
+ * // Resolve invite token from URL
7117
+ * const response = await client.resolveInvite('QMeHD7ew');
7118
+ *
7119
+ * if (response.success) {
7120
+ * const productId = response.data.product.id;
7121
+ * const groupId = response.data.group.id;
7122
+ * // Navigate to product page
7123
+ * window.location.href = `/product/${productId}`;
7124
+ * }
7125
+ * ```
7126
+ */
7127
+ async resolveInvite(inviteToken) {
7128
+ const endpoint = buildApiUrl("", API_ENDPOINTS.INVITE_RESOLVE, undefined, {
7129
+ invite_token: inviteToken,
7130
+ });
7131
+ this.logger.info(`Resolving invite token: ${inviteToken}`);
7132
+ const response = await this.get(endpoint);
7133
+ if (response.success) {
7134
+ const payload = response.data;
7135
+ const inviteData = ((payload === null || payload === void 0 ? void 0 : payload.data) || response.data);
7136
+ this.logger.info(`Invite resolved successfully for product: ${inviteData.product.id}`);
7137
+ return {
7138
+ success: true,
7139
+ data: inviteData,
7140
+ };
7141
+ }
7142
+ return {
7143
+ success: false,
7144
+ error: response.error || {
7145
+ message: "Failed to resolve invite",
7146
+ code: "INVITE_RESOLVE_ERROR",
7147
+ },
7148
+ };
7149
+ }
6811
7150
  /**
6812
7151
  * Set or update contact information for the current session
6813
7152
  *
@@ -7074,6 +7413,7 @@ class AnalyticsClient {
7074
7413
  const event = {
7075
7414
  event: "CTA_CLICKED",
7076
7415
  productId,
7416
+ sessionId: this.sessionId,
7077
7417
  timestamp: new Date().toISOString(),
7078
7418
  context: {
7079
7419
  pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
@@ -7126,12 +7466,32 @@ class AnalyticsClient {
7126
7466
  throw new CoBuyApiError(error instanceof Error ? error.message : "Unknown analytics error", "ANALYTICS_ERROR", { originalError: error });
7127
7467
  }
7128
7468
  }
7469
+ /**
7470
+ * Track session init event fired when the SDK initializes
7471
+ */
7472
+ async trackSessionInit(geo, device) {
7473
+ const event = {
7474
+ event: "SESSION_INIT",
7475
+ sessionId: this.sessionId,
7476
+ timestamp: new Date().toISOString(),
7477
+ context: Object.assign(Object.assign({ pageUrl: typeof window !== "undefined" ? window.location.href : undefined, userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined, sdkVersion: this.sdkVersion }, (geo ? { geo } : {})), (device ? { device } : {})),
7478
+ };
7479
+ try {
7480
+ await this.sendEvent(event);
7481
+ this.logger.info("[Analytics] Session init tracked");
7482
+ }
7483
+ catch (error) {
7484
+ // Non-blocking
7485
+ this.logger.error("[Analytics] Failed to track session init", error);
7486
+ }
7487
+ }
7129
7488
  /**
7130
7489
  * Track page view event
7131
7490
  */
7132
7491
  async trackPageView() {
7133
7492
  const event = {
7134
7493
  event: "PAGE_VIEW",
7494
+ sessionId: this.sessionId,
7135
7495
  timestamp: new Date().toISOString(),
7136
7496
  context: {
7137
7497
  pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
@@ -7148,6 +7508,273 @@ class AnalyticsClient {
7148
7508
  this.logger.error("[Analytics] Failed to track page view", error);
7149
7509
  }
7150
7510
  }
7511
+ /**
7512
+ * Track creative view event (widget rendered and visible)
7513
+ */
7514
+ async trackCreativeView(productId) {
7515
+ const event = {
7516
+ event: "CREATIVE_VIEW",
7517
+ productId,
7518
+ sessionId: this.sessionId,
7519
+ timestamp: new Date().toISOString(),
7520
+ context: {
7521
+ pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
7522
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
7523
+ sdkVersion: this.sdkVersion,
7524
+ },
7525
+ };
7526
+ try {
7527
+ await this.sendEvent(event);
7528
+ this.logger.info(`[Analytics] Creative view tracked for product: ${productId}`);
7529
+ }
7530
+ catch (error) {
7531
+ this.logger.error("[Analytics] Failed to track creative view", error);
7532
+ }
7533
+ }
7534
+ /**
7535
+ * Track popup/lobby modal open event
7536
+ */
7537
+ async trackPopupOpen(productId, groupId) {
7538
+ const event = {
7539
+ event: "POPUP_OPEN",
7540
+ productId,
7541
+ sessionId: this.sessionId,
7542
+ timestamp: new Date().toISOString(),
7543
+ context: {
7544
+ pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
7545
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
7546
+ sdkVersion: this.sdkVersion,
7547
+ groupId,
7548
+ },
7549
+ };
7550
+ try {
7551
+ await this.sendEvent(event);
7552
+ this.logger.info(`[Analytics] Popup open tracked for product: ${productId}`);
7553
+ }
7554
+ catch (error) {
7555
+ this.logger.error("[Analytics] Failed to track popup open", error);
7556
+ }
7557
+ }
7558
+ /**
7559
+ * Track join attempt event (before API call)
7560
+ */
7561
+ async trackJoinAttempt(productId, groupId) {
7562
+ const event = {
7563
+ event: "JOIN_ATTEMPT",
7564
+ productId,
7565
+ sessionId: this.sessionId,
7566
+ timestamp: new Date().toISOString(),
7567
+ context: {
7568
+ pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
7569
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
7570
+ sdkVersion: this.sdkVersion,
7571
+ groupId,
7572
+ },
7573
+ };
7574
+ try {
7575
+ await this.sendEvent(event);
7576
+ this.logger.info(`[Analytics] Join attempt tracked for product: ${productId}`);
7577
+ }
7578
+ catch (error) {
7579
+ this.logger.error("[Analytics] Failed to track join attempt", error);
7580
+ }
7581
+ }
7582
+ /**
7583
+ * Track successful group join event
7584
+ */
7585
+ async trackJoinSuccess(productId, groupId) {
7586
+ const event = {
7587
+ event: "JOIN_SUCCESS",
7588
+ productId,
7589
+ sessionId: this.sessionId,
7590
+ timestamp: new Date().toISOString(),
7591
+ context: {
7592
+ pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
7593
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
7594
+ sdkVersion: this.sdkVersion,
7595
+ groupId,
7596
+ },
7597
+ };
7598
+ try {
7599
+ await this.sendEvent(event);
7600
+ this.logger.info(`[Analytics] Join success tracked for product: ${productId}`);
7601
+ }
7602
+ catch (error) {
7603
+ this.logger.error("[Analytics] Failed to track join success", error);
7604
+ }
7605
+ }
7606
+ /**
7607
+ * Track join failure event
7608
+ */
7609
+ async trackJoinFailure(productId, groupId, errorCode, message) {
7610
+ const event = {
7611
+ event: "JOIN_FAILURE",
7612
+ productId,
7613
+ sessionId: this.sessionId,
7614
+ timestamp: new Date().toISOString(),
7615
+ context: {
7616
+ pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
7617
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
7618
+ sdkVersion: this.sdkVersion,
7619
+ groupId,
7620
+ errorCode,
7621
+ errorMessage: message,
7622
+ },
7623
+ };
7624
+ try {
7625
+ await this.sendEvent(event);
7626
+ this.logger.info(`[Analytics] Join failure tracked for product: ${productId}`);
7627
+ }
7628
+ catch (error) {
7629
+ this.logger.error("[Analytics] Failed to track join failure", error);
7630
+ }
7631
+ }
7632
+ /**
7633
+ * Track already joined event (user attempts to join a group they're already in)
7634
+ */
7635
+ async trackAlreadyJoined(productId, groupId) {
7636
+ const event = {
7637
+ event: "ALREADY_JOINED",
7638
+ productId,
7639
+ sessionId: this.sessionId,
7640
+ timestamp: new Date().toISOString(),
7641
+ context: {
7642
+ pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
7643
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
7644
+ sdkVersion: this.sdkVersion,
7645
+ groupId,
7646
+ },
7647
+ };
7648
+ try {
7649
+ await this.sendEvent(event);
7650
+ this.logger.info(`[Analytics] Already joined tracked for product: ${productId}`);
7651
+ }
7652
+ catch (error) {
7653
+ this.logger.error("[Analytics] Failed to track already joined", error);
7654
+ }
7655
+ }
7656
+ /**
7657
+ * Track group full view event (user sees a fulfilled/full group)
7658
+ */
7659
+ async trackGroupFullView(productId, groupId, totalMembers) {
7660
+ const event = {
7661
+ event: "GROUP_FULL_VIEW",
7662
+ productId,
7663
+ sessionId: this.sessionId,
7664
+ timestamp: new Date().toISOString(),
7665
+ context: {
7666
+ pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
7667
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
7668
+ sdkVersion: this.sdkVersion,
7669
+ groupId,
7670
+ totalMembers,
7671
+ },
7672
+ };
7673
+ try {
7674
+ await this.sendEvent(event);
7675
+ this.logger.info(`[Analytics] Group full view tracked for product: ${productId}`);
7676
+ }
7677
+ catch (error) {
7678
+ this.logger.error("[Analytics] Failed to track group full view", error);
7679
+ }
7680
+ }
7681
+ /**
7682
+ * Track share click event
7683
+ */
7684
+ async trackShareClick(productId, groupId, channel) {
7685
+ const event = {
7686
+ event: "SHARE_CLICK",
7687
+ productId,
7688
+ sessionId: this.sessionId,
7689
+ timestamp: new Date().toISOString(),
7690
+ context: {
7691
+ pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
7692
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
7693
+ sdkVersion: this.sdkVersion,
7694
+ groupId,
7695
+ channel,
7696
+ },
7697
+ };
7698
+ try {
7699
+ await this.sendEvent(event);
7700
+ this.logger.info(`[Analytics] Share click tracked for product: ${productId}`);
7701
+ }
7702
+ catch (error) {
7703
+ this.logger.error("[Analytics] Failed to track share click", error);
7704
+ }
7705
+ }
7706
+ /**
7707
+ * Track group creation attempt event
7708
+ */
7709
+ async trackGroupCreateAttempt(productId) {
7710
+ const event = {
7711
+ event: "GROUP_CREATE_ATTEMPT",
7712
+ productId,
7713
+ sessionId: this.sessionId,
7714
+ timestamp: new Date().toISOString(),
7715
+ context: {
7716
+ pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
7717
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
7718
+ sdkVersion: this.sdkVersion,
7719
+ },
7720
+ };
7721
+ try {
7722
+ await this.sendEvent(event);
7723
+ this.logger.info(`[Analytics] Group create attempt tracked for product: ${productId}`);
7724
+ }
7725
+ catch (error) {
7726
+ this.logger.error("[Analytics] Failed to track group create attempt", error);
7727
+ }
7728
+ }
7729
+ /**
7730
+ * Track successful group creation event
7731
+ */
7732
+ async trackGroupCreateSuccess(productId, groupId) {
7733
+ const event = {
7734
+ event: "GROUP_CREATE_SUCCESS",
7735
+ productId,
7736
+ sessionId: this.sessionId,
7737
+ timestamp: new Date().toISOString(),
7738
+ context: {
7739
+ pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
7740
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
7741
+ sdkVersion: this.sdkVersion,
7742
+ groupId,
7743
+ },
7744
+ };
7745
+ try {
7746
+ await this.sendEvent(event);
7747
+ this.logger.info(`[Analytics] Group create success tracked for product: ${productId}`);
7748
+ }
7749
+ catch (error) {
7750
+ this.logger.error("[Analytics] Failed to track group create success", error);
7751
+ }
7752
+ }
7753
+ /**
7754
+ * Track group creation failure event
7755
+ */
7756
+ async trackGroupCreateFailure(productId, errorCode, message) {
7757
+ const event = {
7758
+ event: "GROUP_CREATE_FAILURE",
7759
+ productId,
7760
+ sessionId: this.sessionId,
7761
+ timestamp: new Date().toISOString(),
7762
+ context: {
7763
+ pageUrl: typeof window !== "undefined" ? window.location.href : undefined,
7764
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
7765
+ sdkVersion: this.sdkVersion,
7766
+ errorCode,
7767
+ errorMessage: message,
7768
+ },
7769
+ };
7770
+ try {
7771
+ await this.sendEvent(event);
7772
+ this.logger.info(`[Analytics] Group create failure tracked for product: ${productId}`);
7773
+ }
7774
+ catch (error) {
7775
+ this.logger.error("[Analytics] Failed to track group create failure", error);
7776
+ }
7777
+ }
7151
7778
  /**
7152
7779
  * Track custom event (extensible for future use)
7153
7780
  */
@@ -12593,8 +13220,14 @@ class CoBuy {
12593
13220
  participantsCount !== undefined &&
12594
13221
  participantsCount >= maxParticipants);
12595
13222
  if (isFulfilled && productId && groupId) {
12596
- // Prepare checkout when group is fulfilled (if not already prepared)
12597
- this.prepareCheckoutIfNotDone(productId, groupId);
13223
+ // Only prepare checkout if the current user is actually a member of this group.
13224
+ // this.widgets tracks all WidgetRoot instances; getJoinedGroupId() returns the
13225
+ // joined group ID only when the user has completed a join this session (not for
13226
+ // observers). Calling prepareCheckout for non-members would generate spurious 403s.
13227
+ const userIsInGroup = [...this.widgets].some((w) => w.getJoinedGroupId() === groupId);
13228
+ if (userIsInGroup) {
13229
+ this.prepareCheckoutIfNotDone(productId, groupId);
13230
+ }
12598
13231
  }
12599
13232
  // Emit user-defined callback if provided
12600
13233
  if ((_a = config.events) === null || _a === void 0 ? void 0 : _a.onGroupMemberJoined) {
@@ -12607,8 +13240,11 @@ class CoBuy {
12607
13240
  this.logger.warn("[SDK] Failed to initialize sockets", e);
12608
13241
  }
12609
13242
  }
12610
- // Track page view event after successful initialization
13243
+ // Track session init + page view events after successful initialization
12611
13244
  if (this.analyticsClient) {
13245
+ this.analyticsClient.trackSessionInit(config.geo, config.device).catch((error) => {
13246
+ this.logger.warn("[SDK] Failed to track session init", error);
13247
+ });
12612
13248
  this.analyticsClient.trackPageView().catch((error) => {
12613
13249
  // Non-blocking: Analytics failure should not affect SDK initialization
12614
13250
  this.logger.warn("[SDK] Failed to track page view", error);
@@ -12914,6 +13550,63 @@ class CoBuy {
12914
13550
  this.logger.error("Error setting contact information", error);
12915
13551
  }
12916
13552
  }
13553
+ /**
13554
+ * Resolve an invite token to get product and group information
13555
+ *
13556
+ * Resolves an invite link token (e.g., from https://brand.cobuyza.co.za/i/QMeHD7ew)
13557
+ * to get the associated product, group, and invite metadata. Use this to handle
13558
+ * invite links in your application routing.
13559
+ *
13560
+ * @param inviteToken - The invite token from the URL
13561
+ * @returns Promise with invite resolution data or null if failed
13562
+ *
13563
+ * @example
13564
+ * ```typescript
13565
+ * // Extract token from URL path like /i/QMeHD7ew
13566
+ * const pathParts = window.location.pathname.split('/i/');
13567
+ * if (pathParts.length > 1) {
13568
+ * const inviteToken = pathParts[1];
13569
+ * const inviteData = await CoBuy.resolveInvite(inviteToken);
13570
+ *
13571
+ * if (inviteData) {
13572
+ * // Navigate to product page
13573
+ * window.location.href = `/product/${inviteData.product.id}`;
13574
+ * }
13575
+ * }
13576
+ * ```
13577
+ */
13578
+ async resolveInvite(inviteToken) {
13579
+ if (!this.configManager.isInitialized()) {
13580
+ this.logger.warn("SDK not initialized, cannot resolve invite");
13581
+ return null;
13582
+ }
13583
+ if (!this.apiClient) {
13584
+ this.logger.error("API client not available");
13585
+ return null;
13586
+ }
13587
+ if (!inviteToken || typeof inviteToken !== "string" || inviteToken.trim() === "") {
13588
+ this.logger.error("Invalid invite token provided");
13589
+ return null;
13590
+ }
13591
+ try {
13592
+ const response = await this.apiClient.resolveInvite(inviteToken.trim());
13593
+ if (response.success && response.data) {
13594
+ this.logger.info(`Invite resolved successfully: product=${response.data.product.id}, group=${response.data.group.id}`);
13595
+ return response.data;
13596
+ }
13597
+ else {
13598
+ if (this.handleFraudError(response.error, "resolveInvite")) {
13599
+ return null;
13600
+ }
13601
+ this.logger.error("Failed to resolve invite", response.error);
13602
+ return null;
13603
+ }
13604
+ }
13605
+ catch (error) {
13606
+ this.logger.error("Error resolving invite", error);
13607
+ return null;
13608
+ }
13609
+ }
12917
13610
  /**
12918
13611
  * Validate checkout for a group
12919
13612
  *