@pixels-online/pixels-client-js-sdk 1.14.0 → 1.15.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.
package/dist/index.js CHANGED
@@ -1171,6 +1171,19 @@ class OfferwallClient {
1171
1171
  }
1172
1172
  }
1173
1173
 
1174
+ const keyPattern = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
1175
+ // renders template by replacing {keyName} with values from dynamic
1176
+ function renderTemplate(template, dynamic) {
1177
+ if (!template)
1178
+ return '';
1179
+ return template.replace(keyPattern, (match, key) => {
1180
+ if (dynamic && dynamic[key] !== undefined) {
1181
+ return String(dynamic[key]);
1182
+ }
1183
+ return '{?}'; // indicate missing key
1184
+ });
1185
+ }
1186
+
1174
1187
  const meetsBaseConditions = ({ conditions, playerSnap, addDetails, }) => {
1175
1188
  const conditionData = [];
1176
1189
  let isValid = true;
@@ -1496,6 +1509,92 @@ const meetsBaseConditions = ({ conditions, playerSnap, addDetails, }) => {
1496
1509
  }
1497
1510
  }
1498
1511
  }
1512
+ // Validate link count conditions
1513
+ if (conditions?.links) {
1514
+ for (const [linkType, constraint] of Object.entries(conditions.links)) {
1515
+ const linkCount = playerSnap.entityLinks?.filter((link) => link.kind === linkType).length || 0;
1516
+ if (constraint.min !== undefined) {
1517
+ const isDisqualify = linkCount < constraint.min;
1518
+ if (addDetails) {
1519
+ conditionData.push({
1520
+ isMet: !isDisqualify,
1521
+ kind: 'links',
1522
+ trackerAmount: linkCount,
1523
+ text: `At least ${constraint.min} ${linkType} link(s)`,
1524
+ });
1525
+ if (isDisqualify)
1526
+ isValid = false;
1527
+ }
1528
+ else {
1529
+ if (isDisqualify)
1530
+ return { isValid: false };
1531
+ }
1532
+ }
1533
+ if (constraint.max !== undefined) {
1534
+ const isDisqualify = linkCount > constraint.max;
1535
+ if (addDetails) {
1536
+ conditionData.push({
1537
+ isMet: !isDisqualify,
1538
+ kind: 'links',
1539
+ trackerAmount: linkCount,
1540
+ text: `At most ${constraint.max} ${linkType} link(s)`,
1541
+ });
1542
+ if (isDisqualify)
1543
+ isValid = false;
1544
+ }
1545
+ else {
1546
+ if (isDisqualify)
1547
+ return { isValid: false };
1548
+ }
1549
+ }
1550
+ }
1551
+ }
1552
+ // Evaluate dynamic conditions
1553
+ if (conditions?.dynamic?.conditions?.length) {
1554
+ const dynamicResult = meetsDynamicConditions(playerSnap.dynamic || {}, conditions.dynamic);
1555
+ if (addDetails) {
1556
+ conditionData.push({
1557
+ isMet: dynamicResult,
1558
+ kind: 'dynamic',
1559
+ text: renderTemplate(conditions.dynamic.template, playerSnap.dynamic) ||
1560
+ 'Dynamic conditions',
1561
+ });
1562
+ if (!dynamicResult)
1563
+ isValid = false;
1564
+ }
1565
+ else {
1566
+ if (!dynamicResult)
1567
+ return { isValid: false };
1568
+ }
1569
+ }
1570
+ if (conditions?.identifiers?.platforms?.length) {
1571
+ const playerPlatforms = new Set(playerSnap.identifiers?.map((i) => i.platform.toLowerCase()) || []);
1572
+ const isAndBehaviour = conditions.identifiers.behaviour === 'AND';
1573
+ const platformsToCheck = conditions.identifiers.platforms;
1574
+ let isMet;
1575
+ let displayText;
1576
+ if (isAndBehaviour) {
1577
+ isMet = platformsToCheck.every((platform) => playerPlatforms.has(platform.toLowerCase()));
1578
+ displayText = `Link all: ${platformsToCheck.join(', ')}`;
1579
+ }
1580
+ else {
1581
+ isMet = platformsToCheck.some((platform) => playerPlatforms.has(platform.toLowerCase()));
1582
+ displayText = `Link any: ${platformsToCheck.join(', ')}`;
1583
+ }
1584
+ if (addDetails) {
1585
+ conditionData.push({
1586
+ isMet,
1587
+ kind: 'identifiers',
1588
+ text: displayText,
1589
+ });
1590
+ if (!isMet)
1591
+ isValid = false;
1592
+ }
1593
+ else {
1594
+ if (!isMet)
1595
+ return { isValid: false };
1596
+ }
1597
+ }
1499
1598
  return { isValid, conditionData: addDetails ? conditionData : undefined };
1500
1599
  };
1501
1600
  const meetsSurfacingConditions = ({ surfacingConditions, playerSnap, context, playerOffers, }) => {
@@ -1504,6 +1603,13 @@ const meetsSurfacingConditions = ({ surfacingConditions, playerSnap, context, pl
1504
1603
  // context is not in the list of surfacing contexts, so we don't want to surface this offer
1505
1604
  return { isValid: false };
1506
1605
  }
1606
+ if (surfacingConditions?.targetEntityTypes?.length) {
1607
+ const playerTarget = playerSnap.target || 'default';
1608
+ // check if entity type is allowed
1609
+ if (!surfacingConditions.targetEntityTypes.includes(playerTarget)) {
1610
+ return { isValid: false };
1611
+ }
1612
+ }
1507
1613
  const conditions = surfacingConditions;
1508
1614
  if (conditions?.andTags?.length) {
1509
1615
  // check if player has all of the tags
@@ -1542,12 +1648,6 @@ const meetsSurfacingConditions = ({ surfacingConditions, playerSnap, context, pl
1542
1648
  (!playerSnap.dateSignedUp || playerSnap.dateSignedUp > conditions.maxDateSignedUp)) {
1543
1649
  return { isValid: false };
1544
1650
  }
1545
- // Check dynamic conditions if present
1546
- if (conditions.dynamic?.conditions?.length) {
1547
- if (!meetsDynamicConditions(playerSnap.dynamic || {}, conditions.dynamic)) {
1548
- return { isValid: false };
1549
- }
1550
- }
1551
1651
  const completedOfferIds = new Set();
1552
1652
  for (const pOffer of playerOffers?.values() || []) {
1553
1653
  if (pOffer.status === 'claimed' || pOffer.status === 'claimable') {
@@ -1583,6 +1683,10 @@ const hasConditions = (conditions) => {
1583
1683
  return true;
1584
1684
  if (conditions.minDaysInGame)
1585
1685
  return true;
1686
+ if (conditions.dynamic?.conditions?.length)
1687
+ return true;
1688
+ if (conditions.identifiers?.platforms?.length)
1689
+ return true;
1586
1690
  const surCond = conditions;
1587
1691
  if (surCond.contexts?.length)
1588
1692
  return true;
@@ -1602,6 +1706,12 @@ const hasConditions = (conditions) => {
1602
1706
  return true;
1603
1707
  if (surCond.completedOffers?.length)
1604
1708
  return true;
1709
+ if (surCond.programmatic)
1710
+ return true;
1711
+ if (surCond.targetEntityTypes?.length)
1712
+ return true;
1713
+ if (surCond.links && Object.keys(surCond.links).length > 0)
1714
+ return true;
1605
1715
  const compCond = conditions;
1606
1716
  if (compCond.context)
1607
1717
  return true;
@@ -1617,6 +1727,8 @@ const hasConditions = (conditions) => {
1617
1727
  return true;
1618
1728
  if (compCond.loginStreak)
1619
1729
  return true;
1730
+ if (compCond.linkedCompletions)
1731
+ return true;
1620
1732
  return false;
1621
1733
  };
1622
1734
  const meetsCompletionConditions = ({ completionConditions, completionTrackers, playerSnap, addDetails = false, }) => {
@@ -1730,51 +1842,107 @@ const meetsCompletionConditions = ({ completionConditions, completionTrackers, p
1730
1842
  }
1731
1843
  }
1732
1844
  if (conditions?.social) {
1733
- const hasAttachedContent = !!completionTrackers?.social?.videoId;
1734
1845
  const tSocial = completionTrackers?.social;
1735
1846
  const cSocial = completionConditions.social;
1736
- let isDisqualify = !hasAttachedContent;
1737
- if (cSocial?.minLikes && tSocial && (tSocial.likes || 0) < cSocial.minLikes) {
1738
- isDisqualify = true;
1739
- }
1740
- if (cSocial?.minViews && tSocial && (tSocial.views || 0) < cSocial.minViews) {
1741
- isDisqualify = true;
1742
- }
1743
- if (cSocial?.minComments && tSocial && (tSocial.comments || 0) < cSocial.minComments) {
1847
+ const mode = cSocial?.mode || 'attach';
1848
+ const hasContent = Boolean(mode === 'accumulate'
1849
+ ? tSocial?.mode === 'accumulate'
1850
+ : tSocial && tSocial.mode !== 'accumulate' && !!tSocial.videoId);
1851
+ const minLikes = cSocial?.minLikes || 0;
1852
+ const minViews = cSocial?.minViews || 0;
1853
+ const minComments = cSocial?.minComments || 0;
1854
+ const likes = tSocial?.likes || 0;
1855
+ const views = tSocial?.views || 0;
1856
+ const comments = tSocial?.comments || 0;
1857
+ let isDisqualify = !hasContent;
1858
+ if (likes < minLikes || views < minViews || comments < minComments) {
1744
1859
  isDisqualify = true;
1745
1860
  }
1746
1861
  if (addDetails) {
1747
- // Build detailed text about requirements
1748
1862
  const platformMap = {
1749
- 'tiktok': 'TikTok',
1750
- 'instagram': 'Instagram',
1751
- 'youtube': 'YouTube',
1863
+ tiktok: 'TikTok',
1864
+ instagram: 'Instagram',
1865
+ youtube: 'YouTube',
1752
1866
  };
1753
- const platformText = conditions.social.platforms.map(platform => platformMap[platform]).join(' | ');
1754
- let displayText = `Attach a ${platformText} post`;
1755
- if (hasAttachedContent && tSocial) {
1756
- let socialTexts = [];
1757
- if (cSocial?.minLikes ?? 0 > 0) {
1758
- socialTexts.push(`${cSocial.minLikes} Likes (${tSocial.likes}/${cSocial.minLikes})`);
1759
- }
1760
- if (cSocial?.minViews ?? 0 > 0) {
1761
- socialTexts.push(`${cSocial.minViews} Views (${tSocial.views}/${cSocial.minViews})`);
1762
- }
1763
- if (cSocial?.minComments ?? 0 > 0) {
1764
- socialTexts.push(`${cSocial.minComments} Comments (${tSocial.comments}/${cSocial.minComments})`);
1765
- }
1766
- displayText = `Reach:\n${socialTexts.join('\n')}`;
1867
+ const platformText = conditions.social.platforms
1868
+ .map((platform) => platformMap[platform])
1869
+ .join(' | ');
1870
+ const requiredWords = cSocial?.requiredWords ?? [];
1871
+ if (mode === 'accumulate') {
1872
+ const matchCount = (tSocial?.mode === 'accumulate' && tSocial.matchCount) || 0;
1873
+ conditionData.push({
1874
+ isMet: hasContent,
1875
+ kind: 'social',
1876
+ trackerAmount: matchCount,
1877
+ text: hasContent
1878
+ ? `Found ${matchCount} matching ${platformText} post${matchCount !== 1 ? 's' : ''}`
1879
+ : requiredWords.length > 0
1880
+ ? `Post ${platformText} content with ${requiredWords.map((w) => `"${w}"`).join(', ')}`
1881
+ : `Post ${platformText} content`,
1882
+ });
1883
+ }
1884
+ else {
1885
+ const title = (tSocial && tSocial.mode !== 'accumulate' && tSocial.title) || undefined;
1886
+ conditionData.push({
1887
+ isMet: hasContent,
1888
+ kind: 'social',
1889
+ trackerAmount: 0,
1890
+ text: !hasContent
1891
+ ? requiredWords.length > 0
1892
+ ? `Attach a ${platformText} post with ${requiredWords.map((w) => `"${w}"`).join(', ')} in the title`
1893
+ : `Attach a ${platformText} post`
1894
+ : `Attached: ${title}`,
1895
+ });
1896
+ }
1897
+ if (minLikes > 0) {
1898
+ conditionData.push({
1899
+ isMet: hasContent && likes >= minLikes,
1900
+ kind: 'social',
1901
+ trackerAmount: likes,
1902
+ text: mode === 'accumulate'
1903
+ ? `Combined ${minLikes} Likes`
1904
+ : `Reach ${minLikes} Likes`,
1905
+ });
1906
+ }
1907
+ if (minViews > 0) {
1908
+ conditionData.push({
1909
+ isMet: hasContent && views >= minViews,
1910
+ kind: 'social',
1911
+ trackerAmount: views,
1912
+ text: mode === 'accumulate'
1913
+ ? `Combined ${minViews} Views`
1914
+ : `Reach ${minViews} Views`,
1915
+ });
1767
1916
  }
1768
- const trackerAmount = (cSocial?.minViews ?? 0) > 0 && (tSocial?.likes ?? 0) < (cSocial.minLikes ?? 0)
1769
- ? (tSocial?.likes ?? 0)
1770
- : (cSocial?.minComments ?? 0) > 0 && (tSocial?.comments ?? 0) < (cSocial.minComments ?? 0)
1771
- ? (tSocial?.comments ?? 0)
1772
- : (tSocial?.views ?? 0);
1917
+ if (minComments > 0) {
1918
+ conditionData.push({
1919
+ isMet: hasContent && comments >= minComments,
1920
+ kind: 'social',
1921
+ trackerAmount: comments,
1922
+ text: mode === 'accumulate'
1923
+ ? `Combined ${minComments} Comments`
1924
+ : `Reach ${minComments} Comments`,
1925
+ });
1926
+ }
1927
+ if (isDisqualify)
1928
+ isValid = false;
1929
+ }
1930
+ else {
1931
+ if (isDisqualify)
1932
+ return { isValid: false };
1933
+ }
1934
+ }
1935
+ // Linked completions - wait for N linked entities to complete
1936
+ if (conditions?.linkedCompletions?.min) {
1937
+ const currentCount = completionTrackers?.linkedCompletions || 0;
1938
+ const requiredCount = conditions.linkedCompletions.min;
1939
+ const isDisqualify = currentCount < requiredCount;
1940
+ if (addDetails) {
1773
1941
  conditionData.push({
1774
- isMet: hasAttachedContent,
1775
- kind: 'social',
1776
- trackerAmount,
1777
- text: displayText,
1942
+ isMet: !isDisqualify,
1943
+ kind: 'linkedCompletions',
1944
+ trackerAmount: currentCount,
1945
+ text: `Wait for ${requiredCount} linked ${requiredCount === 1 ? 'entity' : 'entities'} to complete`,
1778
1946
  });
1779
1947
  if (isDisqualify)
1780
1948
  isValid = false;
@@ -1795,6 +1963,136 @@ const meetsCompletionConditions = ({ completionConditions, completionTrackers, p
1795
1963
  }
1796
1964
  return { isValid: true, conditionData: [] };
1797
1965
  };
1966
+ /**
1967
+ * Checks if completion conditions were met before a specific expiry time.
1968
+ * Returns true if all relevant condition fields were updated before expiryTime.
1969
+ *
1970
+ * @param completionConditions - The completion conditions to check
1971
+ * @param completionTrackers - The completion trackers (for buyItem, spendCurrency, etc.)
1972
+ * @param playerSnap - The player snapshot with field timestamps
1973
+ * @param expiryTime - The expiry timestamp in milliseconds
1974
+ * @returns true if all conditions were met before expiry, false otherwise
1975
+ */
1976
+ const meetsCompletionConditionsBeforeExpiry = ({ completionConditions, completionTrackers, playerSnap, expiryTime, }) => {
1977
+ if (!completionConditions)
1978
+ return false;
1979
+ // Check if there are actually any conditions to evaluate
1980
+ if (!hasConditions(completionConditions))
1981
+ return false;
1982
+ // First check if conditions are actually met
1983
+ const conditionsMet = meetsCompletionConditions({
1984
+ completionConditions,
1985
+ completionTrackers,
1986
+ playerSnap,
1987
+ });
1988
+ if (!conditionsMet.isValid)
1989
+ return false;
1990
+ const lastSnapshotUpdate = new Date(playerSnap.snapshotLastUpdated).getTime();
1991
+ /**
1992
+ * Checks if a field was updated after the expiry time.
1993
+ * Returns true if updated AFTER or AT expiry (violates grace period).
1994
+ * Returns false if updated BEFORE expiry (allows grace period).
1995
+ */
1996
+ function wasUpdatedAfterExpiry(data) {
1997
+ let lastUpdated;
1998
+ if (typeof data === 'object' && data !== null && !(data instanceof Date)) {
1999
+ // Object with optional lastUpdated field
2000
+ lastUpdated = data.lastUpdated
2001
+ ? new Date(data.lastUpdated).getTime()
2002
+ : lastSnapshotUpdate;
2003
+ }
2004
+ else if (data instanceof Date) {
2005
+ lastUpdated = data.getTime();
2006
+ }
2007
+ else if (typeof data === 'string' || typeof data === 'number') {
2008
+ lastUpdated = new Date(data).getTime();
2009
+ }
2010
+ else {
2011
+ // No data provided, use snapshot timestamp
2012
+ lastUpdated = lastSnapshotUpdate;
2013
+ }
2014
+ return lastUpdated >= expiryTime;
2015
+ }
2016
+ if (completionConditions.currencies) {
2017
+ for (const currencyId in completionConditions.currencies) {
2018
+ const currency = playerSnap.currencies?.[currencyId];
2019
+ if (!currency)
2020
+ continue;
2021
+ if (wasUpdatedAfterExpiry(currency))
2022
+ return false;
2023
+ }
2024
+ }
2025
+ if (completionConditions.levels) {
2026
+ for (const skillId in completionConditions.levels) {
2027
+ const level = playerSnap.levels?.[skillId];
2028
+ if (!level)
2029
+ continue;
2030
+ if (wasUpdatedAfterExpiry(level))
2031
+ return false;
2032
+ }
2033
+ }
2034
+ if (completionConditions.quests) {
2035
+ for (const questId in completionConditions.quests) {
2036
+ const quest = playerSnap.quests?.[questId];
2037
+ if (!quest)
2038
+ continue;
2039
+ if (wasUpdatedAfterExpiry(quest))
2040
+ return false;
2041
+ }
2042
+ }
2043
+ if (completionConditions.memberships) {
2044
+ for (const membershipId in completionConditions.memberships) {
2045
+ const membership = playerSnap.memberships?.[membershipId];
2046
+ if (!membership)
2047
+ continue;
2048
+ if (wasUpdatedAfterExpiry(membership))
2049
+ return false;
2050
+ }
2051
+ }
2052
+ if (completionConditions.achievements) {
2053
+ for (const achievementId in completionConditions.achievements) {
2054
+ const achievement = playerSnap.achievements?.[achievementId];
2055
+ if (!achievement)
2056
+ continue;
2057
+ if (wasUpdatedAfterExpiry(achievement))
2058
+ return false;
2059
+ }
2060
+ }
2061
+ if (completionConditions.stakedTokens) {
2062
+ for (const tokenId in completionConditions.stakedTokens) {
2063
+ const stakedToken = playerSnap.stakedTokens?.[tokenId];
2064
+ if (!stakedToken)
2065
+ continue;
2066
+ const lastStakeTime = new Date(stakedToken.lastStake ?? 0).getTime();
2067
+ const lastUnstakeTime = new Date(stakedToken.lastUnstake ?? 0).getTime();
2068
+ const lastUpdated = Math.max(lastStakeTime, lastUnstakeTime);
2069
+ if (lastUpdated >= expiryTime)
2070
+ return false;
2071
+ }
2072
+ }
2073
+ if (completionConditions.minTrustScore !== undefined ||
2074
+ completionConditions.maxTrustScore !== undefined) {
2075
+ if (wasUpdatedAfterExpiry(playerSnap.trustLastUpdated))
2076
+ return false;
2077
+ }
2078
+ if (completionConditions.minDaysInGame !== undefined) {
2079
+ if (wasUpdatedAfterExpiry(playerSnap.daysInGameLastUpdated))
2080
+ return false;
2081
+ }
2082
+ if (completionConditions.login || completionConditions.loginStreak) {
2083
+ if (wasUpdatedAfterExpiry())
2084
+ return false;
2085
+ }
2086
+ if (completionConditions.social) {
2087
+ // Check if social content was attached/validated after expiry
2088
+ if (completionTrackers?.social?.lastChecked) {
2089
+ if (wasUpdatedAfterExpiry(completionTrackers.social.lastChecked))
2090
+ return false;
2091
+ }
2092
+ }
2093
+ // All conditions were met before expiry
2094
+ return true;
2095
+ };
1798
2096
  /**
1799
2097
  * Checks if a dynamic object meets a set of dynamic field conditions.
1800
2098
  * @param dynamicObj - The object with any key and string or number value.
@@ -1873,8 +2171,9 @@ function meetsDynamicConditions(dynamicObj, dynamicGroup) {
1873
2171
  return result;
1874
2172
  }
1875
2173
 
2174
+ const offerListenerEvents = ['claim_offer'];
1876
2175
  const PlayerOfferStatuses = [
1877
- 'inQueue',
2176
+ // 'inQueue', // fuck this shit. just don't surface offers if their offer plate is full.
1878
2177
  'surfaced',
1879
2178
  'viewed',
1880
2179
  'claimable',
@@ -1889,12 +2188,26 @@ const rewardKinds = [
1889
2188
  'exp',
1890
2189
  'trust_points',
1891
2190
  'loyalty_currency', // loyalty currency that the player can exchange for rewards like on-chain via withdraw, etc.
2191
+ 'discount', // handled by the external dev, using the rewardId to identify what it is for in their system
1892
2192
  /** on-chain rewards require the builder to send funds to a custodial wallet that we use to send to player wallets*/
1893
2193
  ];
1894
2194
  const rewardSchema = {
1895
2195
  _id: false,
1896
2196
  kind: { type: String, enum: rewardKinds },
1897
- rewardId: String,
2197
+ rewardId: {
2198
+ type: String,
2199
+ validate: {
2200
+ validator: function (value) {
2201
+ // Require rewardId for item, coins, loyalty_currency, exp, and discount kinds
2202
+ const requiresRewardId = ['item', 'coins', 'loyalty_currency', 'exp', 'discount'].includes(this.kind);
2203
+ if (requiresRewardId) {
2204
+ return !!value;
2205
+ }
2206
+ return true;
2207
+ },
2208
+ message: 'rewardId is required for reward kinds: item, coins, loyalty_currency, exp, and discount',
2209
+ },
2210
+ },
1898
2211
  skillId: String,
1899
2212
  currencyId: String, // could be a loyalty currency
1900
2213
  itemId: String,
@@ -1912,8 +2225,10 @@ exports.SSEConnection = SSEConnection;
1912
2225
  exports.hasConditions = hasConditions;
1913
2226
  exports.meetsBaseConditions = meetsBaseConditions;
1914
2227
  exports.meetsCompletionConditions = meetsCompletionConditions;
2228
+ exports.meetsCompletionConditionsBeforeExpiry = meetsCompletionConditionsBeforeExpiry;
1915
2229
  exports.meetsDynamicConditions = meetsDynamicConditions;
1916
2230
  exports.meetsSurfacingConditions = meetsSurfacingConditions;
2231
+ exports.offerListenerEvents = offerListenerEvents;
1917
2232
  exports.rewardKinds = rewardKinds;
1918
2233
  exports.rewardSchema = rewardSchema;
1919
2234
  //# sourceMappingURL=index.js.map