Files
calendar/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx
T
sean-brydonGitHubcubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
ff1533d344 chore: UI Nits onboarding v3 (#25409)
* Fix UI nits

* fix bio + team name on invite orgs

* use bg-cal-muted

* Fix error from mass edit

* Update apps/web/public/static/locales/en/common.json

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix org labels

* Fix i18n

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-11-27 08:18:43 +00:00

188 lines
6.5 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 { 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;
};
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 } =
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);
// 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;
// Add actual invites first
for (let i = 0; i < validInvites.length && i < maxItems; i++) {
const invite = validInvites[i];
displayItems.push({
name: invite.email.split("@")[0] || t("team_member"),
email: invite.email,
team: invite.team || t("team"),
isReal: true,
});
}
// 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 gap-4 rounded-2xl border p-8">
<div className="flex flex-col items-start gap-4">
<Avatar
size="lg"
imageSrc={avatar || undefined}
alt={displayName}
className="border-default h-12 w-12 border-2"
/>
<div className="flex w-full flex-col items-start gap-1">
<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>
</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>
{item.team && (
<div className="bg-emphasis text-emphasis rounded-md px-2 py-0.5 text-xs">
{item.team}
</div>
)}
</div>
</div>
))}
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
};