thumbgate 1.4.4 → 1.4.6

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.
@@ -2068,8 +2068,11 @@ function buildCheckoutSessionPayload({ successUrl, cancelUrl, customerEmail, che
2068
2068
  packId: pack ? pack.id : null,
2069
2069
  credits: pack ? pack.credits : null,
2070
2070
  }),
2071
- // 7-day free trial for subscriptions — reduces checkout abandonment
2072
- ...(pack ? {} : { subscription_data: { trial_period_days: 7 } }),
2071
+ // 7-day free trial for subscriptions — don't require card upfront
2072
+ ...(pack ? {} : {
2073
+ subscription_data: { trial_period_days: 7 },
2074
+ payment_method_collection: 'if_required',
2075
+ }),
2073
2076
  };
2074
2077
 
2075
2078
  const normalizedCustomerEmail = normalizeText(customerEmail);
@@ -15,6 +15,21 @@ const {
15
15
  } = require('./feedback-paths');
16
16
 
17
17
  const TELEMETRY_FILE_NAME = 'telemetry-pings.jsonl';
18
+ const MARKETING_CLICK_EVENT_TYPES = new Set([
19
+ 'cta_click',
20
+ 'checkout_start',
21
+ 'checkout_bootstrap',
22
+ 'chatgpt_gpt_open',
23
+ 'chatgpt_gpt_click',
24
+ 'install_guide_click',
25
+ 'install_click',
26
+ 'pro_page_click',
27
+ 'pro_checkout_start',
28
+ 'workflow_sprint_intake_click',
29
+ 'demo_click',
30
+ 'github_repo_click',
31
+ 'community_landing_redirect',
32
+ ]);
18
33
 
19
34
  function shouldIncludeLegacyTelemetry() {
20
35
  if (
@@ -64,6 +79,10 @@ function safeRate(num, den) {
64
79
  return Number((num / den).toFixed(4));
65
80
  }
66
81
 
82
+ function isMarketingClickEvent(eventType) {
83
+ return MARKETING_CLICK_EVENT_TYPES.has(String(eventType || '').toLowerCase());
84
+ }
85
+
67
86
  function getTelemetryPath(feedbackDir) {
68
87
  return path.join(feedbackDir, TELEMETRY_FILE_NAME);
69
88
  }
@@ -288,6 +307,8 @@ function sanitizeTelemetryPayload(payload = {}, headers = {}) {
288
307
  ctaId: pickFirstText(raw.ctaId),
289
308
  ctaPlacement: pickFirstText(raw.ctaPlacement),
290
309
  planId: pickFirstText(raw.planId),
310
+ linkSlug: pickFirstText(raw.linkSlug, raw.destinationSlug),
311
+ destinationPath: pickFirstText(raw.destinationPath),
291
312
  pipelineStatus: pickFirstText(raw.pipelineStatus, raw.workflowSprintStatus, raw.status),
292
313
  reasonCode,
293
314
  reasonDetail: pickFirstText(raw.reasonDetail, raw.reasonText, raw.otherReason, raw.notes),
@@ -365,6 +386,7 @@ function summarizeRecentEvents(events) {
365
386
  utmCampaign: entry.utmCampaign || null,
366
387
  ctaId: entry.ctaId || null,
367
388
  page: entry.page || null,
389
+ linkSlug: entry.linkSlug || null,
368
390
  reasonCode: entry.reasonCode || null,
369
391
  creator: entry.creator || null,
370
392
  community: entry.community || null,
@@ -474,7 +496,7 @@ function getTelemetrySummary(feedbackDir, options = {}) {
474
496
  if (entry.attributionTagged) attributedPageViews += 1;
475
497
  }
476
498
 
477
- if ((entry.eventType || entry.event) === 'cta_click' || (entry.eventType || entry.event) === 'checkout_start' || (entry.eventType || entry.event) === 'checkout_bootstrap') {
499
+ if (isMarketingClickEvent(entry.eventType || entry.event)) {
478
500
  ctaClicks += 1;
479
501
  incrementCounter(ctaClicksBySource, entry.source);
480
502
  incrementCounter(ctaClicksByCampaign, entry.utmCampaign);
@@ -601,6 +623,10 @@ function getTelemetrySummary(feedbackDir, options = {}) {
601
623
  pageViewsByTrafficChannel[channelKey] || 0
602
624
  );
603
625
  }
626
+ const installCopies = byEventType.install_copy || 0;
627
+ const gptOpens = (byEventType.chatgpt_gpt_open || 0) + (byEventType.chatgpt_gpt_click || 0);
628
+ const trialEmails = byEventType.trial_email_captured || 0;
629
+ const proConversions = checkoutPaidConfirmations;
604
630
 
605
631
  return {
606
632
  window: serializeAnalyticsWindow(analyticsWindow),
@@ -608,6 +634,19 @@ function getTelemetrySummary(feedbackDir, options = {}) {
608
634
  latestSeenAt,
609
635
  byClientType,
610
636
  byEventType,
637
+ conversionFunnel: {
638
+ landingViews: pageViews,
639
+ installCopies,
640
+ gptOpens,
641
+ checkoutStarts,
642
+ trialEmails,
643
+ proConversions,
644
+ landingToInstallCopyRate: safeRate(installCopies, pageViews),
645
+ landingToGptOpenRate: safeRate(gptOpens, pageViews),
646
+ landingToCheckoutRate: safeRate(checkoutStarts, pageViews),
647
+ checkoutToTrialEmailRate: safeRate(trialEmails, checkoutStarts),
648
+ checkoutToProConversionRate: safeRate(proConversions, checkoutStarts),
649
+ },
611
650
  web: {
612
651
  totalEvents: webEvents,
613
652
  uniqueVisitors: webVisitors.size,
@@ -712,6 +751,7 @@ function getTelemetryAnalytics(feedbackDir, options = {}) {
712
751
  latestSeenAt: summary.latestSeenAt,
713
752
  byClientType: summary.byClientType,
714
753
  byEventType: summary.byEventType,
754
+ conversionFunnel: summary.conversionFunnel,
715
755
  visitors: {
716
756
  totalEvents: summary.web.totalEvents,
717
757
  uniqueVisitors: summary.web.uniqueVisitors,
package/src/api/server.js CHANGED
@@ -227,6 +227,123 @@ const BUILD_METADATA = resolveBuildMetadata();
227
227
  const TERMINAL_JOB_STATUSES = new Set(['completed', 'failed', 'cancelled']);
228
228
  const IDLE_JOB_STATUSES = new Set(['queued', 'paused', 'resume_requested']);
229
229
  const JOB_CONTROL_ACTIONS = new Set(['pause', 'cancel', 'resume']);
230
+ const TRACKED_LINK_QUERY_KEYS = [
231
+ 'utm_source',
232
+ 'utm_medium',
233
+ 'utm_campaign',
234
+ 'utm_content',
235
+ 'utm_term',
236
+ 'source',
237
+ 'creator',
238
+ 'creator_handle',
239
+ 'community',
240
+ 'subreddit',
241
+ 'post_id',
242
+ 'comment_id',
243
+ 'campaign_variant',
244
+ 'offer_code',
245
+ 'acquisition_id',
246
+ 'visitor_id',
247
+ 'session_id',
248
+ 'visitor_session_id',
249
+ 'install_id',
250
+ 'trace_id',
251
+ 'cta_id',
252
+ 'cta_placement',
253
+ 'plan_id',
254
+ 'billing_cycle',
255
+ 'seat_count',
256
+ 'landing_path',
257
+ 'referrer_host',
258
+ ];
259
+ const TRACKED_LINK_TARGETS = Object.freeze({
260
+ gpt: {
261
+ href: 'https://chatgpt.com/g/g-69dcfd1cd5f881918ae31874631d6f08-thumbgate',
262
+ external: true,
263
+ ctaId: 'go_gpt',
264
+ ctaPlacement: 'link_router',
265
+ eventType: 'chatgpt_gpt_open',
266
+ defaults: {
267
+ utm_source: 'website',
268
+ utm_medium: 'link_router',
269
+ utm_campaign: 'chatgpt_gpt',
270
+ },
271
+ },
272
+ pro: {
273
+ path: '/checkout/pro',
274
+ ctaId: 'go_pro',
275
+ ctaPlacement: 'link_router',
276
+ eventType: 'cta_click',
277
+ defaults: {
278
+ utm_source: 'website',
279
+ utm_medium: 'link_router',
280
+ utm_campaign: 'pro_upgrade',
281
+ plan_id: 'pro',
282
+ billing_cycle: 'monthly',
283
+ },
284
+ allowCustomerEmail: true,
285
+ },
286
+ install: {
287
+ path: '/guide',
288
+ ctaId: 'go_install',
289
+ ctaPlacement: 'link_router',
290
+ eventType: 'install_guide_click',
291
+ defaults: {
292
+ utm_source: 'website',
293
+ utm_medium: 'link_router',
294
+ utm_campaign: 'install_free',
295
+ plan_id: 'free',
296
+ },
297
+ },
298
+ reddit: {
299
+ path: '/',
300
+ ctaId: 'go_reddit',
301
+ ctaPlacement: 'link_router',
302
+ eventType: 'community_landing_redirect',
303
+ defaults: {
304
+ utm_source: 'reddit',
305
+ utm_medium: 'organic_social',
306
+ utm_campaign: 'first_party_redirect',
307
+ campaign_variant: 'reddit_shortlink',
308
+ },
309
+ },
310
+ linkedin: {
311
+ path: '/',
312
+ ctaId: 'go_linkedin',
313
+ ctaPlacement: 'link_router',
314
+ eventType: 'community_landing_redirect',
315
+ defaults: {
316
+ utm_source: 'linkedin',
317
+ utm_medium: 'organic_social',
318
+ utm_campaign: 'first_party_redirect',
319
+ campaign_variant: 'linkedin_shortlink',
320
+ },
321
+ },
322
+ x: {
323
+ path: '/',
324
+ ctaId: 'go_x',
325
+ ctaPlacement: 'link_router',
326
+ eventType: 'community_landing_redirect',
327
+ defaults: {
328
+ utm_source: 'x',
329
+ utm_medium: 'organic_social',
330
+ utm_campaign: 'first_party_redirect',
331
+ campaign_variant: 'x_shortlink',
332
+ },
333
+ },
334
+ github: {
335
+ href: 'https://github.com/IgorGanapolsky/ThumbGate',
336
+ external: true,
337
+ ctaId: 'go_github',
338
+ ctaPlacement: 'link_router',
339
+ eventType: 'github_repo_click',
340
+ defaults: {
341
+ utm_source: 'website',
342
+ utm_medium: 'link_router',
343
+ utm_campaign: 'github_repo',
344
+ },
345
+ },
346
+ });
230
347
 
231
348
  // ---------------------------------------------------------------------------
232
349
  // Stripe event tracking helpers
@@ -841,6 +958,130 @@ function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneySt
841
958
  };
842
959
  }
843
960
 
961
+ function normalizeTrackedLinkSlug(value) {
962
+ return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
963
+ }
964
+
965
+ function getTrackedLinkTarget(slug) {
966
+ const normalizedSlug = normalizeTrackedLinkSlug(slug);
967
+ return TRACKED_LINK_TARGETS[normalizedSlug]
968
+ ? { slug: normalizedSlug, ...TRACKED_LINK_TARGETS[normalizedSlug] }
969
+ : null;
970
+ }
971
+
972
+ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
973
+ const params = parsed.searchParams;
974
+ for (const [key, value] of Object.entries(target.defaults || {})) {
975
+ if (!destinationUrl.searchParams.has(key)) {
976
+ appendQueryParam(destinationUrl, key, value);
977
+ }
978
+ }
979
+ for (const key of TRACKED_LINK_QUERY_KEYS) {
980
+ const value = params.get(key);
981
+ if (value && value.trim()) {
982
+ destinationUrl.searchParams.set(key, value.trim());
983
+ }
984
+ }
985
+ if (target.allowCustomerEmail) {
986
+ appendQueryParam(destinationUrl, 'customer_email', params.get('customer_email'));
987
+ }
988
+ if (!destinationUrl.searchParams.has('cta_id')) {
989
+ appendQueryParam(destinationUrl, 'cta_id', target.ctaId);
990
+ }
991
+ if (!destinationUrl.searchParams.has('cta_placement')) {
992
+ appendQueryParam(destinationUrl, 'cta_placement', target.ctaPlacement);
993
+ }
994
+ if (!destinationUrl.searchParams.has('landing_path')) {
995
+ appendQueryParam(destinationUrl, 'landing_path', `/go/${target.slug}`);
996
+ }
997
+ }
998
+
999
+ function buildTrackedLinkDestination(target, hostedConfig, parsed) {
1000
+ const destinationUrl = target.href
1001
+ ? new URL(target.href)
1002
+ : new URL(target.path || '/', hostedConfig.appOrigin);
1003
+ appendTrackedLinkQueryParams(destinationUrl, parsed, target);
1004
+ return destinationUrl;
1005
+ }
1006
+
1007
+ function buildTrackedLinkAttribution(target, parsed, req, journeyState, destinationUrl) {
1008
+ const params = parsed.searchParams;
1009
+ const referrer = pickFirstText(params.get('referrer'), req.headers.referer, req.headers.referrer);
1010
+ const referrerHost = pickFirstText(params.get('referrer_host'), parseReferrerHost(referrer));
1011
+ const source = pickFirstText(
1012
+ params.get('source'),
1013
+ params.get('utm_source'),
1014
+ target.defaults && target.defaults.utm_source,
1015
+ inferSource(referrerHost)
1016
+ );
1017
+
1018
+ return {
1019
+ eventType: target.eventType || 'cta_click',
1020
+ clientType: 'web',
1021
+ acquisitionId: journeyState.acquisitionId,
1022
+ visitorId: journeyState.visitorId,
1023
+ sessionId: journeyState.sessionId,
1024
+ installId: pickFirstText(params.get('install_id')),
1025
+ traceId: pickFirstText(params.get('trace_id')),
1026
+ source,
1027
+ utmSource: pickFirstText(params.get('utm_source'), source),
1028
+ utmMedium: pickFirstText(params.get('utm_medium'), target.defaults && target.defaults.utm_medium, 'link_router'),
1029
+ utmCampaign: pickFirstText(params.get('utm_campaign'), target.defaults && target.defaults.utm_campaign, 'first_party_redirect'),
1030
+ utmContent: pickFirstText(params.get('utm_content')),
1031
+ utmTerm: pickFirstText(params.get('utm_term')),
1032
+ creator: pickFirstText(params.get('creator'), params.get('creator_handle')),
1033
+ community: pickFirstText(params.get('community'), params.get('subreddit')),
1034
+ postId: pickFirstText(params.get('post_id')),
1035
+ commentId: pickFirstText(params.get('comment_id')),
1036
+ campaignVariant: pickFirstText(params.get('campaign_variant'), target.defaults && target.defaults.campaign_variant),
1037
+ offerCode: pickFirstText(params.get('offer_code')),
1038
+ ctaId: pickFirstText(params.get('cta_id'), target.ctaId),
1039
+ ctaPlacement: pickFirstText(params.get('cta_placement'), target.ctaPlacement),
1040
+ planId: pickFirstText(params.get('plan_id'), target.defaults && target.defaults.plan_id),
1041
+ landingPath: pickFirstText(params.get('landing_path'), `/go/${target.slug}`),
1042
+ page: `/go/${target.slug}`,
1043
+ referrer,
1044
+ referrerHost,
1045
+ destinationSlug: target.slug,
1046
+ destinationPath: target.external ? destinationUrl.host : destinationUrl.pathname,
1047
+ };
1048
+ }
1049
+
1050
+ function serveTrackedLinkRedirect({ req, res, parsed, hostedConfig, isHeadRequest, slug }) {
1051
+ const target = getTrackedLinkTarget(slug);
1052
+ if (!target) {
1053
+ sendJson(res, 404, {
1054
+ error: 'Tracked link not found',
1055
+ allowed: Object.keys(TRACKED_LINK_TARGETS),
1056
+ }, {}, {
1057
+ headOnly: isHeadRequest,
1058
+ });
1059
+ return;
1060
+ }
1061
+
1062
+ const { FEEDBACK_DIR } = getFeedbackPaths();
1063
+ const journeyState = resolveJourneyState(req, parsed);
1064
+ const destinationUrl = buildTrackedLinkDestination(target, hostedConfig, parsed);
1065
+ if (!isHeadRequest) {
1066
+ appendBestEffortTelemetry(
1067
+ FEEDBACK_DIR,
1068
+ buildTrackedLinkAttribution(target, parsed, req, journeyState, destinationUrl),
1069
+ req.headers,
1070
+ `tracked_link_redirect:${target.slug}`
1071
+ );
1072
+ }
1073
+
1074
+ res.writeHead(302, {
1075
+ ...(journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}),
1076
+ 'Cache-Control': 'no-store',
1077
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
1078
+ 'X-Robots-Tag': 'noindex,nofollow',
1079
+ 'X-ThumbGate-Link-Slug': target.slug,
1080
+ Location: destinationUrl.toString(),
1081
+ });
1082
+ res.end();
1083
+ }
1084
+
844
1085
  function resolveCheckoutOfferSummary(metadata = {}) {
845
1086
  const planId = normalizePlanId(metadata.planId);
846
1087
  const billingCycle = normalizeBillingCycle(metadata.billingCycle);
@@ -2697,6 +2938,34 @@ function createApiServer() {
2697
2938
  attribution,
2698
2939
  }) + '\n');
2699
2940
  }
2941
+ const journeyState = resolveJourneyState(req, parsed);
2942
+ appendBestEffortTelemetry(getFeedbackPaths().FEEDBACK_DIR, {
2943
+ eventType: 'trial_email_captured',
2944
+ clientType: 'web',
2945
+ acquisitionId: journeyState.acquisitionId,
2946
+ visitorId: journeyState.visitorId,
2947
+ sessionId: journeyState.sessionId,
2948
+ source: attribution.source || 'landing-page',
2949
+ utmSource: attribution.source || null,
2950
+ utmMedium: attribution.medium || 'newsletter',
2951
+ utmCampaign: attribution.campaign || 'trial_email_capture',
2952
+ utmContent: attribution.content || null,
2953
+ utmTerm: attribution.term || null,
2954
+ creator: attribution.creator || null,
2955
+ community: attribution.community || null,
2956
+ postId: attribution.postId || null,
2957
+ commentId: attribution.commentId || null,
2958
+ campaignVariant: attribution.campaignVariant || null,
2959
+ offerCode: attribution.offerCode || null,
2960
+ ctaId: 'trial_email',
2961
+ ctaPlacement: landingPath === '/pro' ? 'pro_email_form' : 'homepage_email_form',
2962
+ planId: 'pro',
2963
+ pipelineStatus: duplicate ? 'duplicate' : 'accepted',
2964
+ page: landingPath,
2965
+ landingPath,
2966
+ referrer: referrer || null,
2967
+ referrerHost,
2968
+ }, req.headers, 'trial_email_captured');
2700
2969
  if (wantsJson) {
2701
2970
  sendJson(res, 200, {
2702
2971
  accepted: true,
@@ -2750,6 +3019,19 @@ function createApiServer() {
2750
3019
  }
2751
3020
 
2752
3021
  // Public endpoints — no auth required
3022
+ const trackedLinkMatch = pathname.match(/^\/go\/([^/]+)$/);
3023
+ if (isGetLikeRequest && trackedLinkMatch) {
3024
+ serveTrackedLinkRedirect({
3025
+ req,
3026
+ res,
3027
+ parsed,
3028
+ hostedConfig,
3029
+ isHeadRequest,
3030
+ slug: trackedLinkMatch[1],
3031
+ });
3032
+ return;
3033
+ }
3034
+
2753
3035
  if (isGetLikeRequest && pathname === '/robots.txt') {
2754
3036
  sendText(res, 200, renderRobotsTxt(hostedConfig), {
2755
3037
  'Content-Type': 'text/plain; charset=utf-8',