@taskforcehq/taskforce 0.3.307 → 0.3.309

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.
Files changed (50) hide show
  1. package/dist/components/views/PlanComparisonPage.d.ts +20 -15
  2. package/dist/components/views/PlanComparisonPage.js +214 -83
  3. package/dist/components/views/PlanComparisonPage.test.js +249 -14
  4. package/dist/components/views/PlansPage.js +15 -9
  5. package/dist/components/views/PlansPage.test.js +4 -2
  6. package/dist/core/GlobalSettingsService.js +18 -0
  7. package/dist/core/GlobalSettingsService.test.d.ts +1 -0
  8. package/dist/core/GlobalSettingsService.test.js +45 -0
  9. package/dist/core/PlanEntitlementService.d.ts +20 -0
  10. package/dist/core/PlanEntitlementService.js +215 -19
  11. package/dist/core/PlanFeatureCatalog.test.js +55 -0
  12. package/dist/core/Taskforce.d.ts +14 -0
  13. package/dist/core/Taskforce.js +6 -0
  14. package/dist/core/types.d.ts +3 -0
  15. package/dist/migrations/taskSchemaMigrations.js +37 -15
  16. package/dist/server/routes/admin.js +146 -5
  17. package/dist/server/routes/authSupport.d.ts +1 -0
  18. package/dist/server/routes/authSupport.js +2 -1
  19. package/dist/server/routes/billing.d.ts +1 -0
  20. package/dist/server/routes/billing.js +105 -63
  21. package/dist/server/routes/billing.test.js +159 -11
  22. package/dist/server/routes.test.js +91 -10
  23. package/dist/ui/agent-logos/Gemini CLI.jpeg +0 -0
  24. package/dist/ui/agent-logos/antigravity.jpeg +0 -0
  25. package/dist/ui/agent-logos/chatgpt.png +0 -0
  26. package/dist/ui/agent-logos/claude-code.jpeg +0 -0
  27. package/dist/ui/agent-logos/codex.jpeg +0 -0
  28. package/dist/ui/agent-logos/cursor.png +0 -0
  29. package/dist/ui/agent-logos/openclaw.jpeg +0 -0
  30. package/dist/ui/agent-logos/windsurf.png +0 -0
  31. package/dist/ui/assets/{AgentsModule-2y82_3DO.js → AgentsModule-BS4Pi_nC.js} +1 -1
  32. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BTLZZCiQ.js → AnnotatedAttachmentWorkspace-CCckzWYZ.js} +1 -1
  33. package/dist/ui/assets/{ContextAttachmentManager-D2epuNnG.js → ContextAttachmentManager-CfG_ER7C.js} +1 -1
  34. package/dist/ui/assets/{DocumentWorkspace-sVa-3Do6.js → DocumentWorkspace-BVaWPA25.js} +1 -1
  35. package/dist/ui/assets/{EntityActivityTimeline-Zods27y_.js → EntityActivityTimeline-DSj0ThaK.js} +1 -1
  36. package/dist/ui/assets/{InitiativesModule-D4XseTwW.js → InitiativesModule-AxMkWtxH.js} +1 -1
  37. package/dist/ui/assets/PlansPage-B_z-D6Nh.css +1 -0
  38. package/dist/ui/assets/PlansPage-Ndt7mYfo.js +1 -0
  39. package/dist/ui/assets/{TaskContextUpload-uC5qx4Bv.js → TaskContextUpload-Dw4rTF4i.js} +1 -1
  40. package/dist/ui/assets/{TaskSettings-CqvPAsTv.js → TaskSettings-f2ga42_V.js} +1 -1
  41. package/dist/ui/assets/{WorkflowsModule-oOA9bcdK.js → WorkflowsModule-E4UL3mov.js} +1 -1
  42. package/dist/ui/assets/documentReferences-BOUcJm6-.js +1 -0
  43. package/dist/ui/assets/{index-CYyodXsg.css → index-BvAbqx5Q.css} +1 -1
  44. package/dist/ui/assets/{index-607WPo_X.js → index-MAHKvFqp.js} +4 -4
  45. package/dist/ui/index.html +2 -2
  46. package/dist/ui/og-image.png +0 -0
  47. package/package.json +1 -1
  48. package/dist/ui/assets/PlansPage-D5AcbO0L.js +0 -1
  49. package/dist/ui/assets/PlansPage-Dvqsz5zc.css +0 -1
  50. package/dist/ui/assets/documentReferences-DPZuTgHh.js +0 -1
@@ -64,9 +64,122 @@ describe('PlanComparisonPage', () => {
64
64
  expect(await screen.findByText('Pricing source: production (cloud)')).toBeInTheDocument();
65
65
  });
66
66
  it('renders plan descriptions when provided by the pricing payload', async () => {
67
- render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
67
+ const { container } = render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
68
68
  expect(await screen.findByText('Start using Taskforce at no cost.')).toBeInTheDocument();
69
69
  expect(screen.getByText('For small collaborative teams.')).toBeInTheDocument();
70
+ expect(screen.getByRole('heading', { name: 'Founding beta' })).toBeInTheDocument();
71
+ expect(screen.getByText(/Taskforce is in founding beta/)).toBeInTheDocument();
72
+ expect(screen.getByText(/Early members get access while the product is still forming/)).toBeInTheDocument();
73
+ expect(screen.getByText('Prices are listed in USD. Taxes may apply.')).toBeInTheDocument();
74
+ expect(screen.getByText('Local install is free on every plan. Cloud limits apply only to hosted workspaces.')).toBeInTheDocument();
75
+ const foundingBeta = screen.getByRole('heading', { name: 'Founding beta' }).closest('section');
76
+ const firstPlanCard = container.querySelector('[data-testid^="plan-card-"]');
77
+ const planCards = container.querySelectorAll('[data-testid^="plan-card-"]');
78
+ const lastPlanCard = planCards[planCards.length - 1];
79
+ const pricingNotes = screen.getByText('Prices are listed in USD. Taxes may apply.').closest('div');
80
+ const cloudCapacity = screen.getByRole('heading', { name: 'Need more cloud capacity?' }).closest('section');
81
+ const teamWorkspacesCard = screen.getByRole('heading', { name: 'Team workspaces are coming soon.' }).closest('div');
82
+ if (!foundingBeta || !firstPlanCard) {
83
+ throw new Error('Expected founding beta copy and a plan card to render.');
84
+ }
85
+ if (!lastPlanCard || !pricingNotes) {
86
+ throw new Error('Expected pricing notes to render below the plan cards.');
87
+ }
88
+ if (!cloudCapacity || !teamWorkspacesCard) {
89
+ throw new Error('Expected cloud capacity copy to render above the team workspaces card.');
90
+ }
91
+ expect(Boolean(foundingBeta.compareDocumentPosition(firstPlanCard) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
92
+ expect(Boolean(lastPlanCard.compareDocumentPosition(pricingNotes) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
93
+ expect(Boolean(pricingNotes.compareDocumentPosition(cloudCapacity) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
94
+ expect(Boolean(cloudCapacity.compareDocumentPosition(teamWorkspacesCard) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
95
+ });
96
+ it('renders pricing above the plan description', async () => {
97
+ render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
98
+ const teamCard = await screen.findByTestId('plan-card-team-5');
99
+ const monthlyPrice = within(teamCard).getByText('$20');
100
+ const description = within(teamCard).getByText('For small collaborative teams.');
101
+ expect(Boolean(monthlyPrice.compareDocumentPosition(description) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
102
+ });
103
+ it('shows founding offer pricing when campaign prices are present', async () => {
104
+ vi.stubGlobal('fetch', vi.fn(async () => ({
105
+ ok: true,
106
+ json: async () => ({
107
+ plans: [
108
+ {
109
+ planId: 'pro',
110
+ displayName: 'Pro',
111
+ description: 'For serious operators.',
112
+ status: 'active',
113
+ defaultVersion: {
114
+ planVersionId: 'pro-v1',
115
+ versionNumber: 1,
116
+ seatLimit: null,
117
+ metadata: {}
118
+ },
119
+ features: [],
120
+ pricing: {
121
+ month: { pricingType: 'stripe', stripePriceId: 'price_pro_month', unitAmount: 3000, currency: 'usd', interval: 'month' },
122
+ year: null
123
+ },
124
+ campaignPricing: {
125
+ month: { pricingType: 'stripe', stripePriceId: 'price_pro_campaign_month', unitAmount: 1200, currency: 'usd', interval: 'month' },
126
+ year: null
127
+ }
128
+ }
129
+ ]
130
+ })
131
+ })));
132
+ render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
133
+ const proCard = await screen.findByTestId('plan-card-pro');
134
+ expect(within(proCard).getByText('$30')).toBeInTheDocument();
135
+ expect(within(proCard).getByText('Founding Offer:')).toBeInTheDocument();
136
+ expect(within(proCard).getByText('$12')).toBeInTheDocument();
137
+ expect(within(proCard).getAllByText('/ month')).toHaveLength(2);
138
+ });
139
+ it('renders and selects campaign-only pricing intervals', async () => {
140
+ const onSelectPlan = vi.fn();
141
+ vi.stubGlobal('fetch', vi.fn(async () => ({
142
+ ok: true,
143
+ json: async () => ({
144
+ plans: [
145
+ {
146
+ planId: 'pro',
147
+ displayName: 'Pro',
148
+ description: 'For serious operators.',
149
+ status: 'active',
150
+ defaultVersion: {
151
+ planVersionId: 'pro-v1',
152
+ versionNumber: 1,
153
+ seatLimit: null,
154
+ metadata: {}
155
+ },
156
+ features: [],
157
+ pricing: {
158
+ month: null,
159
+ year: null
160
+ },
161
+ campaignPricing: {
162
+ month: { pricingType: 'stripe', stripePriceId: 'price_pro_campaign_month', unitAmount: 1200, currency: 'usd', interval: 'month' },
163
+ year: null
164
+ }
165
+ }
166
+ ]
167
+ })
168
+ })));
169
+ render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: onSelectPlan }));
170
+ const proCard = await screen.findByTestId('plan-card-pro');
171
+ expect(within(proCard).queryByText('$30')).not.toBeInTheDocument();
172
+ expect(within(proCard).getByText('Founding Offer:')).toBeInTheDocument();
173
+ expect(within(proCard).getByText('$12')).toBeInTheDocument();
174
+ fireEvent.click(within(proCard).getByRole('button', { name: 'Get Started' }));
175
+ expect(onSelectPlan).toHaveBeenCalledWith({
176
+ planId: 'pro',
177
+ planVersionId: 'pro-v1',
178
+ interval: 'month',
179
+ pricingType: 'stripe',
180
+ pricingAudience: 'campaign',
181
+ isCurrentPlan: false
182
+ });
70
183
  });
71
184
  it('prefers pricing payload header copy when provided', async () => {
72
185
  vi.stubGlobal('fetch', vi.fn(async () => ({
@@ -85,6 +198,40 @@ describe('PlanComparisonPage', () => {
85
198
  expect(await screen.findByRole('heading', { name: 'Choose Your Taskforce Plan' })).toBeInTheDocument();
86
199
  expect(screen.getByText('Compare plans and pick the right fit for your team.')).toBeInTheDocument();
87
200
  });
201
+ it('can keep the provided heading when pricing payload title exists', async () => {
202
+ vi.stubGlobal('fetch', vi.fn(async () => ({
203
+ ok: true,
204
+ json: async () => ({
205
+ pageTitle: 'Choose Your Taskforce Plan',
206
+ pricingSource: {
207
+ environment: 'production',
208
+ runtimeMode: 'cloud'
209
+ },
210
+ plans: []
211
+ })
212
+ })));
213
+ render(_jsx(PlanComparisonPage, { heading: "Pricing for human-agent work.", preferPricingPageTitle: false, isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
214
+ expect(await screen.findByRole('heading', { name: 'Pricing for human-agent work.' })).toBeInTheDocument();
215
+ expect(screen.queryByRole('heading', { name: 'Choose Your Taskforce Plan' })).not.toBeInTheDocument();
216
+ });
217
+ it('renders pricing payload description as sanitized HTML', async () => {
218
+ vi.stubGlobal('fetch', vi.fn(async () => ({
219
+ ok: true,
220
+ json: async () => ({
221
+ pageTitle: 'Choose Your Taskforce Plan',
222
+ pageDescription: '<p>Local install is <strong>free</strong>.</p><p><a href="https://example.com/pricing">Read more</a><a href="javascript:alert(1)">Nope</a></p><script>alert(1)</script>',
223
+ plans: []
224
+ })
225
+ })));
226
+ const { container } = render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", subtitle: "Fallback subtitle", isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
227
+ expect(await screen.findByText(/Local install is/)).toBeInTheDocument();
228
+ expect(container.querySelector('strong')?.textContent).toBe('free');
229
+ expect(container.querySelector('script')).toBeNull();
230
+ expect(screen.queryByText('alert(1)')).not.toBeInTheDocument();
231
+ expect(container.querySelector('a[href="https://example.com/pricing"]')?.textContent).toBe('Read more');
232
+ expect(container.querySelector('a[href^="javascript:"]')).toBeNull();
233
+ expect(screen.getByText('Nope')).toBeInTheDocument();
234
+ });
88
235
  it('falls back to provided pricing source details when the pricing response omits them', async () => {
89
236
  globalThis.__DEBUG_MODE__ = true;
90
237
  vi.stubGlobal('fetch', vi.fn(async () => ({
@@ -143,7 +290,8 @@ describe('PlanComparisonPage', () => {
143
290
  access: 'enabled',
144
291
  label: 'AI Profiles',
145
292
  publicLabel: 'AI teammates',
146
- publicDescription: 'Add reusable AI teammates to your workspace.'
293
+ publicDescription: 'Add reusable AI teammates to your workspace.',
294
+ publicDescriptionVisible: true
147
295
  }
148
296
  ],
149
297
  pricing: {
@@ -159,6 +307,47 @@ describe('PlanComparisonPage', () => {
159
307
  expect(screen.getByText('Add reusable AI teammates to your workspace.')).toBeInTheDocument();
160
308
  expect(screen.queryByText('Collaboration Ai Profiles')).not.toBeInTheDocument();
161
309
  });
310
+ it('hides feature descriptions when publicDescriptionVisible is false', async () => {
311
+ vi.stubGlobal('fetch', vi.fn(async () => ({
312
+ ok: true,
313
+ json: async () => ({
314
+ pricingSource: {
315
+ environment: 'production',
316
+ runtimeMode: 'cloud'
317
+ },
318
+ plans: [
319
+ {
320
+ planId: 'team-5',
321
+ displayName: 'Team 5',
322
+ status: 'active',
323
+ defaultVersion: {
324
+ planVersionId: 'team-5-v2',
325
+ versionNumber: 2,
326
+ seatLimit: 5,
327
+ metadata: {}
328
+ },
329
+ features: [
330
+ {
331
+ featureKey: 'collaboration.ai_profiles',
332
+ access: 'enabled',
333
+ label: 'AI Profiles',
334
+ publicLabel: 'AI teammates',
335
+ publicDescription: 'Add reusable AI teammates to your workspace.',
336
+ publicDescriptionVisible: false
337
+ }
338
+ ],
339
+ pricing: {
340
+ month: { pricingType: 'stripe', stripePriceId: 'price_team_5_month', unitAmount: 2000, currency: 'usd', interval: 'month' },
341
+ year: null
342
+ }
343
+ }
344
+ ]
345
+ })
346
+ })));
347
+ render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
348
+ expect(await screen.findByText('AI teammates')).toBeInTheDocument();
349
+ expect(screen.queryByText('Add reusable AI teammates to your workspace.')).not.toBeInTheDocument();
350
+ });
162
351
  it('renders inline numeric feature limits next to each feature label', async () => {
163
352
  vi.stubGlobal('fetch', vi.fn(async () => ({
164
353
  ok: true,
@@ -215,10 +404,10 @@ describe('PlanComparisonPage', () => {
215
404
  const { container } = render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
216
405
  const freeCard = await screen.findByTestId('plan-card-free');
217
406
  const featureTextNodes = Array.from(freeCard.querySelectorAll('span')).map((node) => node.textContent);
218
- expect(featureTextNodes).toContain('1x Workspaces');
219
- expect(featureTextNodes).toContain('4x AI Agents');
407
+ expect(featureTextNodes).toContain('1 Workspace');
408
+ expect(featureTextNodes).toContain('4 AI Agents');
220
409
  expect(featureTextNodes).toContain('24MB Cloud Storage');
221
- expect(featureTextNodes).toContain('12x Team Management');
410
+ expect(featureTextNodes).toContain('12 Team Management');
222
411
  expect(container.querySelector('[class*="featureLimitUnit"]')?.textContent).toBe('MB');
223
412
  });
224
413
  it('renders the storage unit suffix in a smaller accent span', async () => {
@@ -263,6 +452,48 @@ describe('PlanComparisonPage', () => {
263
452
  expect(accent?.textContent).toBe('64MB');
264
453
  expect(unit?.textContent).toBe('MB');
265
454
  });
455
+ it('renders storage limits above 1000 MB as rounded GB', async () => {
456
+ vi.stubGlobal('fetch', vi.fn(async () => ({
457
+ ok: true,
458
+ json: async () => ({
459
+ pricingSource: {
460
+ environment: 'production',
461
+ runtimeMode: 'cloud'
462
+ },
463
+ plans: [
464
+ {
465
+ planId: 'pro',
466
+ displayName: 'Pro',
467
+ status: 'active',
468
+ defaultVersion: {
469
+ planVersionId: 'pro-v1',
470
+ versionNumber: 1,
471
+ seatLimit: null,
472
+ metadata: {}
473
+ },
474
+ features: [
475
+ {
476
+ featureKey: 'context.uploads',
477
+ access: 'enabled',
478
+ label: 'Cloud Storage',
479
+ config: { storageLimitMb: 2500 }
480
+ }
481
+ ],
482
+ pricing: {
483
+ month: { pricingType: 'stripe', stripePriceId: 'price_pro_month', unitAmount: 1999, currency: 'usd', interval: 'month' },
484
+ year: null
485
+ }
486
+ }
487
+ ]
488
+ })
489
+ })));
490
+ const { container } = render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
491
+ await screen.findByText('Cloud Storage');
492
+ const accent = container.querySelector('[class*="featureLimitAccent"]');
493
+ const unit = container.querySelector('[class*="featureLimitUnit"]');
494
+ expect(accent?.textContent).toBe('3GB');
495
+ expect(unit?.textContent).toBe('GB');
496
+ });
266
497
  it('hides missing yearly pricing rows and buttons instead of rendering unavailable state', async () => {
267
498
  vi.stubGlobal('fetch', vi.fn(async () => ({
268
499
  ok: true,
@@ -297,8 +528,8 @@ describe('PlanComparisonPage', () => {
297
528
  expect(within(operatorCard).getByText('/ month')).toBeInTheDocument();
298
529
  expect(within(operatorCard).queryByText('/ year')).not.toBeInTheDocument();
299
530
  expect(within(operatorCard).queryByText('Unavailable')).not.toBeInTheDocument();
300
- expect(within(operatorCard).getByRole('button', { name: 'Choose Monthly' })).toBeInTheDocument();
301
- expect(within(operatorCard).queryByRole('button', { name: 'Choose Yearly' })).not.toBeInTheDocument();
531
+ expect(within(operatorCard).getByRole('button', { name: 'Get Started' })).toBeInTheDocument();
532
+ expect(within(operatorCard).queryByRole('button', { name: 'Start Yearly' })).not.toBeInTheDocument();
302
533
  });
303
534
  it('does not mark a plan current when only the plan id matches but the version does not', async () => {
304
535
  render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: true, currentPlanId: "team-5", currentPlanVersionId: "team-5-v1", currentEntitlementState: "active", onBack: () => { }, backLabel: "Back", onSelectPlan: () => { } }));
@@ -338,7 +569,7 @@ describe('PlanComparisonPage', () => {
338
569
  expect(await screen.findByRole('heading', { name: 'Subscription Plans' })).toBeInTheDocument();
339
570
  expect(screen.queryByRole('button', { name: 'Manage Plan' })).not.toBeInTheDocument();
340
571
  expect(screen.queryByText('Current Plan')).not.toBeInTheDocument();
341
- expect(screen.getByRole('button', { name: 'Choose Monthly' })).toBeInTheDocument();
572
+ expect(screen.getByRole('button', { name: 'Start Monthly' })).toBeInTheDocument();
342
573
  });
343
574
  it('can disable the page back button while onboarding remains blocked', async () => {
344
575
  render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: true, onBack: () => { }, backLabel: "Back to App", backDisabled: true, onSelectPlan: () => { } }));
@@ -349,21 +580,24 @@ describe('PlanComparisonPage', () => {
349
580
  const view = render(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, authSessionResolved: false, onBack: () => { }, backLabel: "Back", onSelectPlan: onSelectPlan }));
350
581
  expect(await screen.findByRole('heading', { name: 'Subscription Plans' })).toBeInTheDocument();
351
582
  const freeCard = screen.getByTestId('plan-card-free');
352
- expect(screen.getByRole('button', { name: 'Get Started Free' })).toBeDisabled();
353
- expect(screen.getByRole('button', { name: 'Get Started Monthly' })).toBeDisabled();
583
+ expect(screen.getByRole('button', { name: 'Get Started' })).toBeDisabled();
584
+ expect(screen.getByRole('button', { name: 'Start Monthly' })).toBeDisabled();
585
+ expect(screen.getByRole('button', { name: 'Start Yearly' })).toBeDisabled();
354
586
  expect(freeCard).not.toHaveAttribute('role', 'button');
355
587
  view.rerender(_jsx(PlanComparisonPage, { heading: "Subscription Plans", isAuthenticated: false, authSessionResolved: true, onBack: () => { }, backLabel: "Back", onSelectPlan: onSelectPlan }));
356
588
  const enabledFreeCard = screen.getByTestId('plan-card-free');
357
- expect(screen.getByRole('button', { name: 'Get Started Free' })).toBeEnabled();
358
- expect(screen.getByRole('button', { name: 'Get Started Monthly' })).toBeEnabled();
589
+ expect(screen.getByRole('button', { name: 'Get Started' })).toBeEnabled();
590
+ expect(screen.getByRole('button', { name: 'Start Monthly' })).toBeEnabled();
591
+ expect(screen.getByRole('button', { name: 'Start Yearly' })).toBeEnabled();
359
592
  expect(enabledFreeCard).toHaveAttribute('role', 'button');
360
- fireEvent.click(screen.getByRole('button', { name: 'Get Started Free' }));
593
+ fireEvent.click(screen.getByRole('button', { name: 'Get Started' }));
361
594
  await waitFor(() => {
362
595
  expect(onSelectPlan).toHaveBeenCalledWith({
363
596
  planId: 'free',
364
597
  planVersionId: 'free-v1',
365
598
  interval: 'month',
366
599
  pricingType: 'free',
600
+ pricingAudience: 'public',
367
601
  isCurrentPlan: false
368
602
  });
369
603
  });
@@ -375,7 +609,7 @@ describe('PlanComparisonPage', () => {
375
609
  const freeHeading = screen.getByRole('heading', { name: 'Free' });
376
610
  const freeCard = freeHeading.closest('[role="button"]');
377
611
  expect(freeCard).toBeTruthy();
378
- expect(within(freeCard).getByText('$0.00')).toBeInTheDocument();
612
+ expect(within(freeCard).getByText('$0')).toBeInTheDocument();
379
613
  expect(within(freeCard).getByText('/ month')).toBeInTheDocument();
380
614
  fireEvent.click(freeCard);
381
615
  await waitFor(() => {
@@ -384,6 +618,7 @@ describe('PlanComparisonPage', () => {
384
618
  planVersionId: 'free-v1',
385
619
  interval: 'month',
386
620
  pricingType: 'free',
621
+ pricingAudience: 'public',
387
622
  isCurrentPlan: false
388
623
  });
389
624
  });
@@ -24,6 +24,9 @@ function shouldRetryCheckoutConfirmation(statusCode, errorCode) {
24
24
  function parseInterval(raw) {
25
25
  return raw === 'year' ? 'year' : 'month';
26
26
  }
27
+ function parsePricingAudience(raw) {
28
+ return raw === 'campaign' ? 'campaign' : 'public';
29
+ }
27
30
  function normalizeIntervalValue(raw) {
28
31
  const normalized = String(raw || '').trim().toLowerCase();
29
32
  if (normalized === 'year')
@@ -65,6 +68,7 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
65
68
  const queryPlanId = String(query.get('planId') || '').trim();
66
69
  const queryPlanVersionId = String(query.get('planVersionId') || '').trim();
67
70
  const queryInterval = parseInterval(query.get('interval'));
71
+ const queryPricingAudience = parsePricingAudience(query.get('pricingAudience'));
68
72
  const queryIntervalValue = normalizeIntervalValue(query.get('interval'));
69
73
  const querySessionId = String(query.get('session_id') || '').trim();
70
74
  const normalizedApiBaseUrl = useMemo(() => normalizeApiBaseUrl(apiBaseUrl), [apiBaseUrl]);
@@ -93,6 +97,7 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
93
97
  params.delete('planId');
94
98
  params.delete('planVersionId');
95
99
  params.delete('interval');
100
+ params.delete('pricingAudience');
96
101
  const nextQuery = params.toString();
97
102
  navigate(`/${nextQuery ? `?${nextQuery}` : ''}${location.hash || ''}`);
98
103
  }, [location.hash, location.search, navigate]);
@@ -161,7 +166,7 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
161
166
  const shouldAutoContinueAfterActivation = useCallback((summary, fallbackGate) => {
162
167
  return !resolveCommercialLifecyclePresentation(summary, fallbackGate).allowReturnToApp;
163
168
  }, []);
164
- const startCheckout = useCallback(async (planVersionId, interval, options) => {
169
+ const startCheckout = useCallback(async (planVersionId, interval, pricingAudience = 'public', options) => {
165
170
  if (!planVersionId) {
166
171
  setFeedback({ type: 'error', message: 'Selected plan is missing a default plan version.' });
167
172
  return;
@@ -173,7 +178,7 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
173
178
  method: 'POST',
174
179
  credentials: 'include',
175
180
  headers: { 'Content-Type': 'application/json' },
176
- body: JSON.stringify({ planVersionId, interval })
181
+ body: JSON.stringify({ planVersionId, interval, pricingAudience })
177
182
  });
178
183
  const payload = await res.json().catch(() => ({}));
179
184
  if (!res.ok) {
@@ -504,13 +509,13 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
504
509
  checkoutStartRan.current = true;
505
510
  void (async () => {
506
511
  const preCheckoutSummary = await loadAccountProfileSummary({ force: true });
507
- await startCheckout(queryPlanVersionId, queryInterval, {
512
+ await startCheckout(queryPlanVersionId, queryInterval, queryPricingAudience, {
508
513
  autoContinueOnActivation: shouldAutoContinueAfterActivation(preCheckoutSummary, gateState)
509
514
  });
510
515
  })();
511
516
  return;
512
517
  }
513
- }, [authSessionResolved, checkoutState, confirmCheckoutSession, gateState, isAuthenticated, loadAccountProfileSummary, queryInterval, queryPlanVersionId, querySessionId, resolveRecoveryMessage, scheduleTransientQueryClear, shouldAutoContinueAfterActivation, startCheckout]);
518
+ }, [authSessionResolved, checkoutState, confirmCheckoutSession, gateState, isAuthenticated, loadAccountProfileSummary, queryInterval, queryPlanVersionId, queryPricingAudience, querySessionId, resolveRecoveryMessage, scheduleTransientQueryClear, shouldAutoContinueAfterActivation, startCheckout]);
514
519
  useEffect(() => {
515
520
  if (checkoutState === 'success' || checkoutState === 'cancel' || checkoutState === 'start' || checkoutState === 'free') {
516
521
  return;
@@ -534,9 +539,10 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
534
539
  checkout: nextCheckoutState,
535
540
  planId: intent.planId,
536
541
  planVersionId: intent.planVersionId,
537
- interval: intent.interval
542
+ interval: intent.interval,
543
+ pricingAudience: intent.pricingAudience
538
544
  });
539
- navigate(`/login?mode=register&planId=${encodeURIComponent(intent.planId)}&planVersionId=${encodeURIComponent(intent.planVersionId)}&interval=${encodeURIComponent(intent.interval)}&next=${encodeURIComponent(nextPath)}`);
545
+ navigate(`/login?mode=register&planId=${encodeURIComponent(intent.planId)}&planVersionId=${encodeURIComponent(intent.planVersionId)}&interval=${encodeURIComponent(intent.interval)}&pricingAudience=${encodeURIComponent(intent.pricingAudience)}&next=${encodeURIComponent(nextPath)}`);
540
546
  return;
541
547
  }
542
548
  if (intent.isCurrentPlan) {
@@ -549,10 +555,10 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
549
555
  }
550
556
  const autoContinueOnActivation = shouldAutoContinueAfterActivation(accountProfileSummary, gateState);
551
557
  if (intent.pricingType === 'free') {
552
- await startCheckout(intent.planVersionId, intent.interval, { autoContinueOnActivation });
558
+ await startCheckout(intent.planVersionId, intent.interval, intent.pricingAudience, { autoContinueOnActivation });
553
559
  return;
554
560
  }
555
- await startCheckout(intent.planVersionId, intent.interval, { autoContinueOnActivation });
561
+ await startCheckout(intent.planVersionId, intent.interval, intent.pricingAudience, { autoContinueOnActivation });
556
562
  }, [accountProfileSummary, gateState, isAuthenticated, navigate, openBillingPortal, shouldAutoContinueAfterActivation, startCheckout]);
557
563
  const handleBack = useCallback(() => {
558
564
  if (hasResolvedActiveEntitlement && onContinueToTaskforce) {
@@ -571,5 +577,5 @@ export function PlansPage({ isAuthenticated, authUserId, runtimeMode, authSessio
571
577
  }, [closeEmbeddedPlansScreen, embedded, handleContinueToTaskforce, hasResolvedActiveEntitlement, navigate, onContinueToTaskforce]);
572
578
  const headerActions = embedded ? null : (_jsxs(_Fragment, { children: [_jsx("button", { className: styles.navAction, onClick: handleBack, disabled: continueBusy || continueActionBusy || (isAuthenticated && !hasResolvedActiveEntitlement), children: hasResolvedActiveEntitlement ? 'Take Me to Taskforce' : 'Back' }), isAuthenticated && accountProfileSummary?.stripeCustomerId && (_jsx("button", { className: styles.navAction, onClick: () => { void openBillingPortal(); }, disabled: actionBusy, children: "Manage billing" })), !isAuthenticated && (_jsxs("button", { className: styles.navAction, onClick: () => navigate(`/login?mode=login&next=${encodeURIComponent(buildEmbeddedPlansPath())}`), children: [_jsx(User, { size: 16, style: { marginRight: '8px', verticalAlign: 'text-bottom' } }), "Sign In"] }))] }));
573
579
  const chrome = embedded ? null : (_jsx(AppShellHeader, { projectName: projectName, currentWorkspaceId: currentWorkspaceId, runtimeMode: runtimeMode === 'cloud' ? 'cloud' : 'local', theme: currentTheme, onBrandClick: handleBack, actions: headerActions }));
574
- return (_jsx(PlanComparisonPage, { chrome: chrome, heading: "Subscription Plans", subtitle: "Manage your subscription and explore available features. Your current plan is highlighted below.", isAuthenticated: isAuthenticated, authSessionResolved: authSessionResolved, currentPlanId: String(accountProfileSummary?.planId || queryPlanId || '').trim() || null, currentPlanVersionId: String(accountProfileSummary?.planVersionId || queryPlanVersionId || '').trim() || null, currentEntitlementState: String(accountProfileSummary?.entitlementState || '').trim() || null, markCurrentPlan: lifecyclePresentation.markCurrentPlan, canManageCurrentPlan: Boolean(accountProfileSummary?.stripeCustomerId), pricingEndpoint: pricingEndpoint, statusBanner: feedback, onBack: handleBack, backLabel: hasResolvedActiveEntitlement ? 'Take Me to Taskforce' : (embedded ? 'Back to Board' : 'Back to App'), backDisabled: continueBusy || continueActionBusy || (isAuthenticated && !hasResolvedActiveEntitlement), topActions: null, shellOwnsScroll: shellOwnsScroll, showPageBackButton: false, showHeaderBarWithChrome: false, currentPlanCardActionMode: "manage", fallbackPricingSource: fallbackPricingSource, onSelectPlan: handleSelectPlan }));
580
+ return (_jsx(PlanComparisonPage, { chrome: chrome, heading: "Subscription Plans", subtitle: "Manage your subscription and explore available features. Your current plan is highlighted below.", variant: "site", theme: currentTheme, isAuthenticated: isAuthenticated, authSessionResolved: authSessionResolved, currentPlanId: String(accountProfileSummary?.planId || queryPlanId || '').trim() || null, currentPlanVersionId: String(accountProfileSummary?.planVersionId || queryPlanVersionId || '').trim() || null, currentEntitlementState: String(accountProfileSummary?.entitlementState || '').trim() || null, markCurrentPlan: lifecyclePresentation.markCurrentPlan, canManageCurrentPlan: Boolean(accountProfileSummary?.stripeCustomerId), pricingEndpoint: pricingEndpoint, statusBanner: feedback, onBack: handleBack, backLabel: hasResolvedActiveEntitlement ? 'Take Me to Taskforce' : (embedded ? 'Back to Board' : 'Back to App'), backDisabled: continueBusy || continueActionBusy || (isAuthenticated && !hasResolvedActiveEntitlement), topActions: null, shellOwnsScroll: shellOwnsScroll, showPageBackButton: false, showHeaderBarWithChrome: false, currentPlanCardActionMode: "manage", fallbackPricingSource: fallbackPricingSource, onSelectPlan: handleSelectPlan }));
575
581
  }
@@ -5,11 +5,12 @@ import { MemoryRouter } from 'react-router-dom';
5
5
  import { PlansPage } from './PlansPage';
6
6
  import { ACCOUNT_PROFILE_SUMMARY_PATH, invalidateAccountProfileSummaryCache } from '../../utils/accountProfileSummaryCache';
7
7
  vi.mock('./PlanComparisonPage', () => ({
8
- PlanComparisonPage: ({ heading, pricingEndpoint, statusBanner, topActions, onSelectPlan, onBack, backLabel, backDisabled }) => (_jsxs("div", { children: [_jsx("h1", { children: heading }), pricingEndpoint ? _jsx("div", { "data-testid": "pricing-endpoint", children: pricingEndpoint }) : null, statusBanner?.message ? _jsx("div", { "data-status-type": statusBanner.type, children: statusBanner.message }) : null, topActions, onSelectPlan ? (_jsx("button", { onClick: () => onSelectPlan({
8
+ PlanComparisonPage: ({ heading, variant, pricingEndpoint, statusBanner, topActions, onSelectPlan, onBack, backLabel, backDisabled }) => (_jsxs("div", { children: [_jsx("h1", { children: heading }), variant ? _jsx("div", { "data-testid": "plans-variant", children: variant }) : null, pricingEndpoint ? _jsx("div", { "data-testid": "pricing-endpoint", children: pricingEndpoint }) : null, statusBanner?.message ? _jsx("div", { "data-status-type": statusBanner.type, children: statusBanner.message }) : null, topActions, onSelectPlan ? (_jsx("button", { onClick: () => onSelectPlan({
9
9
  planId: 'free',
10
10
  planVersionId: 'free-v1',
11
11
  interval: 'month',
12
12
  pricingType: 'free',
13
+ pricingAudience: 'public',
13
14
  isCurrentPlan: false
14
15
  }), children: "Select Free Plan" })) : null, onBack ? (_jsx("button", { onClick: onBack, disabled: backDisabled, children: backLabel || 'Back' })) : null] }))
15
16
  }));
@@ -25,6 +26,7 @@ describe('PlansPage', () => {
25
26
  })));
26
27
  render(_jsx(MemoryRouter, { initialEntries: ['/?screen=plans'], children: _jsx(PlansPage, { isAuthenticated: false, runtimeMode: "local", authSessionResolved: true, apiBaseUrl: "https://performance-app.taskforcehq.ai/api", resolveCloudAuthUrl: (path) => `/api/proxy?path=${encodeURIComponent(path)}` }) }));
27
28
  expect(await screen.findByTestId('pricing-endpoint')).toHaveTextContent('/api/proxy?path=%2Fapi%2Ftaskforce%2Fpublic%2Fpricing');
29
+ expect(screen.getByTestId('plans-variant')).toHaveTextContent('site');
28
30
  });
29
31
  it('shows recovery messaging only after bounded retries are exhausted', async () => {
30
32
  vi.useFakeTimers();
@@ -641,7 +643,7 @@ describe('PlansPage', () => {
641
643
  planId: 'starter',
642
644
  planVersionId: 'starter-v1',
643
645
  gate: 'ok',
644
- trialEnd: '2026-05-01T12:00:00.000Z'
646
+ trialEnd: '2026-06-01T12:00:00.000Z'
645
647
  })
646
648
  });
647
649
  vi.stubGlobal('fetch', fetchMock);
@@ -200,6 +200,15 @@ export function getGlobalSettingsService(deps) {
200
200
  : {}),
201
201
  ...(global.site.siteTheme === 'light' || global.site.siteTheme === 'dark'
202
202
  ? { siteTheme: global.site.siteTheme }
203
+ : {}),
204
+ ...(typeof global.site.pricingPageTitle === 'string'
205
+ ? { pricingPageTitle: global.site.pricingPageTitle.trim() }
206
+ : {}),
207
+ ...(typeof global.site.pricingPageDescription === 'string'
208
+ ? { pricingPageDescription: global.site.pricingPageDescription.trim() }
209
+ : {}),
210
+ ...(typeof global.site.pricingPageShowPublicDescriptions === 'boolean'
211
+ ? { pricingPageShowPublicDescriptions: global.site.pricingPageShowPublicDescriptions }
203
212
  : {})
204
213
  }
205
214
  }
@@ -419,6 +428,15 @@ export function updateGlobalSettingsService(deps, settings) {
419
428
  : {}),
420
429
  ...(incoming.siteTheme === 'light' || incoming.siteTheme === 'dark'
421
430
  ? { siteTheme: incoming.siteTheme }
431
+ : {}),
432
+ ...(typeof incoming.pricingPageTitle === 'string'
433
+ ? { pricingPageTitle: incoming.pricingPageTitle.trim() }
434
+ : {}),
435
+ ...(typeof incoming.pricingPageDescription === 'string'
436
+ ? { pricingPageDescription: incoming.pricingPageDescription.trim() }
437
+ : {}),
438
+ ...(typeof incoming.pricingPageShowPublicDescriptions === 'boolean'
439
+ ? { pricingPageShowPublicDescriptions: incoming.pricingPageShowPublicDescriptions }
422
440
  : {})
423
441
  };
424
442
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { TaskforceCore } from './Taskforce';
6
+ const roots = [];
7
+ function makeProjectRoot() {
8
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-global-settings-'));
9
+ roots.push(root);
10
+ return root;
11
+ }
12
+ afterEach(() => {
13
+ while (roots.length > 0) {
14
+ const root = roots.pop();
15
+ if (root)
16
+ fs.rmSync(root, { recursive: true, force: true });
17
+ }
18
+ });
19
+ describe('TaskforceCore global site settings', () => {
20
+ it('persists pricing page title and description', () => {
21
+ const projectRoot = makeProjectRoot();
22
+ const storagePath = '.taskforce';
23
+ const core = new TaskforceCore({ projectRoot, storagePath });
24
+ core.updateGlobalSettings({
25
+ site: {
26
+ pricingPageTitle: 'Choose Your Plan',
27
+ pricingPageDescription: 'Compare plans and pick the right fit.',
28
+ pricingPageShowPublicDescriptions: false
29
+ }
30
+ });
31
+ expect(core.getGlobalSettings().site).toMatchObject({
32
+ pricingPageTitle: 'Choose Your Plan',
33
+ pricingPageDescription: 'Compare plans and pick the right fit.',
34
+ pricingPageShowPublicDescriptions: false
35
+ });
36
+ core.close();
37
+ const reopened = new TaskforceCore({ projectRoot, storagePath });
38
+ expect(reopened.getGlobalSettings().site).toMatchObject({
39
+ pricingPageTitle: 'Choose Your Plan',
40
+ pricingPageDescription: 'Compare plans and pick the right fit.',
41
+ pricingPageShowPublicDescriptions: false
42
+ });
43
+ reopened.close();
44
+ });
45
+ });
@@ -20,6 +20,14 @@ export declare class PlanEntitlementService {
20
20
  private normalizeOptionalCatalogMarketingCopy;
21
21
  private canonicalizePlanFeatureKey;
22
22
  private normalizeOptionalCatalogDisplayOrder;
23
+ private normalizeOptionalCatalogDescriptionVisible;
24
+ private isBuiltInPlanFeatureKey;
25
+ private normalizeRequiredCatalogLabel;
26
+ private slugifyCustomPlanFeatureLabel;
27
+ private featureKeyExists;
28
+ private generateUniqueCustomPlanFeatureKey;
29
+ private listCustomPlanFeatureCatalogEntries;
30
+ private getCustomPlanFeatureCatalogEntry;
23
31
  private listPlanFeatureCatalogOverrideMap;
24
32
  normalizeEntitlementState(value: unknown): EntitlementState;
25
33
  private normalizeFeatureAccess;
@@ -171,10 +179,22 @@ export declare class PlanEntitlementService {
171
179
  };
172
180
  listPlanCatalog(): PlanCatalogSnapshot[];
173
181
  listPlanFeatureCatalog(): PlanFeatureCatalogItem[];
182
+ createCustomPlanFeatureCatalogEntry(input: {
183
+ label: string;
184
+ description?: string | null;
185
+ publicLabel?: string | null;
186
+ publicDescription?: string | null;
187
+ publicDescriptionVisible?: boolean | null;
188
+ publicDisplayOrder?: number | null;
189
+ }): PlanFeatureCatalogItem;
190
+ deleteCustomPlanFeatureCatalogEntry(featureKeyRaw: unknown): void;
174
191
  updatePlanFeatureCatalogMarketingCopy(input: {
175
192
  featureKey: string;
193
+ label?: string | null;
194
+ description?: string | null;
176
195
  publicLabel?: string | null;
177
196
  publicDescription?: string | null;
197
+ publicDescriptionVisible?: boolean | null;
178
198
  publicDisplayOrder?: number | null;
179
199
  }): PlanFeatureCatalogItem;
180
200
  listPlanVersions(planId: string): PlanVersionSnapshot[];