diff --git a/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx b/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx index e129926b5e..d0ee2f0f23 100644 --- a/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx +++ b/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx @@ -1,20 +1,31 @@ "use client"; import { useState } from "react"; +import Image from "next/image"; import { Button } from "@/components/ui/button"; import { useAccount } from "@/providers/EmailAccountProvider"; import { toastError } from "@/components/Toast"; import type { GetCalendarAuthUrlResponse } from "@/app/api/google/calendar/auth-url/route"; import { fetchWithAccount } from "@/utils/fetch"; import { createScopedLogger } from "@/utils/logger"; -import Image from "next/image"; +import { CALENDAR_ONBOARDING_RETURN_COOKIE } from "@/utils/calendar/constants"; -export function ConnectCalendar() { +export function ConnectCalendar({ + onboardingReturnPath, +}: { + onboardingReturnPath?: string; +}) { const { emailAccountId } = useAccount(); const [isConnectingGoogle, setIsConnectingGoogle] = useState(false); const [isConnectingMicrosoft, setIsConnectingMicrosoft] = useState(false); const logger = createScopedLogger("calendar-connection"); + const setOnboardingReturnCookie = () => { + if (onboardingReturnPath) { + document.cookie = `${CALENDAR_ONBOARDING_RETURN_COOKIE}=${encodeURIComponent(onboardingReturnPath)}; path=/; max-age=180`; + } + }; + const handleConnectGoogle = async () => { setIsConnectingGoogle(true); try { @@ -29,6 +40,7 @@ export function ConnectCalendar() { } const data: GetCalendarAuthUrlResponse = await response.json(); + setOnboardingReturnCookie(); window.location.href = data.url; } catch (error) { logger.error("Error initiating Google calendar connection", { @@ -58,6 +70,7 @@ export function ConnectCalendar() { } const data: GetCalendarAuthUrlResponse = await response.json(); + setOnboardingReturnCookie(); window.location.href = data.url; } catch (error) { logger.error("Error initiating Microsoft calendar connection", { diff --git a/apps/web/app/(app)/[emailAccountId]/calendars/page.tsx b/apps/web/app/(app)/[emailAccountId]/calendars/page.tsx index a6c124a596..ac1dd47ee8 100644 --- a/apps/web/app/(app)/[emailAccountId]/calendars/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/calendars/page.tsx @@ -1,11 +1,26 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; import { PageWrapper } from "@/components/PageWrapper"; import { PageHeader } from "@/components/PageHeader"; import { CalendarConnections } from "./CalendarConnections"; import { CalendarSettings } from "./CalendarSettings"; import { ConnectCalendar } from "@/app/(app)/[emailAccountId]/calendars/ConnectCalendar"; import { TimezoneDetector } from "./TimezoneDetector"; +import { CALENDAR_ONBOARDING_RETURN_COOKIE } from "@/utils/calendar/constants"; + +export default async function CalendarsPage() { + const cookieStore = await cookies(); + const returnPathCookie = cookieStore.get(CALENDAR_ONBOARDING_RETURN_COOKIE); + + if (returnPathCookie?.value) { + const returnPath = decodeURIComponent(returnPathCookie.value); + const isInternalPath = + returnPath.startsWith("/") && !returnPath.startsWith("//"); + if (isInternalPath) { + redirect(returnPath); + } + } -export default function CalendarsPage() { return ( diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding-brief/MeetingBriefsOnboardingContent.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding-brief/MeetingBriefsOnboardingContent.tsx new file mode 100644 index 0000000000..79af23b9c2 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/onboarding-brief/MeetingBriefsOnboardingContent.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { StepConnectCalendar } from "./StepConnectCalendar"; +import { StepSendTestBrief } from "./StepSendTestBrief"; +import { StepReady } from "./StepReady"; +import { prefixPath } from "@/utils/path"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { OnboardingWrapper } from "@/app/(app)/[emailAccountId]/onboarding/OnboardingWrapper"; + +const TOTAL_STEPS = 3; + +interface MeetingBriefsOnboardingContentProps { + step: number; +} + +export function MeetingBriefsOnboardingContent({ + step, +}: MeetingBriefsOnboardingContentProps) { + const { emailAccountId } = useAccount(); + const router = useRouter(); + + const clampedStep = Math.min(Math.max(step, 1), TOTAL_STEPS); + + const onNext = useCallback(async () => { + if (clampedStep < TOTAL_STEPS) { + const nextStep = clampedStep + 1; + router.push( + prefixPath(emailAccountId, `/onboarding-brief?step=${nextStep}`), + ); + } + }, [router, emailAccountId, clampedStep]); + + return ( + + {clampedStep === 1 && } + {clampedStep === 2 && } + {clampedStep === 3 && } + + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding-brief/StepConnectCalendar.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding-brief/StepConnectCalendar.tsx new file mode 100644 index 0000000000..58a1c199b6 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/onboarding-brief/StepConnectCalendar.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Calendar, CheckIcon } from "lucide-react"; +import { PageHeading, TypographyP } from "@/components/Typography"; +import { useCalendars } from "@/hooks/useCalendars"; +import { IconCircle } from "@/app/(app)/[emailAccountId]/onboarding/IconCircle"; +import { ConnectCalendar } from "@/app/(app)/[emailAccountId]/calendars/ConnectCalendar"; +import { Button } from "@/components/ui/button"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { prefixPath } from "@/utils/path"; + +export function StepConnectCalendar({ onNext }: { onNext: () => void }) { + const { emailAccountId } = useAccount(); + const { data: calendarsData } = useCalendars(); + + const hasCalendarConnected = + calendarsData?.connections && calendarsData.connections.length > 0; + + return ( + <> +
+ + + +
+ +
+ Connect Your Calendar + + We'll automatically detect your upcoming meetings with external guests + and prepare personalized briefings. + +
+ +
+ {hasCalendarConnected ? ( + <> +
+ + Calendar Connected! +
+ + + ) : ( + + )} +
+ + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding-brief/StepReady.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding-brief/StepReady.tsx new file mode 100644 index 0000000000..d352bfb190 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/onboarding-brief/StepReady.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { + Sparkles, + CheckIcon, + ChevronRightIcon, + ExternalLinkIcon, +} from "lucide-react"; +import { PageHeading, TypographyP } from "@/components/Typography"; +import { Button } from "@/components/ui/button"; +import { CardBasic } from "@/components/ui/card"; +import { IconCircle } from "@/app/(app)/[emailAccountId]/onboarding/IconCircle"; +import { getGmailBasicSearchUrl } from "@/utils/url"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { isGoogleProvider } from "@/utils/email/provider-types"; +import { + PricingFrequencyToggle, + frequencies, + DiscountBadge, +} from "@/app/(app)/premium/PricingFrequencyToggle"; +import { + BRIEF_MY_MEETING_PRICE_ID_MONTHLY, + BRIEF_MY_MEETING_PRICE_ID_ANNUALLY, +} from "@/app/(app)/premium/config"; +import { generateCheckoutSessionAction } from "@/utils/actions/premium"; +import { toastError } from "@/components/Toast"; + +const PRICING_FEATURES = [ + "Briefs for every external meeting", + "Google Calendar & Outlook", + "LinkedIn & web research", + "Sent 1-24 hours before (you choose)", +]; + +export function StepReady() { + const { emailAccount } = useAccount(); + const [frequency, setFrequency] = useState(frequencies[1]); + const [loading, setLoading] = useState(false); + + async function handleCheckout() { + setLoading(true); + try { + const tier = + frequency.value === "annually" + ? "BUSINESS_ANNUALLY" + : "BUSINESS_MONTHLY"; + const priceId = + frequency.value === "annually" + ? BRIEF_MY_MEETING_PRICE_ID_ANNUALLY + : BRIEF_MY_MEETING_PRICE_ID_MONTHLY; + + const result = await generateCheckoutSessionAction({ tier, priceId }); + + if (!result?.data?.url) { + toastError({ description: "Error creating checkout session" }); + return; + } + + window.location.href = result.data.url; + } catch { + toastError({ description: "Error creating checkout session" }); + } finally { + setLoading(false); + } + } + + return ( + <> +
+ + + +
+ +
+ + Ready to walk into every meeting prepared? + + + You'll get a brief like this before every external meeting, + automatically. + +
+ +
+ +
+ 2 months free! +
+
+ + +
+
+

+ Meeting Briefs Pro +

+

+ ${frequency.value === "annually" ? "7.50" : "9"} + + /month + +

+

+ {frequency.value === "annually" + ? "billed annually ($90/year)" + : "billed monthly"} +

+
+
+ 7-day free trial +
+
+ +
+ {PRICING_FEATURES.map((feature) => ( +
+
+ +
+ {feature} +
+ ))} +
+
+
+ +
+ + + {emailAccount?.email && + isGoogleProvider(emailAccount?.account?.provider) && ( + + )} +
+ + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding-brief/StepSendTestBrief.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding-brief/StepSendTestBrief.tsx new file mode 100644 index 0000000000..5cac4470c3 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/onboarding-brief/StepSendTestBrief.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { format } from "date-fns"; +import { Send, CheckIcon, CalendarIcon, Building2 } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { PageHeading, TypographyP } from "@/components/Typography"; +import { Button } from "@/components/ui/button"; +import { LoadingContent } from "@/components/LoadingContent"; +import { toastSuccess, toastError } from "@/components/Toast"; +import { IconCircle } from "@/app/(app)/[emailAccountId]/onboarding/IconCircle"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { useCalendarUpcomingEvents } from "@/hooks/useCalendarUpcomingEvents"; +import { sendBriefAction } from "@/utils/actions/meeting-briefs"; +import { cn } from "@/utils"; +import { extractDomainFromEmail } from "@/utils/email"; +import { sleep } from "@/utils/sleep"; + +export function StepSendTestBrief({ onNext }: { onNext: () => void }) { + const { emailAccountId } = useAccount(); + const { data, isLoading, error } = useCalendarUpcomingEvents(); + const [selectedEventId, setSelectedEventId] = useState(null); + const [briefSent, setBriefSent] = useState(false); + + const { execute, isExecuting } = useAction( + sendBriefAction.bind(null, emailAccountId), + { + onSuccess: async ({ data: result }) => { + toastSuccess({ + description: result.message || "Test brief sent! Check your inbox.", + }); + setBriefSent(true); + await sleep(1000); + onNext(); + }, + onError: ({ error: err }) => { + toastError({ + description: err.serverError || "Failed to send brief", + }); + }, + }, + ); + + const handleSendTestBrief = useCallback(() => { + const event = data?.events.find((e) => e.id === selectedEventId); + if (!event) return; + + execute({ + event: { + id: event.id, + title: event.title, + description: event.description, + location: event.location, + eventUrl: event.eventUrl, + videoConferenceLink: event.videoConferenceLink, + startTime: new Date(event.startTime).toISOString(), + endTime: new Date(event.endTime).toISOString(), + attendees: event.attendees, + }, + }); + }, [data?.events, selectedEventId, execute]); + + return ( + <> +
+ + + +
+ +
+ Send a Test Brief + + Pick an upcoming meeting and we'll send you a sample brief so you can + see exactly what you'll receive. + +
+ +
+ + {!data?.events.length ? ( +
+ +
+

No upcoming meetings found

+

+ We couldn't find any upcoming meetings with external guests. +

+
+ +
+ ) : ( +
+ {data.events.map((event) => { + const isSelected = selectedEventId === event.id; + const companyDomain = extractDomainFromEmail( + event.attendees[0]?.email || "", + ); + + return ( + + ); + })} +
+ )} +
+
+ + {data?.events.length ? ( +
+ +
+ ) : null} + + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding-brief/page.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding-brief/page.tsx new file mode 100644 index 0000000000..853ef36068 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/onboarding-brief/page.tsx @@ -0,0 +1,36 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { MeetingBriefsOnboardingContent } from "./MeetingBriefsOnboardingContent"; +import { registerUtmTracking } from "@/app/(landing)/welcome/utms"; +import { auth } from "@/utils/auth"; + +export const metadata: Metadata = { + title: "Meeting Briefs Setup | Inbox Zero", + description: + "Set up meeting briefs to receive personalized briefings before your meetings.", + alternates: { canonical: "/onboarding-brief" }, +}; + +export default async function MeetingBriefsOnboardingPage(props: { + params: Promise<{ emailAccountId: string }>; + searchParams: Promise<{ step?: string }>; +}) { + const [searchParams, cookieStore] = await Promise.all([ + props.searchParams, + cookies(), + ]); + const parsedStep = searchParams.step ? Number.parseInt(searchParams.step) : 1; + const step = Number.isNaN(parsedStep) ? 1 : parsedStep; + + registerUtmTracking({ + authPromise: auth(), + cookieStore, + }); + + return ( + + + + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding/page.tsx index d3bb0769ca..b158f81edd 100644 --- a/apps/web/app/(app)/[emailAccountId]/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/onboarding/page.tsx @@ -1,12 +1,9 @@ import { Suspense } from "react"; import type { Metadata } from "next"; import { cookies } from "next/headers"; -import { after } from "next/server"; +import { redirect } from "next/navigation"; import { OnboardingContent } from "@/app/(app)/[emailAccountId]/onboarding/OnboardingContent"; -import { - extractUtmValues, - fetchUserAndStoreUtms, -} from "@/app/(landing)/welcome/utms"; +import { registerUtmTracking } from "@/app/(landing)/welcome/utms"; import { auth } from "@/utils/auth"; export const maxDuration = 300; @@ -21,21 +18,23 @@ export default async function OnboardingPage(props: { params: Promise<{ emailAccountId: string }>; searchParams: Promise<{ step?: string; force?: string }>; }) { - const searchParams = await props.searchParams; + const [searchParams, { emailAccountId }, cookieStore] = await Promise.all([ + props.searchParams, + props.params, + cookies(), + ]); const step = searchParams.step ? Number.parseInt(searchParams.step, 10) : 1; - const authPromise = auth(); - - const cookieStore = await cookies(); - const utmValues = extractUtmValues(cookieStore); - - after(async () => { - const user = await authPromise; - if (!user?.user) return; - await fetchUserAndStoreUtms(user.user.id, utmValues); + const utmValues = registerUtmTracking({ + authPromise: auth(), + cookieStore, }); + if (utmValues.utmSource === "briefmymeeting" && !searchParams.force) { + redirect(`/${emailAccountId}/onboarding-brief`); + } + return ( diff --git a/apps/web/app/(app)/accounts/page.tsx b/apps/web/app/(app)/accounts/page.tsx index 4a91c194a0..62269182d2 100644 --- a/apps/web/app/(app)/accounts/page.tsx +++ b/apps/web/app/(app)/accounts/page.tsx @@ -203,7 +203,7 @@ function AccountOptionsDropdown({ className="flex items-center gap-2" onClick={(e) => e.stopPropagation()} > - + Setup @@ -215,8 +215,8 @@ function AccountOptionsDropdown({ }} onClick={(e) => e.stopPropagation()} > - - Transfer rules to... + + Copy rules to... )} - + Delete } diff --git a/apps/web/app/(app)/premium/Pricing.tsx b/apps/web/app/(app)/premium/Pricing.tsx index 1f46bfca50..fa7aeb9f60 100644 --- a/apps/web/app/(app)/premium/Pricing.tsx +++ b/apps/web/app/(app)/premium/Pricing.tsx @@ -3,13 +3,18 @@ import { useState } from "react"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; -import { Label, Radio, RadioGroup } from "@headlessui/react"; import { CheckIcon, SparklesIcon } from "lucide-react"; import Link from "next/link"; import { env } from "@/env"; import { LoadingContent } from "@/components/LoadingContent"; import { usePremium } from "@/components/PremiumAlert"; import { Button } from "@/components/ui/button"; +import { + PricingFrequencyToggle, + frequencies, + DiscountBadge, + type Frequency, +} from "@/app/(app)/premium/PricingFrequencyToggle"; import { getUserTier } from "@/utils/premium"; import { type Tier, tiers } from "@/app/(app)/premium/config"; import { AlertWithButton } from "@/components/Alert"; @@ -25,19 +30,6 @@ import { cn } from "@/utils"; import { ManageSubscription } from "@/app/(app)/premium/ManageSubscription"; import { captureException } from "@/utils/error"; -const frequencies = [ - { - value: "monthly" as const, - label: "Monthly", - priceSuffix: "/month, billed monthly", - }, - { - value: "annually" as const, - label: "Annually", - priceSuffix: "/month, billed annually", - }, -]; - export type PricingProps = { header?: React.ReactNode; showSkipUpgrade?: boolean; @@ -120,33 +112,14 @@ export default function Pricing(props: PricingProps) { )} -
- - - {frequencies.map((option) => ( - - cn( - checked ? "bg-black text-white" : "text-gray-500", - "cursor-pointer rounded-full px-2.5 py-1", - ) - } - > - {option.label} - - ))} - - +
- Save up to 16% + Save up to 16%
-
+
{tiers.map((tier) => { @@ -182,7 +155,7 @@ function PriceTier({ }: { tier: Tier; userPremiumTier: PremiumTier | null; - frequency: (typeof frequencies)[number]; + frequency: Frequency; stripeSubscriptionId: string | null | undefined; stripeSubscriptionStatus: string | null | undefined; isLoggedIn: boolean; @@ -215,7 +188,7 @@ function PriceTier({ > {tier.name} - {tier.mostPopular ? Popular : null} + {tier.mostPopular ? Popular : null}

{tier.description} @@ -237,11 +210,11 @@ function PriceTier({ )} {!!tier.discount?.[frequency.value] && ( - + SAVE {tier.discount[frequency.value].toFixed(0)}% - + )}

@@ -380,11 +353,3 @@ function ThreeColItem({ }) { return
{children}
; } - -function Badge({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/apps/web/app/(app)/premium/PricingFrequencyToggle.tsx b/apps/web/app/(app)/premium/PricingFrequencyToggle.tsx new file mode 100644 index 0000000000..bf6691b1be --- /dev/null +++ b/apps/web/app/(app)/premium/PricingFrequencyToggle.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Label, Radio, RadioGroup } from "@headlessui/react"; +import { cn } from "@/utils"; + +export const frequencies = [ + { + value: "monthly" as const, + label: "Monthly", + priceSuffix: "/month, billed monthly", + }, + { + value: "annually" as const, + label: "Annually", + priceSuffix: "/month, billed annually", + }, +]; + +export type Frequency = (typeof frequencies)[number]; + +export function PricingFrequencyToggle({ + frequency, + setFrequency, + className, + children, +}: { + frequency: Frequency; + setFrequency: (frequency: Frequency) => void; + className?: string; + children?: React.ReactNode; +}) { + return ( +
+ + + {frequencies.map((option) => ( + + cn( + checked ? "bg-black text-white" : "text-gray-500", + "cursor-pointer rounded-full px-2.5 py-1", + ) + } + > + {option.label} + + ))} + + {children} +
+ ); +} + +export function DiscountBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/(app)/premium/config.ts b/apps/web/app/(app)/premium/config.ts index 01327e143b..c83b8af828 100644 --- a/apps/web/app/(app)/premium/config.ts +++ b/apps/web/app/(app)/premium/config.ts @@ -39,6 +39,11 @@ const variantIdToTier: Record = { [env.NEXT_PUBLIC_COPILOT_MONTHLY_VARIANT_ID]: "COPILOT_MONTHLY", }; +export const BRIEF_MY_MEETING_PRICE_ID_MONTHLY = + "price_1SjoaXKGf8mwZWHnOdyaf2IN"; +export const BRIEF_MY_MEETING_PRICE_ID_ANNUALLY = + "price_1SjoawKGf8mwZWHnfAeShYhb"; + const STRIPE_PRICE_ID_CONFIG: Record< PremiumTier, { @@ -63,6 +68,8 @@ const STRIPE_PRICE_ID_CONFIG: Record< "price_1Rg0QfKGf8mwZWHnDsiocBVD", "price_1Rg0LEKGf8mwZWHndYXYg7ie", "price_1Rg03pKGf8mwZWHnWMNeQzLc", + // brief my meeting + BRIEF_MY_MEETING_PRICE_ID_MONTHLY, ], }, BUSINESS_ANNUALLY: { @@ -72,6 +79,8 @@ const STRIPE_PRICE_ID_CONFIG: Record< "price_1S1QGGKGf8mwZWHnYpUcqNua", "price_1RMSnIKGf8mwZWHnymtuW2s0", "price_1RfSoxKGf8mwZWHngHcug4YM", + // brief my meeting + BRIEF_MY_MEETING_PRICE_ID_ANNUALLY, ], }, BUSINESS_PLUS_MONTHLY: { diff --git a/apps/web/app/(landing)/onboarding-brief/page.tsx b/apps/web/app/(landing)/onboarding-brief/page.tsx new file mode 100644 index 0000000000..d8603de845 --- /dev/null +++ b/apps/web/app/(landing)/onboarding-brief/page.tsx @@ -0,0 +1,5 @@ +import { redirectToEmailAccountPath } from "@/utils/account"; + +export default async function OnboardingBriefPage() { + await redirectToEmailAccountPath("/onboarding-brief"); +} diff --git a/apps/web/app/(landing)/welcome/utms.tsx b/apps/web/app/(landing)/welcome/utms.tsx index 9ffdab4496..15bdae425f 100644 --- a/apps/web/app/(landing)/welcome/utms.tsx +++ b/apps/web/app/(landing)/welcome/utms.tsx @@ -1,6 +1,8 @@ +import { after } from "next/server"; import prisma from "@/utils/prisma"; import { createScopedLogger } from "@/utils/logger"; import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; +import type { auth } from "@/utils/auth"; const logger = createScopedLogger("utms"); @@ -13,6 +15,24 @@ type UtmValues = { referralCode?: string; }; +export function registerUtmTracking({ + authPromise, + cookieStore, +}: { + authPromise: ReturnType; + cookieStore: ReadonlyRequestCookies; +}) { + const utmValues = extractUtmValues(cookieStore); + + after(async () => { + const user = await authPromise; + if (!user?.user) return; + await fetchUserAndStoreUtms(user.user.id, utmValues); + }); + + return utmValues; +} + // Extract UTM values from cookies before passing to after() callback // This is required because request APIs (cookies/headers) cannot be used // inside after() in Server Components - only in Server Actions and Route Handlers diff --git a/apps/web/components/SideNav.tsx b/apps/web/components/SideNav.tsx index ff7fee8e1b..3244c451ab 100644 --- a/apps/web/components/SideNav.tsx +++ b/apps/web/components/SideNav.tsx @@ -131,7 +131,6 @@ export const useNavigation = () => { name: "Meeting Briefs", href: prefixPath(currentEmailAccountId, "/briefs"), icon: FileTextIcon, - beta: true, }, ] : []), diff --git a/apps/web/components/SideNavWithTopNav.tsx b/apps/web/components/SideNavWithTopNav.tsx index 6348c73b77..24d573c0c3 100644 --- a/apps/web/components/SideNavWithTopNav.tsx +++ b/apps/web/components/SideNavWithTopNav.tsx @@ -55,9 +55,13 @@ export function SideNavWithTopNav({ if (!pathname) return null; // Ugly code. May change the onboarding path later so we don't need to do this. - // Only return children for the main onboarding page: /[emailAccountId]/onboarding + // Only return children for the onboarding or onboarding-brief pages: /[emailAccountId]/onboarding or /[emailAccountId]/onboarding-brief const segments = pathname.split("/").filter(Boolean); - if (segments.length === 2 && segments[1] === "onboarding") return children; + if ( + segments.length === 2 && + (segments[1] === "onboarding" || segments[1] === "onboarding-brief") + ) + return children; return ( { - const priceId = getStripePriceId({ tier }); + .inputSchema( + z.object({ + tier: z.nativeEnum(PremiumTier), + priceId: z.string().optional(), + }), + ) + .action( + async ({ + ctx: { userId, logger }, + parsedInput: { tier, priceId: inputPriceId }, + }) => { + const priceId = inputPriceId || getStripePriceId({ tier }); - if (!priceId) throw new SafeError("Unknown tier. Contact support."); + if (!priceId) throw new SafeError("Unknown tier. Contact support."); - const stripe = getStripe(); + const stripe = getStripe(); - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - email: true, - premium: { - select: { - id: true, - stripeCustomerId: true, - users: { - select: { - _count: { select: { emailAccounts: true } }, + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + email: true, + premium: { + select: { + id: true, + stripeCustomerId: true, + users: { + select: { + _count: { select: { emailAccounts: true } }, + }, }, }, }, }, - }, - }); - if (!user) { - logger.error("User not found"); - throw new SafeError("User not found"); - } + }); + if (!user) { + logger.error("User not found"); + throw new SafeError("User not found"); + } - // Get the stripeCustomerId from your KV store - let stripeCustomerId = user.premium?.stripeCustomerId; + let stripeCustomerId = user.premium?.stripeCustomerId; - // Create a new Stripe customer if this user doesn't have one - if (!stripeCustomerId) { - const newCustomer = await stripe.customers.create( - { - email: user.email, - metadata: { userId }, - }, - // prevent race conditions of creating 2 customers in stripe for on user - // https://github.com/stripe/stripe-node/issues/476#issuecomment-402541143 - { idempotencyKey: userId }, - ); + if (!stripeCustomerId) { + const newCustomer = await stripe.customers.create( + { + email: user.email, + metadata: { userId }, + }, + // prevent race conditions of creating 2 customers in stripe for on user + // https://github.com/stripe/stripe-node/issues/476#issuecomment-402541143 + { idempotencyKey: userId }, + ); - after(() => trackStripeCustomerCreated(user.email, newCustomer.id)); + after(() => trackStripeCustomerCreated(user.email, newCustomer.id)); - // Store the relation between userId and stripeCustomerId - const premium = user.premium || (await createPremiumForUser({ userId })); + const premium = + user.premium || (await createPremiumForUser({ userId })); - stripeCustomerId = newCustomer.id; + stripeCustomerId = newCustomer.id; - await prisma.premium.update({ - where: { id: premium.id }, - data: { stripeCustomerId }, - }); - } + await prisma.premium.update({ + where: { id: premium.id }, + data: { stripeCustomerId }, + }); + } - const quantity = - sumBy(user.premium?.users || [], (u) => u._count.emailAccounts) || 1; - - // ALWAYS create a checkout with a stripeCustomerId - const checkout = await stripe.checkout.sessions.create({ - customer: stripeCustomerId, - success_url: `${env.NEXT_PUBLIC_BASE_URL}/api/stripe/success`, - cancel_url: `${env.NEXT_PUBLIC_BASE_URL}/premium`, - mode: "subscription", - subscription_data: { trial_period_days: 7 }, - line_items: [{ price: priceId, quantity }], - allow_promotion_codes: true, - payment_method_collection: "if_required", - metadata: { - dubCustomerId: userId, - }, - }); + const quantity = + sumBy(user.premium?.users || [], (u) => u._count.emailAccounts) || 1; + + // ALWAYS create a checkout with a stripeCustomerId + const checkout = await stripe.checkout.sessions.create({ + customer: stripeCustomerId, + success_url: `${env.NEXT_PUBLIC_BASE_URL}/api/stripe/success`, + cancel_url: `${env.NEXT_PUBLIC_BASE_URL}/premium`, + mode: "subscription", + subscription_data: { trial_period_days: 7 }, + line_items: [{ price: priceId, quantity }], + allow_promotion_codes: true, + payment_method_collection: "if_required", + metadata: { + dubCustomerId: userId, + }, + }); - after(() => trackStripeCheckoutCreated(user.email)); + after(() => trackStripeCheckoutCreated(user.email)); - return { url: checkout.url }; - }); + return { url: checkout.url }; + }, + ); diff --git a/apps/web/utils/calendar/constants.ts b/apps/web/utils/calendar/constants.ts index e1dc55bd1c..2e6df9d29e 100644 --- a/apps/web/utils/calendar/constants.ts +++ b/apps/web/utils/calendar/constants.ts @@ -1 +1,2 @@ export const CALENDAR_STATE_COOKIE_NAME = "calendar_state"; +export const CALENDAR_ONBOARDING_RETURN_COOKIE = "calendar_onboarding_return";