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