feat: onboarding v3 - app install flow personal (#24710)
## What does this PR do? Refactors the personal onboarding flow by creating reusable components for better code organization and maintainability. The changes include: - Creates shared components for the onboarding experience: `OnboardingLayout`, `OnboardingCard`, `InstallableAppCard`, and `SkipButton` - Implements a custom hook `useAppInstallation` to manage app installation state and callbacks - Enhances the video app connection step to automatically set the first connected video app as default - Improves the calendar connection flow with proper installation handling - Adds visual indicators for connected apps ## Visual Demo (For contributors especially) The refactored components maintain the same visual appearance while improving code structure and reusability. ## Mandatory Tasks - [x] I have self-reviewed the code - [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? - Go through the personal onboarding flow - Test connecting calendar apps in step 3 - Test connecting video apps in step 4 - Verify that the first connected video app is automatically set as default - Check that connected apps show the "connected" indicator - Verify that skipping steps works correctly ## Checklist - I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code follows the style guidelines of this project - I have commented my code, particularly in hard-to-understand areas - I have checked if my changes generate no new warnings
This commit is contained in:
1 parent
01346abb85
commit
6ddc666827
7 files changed
+306
-194
No files matched your search
@@ -0,0 +1,69 @@
|
||||
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/InstallAppButtonWithoutPlanCheck";
|
||||
import type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
type AppData = {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logo: string;
|
||||
type: App["type"];
|
||||
userCredentialIds: number[];
|
||||
};
|
||||
|
||||
type InstallableAppCardProps = {
|
||||
app: AppData;
|
||||
isInstalling: boolean;
|
||||
onInstallClick: (appSlug: string) => void;
|
||||
installOptions: UseAddAppMutationOptions;
|
||||
};
|
||||
|
||||
export const InstallableAppCard = ({
|
||||
app,
|
||||
isInstalling,
|
||||
onInstallClick,
|
||||
installOptions,
|
||||
}: InstallableAppCardProps) => {
|
||||
const { t } = useLocale();
|
||||
const isInstalled = app.userCredentialIds && app.userCredentialIds.length > 0;
|
||||
|
||||
return (
|
||||
<div className="border-subtle bg-default relative flex flex-col items-start gap-4 rounded-xl border p-5">
|
||||
{isInstalled && (
|
||||
<span className="bg-success text-success absolute right-2 top-2 rounded-md px-2 py-1 text-xs font-medium">
|
||||
{t("connected")}
|
||||
</span>
|
||||
)}
|
||||
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
|
||||
<p
|
||||
className="text-default line-clamp-1 break-words text-left text-sm font-medium leading-none"
|
||||
title={app.name}>
|
||||
{app.name}
|
||||
</p>
|
||||
<p className="text-subtle line-clamp-2 text-left text-xs leading-tight">{app.description}</p>
|
||||
<InstallAppButtonWithoutPlanCheck
|
||||
type={app.type}
|
||||
options={installOptions}
|
||||
render={(buttonProps) => (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
color="secondary"
|
||||
disabled={isInstalled}
|
||||
type="button"
|
||||
loading={isInstalling || buttonProps?.loading}
|
||||
className="mt-auto w-full items-center justify-center rounded-[10px]"
|
||||
onClick={(event) => {
|
||||
// Save cookie to return to this page after OAuth
|
||||
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
||||
onInstallClick(app.slug);
|
||||
buttonProps?.onClick?.(event);
|
||||
}}>
|
||||
{isInstalled ? t("connected") : t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
type OnboardingCardProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: ReactNode;
|
||||
footer: ReactNode;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const OnboardingCard = ({ title, subtitle, children, footer, isLoading }: OnboardingCardProps) => {
|
||||
return (
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
{/* Card Header */}
|
||||
<div className="flex w-full gap-1.5 px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-6">{title}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex w-full flex-col gap-4 px-5 py-5">
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<SkeletonText className="h-40 w-full" />
|
||||
<SkeletonText className="h-40 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">{footer}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
|
||||
type OnboardingLayoutProps = {
|
||||
userEmail: string;
|
||||
currentStep: 1 | 2 | 3 | 4;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const OnboardingLayout = ({ userEmail, currentStep, children }: OnboardingLayoutProps) => {
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`bg-${step <= currentStep ? "emphasis" : "subtle"} ${
|
||||
step === currentStep ? "h-1.5 w-1.5" : "h-1 w-1"
|
||||
} rounded-full`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
type SkipButtonProps = {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SkipButton = ({ onClick, disabled }: SkipButtonProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4 disabled:opacity-50">
|
||||
{t("ill_do_this_later")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
type InstallSuccessCallback = (appSlug: string) => void;
|
||||
|
||||
export const useAppInstallation = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [installingAppSlug, setInstallingAppSlug] = useState<string | null>(null);
|
||||
|
||||
const createInstallHandlers = (appSlug: string, onSuccess?: InstallSuccessCallback) => {
|
||||
return {
|
||||
onSuccess: () => {
|
||||
setInstallingAppSlug(null);
|
||||
utils.viewer.apps.integrations.invalidate();
|
||||
showToast(t("app_successfully_installed"), "success");
|
||||
|
||||
// Call custom success callback if provided
|
||||
onSuccess?.(appSlug);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setInstallingAppSlug(null);
|
||||
if (error instanceof Error) {
|
||||
showToast(error.message || t("app_could_not_be_installed"), "error");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
installingAppSlug,
|
||||
setInstallingAppSlug,
|
||||
createInstallHandlers,
|
||||
};
|
||||
};
|
||||
@@ -5,8 +5,12 @@ import { useRouter } from "next/navigation";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import { InstallableAppCard } from "../_components/InstallableAppCard";
|
||||
import { OnboardingCard } from "../_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../_components/OnboardingLayout";
|
||||
import { SkipButton } from "../_components/SkipButton";
|
||||
import { useAppInstallation } from "../_components/useAppInstallation";
|
||||
|
||||
type PersonalCalendarViewProps = {
|
||||
userEmail: string;
|
||||
@@ -15,6 +19,7 @@ type PersonalCalendarViewProps = {
|
||||
export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation();
|
||||
|
||||
const queryIntegrations = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "calendar",
|
||||
@@ -32,93 +37,30 @@ export const PersonalCalendarView = ({ userEmail }: PersonalCalendarViewProps) =
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
<div className="bg-subtle h-1 w-1 rounded-full" />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3}>
|
||||
<OnboardingCard
|
||||
title={t("connect_your_calendar")}
|
||||
subtitle={t("connect_calendar_to_prevent_conflicts")}
|
||||
isLoading={queryIntegrations.isPending}
|
||||
footer={
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
}>
|
||||
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
|
||||
{queryIntegrations.data?.items?.map((app) => (
|
||||
<InstallableAppCard
|
||||
key={app.slug}
|
||||
app={app}
|
||||
isInstalling={installingAppSlug === app.slug}
|
||||
onInstallClick={setInstallingAppSlug}
|
||||
installOptions={createInstallHandlers(app.slug)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-4">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
{/* Card Header */}
|
||||
<div className="flex w-full gap-1.5 px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-6">{t("connect_your_calendar")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("connect_calendar_to_prevent_conflicts")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex w-full flex-col gap-4 px-5 py-5">
|
||||
{queryIntegrations.isPending ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<SkeletonText className="h-40 w-full" />
|
||||
<SkeletonText className="h-40 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
|
||||
{queryIntegrations.data?.items.map((app) => (
|
||||
<div
|
||||
key={app.slug}
|
||||
className="border-subtle bg-default flex flex-col items-start gap-4 rounded-xl border p-5">
|
||||
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
|
||||
<p
|
||||
className="text-default line-clamp-1 break-words text-left text-sm font-medium leading-none"
|
||||
title={app.name}>
|
||||
{app.name}
|
||||
</p>
|
||||
<p className="text-subtle line-clamp-2 text-left text-xs leading-tight">
|
||||
{app.description}
|
||||
</p>
|
||||
<Button
|
||||
color="secondary"
|
||||
href={`/apps/${app.slug}`}
|
||||
className="mt-auto w-full items-center justify-center rounded-[10px]">
|
||||
{t("connect")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button color="primary" className="rounded-[10px]" onClick={handleContinue}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip button */}
|
||||
<div className="flex w-full justify-center">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4">
|
||||
{t("ill_do_this_later")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SkipButton onClick={handleSkip} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
@@ -5,10 +5,16 @@ import { useRouter } from "next/navigation";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Logo } from "@calcom/ui/components/logo";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { InstallableAppCard } from "../_components/InstallableAppCard";
|
||||
import { OnboardingCard } from "../_components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../_components/OnboardingLayout";
|
||||
import { SkipButton } from "../_components/SkipButton";
|
||||
import { useAppInstallation } from "../_components/useAppInstallation";
|
||||
|
||||
type PersonalVideoViewProps = {
|
||||
userEmail: string;
|
||||
@@ -38,6 +44,8 @@ export const PersonalVideoView = ({ userEmail }: PersonalVideoViewProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const telemetry = useTelemetry();
|
||||
const utils = trpc.useUtils();
|
||||
const { installingAppSlug, setInstallingAppSlug, createInstallHandlers } = useAppInstallation();
|
||||
|
||||
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "conferencing",
|
||||
@@ -46,10 +54,19 @@ export const PersonalVideoView = ({ userEmail }: PersonalVideoViewProps) => {
|
||||
sortByInstalledFirst: true,
|
||||
});
|
||||
|
||||
const setDefaultConferencingApp = trpc.viewer.apps.setDefaultConferencingApp.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.me.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(t("something_went_wrong"), "error");
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: eventTypes } = trpc.viewer.eventTypes.list.useQuery();
|
||||
const createEventType = trpc.viewer.eventTypesHeavy.create.useMutation();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.viewer.me.updateProfile.useMutation({
|
||||
onSuccess: async () => {
|
||||
try {
|
||||
@@ -77,8 +94,6 @@ export const PersonalVideoView = ({ userEmail }: PersonalVideoViewProps) => {
|
||||
|
||||
const handleContinue = async () => {
|
||||
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
|
||||
// Complete onboarding
|
||||
mutation.mutate({
|
||||
completedOnboarding: true,
|
||||
});
|
||||
@@ -86,118 +101,62 @@ export const PersonalVideoView = ({ userEmail }: PersonalVideoViewProps) => {
|
||||
|
||||
const handleSkip = async () => {
|
||||
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
|
||||
// Complete onboarding
|
||||
mutation.mutate({
|
||||
completedOnboarding: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null; // or a loading spinner
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user has a default conferencing app
|
||||
const result = userMetadata.safeParse(user.metadata);
|
||||
const metadata = result?.success ? result.data : null;
|
||||
const hasDefaultConferencingApp = !!metadata?.defaultConferencingApp?.appSlug;
|
||||
|
||||
return (
|
||||
<div className="bg-default flex min-h-screen w-full flex-col items-start overflow-clip rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||
<Logo className="h-5 w-auto" />
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={4}>
|
||||
<OnboardingCard
|
||||
title={t("connect_video_app")}
|
||||
subtitle={t("video_app_connection_subtitle")}
|
||||
isLoading={isPending}
|
||||
footer={
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending}>
|
||||
{t("finish_and_start")}
|
||||
</Button>
|
||||
}>
|
||||
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
|
||||
{queryConnectedVideoApps?.items
|
||||
.filter((app) => app.slug !== "daily-video")
|
||||
.map((app) => {
|
||||
const shouldAutoSetDefault =
|
||||
!hasDefaultConferencingApp && app.appData?.location?.linkType === "dynamic";
|
||||
|
||||
{/* Progress dots - centered */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-center justify-center gap-1">
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1 w-1 rounded-full" />
|
||||
<div className="bg-emphasis h-1.5 w-1.5 rounded-full" />
|
||||
return (
|
||||
<InstallableAppCard
|
||||
key={app.slug}
|
||||
app={app}
|
||||
isInstalling={installingAppSlug === app.slug}
|
||||
onInstallClick={setInstallingAppSlug}
|
||||
installOptions={createInstallHandlers(app.slug, (appSlug) => {
|
||||
// Auto-set as default if it's the first connected video app
|
||||
if (shouldAutoSetDefault) {
|
||||
setDefaultConferencingApp.mutate({ slug: appSlug });
|
||||
}
|
||||
})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
|
||||
<div className="bg-muted flex items-center gap-2 rounded-full px-3 py-2">
|
||||
<p className="text-emphasis text-sm font-medium leading-none">{userEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex h-full w-full items-start justify-center px-6 py-8">
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-4">
|
||||
{/* Card */}
|
||||
<div className="bg-muted border-muted relative rounded-xl border p-1">
|
||||
<div className="rounded-inherit flex w-full flex-col items-start overflow-clip">
|
||||
{/* Card Header */}
|
||||
<div className="flex w-full gap-1.5 px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h1 className="font-cal text-xl font-semibold leading-6">{t("connect_video_app")}</h1>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("video_app_connection_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex w-full flex-col gap-4 px-5 py-5">
|
||||
{isPending ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<SkeletonText className="h-40 w-full" />
|
||||
<SkeletonText className="h-40 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="scroll-bar grid max-h-[45vh] grid-cols-1 gap-3 overflow-y-scroll sm:grid-cols-2">
|
||||
{queryConnectedVideoApps?.items
|
||||
.filter((app) => app.slug !== "daily-video")
|
||||
.map((app) => (
|
||||
<div
|
||||
key={app.slug}
|
||||
className="border-subtle bg-default relative flex flex-col items-start gap-4 rounded-xl border p-5">
|
||||
{app.userCredentialIds.length > 0 && (
|
||||
<span className="bg-success text-success absolute right-2 top-2 rounded-md px-2 py-1 text-xs font-medium">
|
||||
{t("connected")}
|
||||
</span>
|
||||
)}
|
||||
{app.logo && <img src={app.logo} alt={app.name} className="h-9 w-9 rounded-md" />}
|
||||
<p
|
||||
className="text-default max-w- line-clamp-1 break-words text-left text-sm font-medium leading-none"
|
||||
title={app.name}>
|
||||
{app.name}
|
||||
</p>
|
||||
<p className="text-subtle line-clamp-2 text-left text-xs leading-tight">
|
||||
{app.description}
|
||||
</p>
|
||||
<Button
|
||||
color="secondary"
|
||||
href={`/apps/${app.slug}`}
|
||||
className="mt-auto w-full items-center justify-center rounded-[10px]">
|
||||
{t("connect")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex w-full items-center justify-end gap-1 px-5 py-4">
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
loading={mutation.isPending}
|
||||
disabled={mutation.isPending}>
|
||||
{t("finish_and_start")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip button */}
|
||||
<div className="flex w-full justify-center">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={mutation.isPending}
|
||||
className="text-subtle hover:bg-subtle rounded-[10px] px-2 py-1.5 text-sm font-medium leading-4 disabled:opacity-50">
|
||||
{t("ill_do_this_later")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SkipButton onClick={handleSkip} disabled={mutation.isPending} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user