Skip to content
Merged
17 changes: 15 additions & 2 deletions apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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", {
Expand Down Expand Up @@ -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", {
Expand Down
17 changes: 16 additions & 1 deletion apps/web/app/(app)/[emailAccountId]/calendars/page.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
elie222 marked this conversation as resolved.
redirect(returnPath);
}
}
Comment thread
elie222 marked this conversation as resolved.

export default function CalendarsPage() {
return (
<PageWrapper>
<TimezoneDetector />
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
elie222 marked this conversation as resolved.

const onNext = useCallback(async () => {
if (clampedStep < TOTAL_STEPS) {
const nextStep = clampedStep + 1;
router.push(
prefixPath(emailAccountId, `/onboarding-brief?step=${nextStep}`),
);
}
}, [router, emailAccountId, clampedStep]);

return (
<OnboardingWrapper>
{clampedStep === 1 && <StepConnectCalendar onNext={onNext} />}
{clampedStep === 2 && <StepSendTestBrief onNext={onNext} />}
{clampedStep === 3 && <StepReady />}
</OnboardingWrapper>
);
}
Original file line number Diff line number Diff line change
@@ -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 =
Comment thread
elie222 marked this conversation as resolved.
calendarsData?.connections && calendarsData.connections.length > 0;

return (
<>
<div className="flex justify-center">
<IconCircle size="lg">
<Calendar className="size-6" />
</IconCircle>
</div>

<div className="text-center">
<PageHeading className="mt-4">Connect Your Calendar</PageHeading>
<TypographyP className="mt-2 max-w-lg mx-auto">
We'll automatically detect your upcoming meetings with external guests
and prepare personalized briefings.
</TypographyP>
</div>

<div className="flex flex-col items-center justify-center mt-8 gap-4">
{hasCalendarConnected ? (
<>
<div className="flex items-center gap-2 text-green-600 font-medium animate-in fade-in zoom-in duration-300">
<CheckIcon className="h-5 w-5" />
Calendar Connected!
</div>
<Button onClick={onNext} className="mt-2">
Continue
</Button>
</>
) : (
<ConnectCalendar
onboardingReturnPath={prefixPath(
emailAccountId,
"/onboarding-brief?step=2",
)}
/>
)}
</div>
</>
);
}
163 changes: 163 additions & 0 deletions apps/web/app/(app)/[emailAccountId]/onboarding-brief/StepReady.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div className="flex justify-center">
<IconCircle size="lg">
<Sparkles className="size-6" />
</IconCircle>
</div>

<div className="text-center">
<PageHeading className="mt-4">
Ready to walk into every meeting prepared?
</PageHeading>
<TypographyP className="mt-2 max-w-lg mx-auto">
You'll get a brief like this before every external meeting,
automatically.
</TypographyP>
</div>

<div className="mt-8 flex flex-col items-center">
<PricingFrequencyToggle
frequency={frequency}
setFrequency={setFrequency}
>
<div className="ml-1">
<DiscountBadge>2 months free!</DiscountBadge>
</div>
</PricingFrequencyToggle>

<CardBasic className="mt-4 p-6 w-full">
<div className="flex items-center justify-between mb-5">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Meeting Briefs Pro
</p>
<p className="text-3xl font-bold text-foreground mt-1">
${frequency.value === "annually" ? "7.50" : "9"}
<span className="text-base font-normal text-muted-foreground">
/month
</span>
</p>
<p className="text-sm text-muted-foreground mt-1">
{frequency.value === "annually"
? "billed annually ($90/year)"
: "billed monthly"}
</p>
</div>
<div className="rounded-full border border-green-200 bg-green-50 px-3 py-1.5 text-sm font-semibold text-green-700">
7-day free trial
</div>
</div>

<div className="flex flex-col gap-2.5">
{PRICING_FEATURES.map((feature) => (
<div key={feature} className="flex items-center gap-2.5">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-green-50">
<CheckIcon className="h-3 w-3 text-green-600" />
</div>
<span className="text-foreground">{feature}</span>
</div>
))}
</div>
</CardBasic>
</div>

<div className="flex flex-col gap-3 mt-8">
<Button
size="lg"
className="w-full"
onClick={handleCheckout}
loading={loading}
>
Start Free Trial
<ChevronRightIcon className="ml-2 h-4 w-4" />
</Button>
Comment thread
elie222 marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{emailAccount?.email &&
isGoogleProvider(emailAccount?.account?.provider) && (
<Button variant="outline" size="lg" className="w-full" asChild>
<Link
href={getGmailBasicSearchUrl(
emailAccount.email,
"from:(getinboxzero.com) subject:(Briefing for)",
)}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon className="mr-2 h-4 w-4" />
View test brief in Gmail
</Link>
</Button>
)}
</div>
</>
);
}
Loading
Loading