Files
calendar/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx
T
Eunjae LeeGitHubClaude Opus 4.5Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
81e4241f85 refactor: apply biome formatting to apps/web (#27692)
* refactor: apply biome formatting to apps/web (batch 1)

Formats apps/web non-modules directories:
- apps/web/app
- apps/web/lib
- apps/web/pages
- apps/web/styles
- apps/web/server
- apps/web/test
- Root-level .ts and .mjs files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/modules (batch 2)

Formats smaller apps/web/modules directories:
- onboarding, data-table, users, apps, auth
- integration-attribute-sync, shell, availability
- feature-flags, team, webhooks, getting-started
- feature-opt-in, troubleshooter, schedules, notifications
- signup-view.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/modules (batch 3)

Formats medium apps/web/modules directories:
- bookings
- event-types
- insights
- embed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/modules/ee (batch 4)

Formats apps/web/modules/ee directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web (batch 5)

Formats remaining apps/web directories:
- apps/web/modules/settings
- apps/web/modules/booking-audit
- apps/web/playwright

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/components (batch 6)

Formats remaining apps/web/components files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to new apps/web files from main

Formats newly added/modified files from main merge:
- WorkflowStepContainer.tsx (resolved merge conflicts)
- Newly moved files (blocklist, form-builder, schedules, etc.)
- Other files modified in main

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to new apps/web files from main

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-10 23:25:24 +05:30

284 lines
10 KiB
TypeScript

"use client";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import classNames from "@calcom/ui/classNames";
import { useOnboardingStore, type Invite } from "../store/onboarding-store";
type OnboardingInviteBrowserViewProps = {
teamName?: string;
/** If true, use organization invites instead of team invites */
useOrganizationInvites?: boolean;
/** Optional: Watched form invites to display in real-time. Takes precedence over store values. */
watchedInvites?: Array<{
email: string;
team?: string;
role?: string;
}>;
};
type DisplayItem = {
name: string;
email: string;
team: string;
isReal?: boolean;
isMigrated?: boolean;
};
export const OnboardingInviteBrowserView = ({
teamName,
useOrganizationInvites = false,
watchedInvites,
}: OnboardingInviteBrowserViewProps) => {
const pathname = usePathname();
const { data: user } = trpc.viewer.me.get.useQuery();
const {
teamBrand,
teamInvites,
invites,
teamDetails,
organizationBrand,
organizationDetails,
migratedMembers,
teams,
} = useOnboardingStore();
const { t } = useLocale();
// Animation variants for entry and exit
const containerVariants = {
initial: {
opacity: 0,
y: -20,
},
animate: {
opacity: 1,
y: 0,
},
exit: {
opacity: 0,
y: 20,
},
};
// Use default values if not provided
const rawInviterName = user?.name || user?.username || "Alex";
const displayInviterName = rawInviterName.charAt(0).toUpperCase() + rawInviterName.slice(1);
// Use organization or team data based on context
const displayName = useOrganizationInvites
? organizationDetails.name || teamName || "Deel"
: teamName || teamDetails.name || "Deel";
const displayBio = useOrganizationInvites ? organizationDetails.bio || "" : teamDetails.bio || "";
const avatar = useOrganizationInvites ? organizationBrand.logo || null : teamBrand.logo || null;
// Get invites based on context - use watched invites if provided, otherwise fall back to store
let actualInvites: Invite[] = [];
if (watchedInvites) {
// Transform watched invites to Invite format
actualInvites = watchedInvites.map((invite) => ({
email: invite.email,
team: invite.team || (useOrganizationInvites ? "" : teamDetails.name),
role: (invite.role as Invite["role"]) || "MEMBER",
}));
} else {
// Fall back to store values
actualInvites = useOrganizationInvites ? invites : teamInvites;
}
// Filter out empty invites (where email is empty or just whitespace)
const validInvites = actualInvites.filter((invite) => invite.email && invite.email.trim().length > 0);
// Add migrated members if using organization invites
const migratedInvites: Invite[] = [];
if (useOrganizationInvites && migratedMembers.length > 0) {
migratedInvites.push(
...migratedMembers.map((member) => {
// Find team name from teamId
const team = teams.find((t) => t.id === member.teamId);
return {
email: member.email,
team: team?.name || "",
role: "MEMBER" as const,
};
})
);
}
// Combine form invites with migrated members, avoiding duplicates
const allInvites = [...validInvites];
const existingEmails = new Set(validInvites.map((inv) => inv.email.toLowerCase()));
migratedInvites.forEach((migratedInvite) => {
if (!existingEmails.has(migratedInvite.email.toLowerCase())) {
allInvites.push(migratedInvite);
existingEmails.add(migratedInvite.email.toLowerCase());
}
});
// Create empty state items
const emptyStateItem = {
name: t("team_member"),
email: "name@example.com",
team: t("team"),
};
// Combine actual invites with empty state to fill up to 9 items
const displayItems: DisplayItem[] = [];
const maxItems = 9;
// Create a set of migrated member emails for quick lookup
const migratedEmails = new Set(migratedMembers.map((member) => member.email.toLowerCase()));
// Add all invites (form invites + migrated members)
for (let i = 0; i < allInvites.length && i < maxItems; i++) {
const invite = allInvites[i];
const isMigrated = migratedEmails.has(invite.email.toLowerCase());
displayItems.push({
name: invite.email.split("@")[0] || t("team_member"),
email: invite.email,
team: invite.team || t("team"),
isReal: true,
isMigrated,
});
}
// Fill remaining slots with empty state
const remainingSlots = maxItems - displayItems.length;
for (let i = 0; i < remainingSlots; i++) {
displayItems.push({
name: emptyStateItem.name,
email: emptyStateItem.email,
team: emptyStateItem.team,
isReal: false,
});
}
return (
<div className="border-subtle bg-cal-muted hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border xl:flex">
{/* Content */}
<div className="h-full px-6 pt-6">
<AnimatePresence mode="wait">
<motion.div
key={pathname}
className="flex h-full flex-col"
variants={containerVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{
duration: 0.5,
ease: "backOut",
}}>
<div className="bg-default border-subtle flex flex-col rounded-2xl border">
{useOrganizationInvites || organizationBrand.banner || avatar ? (
<div className="relative p-1">
{/* Banner Image */}
{useOrganizationInvites && (
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
{organizationBrand.banner ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={organizationBrand.banner}
alt={displayName}
className="h-full w-full object-cover"
/>
) : (
<div className="bg-emphasis h-full w-full" />
)}
</div>
)}
{!useOrganizationInvites && organizationBrand.banner && (
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={organizationBrand.banner}
alt={displayName}
className="h-full w-full object-cover"
/>
</div>
)}
{/* Organization Avatar - Overlaying the banner */}
{useOrganizationInvites && avatar && (
<div className="absolute -bottom-6 left-4">
<Avatar size="lg" imageSrc={avatar} alt={displayName} className="h-12 w-12 border" />
</div>
)}
{!useOrganizationInvites && organizationBrand.banner && avatar && (
<div className="absolute -bottom-6 left-4">
<Avatar size="lg" imageSrc={avatar} alt={displayName} className="h-12 w-12 border" />
</div>
)}
</div>
) : null}
{/* Organization Info */}
<div
className={classNames(
`flex flex-col items-start gap-1 px-4 pb-4`,
useOrganizationInvites || organizationBrand.banner || avatar ? "pt-8" : "pt-4"
)}>
{!useOrganizationInvites && !organizationBrand.banner && avatar && (
<Avatar
size="lg"
imageSrc={avatar}
alt={displayName}
className="border-default mb-4 h-12 w-12 border-2"
/>
)}
<h2 className="text-emphasis font-cal w-full text-left text-xl font-semibold leading-tight">
{displayName}
</h2>
<p className="text-subtle text-left text-sm font-normal leading-tight">
{displayBio || "We're emailing you all the details"}
</p>
</div>
</div>
{/* Email Body */}
<div className="bg-default border-subtle mt-3 grid grid-cols-3 gap-4 rounded-t-2xl border p-6">
{displayItems.map((item, index) => (
<div
key={`${item.email}-${index}`}
className={`bg-default border-subtle flex aspect-square w-full min-w-0 flex-col items-center justify-center gap-2 overflow-hidden rounded-lg border p-4 ${
!item.isReal ? "opacity-60" : ""
}`}>
<Avatar size="mdLg" imageSrc={undefined} alt={item.name} className="mt-4" />
<div className="flex w-full min-w-0 flex-col items-center gap-4">
<div className="flex w-full min-w-0 flex-col items-center">
<p className="text-default w-full truncate text-center text-sm font-semibold leading-tight">
{item.name}
</p>
<p className="text-subtle w-full truncate text-center text-xs font-medium leading-tight">
{item.email}
</p>
</div>
<div className="flex flex-col items-center gap-1">
{item.team && !item.isMigrated && (
<div className="bg-emphasis text-emphasis line-clamp-1 max-w-[86px] truncate rounded-md px-2 py-0.5 text-xs">
{item.team}
</div>
)}
{item.isMigrated && (
<Badge variant="green" className="text-xs">
{t("migrating")}
</Badge>
)}
</div>
</div>
</div>
))}
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
};