Files
reui-blocks/blocks/application-shell10.tsx
T

1338 lines
45 KiB
TypeScript
Raw Normal View History

"use client";
import {
Archive,
ArrowLeft,
Bug,
ChevronDown,
Clock,
CommandIcon,
File,
Inbox,
Lightbulb,
MailOpen,
MessageSquare,
MoreHorizontal,
PanelRight,
Plus,
Search,
Send,
Sparkles,
Trash2,
User,
UserCheck,
Users,
} from "lucide-react";
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
useSidebar,
} from "@/components/ui/sidebar";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
type NavItemId =
| "inbox"
| "unassigned"
| "assigned"
| "drafts"
| "archived"
| "spam";
type BucketId = "support" | "bugs" | "features" | "internal";
type TicketStatus = "active" | "pending" | "closed";
type NavItem = {
id: NavItemId;
label: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
count?: number;
};
type Bucket = {
id: BucketId;
label: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
};
type User = {
id: string;
name: string;
email: string;
avatar: string;
role?: string;
};
type Customer = {
id: string;
name: string;
email: string;
company: string;
role: string;
avatar: string;
isHighValue: boolean;
};
type Message = {
id: string;
sender: User | Customer;
content: string;
timestamp: string;
date: string;
isStaff: boolean;
};
type Ticket = {
id: string;
subject: string;
preview: string;
timestamp: string;
read: boolean;
customer: Customer;
assignee?: User;
status: TicketStatus;
messages: Message[];
respondingUser?: User;
};
type PreviousConversation = {
id: string;
subject: string;
timestamp: string;
};
const navItems: NavItem[] = [
{ id: "inbox", label: "صندوق ورودی", icon: Inbox, count: 6 },
{ id: "unassigned", label: "بدون مسئول", icon: MailOpen, count: 10 },
{ id: "assigned", label: "محول‌شده", icon: UserCheck, count: 3 },
{ id: "drafts", label: "پیش‌نویس‌ها", icon: File, count: 1 },
{ id: "archived", label: "بایگانی", icon: Archive },
{ id: "spam", label: "هرزنامه", icon: Trash2 },
];
const buckets: Bucket[] = [
{ id: "support", label: "درخواست‌های پشتیبانی", icon: MessageSquare },
{ id: "bugs", label: "گزارش باگ", icon: Bug },
{ id: "features", label: "درخواست ویژگی", icon: Lightbulb },
{ id: "internal", label: "داخلی", icon: Users },
];
const staffUsers: User[] = [
{
id: "peter",
name: "رضا کریمی",
email: "peter.lann@cloudstar.com",
avatar:
"/block-media/shadcnblocks/block/avatar/avatar3.webp",
role: "کارشناس پشتیبانی",
},
{
id: "alex",
name: "امیر چناری",
email: "alex.chen@cloudstar.com",
avatar:
"/block-media/shadcnblocks/block/avatar/avatar3.webp",
role: "کارشناس پشتیبانی",
},
];
const mockCustomers: Customer[] = [
{
id: "sarah",
name: "مریم Tran",
email: "sarah.tran@example.com",
company: "BrightWave Marketing",
role: "مدیر عملیات",
avatar:
"/block-media/shadcnblocks/block/avatar/avatar2.webp",
isHighValue: true,
},
{
id: "mike",
name: "محمد جعفری",
email: "mike.j@techcorp.io",
company: "TechCorp",
role: "توسعه‌دهنده",
avatar:
"/block-media/shadcnblocks/block/avatar/avatar5.webp",
isHighValue: false,
},
];
const mockTickets: Ticket[] = [
{
id: "1",
subject: "مشکل اتصال یکپارچه‌سازی Slack",
preview:
"تیم ما می‌خواهد Slack را به CloudStar وصل کند، اما فرایند احراز هویت با خطا مواجه می‌شود...",
timestamp: "۶ ساعت پیش",
read: false,
customer: mockCustomers[0],
assignee: staffUsers[0],
status: "active",
respondingUser: staffUsers[0],
messages: [
{
id: "m1",
sender: mockCustomers[0],
content: `سلام پشتیبانی CloudStar،
تیم ما می‌خواهد پیام‌رسان تیمی را به CloudStar وصل کند، اما فرایند احراز هویت با این خطا متوقف می‌شود: «احراز هویت token invalid».
چند بار دوباره وصل شدیم و حتی workspace را ری‌استارت کردیم، اما نتیجه‌ای نگرفتیم.
می‌توانید کمک کنید این یکپارچه‌سازی کار کند؟
ممنون،
مریم Tran
مدیر عملیات، BrightWave Marketing`,
timestamp: "۸:۰۳",
date: "۲۹ مرداد",
isStaff: false,
},
{
id: "m2",
sender: staffUsers[0],
content: `سلام مریم،
ممنون از پیام‌تان خوشحال می‌شوم کمک کنم! این خطا معمولاً وقتی رخ می‌دهد که پیام‌رسان تیمی هنگام اتصال، مجوزهای لازم را به CloudStar نمی‌دهد. این موارد را امتحان کنید:
۱ قبل از شروع اتصال، در workspace درست پیام‌رسان تیمی وارد شده باشید.
۲ هنگام درخواست، همه مجوزها را به CloudStar بدهید (گاهی «Deny» اشتباهی زده می‌شود).
۳. یک پنجره ناشناس/خصوصی امتحان کنید تا credentialهای قدیمی مزاحم نباشند.
اگر جواب داد یا هنوز خطا می‌بینید، خبر دهید!`,
timestamp: "۱۲:۵۶",
date: "۲۹ مرداد",
isStaff: true,
},
],
},
{
id: "2",
subject: "فایل‌های گم‌شده در فضای کار مشترک",
preview:
"دیروز مجموعه‌ای از فایل‌های پروژه را در فضای کار مشترک بارگذاری کردم. امروز دو فایل دیگر نیست...",
timestamp: "۶ ساعت پیش",
read: false,
customer: mockCustomers[1],
status: "active",
respondingUser: staffUsers[1],
messages: [],
},
{
id: "3",
subject: "مغایرت در صورتحساب آخر",
preview:
"فاکتور این ماه ۱۰ لایسنس Pro نشان می‌دهد، در حالی که فقط ۸ کاربر فعال داریم. لطفاً بررسی کنید...",
timestamp: "۸ ساعت پیش",
read: true,
customer: mockCustomers[0],
status: "pending",
messages: [],
},
{
id: "4",
subject: "امکان بازنشانی رمز عبور نیست",
preview:
'با لینک «فراموشی رمز عبور» سعی کردم رمز CloudStar را بازنشانی کنم، اما ایمیل بازنشانی هرگز نرسید...',
timestamp: "۱۲ ساعت پیش",
read: true,
customer: mockCustomers[1],
status: "active",
messages: [],
},
{
id: "5",
subject: "آمار داشبورد به‌روز نمی‌شود",
preview:
"داشبورد آمار از دیروز حدود ساعت ۱۵ به‌روزرسانی نمی‌شود. همه نمودارها روی همان مقادیر مانده‌اند...",
timestamp: "۱ روز پیش",
read: true,
customer: mockCustomers[0],
status: "closed",
messages: [],
},
{
id: "6",
subject: "درخواست مستندات انطباق HIPAA",
preview:
"قبل از ادامه فرایند onboarding، تیم حقوقی ما به مستندات CloudStar درباره HIPAA نیاز دارد...",
timestamp: "۲ هفته پیش",
read: true,
customer: mockCustomers[1],
status: "pending",
messages: [],
},
];
const previousConversations: PreviousConversation[] = [
{
id: "prev1",
subject: "مشکل اتصال یکپارچه‌سازی Slack",
timestamp: "۶ ساعت پیش",
},
{
id: "prev2",
subject: "انقضای توکن API",
timestamp: "۲ هفته پیش",
},
];
function getInitials(name: string) {
return (
name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || "U"
);
}
function getStatusColor(status: TicketStatus) {
switch (status) {
case "active":
return "bg-emerald-500";
case "pending":
return "bg-amber-500";
case "closed":
return "bg-muted-foreground";
default:
return "bg-muted-foreground";
}
}
function getStatusLabel(status: TicketStatus) {
switch (status) {
case "active":
return "فعال";
case "pending":
return "در انتظار";
case "closed":
return "بسته";
default:
return status;
}
}
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
activeNavItem: NavItemId;
activeBucket: BucketId | null;
onNavItemChange: (id: NavItemId) => void;
onBucketChange: (id: BucketId | null) => void;
onSearchClick: () => void;
}
function AppSidebar({
activeNavItem,
activeBucket,
onNavItemChange,
onBucketChange,
onSearchClick,
className,
...props
}: AppSidebarProps) {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
return (
<Sidebar
side="right"
collapsible="offcanvas"
variant="inset"
className={cn(className)}
{...props}
>
<SidebarHeader
className={cn("flex h-14 flex-row items-center justify-between")}
>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<div className="flex w-full items-center group-data-[collapsible=icon]:justify-center">
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-auto items-center gap-3 p-0! hover:bg-transparent"
>
<div className="flex size-8 items-center justify-center rounded-sm bg-primary">
<img
src="/block-media/shadcnblocks/block/logos/ShadcnBlocks-logo.svg"
alt="شاد‌سی‌ان بلاکس"
className="size-5 invert dark:invert-0"
/>
</div>
{!isCollapsed && (
<>
<span className="font-semibold">ری یو آی</span>
<ChevronDown className="size-3 text-muted-foreground" />
</>
)}
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>فضای کار</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>تنظیمات فضای کار</DropdownMenuItem>
<DropdownMenuItem>دعوت هم‌تیمی‌ها</DropdownMenuItem>
<DropdownMenuItem>صورتحساب</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>خروج</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontal className="size-4" />
</Button>
</SidebarHeader>
<SidebarContent>
<SidebarGroup className="py-2">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton onClick={onSearchClick} className="px-2">
<Search className="size-4" />
<span>جستجو</span>
<kbd className="ms-auto hidden items-center justify-center rounded-md font-mono font-medium text-muted-foreground/70 sm:flex">
<CommandIcon
className="size-3 font-medium"
strokeWidth={1.5}
/>
<span>K</span>
</kbd>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup className="py-0">
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
activeNavItem === item.id && activeBucket === null;
return (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
onClick={() => {
onNavItemChange(item.id);
onBucketChange(null);
}}
isActive={isActive}
className="px-2"
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.count !== undefined && (
<SidebarMenuBadge>{item.count}</SidebarMenuBadge>
)}
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>دسته‌ها</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{buckets.map((bucket) => {
const Icon = bucket.icon;
return (
<SidebarMenuItem key={bucket.id}>
<SidebarMenuButton
onClick={() => onBucketChange(bucket.id)}
isActive={activeBucket === bucket.id}
className="px-2"
>
<Icon className="size-4" />
<span>{bucket.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
}
interface TicketListPanelProps {
tickets: Ticket[];
selectedTicketId: string | null;
onTicketSelect: (ticketId: string) => void;
activeNavItem: NavItemId;
}
function TicketListPanel({
tickets,
selectedTicketId,
onTicketSelect,
activeNavItem,
}: TicketListPanelProps) {
const navItem = navItems.find((item) => item.id === activeNavItem);
const title = navItem?.label ?? "صندوق ورودی";
return (
<div className="flex h-full w-1/4 max-w-[320px] min-w-[240px] shrink-0 flex-col overflow-hidden border-e bg-background">
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
<div className="truncate text-start text-base font-medium text-foreground">
{title} شما
</div>
</div>
<ScrollArea className="min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]>div]:!block">
{tickets.map((ticket) => {
const isSelected = selectedTicketId === ticket.id;
return (
<button
type="button"
key={ticket.id}
onClick={() => onTicketSelect(ticket.id)}
className={cn(
"w-full border-b p-4 text-start text-sm leading-tight last:border-b-0 hover:bg-muted/50",
!ticket.read && "bg-muted/30",
isSelected && "bg-muted",
)}
>
<div className="min-w-0">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"truncate text-sm",
!ticket.read && "font-semibold",
)}
>
{ticket.subject}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{ticket.preview}
</p>
{ticket.respondingUser && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<Avatar className="size-4">
<AvatarImage
src={ticket.respondingUser.avatar}
alt={ticket.respondingUser.name}
/>
<AvatarFallback className="text-[8px]">
{getInitials(ticket.respondingUser.name)}
</AvatarFallback>
</Avatar>
<span>
{ticket.respondingUser.name.split(" ")[0]} در حال
پاسخ است...
</span>
</div>
)}
</div>
</button>
);
})}
</ScrollArea>
</div>
);
}
interface MessageBubbleProps {
message: Message;
}
function MessageBubble({ message }: MessageBubbleProps) {
const sender = message.sender;
const isCustomer = "company" in sender;
return (
<div className="space-y-3">
<div className="flex items-start gap-3">
<Avatar className="mt-0.5 size-10 shrink-0">
<AvatarImage src={sender.avatar} alt={sender.name} />
<AvatarFallback className="bg-primary text-xs font-medium text-primary-foreground">
{getInitials(sender.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{sender.name}</span>
{!isCustomer && (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
پشتیبانی
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>از</span>
<span>{sender.email}</span>
</div>
<span className="text-xs text-muted-foreground">
{message.date}, {message.timestamp}
</span>
</div>
</div>
<div className="ps-13 text-sm leading-relaxed whitespace-pre-wrap">
{message.content}
</div>
</div>
);
}
interface ReplyComposerProps {
recipientEmail: string;
respondingUser?: User;
}
function ReplyComposer({ recipientEmail, respondingUser }: ReplyComposerProps) {
return (
<div className="shrink-0 border-t bg-background px-6 py-4 lg:px-10">
<div className="mx-auto max-w-3xl rounded-lg border bg-muted/30">
<div className="flex items-center gap-2 border-b px-3 py-2">
<ArrowLeft className="size-4 shrink-0 rotate-[135deg] text-muted-foreground" />
<span className="truncate text-sm text-muted-foreground">
{recipientEmail}
</span>
<Button
variant="ghost"
size="sm"
className="ms-auto h-6 shrink-0 px-2 text-xs"
>
رونوشت
</Button>
</div>
<Textarea
placeholder="پاسخ خود را بنویسید..."
className="min-h-[80px] resize-none border-0 bg-transparent focus-visible:ring-0 md:min-h-[100px]"
/>
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-3 py-2">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="size-8 shrink-0">
<Plus className="size-4" />
</Button>
<span className="hidden text-xs text-muted-foreground sm:inline">
از <kbd className="rounded bg-muted px-1">/</kbd> برای میانبرها
</span>
</div>
<div className="flex items-center gap-2">
{respondingUser && (
<div className="hidden items-center gap-1.5 text-xs text-muted-foreground sm:flex">
<Avatar className="size-5">
<AvatarImage
src={respondingUser.avatar}
alt={respondingUser.name}
/>
<AvatarFallback className="text-[8px]">
{getInitials(respondingUser.name)}
</AvatarFallback>
</Avatar>
<span>
{respondingUser.name.split(" ")[0]} در حال پاسخ است...
</span>
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
بستن
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem>بستن به‌عنوان حل‌شده</DropdownMenuItem>
<DropdownMenuItem>بستن به‌عنوان هرزنامه</DropdownMenuItem>
<DropdownMenuItem>بستن بدون پاسخ</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button size="sm" className="gap-1">
<Send className="size-3" />
ارسال
</Button>
</div>
</div>
</div>
</div>
);
}
interface InboxAgentPanelProps {
ticket: Ticket;
previousConversations: PreviousConversation[];
}
function InboxAgentPanel({
ticket,
previousConversations,
}: InboxAgentPanelProps) {
const customer = ticket.customer;
return (
<div className="flex h-full w-1/4 max-w-[360px] min-w-[280px] shrink-0 flex-col overflow-hidden border-s bg-background">
<div className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<Sparkles className="size-4" />
<span className="font-semibold">دستیار صندوق ورودی</span>
</div>
<ScrollArea className="min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]>div]:!block">
<div className="space-y-6 p-4 text-start">
<section className="space-y-3">
<div className="flex items-start gap-3">
<Avatar className="size-10 shrink-0">
<AvatarImage src={customer.avatar} alt={customer.name} />
<AvatarFallback className="bg-primary font-medium text-primary-foreground">
{getInitials(customer.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="font-medium">{customer.name}</div>
<div className="text-sm text-muted-foreground">
{customer.role}, {customer.company}
</div>
{customer.isHighValue && (
<Badge
variant="outline"
className="mt-1.5 border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-400"
>
مشتری ارزشمند
</Badge>
)}
</div>
</div>
</section>
<section className="space-y-3">
<div className="rounded-lg bg-muted/50 p-3 text-sm">
<p className="text-muted-foreground">
مشتری آرام به نظر می‌رسد، اما ممکن است ناراضی شود. احتمالاً
فنی نیست چون راهنمای یکپارچه‌سازی پیام‌رسان تیمی را نخوانده است.
</p>
<p className="mt-3 text-muted-foreground">
رضا با جزئیات لازم پاسخ داده، اما هنوز تأیید نکرده که مشکل
حل شده است یا نه.
</p>
</div>
</section>
<section className="space-y-3">
<p className="text-sm text-muted-foreground">
اگر مشتری در ۱۲ ساعت آینده پاسخ ندهد، پیگیری ارسال شود:
</p>
<div className="rounded-lg border bg-muted/30 p-3 text-sm">
<p>
سلام مریم، پیگیری می‌کنم ببینم توانستید یکپارچه‌سازی را راه
بیندازید؟
</p>
<p className="mt-2">
اگر هنوز مشکل دارید، خوشحال می‌شوم یک تماس هماهنگ کنیم تا
سریع‌تر راه بیفتید.
</p>
<p className="mt-2">با احترام، رضا کریمی</p>
</div>
<Button variant="outline" className="w-full gap-2">
<Clock className="size-4" />
زمان‌بندی پیگیری
</Button>
</section>
<section className="space-y-3">
<p className="text-xs font-medium tracking-wide text-muted-foreground">
گفتگوهای قبلی
</p>
<div className="space-y-2">
{previousConversations.map((conv) => (
<button
key={conv.id}
type="button"
className="flex w-full items-start justify-between gap-2 rounded-md p-2 text-start hover:bg-muted"
>
<span className="line-clamp-2 text-sm">{conv.subject}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{conv.timestamp}
</span>
</button>
))}
</div>
</section>
</div>
</ScrollArea>
</div>
);
}
interface TicketCommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tickets: Ticket[];
onTicketSelect: (ticketId: string) => void;
onNavItemSelect: (navItemId: NavItemId) => void;
}
function TicketCommandPalette({
open,
onOpenChange,
tickets,
onTicketSelect,
onNavItemSelect,
}: TicketCommandPaletteProps) {
return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="جستجوی تیکت، مشتری یا ناوبری..." />
<CommandList>
<CommandEmpty>نتیجه‌ای یافت نشد.</CommandEmpty>
<CommandGroup heading="تیکت‌ها">
{tickets.slice(0, 5).map((ticket) => (
<CommandItem
key={ticket.id}
onSelect={() => {
onTicketSelect(ticket.id);
onOpenChange(false);
}}
>
<MessageSquare className="me-2 size-4" />
<span className="truncate">{ticket.subject}</span>
<span className="ms-auto text-xs text-muted-foreground">
{ticket.timestamp}
</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="مشتریان">
{mockCustomers.map((customer) => (
<CommandItem key={customer.id}>
<User className="me-2 size-4" />
<span>{customer.name}</span>
<span className="ms-2 text-xs text-muted-foreground">
{customer.company}
</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="ناوبری">
{navItems.map((item) => {
const Icon = item.icon;
return (
<CommandItem
key={item.id}
onSelect={() => {
onNavItemSelect(item.id);
onOpenChange(false);
}}
>
<Icon className="me-2 size-4" />
<span>{item.label}</span>
{item.count !== undefined && (
<Badge variant="secondary" className="ms-auto">
{item.count}
</Badge>
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</CommandDialog>
);
}
export function ApplicationShell10() {
const [activeNavItem, setActiveNavItem] = React.useState<NavItemId>("inbox");
const [activeBucket, setActiveBucket] = React.useState<BucketId | null>(null);
const [selectedTicketId, setSelectedTicketId] = React.useState<string | null>(
mockTickets[0]?.id ?? null,
);
const [tickets, setTickets] = React.useState<Ticket[]>(mockTickets);
const [isAgentPanelOpen, setIsAgentPanelOpen] = React.useState(true);
const [isCommandOpen, setIsCommandOpen] = React.useState(false);
const [isMobileTicketListOpen, setIsMobileTicketListOpen] =
React.useState(false);
const [isMobileConversationOpen, setIsMobileConversationOpen] =
React.useState(false);
const [isMobileAgentOpen, setIsMobileAgentOpen] = React.useState(false);
const selectedTicket = tickets.find((t) => t.id === selectedTicketId) ?? null;
// Keyboard shortcut for command palette
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setIsCommandOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const handleTicketSelect = (ticketId: string) => {
setSelectedTicketId(ticketId);
setTickets((prev) =>
prev.map((t) => (t.id === ticketId ? { ...t, read: true } : t)),
);
if (window.innerWidth < 768) {
setIsMobileConversationOpen(true);
}
};
return (
<SidebarProvider dir="rtl" lang="fa" className="h-svh overflow-hidden">
<AppSidebar
activeNavItem={activeNavItem}
activeBucket={activeBucket}
onNavItemChange={setActiveNavItem}
onBucketChange={setActiveBucket}
onSearchClick={() => setIsCommandOpen(true)}
className="hidden md:flex"
/>
<div className="flex h-full flex-col overflow-hidden pb-16 md:hidden">
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-sm bg-primary">
<img
src="/block-media/shadcnblocks/block/logos/ShadcnBlocks-logo.svg"
alt="شاد‌سی‌ان بلاکس"
className="size-5 invert dark:invert-0"
/>
</div>
<span className="font-semibold">
{navItems.find((item) => item.id === activeNavItem)?.label ??
"صندوق ورودی"}{" "}
شما
</span>
</div>
</div>
<ScrollArea className="min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]>div]:!block">
{tickets.map((ticket) => {
const isSelected = selectedTicketId === ticket.id;
return (
<button
type="button"
key={ticket.id}
onClick={() => handleTicketSelect(ticket.id)}
className={cn(
"w-full border-b p-4 text-start text-sm leading-tight last:border-b-0 hover:bg-muted/50",
!ticket.read && "bg-muted/30",
isSelected && "bg-muted",
)}
>
<div className="min-w-0">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"truncate text-sm",
!ticket.read && "font-semibold",
)}
>
{ticket.subject}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{ticket.timestamp}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{ticket.preview}
</p>
{ticket.respondingUser && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<Avatar className="size-4">
<AvatarImage
src={ticket.respondingUser.avatar}
alt={ticket.respondingUser.name}
/>
<AvatarFallback className="text-[8px]">
{getInitials(ticket.respondingUser.name)}
</AvatarFallback>
</Avatar>
<span>
{ticket.respondingUser.name.split(" ")[0]} در حال
پاسخ است...
</span>
</div>
)}
</div>
</button>
);
})}
</ScrollArea>
</div>
<SidebarInset className="hidden min-h-0 overflow-hidden md:flex">
<div className="flex h-full w-full">
<TicketListPanel
tickets={tickets}
selectedTicketId={selectedTicketId}
onTicketSelect={handleTicketSelect}
activeNavItem={activeNavItem}
/>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{selectedTicket ? (
<>
<header className="flex h-14 shrink-0 items-center justify-between border-b bg-background px-4">
<div className="flex items-center gap-3">
<MessageSquare className="size-5" />
<span className="font-medium">
پاسخ: {selectedTicket.subject}
</span>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<span
className={cn(
"size-2 rounded-full",
getStatusColor(selectedTicket.status),
)}
/>
<span>{getStatusLabel(selectedTicket.status)}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem>فعال</DropdownMenuItem>
<DropdownMenuItem>در انتظار</DropdownMenuItem>
<DropdownMenuItem>بسته</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => setIsAgentPanelOpen(!isAgentPanelOpen)}
>
<PanelRight
className={cn(
"size-4",
isAgentPanelOpen && "text-primary",
)}
/>
</Button>
</div>
</header>
<ScrollArea className="min-h-0 flex-1">
<div className="px-6 py-6 lg:px-10">
<div className="mx-auto max-w-3xl space-y-8">
{selectedTicket.messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</div>
</div>
</ScrollArea>
<ReplyComposer
recipientEmail={selectedTicket.customer.email}
respondingUser={selectedTicket.respondingUser}
/>
</>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
<div className="text-center">
<MessageSquare className="mx-auto size-12 opacity-50" />
<p className="mt-2 text-sm">یک تیکت را برای مشاهده انتخاب کنید</p>
</div>
</div>
)}
</div>
{isAgentPanelOpen && selectedTicket && (
<InboxAgentPanel
ticket={selectedTicket}
previousConversations={previousConversations}
/>
)}
</div>
</SidebarInset>
<TicketCommandPalette
open={isCommandOpen}
onOpenChange={setIsCommandOpen}
tickets={tickets}
onTicketSelect={handleTicketSelect}
onNavItemSelect={(navItemId) => {
setActiveNavItem(navItemId);
setActiveBucket(null);
}}
/>
<Drawer
open={isMobileTicketListOpen}
onOpenChange={setIsMobileTicketListOpen}
dismissible
>
<DrawerContent dir="rtl" className="h-[85vh] text-start md:hidden">
<DrawerHeader>
<DrawerTitle className="text-start">تیکت‌ها</DrawerTitle>
</DrawerHeader>
<ScrollArea className="min-h-0 flex-1 px-4">
{tickets.map((ticket) => (
<button
type="button"
key={ticket.id}
onClick={() => {
handleTicketSelect(ticket.id);
setIsMobileTicketListOpen(false);
}}
className={cn(
"w-full border-b p-4 text-start text-sm",
!ticket.read && "bg-muted/30",
)}
>
<div className="min-w-0">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"truncate",
!ticket.read && "font-semibold",
)}
>
{ticket.subject}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{ticket.timestamp}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{ticket.preview}
</p>
</div>
</button>
))}
</ScrollArea>
</DrawerContent>
</Drawer>
<Drawer
open={isMobileConversationOpen}
onOpenChange={setIsMobileConversationOpen}
dismissible
>
<DrawerContent
dir="rtl"
className="h-[90vh] overflow-hidden text-start md:hidden"
>
<DrawerHeader className="sr-only">
<DrawerTitle>گفتگو</DrawerTitle>
</DrawerHeader>
{selectedTicket && (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between border-b px-4 py-3">
<span className="line-clamp-1 font-medium">
{selectedTicket.subject}
</span>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={() => setIsMobileAgentOpen(true)}
>
<Sparkles className="size-4" />
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-6 p-4">
{selectedTicket.messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</div>
</ScrollArea>
<div className="shrink-0">
<ReplyComposer
recipientEmail={selectedTicket.customer.email}
respondingUser={selectedTicket.respondingUser}
/>
</div>
</div>
)}
</DrawerContent>
</Drawer>
<Drawer
open={isMobileAgentOpen}
onOpenChange={setIsMobileAgentOpen}
dismissible
>
<DrawerContent dir="rtl" className="h-[85vh] text-start md:hidden">
<DrawerHeader>
<DrawerTitle className="flex items-center gap-2 text-start">
<Sparkles className="size-4" />
دستیار صندوق ورودی
</DrawerTitle>
</DrawerHeader>
{selectedTicket && (
<ScrollArea className="min-h-0 flex-1 px-4 pb-6">
<div className="space-y-6">
<section className="space-y-3">
<div className="flex items-start gap-3">
<Avatar className="size-10 shrink-0">
<AvatarImage
src={selectedTicket.customer.avatar}
alt={selectedTicket.customer.name}
/>
<AvatarFallback>
{getInitials(selectedTicket.customer.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="font-medium">
{selectedTicket.customer.name}
</div>
<div className="text-sm text-muted-foreground">
{selectedTicket.customer.role},{" "}
{selectedTicket.customer.company}
</div>
{selectedTicket.customer.isHighValue && (
<Badge
variant="outline"
className="mt-1.5 border-amber-500 bg-amber-50 text-amber-700"
>
مشتری ارزشمند
</Badge>
)}
</div>
</div>
</section>
<section className="rounded-lg bg-muted/50 p-3 text-sm text-muted-foreground">
<p>
مشتری آرام به نظر می‌رسد، اما ممکن است ناراضی شود.
</p>
</section>
<section className="space-y-3">
<div className="rounded-lg border bg-muted/30 p-3 text-sm">
<p>سلام مریم، پیگیری می‌کنم...</p>
</div>
<Button variant="outline" className="w-full gap-2">
<Clock className="size-4" />
زمان‌بندی پیگیری
</Button>
</section>
</div>
</ScrollArea>
)}
</DrawerContent>
</Drawer>
<nav
aria-label="ناوبری پایین موبایل"
className="fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 backdrop-blur md:hidden"
>
<div className="grid grid-cols-5">
{[
{
id: "inbox",
label: "صندوق ورودی",
icon: Inbox,
onClick: () => {
setActiveNavItem("inbox");
setIsMobileTicketListOpen(true);
},
},
{
id: "unassigned",
label: "بدون مسئول",
icon: MailOpen,
onClick: () => {
setActiveNavItem("unassigned");
setIsMobileTicketListOpen(true);
},
},
{
id: "assigned",
label: "محول‌شده",
icon: UserCheck,
onClick: () => {
setActiveNavItem("assigned");
setIsMobileTicketListOpen(true);
},
},
{
id: "search",
label: "جستجو",
icon: Search,
onClick: () => setIsCommandOpen(true),
},
{
id: "more",
label: "بیشتر",
icon: MoreHorizontal,
onClick: () => {},
},
].map((item) => {
const Icon = item.icon;
const isActive = activeNavItem === item.id;
return (
<button
key={item.id}
type="button"
onClick={item.onClick}
className={cn(
"flex flex-col items-center gap-1 py-2 text-xs",
isActive
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-5" />
<span>{item.label}</span>
</button>
);
})}
</div>
</nav>
</SidebarProvider>
);
}