Skip to content

Commit f85f6b1

Browse files
authored
chore(web): v5 license / upsell UX polish (#1233)
1 parent 3ee7914 commit f85f6b1

9 files changed

Lines changed: 176 additions & 115 deletions

File tree

packages/web/src/app/(app)/@sidebar/components/whatsNewSidebarButton.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { listChangelogEntries } from "@/app/api/(client)/client"
1515
import { unwrapServiceError } from "@/lib/utils"
1616
import { env, SOURCEBOT_VERSION } from "@sourcebot/shared/client"
1717
import { useQuery } from "@tanstack/react-query"
18-
import { Compass, Loader2, Mail, MailOpen } from "lucide-react"
18+
import { Loader2, Mail, MailOpen, Megaphone } from "lucide-react"
1919
import Link from "next/link"
2020
import { useCallback, useMemo, useState } from "react"
2121
import { useHotkeys } from "react-hotkeys-hook"
@@ -104,7 +104,7 @@ export function WhatsNewSidebarButton() {
104104
<TooltipTrigger asChild>
105105
<PopoverTrigger asChild>
106106
<SidebarMenuButton>
107-
<Compass className="h-4 w-4" />
107+
<Megaphone className="h-4 w-4" />
108108
<span>{"What's new"}</span>
109109
</SidebarMenuButton>
110110
</PopoverTrigger>

packages/web/src/app/(app)/settings/license/activationCodeCard.tsx

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,16 @@
22

33
import { useState, useCallback } from "react";
44
import { Input } from "@/components/ui/input";
5-
import { Button } from "@/components/ui/button";
65
import { LoadingButton } from "@/components/ui/loading-button";
76
import { SettingsCard } from "../components/settingsCard";
87
import { activateLicense } from "@/ee/features/lighthouse/actions";
98
import { isServiceError } from "@/lib/utils";
109
import { useToast } from "@/components/hooks/use-toast";
1110
import { Separator } from "@/components/ui/separator";
12-
import { UpsellDialog } from "@/ee/features/lighthouse/upsellDialog";
1311

1412
export function ActivationCodeCard() {
1513
const [activationCode, setActivationCode] = useState("");
1614
const [isActivating, setIsActivating] = useState(false);
17-
const [isUpsellOpen, setIsUpsellOpen] = useState(false);
1815
const { toast } = useToast();
1916

2017
const handleActivate = useCallback(() => {
@@ -73,24 +70,8 @@ export function ActivationCodeCard() {
7370
Activate
7471
</LoadingButton>
7572
</div>
76-
<p className="text-sm text-muted-foreground flex items-center gap-1.5">
77-
Don&apos;t have an activation code?
78-
<Button
79-
variant="link"
80-
className="h-auto p-0"
81-
onClick={() => setIsUpsellOpen(true)}
82-
>
83-
See plans
84-
</Button>
85-
</p>
8673
</div>
8774
</div>
88-
<UpsellDialog
89-
open={isUpsellOpen}
90-
onOpenChange={setIsUpsellOpen}
91-
source="license_settings"
92-
returnPath="/settings/license"
93-
/>
9475
</SettingsCard>
9576
);
9677
}

packages/web/src/app/(app)/settings/license/licenseInactiveBanner.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@ export function LicenseInactiveBanner() {
1010

1111
return (
1212
<>
13-
<div className="flex items-start justify-between gap-3 border-t bg-destructive p-4 text-gray-50">
14-
<div className="flex items-start gap-3">
15-
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
16-
<div className="flex flex-col gap-0.5">
17-
<p className="font-medium leading-none">License is not active</p>
18-
<p className="text-sm">Paid features are disabled for this deployment.</p>
19-
</div>
13+
<div className="flex items-center justify-between gap-3 border-t bg-destructive px-4 py-2.5 text-gray-50">
14+
<div className="flex items-center gap-2 min-w-0">
15+
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
16+
<p className="text-sm">
17+
<span className="font-medium">License is not active.</span>{" "}
18+
<span className="text-gray-50/90">Paid features are disabled for this deployment.</span>
19+
</p>
2020
</div>
2121
<Button
2222
size="sm"
2323
variant="outline"
2424
onClick={() => setIsRemoveDialogOpen(true)}
25-
className="border-gray-50/40 bg-transparent text-gray-50 hover:bg-white/10 hover:text-gray-50"
25+
className="border-gray-50/40 bg-transparent text-gray-50 hover:bg-white/10 hover:text-gray-50 flex-shrink-0"
2626
>
2727
Remove activation code
2828
</Button>

packages/web/src/app/(app)/settings/license/page.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { authenticatedPage } from "@/middleware/authenticatedPage";
22
import { OrgRole } from "@sourcebot/db";
3-
import { getOfflineLicenseMetadata } from "@sourcebot/shared";
3+
import { _isValidLicenseActive, getOfflineLicenseMetadata } from "@sourcebot/shared";
44
import { Button } from "@/components/ui/button";
55
import { ExternalLink } from "lucide-react";
66
import { redirect } from "next/navigation";
77
import { ActivationCodeCard } from "./activationCodeCard";
88
import { OnlineLicenseCard } from "./onlineLicenseCard";
99
import { OfflineLicenseCard } from "./offlineLicenseCard";
1010
import { RecentInvoicesCard } from "./recentInvoicesCard";
11+
import { SettingsCard } from "../components/settingsCard";
12+
import { UpsellPanel } from "@/ee/features/lighthouse/upsellDialog";
1113
import { getAllInvoices } from "@/ee/features/lighthouse/actions";
1214
import { syncWithLighthouse } from "@/ee/features/lighthouse/servicePing";
1315
import { isServiceError } from "@/lib/utils";
@@ -46,6 +48,13 @@ export default authenticatedPage<LicensePageProps>(async ({ prisma, org }, props
4648
const invoicesResult = license ? await getAllInvoices() : null;
4749
const invoices = invoicesResult && !isServiceError(invoicesResult) ? invoicesResult : [];
4850

51+
// Show the upsell when the user has no usable license on this deployment:
52+
// either nothing is provisioned at all, or their online license has lapsed
53+
// (canceled/past_due/etc.). Offline licenses are out-of-band, so we don't
54+
// present a Stripe upgrade path for them.
55+
const isOnlineLicenseInactive = license ? !_isValidLicenseActive(license) : false;
56+
const showUpsell = !offlineLicense && (!license || isOnlineLicenseInactive);
57+
4958
return (
5059
<div className="flex flex-col gap-6">
5160
<div>
@@ -65,12 +74,17 @@ export default authenticatedPage<LicensePageProps>(async ({ prisma, org }, props
6574
</Button>
6675
</div>
6776
</div>
77+
{showUpsell && (
78+
<SettingsCard>
79+
<UpsellPanel source="license_settings" returnPath="/settings/license" />
80+
</SettingsCard>
81+
)}
6882
{offlineLicense && (
6983
<OfflineLicenseCard license={offlineLicense} isExpired={isOfflineLicenseExpired} />
7084
)}
7185
{license && <OnlineLicenseCard license={license} />}
72-
{license && <RecentInvoicesCard invoices={invoices} />}
7386
{!offlineLicense && !license && <ActivationCodeCard />}
87+
{license && <RecentInvoicesCard invoices={invoices} />}
7488
</div>
7589
);
7690
}, {

packages/web/src/app/onboard/components/trialStep.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@ function useTrialStepCopy(): TrialStepCopy | null {
3838
}
3939
if (offers.trial.creditCardRequired) {
4040
return {
41-
title: "Try Sourcebot Pro free",
41+
title: "Try Sourcebot Pro",
4242
subtitle: `Get full access free for ${offers.trial.durationDays} days. Card required, cancel anytime.`,
4343
};
4444
}
4545
return {
46-
title: "Try Sourcebot Pro free",
46+
title: "Try Sourcebot Pro",
4747
subtitle: `Get full access free for ${offers.trial.durationDays} days. No credit card required.`,
4848
};
4949
}
@@ -197,7 +197,7 @@ export function TrialStep({ stepIndex }: TrialStepProps) {
197197

198198
const isTrialEligible = offers.trial.eligible;
199199
const primaryButtonText = isTrialEligible
200-
? `Start ${offers.trial.durationDays}-day trial`
200+
? `Start ${offers.trial.durationDays}-day free trial`
201201
: "Purchase a license";
202202

203203
return (
@@ -220,6 +220,7 @@ export function TrialStep({ stepIndex }: TrialStepProps) {
220220
<CheckoutDisclosures
221221
sessionEmail={sessionEmail}
222222
onEmailChanged={setCurrentEmail}
223+
showNoCreditCardRequired={isTrialEligible && !offers.trial.creditCardRequired}
223224
/>
224225
<LoadingButton
225226
variant="link"

packages/web/src/components/ui/dialog.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ const DialogContent = React.forwardRef<
3333
React.ElementRef<typeof DialogPrimitive.Content>,
3434
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
3535
closeButtonClassName?: string
36+
hideCloseButton?: boolean
3637
}
37-
>(({ className, children, closeButtonClassName, ...props }, ref) => (
38+
>(({ className, children, closeButtonClassName, hideCloseButton, ...props }, ref) => (
3839
<DialogPortal>
3940
<DialogOverlay />
4041
<DialogPrimitive.Content
@@ -46,10 +47,12 @@ const DialogContent = React.forwardRef<
4647
{...props}
4748
>
4849
{children}
49-
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
50-
<X className={cn("h-4 w-4", closeButtonClassName)} />
51-
<span className="sr-only">Close</span>
52-
</DialogPrimitive.Close>
50+
{!hideCloseButton && (
51+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
52+
<X className={cn("h-4 w-4", closeButtonClassName)} />
53+
<span className="sr-only">Close</span>
54+
</DialogPrimitive.Close>
55+
)}
5356
</DialogPrimitive.Content>
5457
</DialogPortal>
5558
))

packages/web/src/ee/features/lighthouse/checkoutDisclosures.tsx

Lines changed: 69 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
'use client';
22

33
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
4-
import { Input } from "@/components/ui/input";
54
import { cn } from "@/lib/utils";
65
import { zodResolver } from "@hookform/resolvers/zod";
7-
import { Pencil } from "lucide-react";
6+
import { Pencil, Save } from "lucide-react";
87
import { useEffect, useState } from "react";
98
import { useForm } from "react-hook-form";
109
import { z } from "zod";
@@ -16,9 +15,10 @@ const emailFormSchema = z.object({
1615
interface CheckoutDisclosuresProps {
1716
sessionEmail: string;
1817
onEmailChanged: (email: string) => void;
18+
showNoCreditCardRequired?: boolean;
1919
}
2020

21-
export const CheckoutDisclosures = ({ sessionEmail, onEmailChanged }: CheckoutDisclosuresProps) => {
21+
export const CheckoutDisclosures = ({ sessionEmail, onEmailChanged, showNoCreditCardRequired }: CheckoutDisclosuresProps) => {
2222
const [isEditing, setIsEditing] = useState(false);
2323

2424
const form = useForm<z.infer<typeof emailFormSchema>>({
@@ -65,58 +65,75 @@ export const CheckoutDisclosures = ({ sessionEmail, onEmailChanged }: CheckoutDi
6565
return (
6666
<div className="text-xs text-muted-foreground text-center space-y-1">
6767
{sessionEmail && (
68-
<div className="inline-flex items-center justify-center gap-1.5">
69-
<span>Your activation code will be sent to</span>
70-
{isEditing ? (
71-
<Form {...form}>
72-
<FormField
73-
control={form.control}
74-
name="email"
75-
render={({ field }) => (
76-
<FormItem className="space-y-0">
77-
<FormControl>
78-
<Input
79-
{...field}
80-
type="email"
81-
onBlur={() => {
82-
if (!isValid) {
83-
revertAndExit();
84-
} else {
85-
setIsEditing(false);
86-
}
87-
}}
88-
onKeyDown={(e) => {
89-
if (e.key === "Enter") {
90-
e.preventDefault();
91-
commit();
92-
} else if (e.key === "Escape") {
93-
revertAndExit();
94-
}
95-
}}
96-
aria-invalid={!isValid}
97-
className={cn(
98-
"h-6 px-1.5 py-0 text-xs w-56",
99-
!isValid && "border-destructive focus-visible:ring-destructive",
100-
)}
101-
/>
102-
</FormControl>
103-
</FormItem>
104-
)}
105-
/>
106-
</Form>
107-
) : (
68+
<div className="inline-flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
69+
{showNoCreditCardRequired && (
10870
<>
109-
<span className="font-medium text-foreground">{email}</span>
110-
<button
111-
type="button"
112-
onClick={() => setIsEditing(true)}
113-
className="text-muted-foreground hover:text-foreground"
114-
aria-label="Edit email"
115-
>
116-
<Pencil className="h-3 w-3" />
117-
</button>
71+
<span>No credit card required</span>
72+
<span aria-hidden="true" className="text-muted-foreground/50">·</span>
11873
</>
11974
)}
75+
<span className="inline-flex items-center gap-1.5">
76+
<span>Your activation code will be sent to</span>
77+
{isEditing ? (
78+
<Form {...form}>
79+
<FormField
80+
control={form.control}
81+
name="email"
82+
render={({ field }) => (
83+
<FormItem className="space-y-0">
84+
<FormControl>
85+
<input
86+
{...field}
87+
type="email"
88+
autoComplete="off"
89+
data-1p-ignore="true"
90+
data-lpignore="true"
91+
data-form-type="other"
92+
data-bwignore="true"
93+
onKeyDown={(e) => {
94+
if (e.key === "Enter") {
95+
e.preventDefault();
96+
commit();
97+
} else if (e.key === "Escape") {
98+
revertAndExit();
99+
}
100+
}}
101+
aria-invalid={!isValid}
102+
className={cn(
103+
"bg-transparent border-none outline-none p-0 m-0 font-medium text-foreground [font:inherit] [letter-spacing:inherit] [field-sizing:content] min-w-[8ch]",
104+
!isValid && "text-destructive",
105+
)}
106+
style={{ fontWeight: 500 }}
107+
/>
108+
</FormControl>
109+
</FormItem>
110+
)}
111+
/>
112+
<button
113+
type="button"
114+
onClick={commit}
115+
disabled={!isValid}
116+
className="text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
117+
aria-label="Save email (press Escape to cancel)"
118+
title="Press Escape to cancel"
119+
>
120+
<Save className="h-3 w-3" />
121+
</button>
122+
</Form>
123+
) : (
124+
<>
125+
<span className="font-medium text-foreground">{email}</span>
126+
<button
127+
type="button"
128+
onClick={() => setIsEditing(true)}
129+
className="text-muted-foreground hover:text-foreground"
130+
aria-label="Edit email"
131+
>
132+
<Pencil className="h-3 w-3" />
133+
</button>
134+
</>
135+
)}
136+
</span>
120137
</div>
121138
)}
122139
</div>

packages/web/src/ee/features/lighthouse/licenseActivactionDialog.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,18 @@ export function LicenseActivactionDialog({ userEmail }: CheckoutSuccessModalProp
140140

141141
return (
142142
<Dialog open={open} onOpenChange={handleOpenChange}>
143-
<DialogContent className="sm:max-w-md gap-6">
143+
<DialogContent
144+
className="sm:max-w-md gap-6"
145+
hideCloseButton={isPolling}
146+
onEscapeKeyDown={(e) => {
147+
if (isPolling) {
148+
e.preventDefault();
149+
}
150+
}}
151+
onInteractOutside={(e) => {
152+
e.preventDefault();
153+
}}
154+
>
144155
<DialogHeader className="items-center text-center sm:text-center gap-3">
145156
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-950">
146157
<CheckCircle2 className="h-6 w-6 text-green-600 dark:text-green-400" />

0 commit comments

Comments
 (0)