Skip to content

Commit b97bb00

Browse files
authored
feat(billing): render cloud plans dynamically for three-tier pricing (#2710)
cloud now builds its plan list from the billing catalog (pro, premium, enterprise) instead of a hardcoded two-plan default, enriched with default comparison copy and sorted deterministically (checkout plans by price, contact plans last). plan cards show curated marketing highlights with a global expand/collapse chevron and fade, an "on trial"/"current plan" pill, and the current-plan check. comparison table grid columns are dynamic and scroll instead of clipping a third plan. usage-period date shrinks to one line only for year-spanning ranges.
1 parent 619f162 commit b97bb00

15 files changed

Lines changed: 512 additions & 101 deletions

docs/docs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Package docs Code generated by swaggo/swag at 2026-07-08 12:40:14.201392 +0100 WAT m=+2.539290417. DO NOT EDIT
1+
// Package docs Code generated by swaggo/swag at 2026-07-08 17:26:52.926678 +0100 WAT m=+2.275247710. DO NOT EDIT
22
package docs
33

44
import "github.com/swaggo/swag"

web/ui/dashboard/src/app/private/pages/settings/billing/billing-overview.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ <h2 class="billing-section-title">Billing overview</h2>
4545
<div class="billing-card">
4646
<div class="billing-card-header">
4747
<div class="billing-card-label">Usage period</div>
48-
<div class="billing-card-value">
48+
<div class="billing-card-value" [class.billing-card-value-period]="isUsagePeriodCompact">
4949
@if (isLoading) {
5050
<convoy-skeleton-loader className="w-20 h-16px"></convoy-skeleton-loader>
5151
}

web/ui/dashboard/src/app/private/pages/settings/billing/billing-overview.component.scss

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
align-items: flex-start;
1515
gap: 12px;
1616
outline-offset: -1px;
17+
// Fail-safe containment: the compact year-spanning usage period fits (~206px in
18+
// ~214px of content), but clip any value to the fixed card box so a wider
19+
// fallback-font render can never spill into the adjacent overview card.
20+
overflow: hidden;
1721

1822
.billing-card-header {
1923
display: flex;
@@ -36,6 +40,17 @@
3640
line-height: 28px;
3741
}
3842

43+
// A year-spanning range (e.g. "Jun 19, 2026 - Jun 19, 2027") overflows the
44+
// fixed-width card at the normal 18px and wraps to two lines. Nudge just this
45+
// value down to 16px (still ~200px, inside the ~216px content width) and pin
46+
// to one line. 16px reads as normal size, unlike the earlier 14px which looked
47+
// tiny next to the sibling cards. Only applied for the year-spanning case;
48+
// short labels keep the 18px value size.
49+
.billing-card-value-period {
50+
font-size: 16px;
51+
white-space: nowrap;
52+
}
53+
3954
.billing-card-link {
4055
color: var(--Primary, #0084FF);
4156
font-family: 'Open Sauce Sans', Arial, sans-serif;

web/ui/dashboard/src/app/private/pages/settings/billing/billing-overview.component.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ export class BillingOverviewComponent implements OnInit, OnChanges {
2929
return !this.isLoading && !!this.overview?.pastDue;
3030
}
3131

32+
// Only a range that spans years ("MMM DD, YYYY - MMM DD, YYYY", e.g. annual
33+
// terms) overflows the fixed-width card at the normal value size, so shrink
34+
// just that case. Short labels ("May 28 - Jun 28", "No active cycle") keep the
35+
// normal 18px size.
36+
get isUsagePeriodCompact(): boolean {
37+
const period = this.overview?.usage?.period || '';
38+
return (period.match(/\d{4}/g) || []).length >= 2;
39+
}
40+
3241
ngOnInit() {
3342
}
3443

web/ui/dashboard/src/app/private/pages/settings/billing/billing-page.component.html

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -609,36 +609,61 @@ <h3 class="text-20 font-medium mb-24px">Manage your plan</h3>
609609
(click)="selectPlan(plan.id)"
610610
>
611611
<div class="plan-card-header">
612-
<h4 class="plan-card-title">{{ plan.name }}</h4>
613-
@if (selectedPlan === plan.id) {
612+
<div class="flex items-center gap-8px">
613+
<h4 class="plan-card-title">{{ plan.name }}</h4>
614+
@if (isTrialConvertPlan(plan.id)) {
615+
<span class="text-10 px-6px py-2px bg-primary-100 text-white-100 rounded-4px font-medium">On trial</span>
616+
} @else if (isCurrentPlan(plan.id)) {
617+
<span class="text-10 px-6px py-2px bg-neutral-1 border border-neutral-4 text-neutral-11 rounded-4px font-medium">Current plan</span>
618+
}
619+
</div>
620+
@if (isCurrentPlan(plan.id)) {
614621
<svg class="plan-card-check" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
615622
<circle cx="10" cy="10" r="10" fill="#2780F1"/>
616623
<path d="M14 7L8.5 12.5L6 10" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
617624
</svg>
618625
}
619626
</div>
620-
@if (plan.features && plan.features.length > 0) {
621-
<ul class="plan-card-features">
622-
@for (feature of plan.features.slice(0, 3); track $index) {
623-
<li>
627+
@if (planCardHighlights(plan); as highlights) {
628+
@if (highlights.items.length > 0) {
629+
<ul class="plan-card-features" [class.plan-card-features-collapsed]="hasCollapsedHighlights(highlights) && !highlightsExpanded">
630+
@if (highlights.heading) {
631+
<li class="plan-card-features-heading">{{ highlights.heading }}</li>
632+
}
633+
@for (item of highlights.items; track item) {
634+
<li>
635+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
636+
<path d="M13.3332 4L5.99984 11.3333L2.6665 8" stroke="#16A34A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
637+
</svg>
638+
<span>{{ item }}</span>
639+
</li>
640+
}
641+
</ul>
642+
}
643+
}
644+
<div class="plan-card-footer">
645+
@if (planCardHighlights(plan); as highlights) {
646+
@if (hasCollapsedHighlights(highlights)) {
647+
<button type="button" class="plan-card-more" [class.plan-card-more-open]="highlightsExpanded"
648+
[attr.aria-label]="highlightsExpanded ? 'Show fewer features' : 'Show more features'"
649+
(click)="toggleHighlights(); $event.stopPropagation()">
624650
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
625-
<path d="M13.3332 4L5.99984 11.3333L2.6665 8" stroke="#16A34A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
651+
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
626652
</svg>
627-
<span>{{ feature.name }}</span>
628-
</li>
653+
</button>
629654
}
630-
</ul>
631-
}
632-
<button
633-
type="button"
634-
convoy-button
635-
[fill]="selectedPlan === plan.id ? 'solid' : 'outline'"
636-
[color]="selectedPlan === plan.id ? 'primary' : 'neutral'"
637-
[disabled]="(isCurrentPlan(plan.id) && !isTrialConvertPlan(plan.id)) || !canUsePlan(plan) || (selectedPlan === plan.id && isLoadingCheckout)"
638-
class="plan-card-button"
639-
(click)="onPlanCardButtonClick(plan.id); $event.stopPropagation()">
640-
{{ getButtonText(plan.id) }}
641-
</button>
655+
}
656+
<button
657+
type="button"
658+
convoy-button
659+
[fill]="selectedPlan === plan.id ? 'solid' : 'outline'"
660+
[color]="selectedPlan === plan.id ? 'primary' : 'neutral'"
661+
[disabled]="(isCurrentPlan(plan.id) && !isTrialConvertPlan(plan.id)) || !canUsePlan(plan) || (selectedPlan === plan.id && isLoadingCheckout)"
662+
class="plan-card-button"
663+
(click)="onPlanCardButtonClick(plan.id); $event.stopPropagation()">
664+
{{ getButtonText(plan.id) }}
665+
</button>
666+
</div>
642667
</div>
643668
}
644669
</div>
@@ -670,7 +695,7 @@ <h4 class="compare-plans-title">Compare plans</h4>
670695
<!-- Comparison Table -->
671696
<div class="comparison-table">
672697
<!-- Table Header -->
673-
<div class="comparison-table-header">
698+
<div class="comparison-table-header" [style.grid-template-columns]="comparisonGridColumns">
674699
<div class="comparison-table-cell header-category"></div>
675700
@for (plan of plans; track plan.id) {
676701
<div class="comparison-table-cell header-plan">{{ plan.name }}</div>
@@ -681,7 +706,7 @@ <h4 class="compare-plans-title">Compare plans</h4>
681706
<div class="comparison-section">
682707
<div class="comparison-section-title">Core Capabilities</div>
683708
@for (feature of getFeaturesByCategory('core'); track $index) {
684-
<div class="comparison-table-row">
709+
<div class="comparison-table-row" [style.grid-template-columns]="comparisonGridColumns">
685710
<div class="comparison-table-cell feature-name">{{ feature.name }}</div>
686711
@for (plan of plans; track plan.id) {
687712
<div class="comparison-table-cell feature-value">
@@ -702,7 +727,7 @@ <h4 class="compare-plans-title">Compare plans</h4>
702727
<div class="comparison-section">
703728
<div class="comparison-section-title">Security & Compliance</div>
704729
@for (feature of getFeaturesByCategory('security'); track $index) {
705-
<div class="comparison-table-row">
730+
<div class="comparison-table-row" [style.grid-template-columns]="comparisonGridColumns">
706731
<div class="comparison-table-cell feature-name">{{ feature.name }}</div>
707732
@for (plan of plans; track plan.id) {
708733
<div class="comparison-table-cell feature-value">
@@ -723,7 +748,7 @@ <h4 class="compare-plans-title">Compare plans</h4>
723748
<div class="comparison-section">
724749
<div class="comparison-section-title">Support</div>
725750
@for (feature of getFeaturesByCategory('support'); track $index) {
726-
<div class="comparison-table-row">
751+
<div class="comparison-table-row" [style.grid-template-columns]="comparisonGridColumns">
727752
<div class="comparison-table-cell feature-name">{{ feature.name }}</div>
728753
@for (plan of plans; track plan.id) {
729754
<div class="comparison-table-cell feature-value">

web/ui/dashboard/src/app/private/pages/settings/billing/billing-page.component.scss

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,16 @@ button.text-primary-600 {
178178
margin: 0;
179179
list-style: none;
180180

181+
// Collapsed: clip the list to a fixed height and fade the overflow instead of
182+
// hard-truncating, so it reads as "there is more" without hiding a hard cut.
183+
// The chevron below expands to the full list.
184+
&.plan-card-features-collapsed {
185+
max-height: 148px;
186+
overflow: hidden;
187+
-webkit-mask-image: linear-gradient(to bottom, #000 60%, transparent 100%);
188+
mask-image: linear-gradient(to bottom, #000 60%, transparent 100%);
189+
}
190+
181191
li {
182192
display: flex;
183193
flex-direction: row;
@@ -194,6 +204,45 @@ button.text-primary-600 {
194204
flex-shrink: 0;
195205
}
196206
}
207+
208+
li.plan-card-features-heading {
209+
font-weight: 600;
210+
margin-bottom: 2px;
211+
}
212+
}
213+
214+
// Chevron + CTA are pinned to the bottom of the card together, so the chevron
215+
// sits at the same position across cards regardless of how many bullets a card
216+
// shows or whether any bullet wraps onto a second line.
217+
.plan-card-footer {
218+
display: flex;
219+
flex-direction: column;
220+
gap: 12px;
221+
margin-top: auto;
222+
}
223+
224+
.plan-card-more {
225+
display: inline-flex;
226+
align-items: center;
227+
justify-content: center;
228+
align-self: center;
229+
padding: 0;
230+
border: none;
231+
background: transparent;
232+
cursor: pointer;
233+
color: #71717A;
234+
235+
svg {
236+
transition: transform 0.15s ease;
237+
}
238+
239+
&:hover {
240+
color: #2780F1;
241+
}
242+
243+
&.plan-card-more-open svg {
244+
transform: rotate(180deg);
245+
}
197246
}
198247

199248
.plan-card-button {
@@ -226,13 +275,20 @@ button.text-primary-600 {
226275
border: 1px solid #E7E7E7;
227276
border-radius: 8px;
228277
overflow: hidden;
278+
// With three cloud plans the row (384px label + N plan columns via fit-content)
279+
// can exceed the modal width; scroll horizontally instead of clipping columns,
280+
// matching the plan-catalog-preview table. .manage-plan-content keeps overflow-x
281+
// hidden, so the scroll must live on the table itself.
282+
overflow-x: auto;
229283
}
230284

231285
.comparison-table-header {
232286
display: grid;
233-
grid-template-columns: 384px 1fr 1fr;
287+
// grid-template-columns is bound inline from the plan count; do not hardcode
288+
// a two-plan track list here or a third plan wraps onto a new row.
234289
background: #F3F4F6;
235290
border-bottom: 1px solid #E7E7E7;
291+
min-width: fit-content;
236292
}
237293

238294
.comparison-table-cell {
@@ -293,8 +349,9 @@ button.text-primary-600 {
293349

294350
.comparison-table-row {
295351
display: grid;
296-
grid-template-columns: 384px 1fr 1fr;
352+
// grid-template-columns is bound inline from the plan count (see header note).
297353
border-bottom: 1px solid #E5E7EB;
354+
min-width: fit-content;
298355

299356
&:last-child {
300357
border-bottom: none;

web/ui/dashboard/src/app/private/pages/settings/billing/billing-page.component.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,13 @@ import {vatNumberValidator} from './vat-number.validator';
3838
import {zipCodeValidator} from './zip-code.validator';
3939
import {pollUntil, POLL_BUDGET_MS} from 'src/app/utils/poll.util';
4040
import {
41+
CardHighlights,
42+
comparisonTableGridColumns,
4143
getFeatureValue as planFeatureValue,
4244
getFeatureValueType as planFeatureValueType,
43-
getFeaturesByCategory as planFeaturesByCategory
45+
getFeaturesByCategory as planFeaturesByCategory,
46+
hasCollapsedHighlights,
47+
planCardHighlights
4448
} from './plan-comparison.util';
4549
import {plansMatch} from './plan-identity.util';
4650

@@ -993,6 +997,7 @@ export class BillingPageComponent implements OnInit, AfterViewInit {
993997

994998
openManagePlan() {
995999
this.selectedPlan = null; // Reset selection when opening dialog
1000+
this.highlightsExpanded = false; // Cards start collapsed each time the dialog opens
9961001
this.loadPlans();
9971002
this.managePlanDialog.nativeElement.showModal();
9981003
}
@@ -1700,13 +1705,7 @@ export class BillingPageComponent implements OnInit, AfterViewInit {
17001705
}
17011706

17021707
planRequiresContact(plan: Plan): boolean {
1703-
if (plan.requires_contact !== undefined) {
1704-
return plan.requires_contact;
1705-
}
1706-
if (plan.checkout_enabled !== undefined) {
1707-
return !plan.checkout_enabled;
1708-
}
1709-
return this.planCatalog.isEnterprisePlan(plan);
1708+
return this.planCatalog.planRequiresContact(plan);
17101709
}
17111710

17121711
private openPlanContact(plan: Plan): void {
@@ -1742,6 +1741,28 @@ export class BillingPageComponent implements OnInit, AfterViewInit {
17421741
return planFeaturesByCategory(this.plans, category);
17431742
}
17441743

1744+
planCardHighlights(plan: Plan): CardHighlights {
1745+
return planCardHighlights(plan);
1746+
}
1747+
1748+
// Cards collapse to a fixed, faded height by default. The chevron expands
1749+
// every card at once (shared flag) so none are left partially faded.
1750+
highlightsExpanded = false;
1751+
1752+
hasCollapsedHighlights(highlights: CardHighlights): boolean {
1753+
return hasCollapsedHighlights(highlights);
1754+
}
1755+
1756+
toggleHighlights(): void {
1757+
this.highlightsExpanded = !this.highlightsExpanded;
1758+
}
1759+
1760+
// Comparison table columns must track the plan count (label + one per plan);
1761+
// hardcoding two plan columns wraps a third plan onto a new grid row.
1762+
get comparisonGridColumns(): string {
1763+
return comparisonTableGridColumns(this.plans.length);
1764+
}
1765+
17451766
get hasPlanLoadingState(): boolean {
17461767
return this.isLoadingPlans;
17471768
}

web/ui/dashboard/src/app/private/pages/settings/billing/plan-catalog-preview.component.html

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,31 @@ <h4 class="plan-catalog-title">Plans</h4>
1212
<div class="plan-card-header">
1313
<h4 class="plan-card-title">{{ plan.name }}</h4>
1414
</div>
15-
@if (cardHighlightFeatures(plan).length > 0) {
16-
<ul class="plan-card-features">
17-
@for (feature of cardHighlightFeatures(plan); track feature.name) {
18-
<li>
15+
@if (planCardHighlights(plan); as highlights) {
16+
@if (highlights.items.length > 0) {
17+
<ul class="plan-card-features" [class.plan-card-features-collapsed]="hasCollapsedHighlights(highlights) && !highlightsExpanded">
18+
@if (highlights.heading) {
19+
<li class="plan-card-features-heading">{{ highlights.heading }}</li>
20+
}
21+
@for (item of highlights.items; track item) {
22+
<li>
23+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
24+
<path d="M13.3332 4L5.99984 11.3333L2.6665 8" stroke="#16A34A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
25+
</svg>
26+
<span>{{ item }}</span>
27+
</li>
28+
}
29+
</ul>
30+
@if (hasCollapsedHighlights(highlights)) {
31+
<button type="button" class="plan-card-more" [class.plan-card-more-open]="highlightsExpanded"
32+
[attr.aria-label]="highlightsExpanded ? 'Show fewer features' : 'Show more features'"
33+
(click)="toggleHighlights()">
1934
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
20-
<path d="M13.3332 4L5.99984 11.3333L2.6665 8" stroke="#16A34A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
35+
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
2136
</svg>
22-
<span>{{ feature.name }}</span>
23-
</li>
37+
</button>
2438
}
25-
</ul>
39+
}
2640
}
2741
</div>
2842
}

0 commit comments

Comments
 (0)