feat: global ui & functional update

This commit is contained in:
ivannoskov
2026-01-21 22:43:54 +03:00
parent 8958d89d12
commit 619fd5c1ef
32 changed files with 3803 additions and 1325 deletions

View File

@@ -0,0 +1,199 @@
"use client";
import { useState } from "react";
import { Loader2, Plus, AlertCircle, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { parseChannelInput } from "@/lib/utils/channel-parser";
import { channelsApi, placementsApi } from "@/lib/api";
import type { Placement } from "@/lib/types/api";
interface AddChannelDialogProps {
workspaceId: string;
projectId: string;
existingPlacements: Placement[];
onSuccess: () => void;
children?: React.ReactNode;
}
export function AddChannelDialog({
workspaceId,
projectId,
existingPlacements,
onSuccess,
children,
}: AddChannelDialogProps) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const existingUsernames = new Set(
existingPlacements
.map((p) => p.channel.username?.toLowerCase())
.filter(Boolean)
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(false);
const username = parseChannelInput(inputValue);
if (!username) {
setError("Не удалось распознать username из ссылки");
return;
}
if (existingUsernames.has(username.toLowerCase())) {
setError("Этот канал уже добавлен в проект");
return;
}
try {
setLoading(true);
// Step 1: Verify/add channel via channels API
await channelsApi.list({ username });
// Step 2: Create empty placement
await placementsApi.createBulk(workspaceId, projectId, {
channels: [
{
username,
status: "Без статуса",
},
],
});
setSuccess(true);
setInputValue("");
// Close dialog after short delay
setTimeout(() => {
setOpen(false);
setSuccess(false);
onSuccess();
}, 1500);
} catch (err: any) {
console.error("Failed to add channel:", err);
setError(err.detail || "Не удалось добавить канал");
} finally {
setLoading(false);
}
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
setInputValue("");
setError(null);
setSuccess(false);
}
setOpen(isOpen);
};
const examples = [
"t.me/rian_ru",
"@rian_ru",
"telemetr.me/content/rian_ru",
"tgstat.ru/channel/@rian_ru",
];
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Добавить канал</DialogTitle>
<DialogDescription>
Введите ссылку или username канала для проверки и добавления в проект
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="channel-input">Ссылка или username канала</Label>
<Input
id="channel-input"
placeholder="https://t.me/rian_ru или @rian_ru"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
disabled={loading}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Поддерживаются: t.me, telemetr.me, tgstat.ru, а также username с @
</p>
</div>
{error && (
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{success && (
<div className="flex items-center gap-2 p-3 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400 text-sm">
<CheckCircle2 className="h-4 w-4 shrink-0" />
Канал успешно добавлен!
</div>
)}
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Примеры:</p>
<div className="flex flex-wrap gap-2">
{examples.map((example) => (
<button
key={example}
type="button"
onClick={() => setInputValue(example)}
className="text-xs px-2 py-1 rounded-md bg-muted hover:bg-muted/80 transition-colors"
>
{example}
</button>
))}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={loading}
>
Отмена
</Button>
<Button type="submit" disabled={loading || !inputValue.trim()}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Проверка...
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Проверить и добавить
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,454 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Loader2, Plus, Check, ChevronRight, X, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { parseChannelInput } from "@/lib/utils/channel-parser";
import { placementsApi, creativesApi } from "@/lib/api";
import type { Placement, Creative } from "@/lib/types/api";
interface ChannelInput {
username: string;
isNew: boolean;
}
interface CreatePlacementsDialogProps {
workspaceId: string;
projectId: string;
existingPlacements: Placement[];
onSuccess: () => void;
children?: React.ReactNode;
}
const DEFAULT_STATUS = "Согласование условий";
const DEFAULT_PLACEMENT_TYPE = "standard";
export function CreatePlacementsDialog({
workspaceId,
projectId,
existingPlacements,
onSuccess,
children,
}: CreatePlacementsDialogProps) {
const [open, setOpen] = useState(false);
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Step 1: Channels
const [newChannelInput, setNewChannelInput] = useState("");
const [selectedChannels, setSelectedChannels] = useState<ChannelInput[]>([]);
const [existingChannels, setExistingChannels] = useState<string[]>([]);
// Step 2: Details
const [creativeId, setCreativeId] = useState<string | null>(null);
const [format, setFormat] = useState("");
const [cost, setCost] = useState("");
const [placementDate, setPlacementDate] = useState("");
const [comment, setComment] = useState("");
// Step 3: Confirm
const [creatives, setCreatives] = useState<Creative[]>([]);
const [creating, setCreating] = useState(false);
const [createdCount, setCreatedCount] = useState<number | null>(null);
const existingUsernames = new Set(
existingPlacements
.map((p) => p.channel.username?.toLowerCase())
.filter(Boolean)
);
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
resetForm();
}
setOpen(isOpen);
};
const resetForm = () => {
setStep(1);
setNewChannelInput("");
setSelectedChannels([]);
setExistingChannels([]);
setCreativeId(null);
setFormat("");
setCost("");
setPlacementDate("");
setComment("");
setError(null);
setCreatedCount(null);
};
const loadCreatives = async () => {
try {
const response = await creativesApi.listByProject(workspaceId, projectId);
setCreatives(response.items);
} catch (err) {
console.error("Failed to load creatives:", err);
}
};
const addChannel = () => {
const username = parseChannelInput(newChannelInput);
if (!username) {
setError("Не удалось распознать username");
return;
}
const lowerUsername = username.toLowerCase();
if (existingUsernames.has(lowerUsername)) {
setError("Этот канал уже добавлен в проект");
return;
}
if (selectedChannels.some((c) => c.username.toLowerCase() === lowerUsername)) {
setError("Канал уже добавлен в список");
return;
}
setSelectedChannels([...selectedChannels, { username, isNew: true }]);
setNewChannelInput("");
setError(null);
};
const removeChannel = (username: string) => {
setSelectedChannels(selectedChannels.filter((c) => c.username !== username));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
addChannel();
}
};
const canProceedStep1 = selectedChannels.length > 0;
const handleCreate = async () => {
try {
setCreating(true);
setError(null);
const details: any = {};
details.placement_type = DEFAULT_PLACEMENT_TYPE;
if (format) details.format = format;
if (cost) details.cost = { type: "fixed", value: parseFloat(cost) };
if (placementDate) details.placement_at = new Date(placementDate).toISOString();
if (comment) details.comment = comment;
await placementsApi.createBulk(workspaceId, projectId, {
creative_id: creativeId || undefined,
channels: selectedChannels.map((c) => ({
username: c.username,
status: DEFAULT_STATUS,
})),
details: Object.keys(details).length > 0 ? details : undefined,
});
setCreatedCount(selectedChannels.length);
onSuccess();
} catch (err: any) {
console.error("Failed to create placements:", err);
setError(err.detail || "Не удалось создать размещения");
setCreating(false);
}
};
const handleTriggerClick = () => {
resetForm();
setOpen(true);
};
const selectedCreative = creatives.find((c) => c.id === creativeId);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild onClick={handleTriggerClick}>{children}</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Создать размещения</DialogTitle>
<DialogDescription>
Шаг {step} из 3:{" "}
{step === 1 && "Выберите каналы"}
{step === 2 && "Укажите параметры"}
{step === 3 && "Подтвердите создание"}
</DialogDescription>
</DialogHeader>
{createdCount !== null ? (
<div className="py-8 text-center">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-emerald-100 text-emerald-600 mb-4">
<Check className="h-8 w-8" />
</div>
<h3 className="text-lg font-semibold mb-2">
Размещения созданы!
</h3>
<p className="text-muted-foreground">
Создано {createdCount} размещений
</p>
</div>
) : (
<>
{/* Progress Steps */}
<div className="flex items-center justify-center gap-2 mb-6">
{[1, 2, 3].map((s) => (
<div
key={s}
className={cn(
"flex items-center gap-2",
s < 3 && "mr-4"
)}
>
<div
className={cn(
"flex items-center justify-center h-8 w-8 rounded-full text-sm font-medium",
step === s
? "bg-primary text-primary-foreground"
: step > s
? "bg-emerald-500 text-white"
: "bg-muted text-muted-foreground"
)}
>
{step > s ? <Check className="h-4 w-4" /> : s}
</div>
</div>
))}
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm mb-4">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{/* Step 1: Channels */}
{step === 1 && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Добавить каналы</Label>
<div className="flex gap-2">
<Input
placeholder="t.me/username или @username"
value={newChannelInput}
onChange={(e) => setNewChannelInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={loading}
/>
<Button type="button" onClick={addChannel} disabled={!newChannelInput.trim()}>
<Plus className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Поддерживаются: t.me, telemetr.me, tgstat.ru, username с @
</p>
</div>
{selectedChannels.length > 0 && (
<div className="space-y-2">
<Label>Выбранные каналы ({selectedChannels.length})</Label>
<div className="max-h-48 overflow-y-auto space-y-2">
{selectedChannels.map((channel) => (
<div
key={channel.username}
className="flex items-center justify-between p-2 rounded-md bg-muted"
>
<span className="text-sm">@{channel.username}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeChannel(channel.username)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Step 2: Details */}
{step === 2 && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Креатив</Label>
<Select value={creativeId || ""} onValueChange={setCreativeId}>
<SelectTrigger>
<SelectValue placeholder="Выберите креатив" />
</SelectTrigger>
<SelectContent>
{creatives.map((creative) => (
<SelectItem key={creative.id} value={creative.id}>
{creative.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Формат</Label>
<Input
placeholder="Стандарт"
value={format}
onChange={(e) => setFormat(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Стоимость ()</Label>
<Input
type="number"
placeholder="5000"
value={cost}
onChange={(e) => setCost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Дата размещения</Label>
<Input
type="date"
value={placementDate}
onChange={(e) => setPlacementDate(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label>Комментарий</Label>
<Textarea
placeholder="Дополнительные заметки..."
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={2}
/>
</div>
</div>
)}
{/* Step 3: Confirm */}
{step === 3 && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Каналы ({selectedChannels.length})</Label>
<div className="max-h-32 overflow-y-auto p-3 rounded-md bg-muted text-sm space-y-1">
{selectedChannels.map((c) => (
<div key={c.username}>@{c.username}</div>
))}
</div>
</div>
<div className="space-y-2">
<Label>Параметры</Label>
<div className="grid grid-cols-2 gap-2 text-sm p-3 rounded-md bg-muted">
{selectedCreative && (
<div>
<span className="text-muted-foreground">Креатив:</span>{" "}
{selectedCreative.name}
</div>
)}
<div>
<span className="text-muted-foreground">Тип:</span>{" "}
Стандартный
</div>
{format && (
<div>
<span className="text-muted-foreground">Формат:</span>{" "}
{format}
</div>
)}
{cost && (
<div>
<span className="text-muted-foreground">Стоимость:</span>{" "}
{parseFloat(cost).toLocaleString("ru-RU")}
</div>
)}
{placementDate && (
<div>
<span className="text-muted-foreground">Дата:</span>{" "}
{new Date(placementDate).toLocaleDateString("ru-RU")}
</div>
)}
{comment && (
<div className="col-span-2">
<span className="text-muted-foreground">Комментарий:</span>{" "}
{comment}
</div>
)}
</div>
</div>
</div>
)}
{/* Navigation */}
<DialogFooter>
{step > 1 && (
<Button
type="button"
variant="outline"
onClick={() => setStep(step - 1)}
>
Назад
</Button>
)}
{step < 3 ? (
<Button
type="button"
onClick={() => {
if (step === 1) {
loadCreatives();
}
setStep(step + 1);
}}
disabled={step === 1 && !canProceedStep1}
>
Далее
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button type="button" onClick={handleCreate} disabled={creating}>
{creating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Создание...
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Создать {selectedChannels.length} размещений
</>
)}
</Button>
)}
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,523 @@
"use client";
import { useState } from "react";
import { Loader2, Filter, ArrowUpDown, X, Check, ArrowUp, ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import type { Placement, PlacementStatus } from "@/lib/types/api";
type FilterCondition = "contains" | "not_contains" | "equals";
interface FilterField {
key: string;
label: string;
type: "text" | "number" | "date";
}
interface SortField {
key: string;
label: string;
}
interface SortOption {
field: string;
direction: "asc" | "desc";
}
interface FiltersModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onApply: (filters: PlacementFilters) => void;
existingPlacements: Placement[];
initialFilters: PlacementFilters;
}
export interface PlacementFilters {
channelFilters: {
name: { value: string; condition: FilterCondition };
username: { value: string; condition: FilterCondition };
subscribersMin: number | null;
subscribersMax: number | null;
};
placementFilters: {
statuses: PlacementStatus[];
costMin: number | null;
costMax: number | null;
dateFrom: string | null;
dateTo: string | null;
hasPlacement: boolean | null;
};
sort: SortOption | null;
}
const CHANNEL_FILTER_FIELDS: FilterField[] = [
{ key: "name", label: "Название канала", type: "text" },
{ key: "username", label: "Username", type: "text" },
];
const SORT_FIELDS: SortField[] = [
{ key: "title", label: "Название канала" },
{ key: "subscribers", label: "Подписчики" },
{ key: "cost", label: "Стоимость" },
{ key: "date", label: "Дата размещения" },
{ key: "cpm", label: "CPM" },
{ key: "cpf", label: "CPF" },
];
const CONDITION_OPTIONS: { value: FilterCondition; label: string }[] = [
{ value: "contains", label: "содержит" },
{ value: "not_contains", label: "не содержит" },
{ value: "equals", label: "равно" },
];
const STATUS_OPTIONS: PlacementStatus[] = [
"Без статуса",
"Написать",
"Ждём ответа",
"Согласование условий",
"Оплатить",
"Оплачено",
"Отмена",
"Не подходит цена",
"Неактуально",
"Не отвечает",
];
const CONDITION_LABELS: Record<FilterCondition, string> = {
contains: "содержит",
not_contains: "не содержит",
equals: "равно",
};
export function FiltersModal({
open,
onOpenChange,
onApply,
existingPlacements,
initialFilters,
}: FiltersModalProps) {
const [filters, setFilters] = useState<PlacementFilters>(initialFilters);
const [activeTab, setActiveTab] = useState<"channels" | "placements" | "sort">("channels");
const handleApply = () => {
onApply(filters);
onOpenChange(false);
};
const handleReset = () => {
const defaultFilters: PlacementFilters = {
channelFilters: {
name: { value: "", condition: "contains" },
username: { value: "", condition: "contains" },
subscribersMin: null,
subscribersMax: null,
},
placementFilters: {
statuses: [],
costMin: null,
costMax: null,
dateFrom: null,
dateTo: null,
hasPlacement: null,
},
sort: null,
};
setFilters(defaultFilters);
};
const toggleStatus = (status: PlacementStatus) => {
const currentStatuses = filters.placementFilters.statuses;
const newStatuses = currentStatuses.includes(status)
? currentStatuses.filter((s) => s !== status)
: [...currentStatuses, status];
setFilters({
...filters,
placementFilters: { ...filters.placementFilters, statuses: newStatuses },
});
};
const updateChannelFilter = (
key: "name" | "username",
updates: { value?: string; condition?: FilterCondition }
) => {
setFilters({
...filters,
channelFilters: {
...filters.channelFilters,
[key]: { ...filters.channelFilters[key], ...updates },
},
});
};
const hasActiveFilters =
filters.channelFilters.name.value ||
filters.channelFilters.username.value ||
filters.channelFilters.subscribersMin !== null ||
filters.channelFilters.subscribersMax !== null ||
filters.placementFilters.statuses.length > 0 ||
filters.placementFilters.costMin !== null ||
filters.placementFilters.costMax !== null ||
filters.placementFilters.dateFrom ||
filters.placementFilters.dateTo ||
filters.sort;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Filter className="h-5 w-5" />
Фильтры и сортировка
</DialogTitle>
<DialogDescription>
Настройте фильтры для каналов и размещений
</DialogDescription>
</DialogHeader>
<div className="flex border-b">
<button
onClick={() => setActiveTab("channels")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
activeTab === "channels"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
Каналы
</button>
<button
onClick={() => setActiveTab("placements")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
activeTab === "placements"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
Размещения
</button>
<button
onClick={() => setActiveTab("sort")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1",
activeTab === "sort"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<ArrowUpDown className="h-4 w-4" />
Сортировка
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "channels" && (
<div className="space-y-4">
{CHANNEL_FILTER_FIELDS.map((field) => (
<div key={field.key} className="space-y-2">
<Label className="flex items-center gap-2">
{field.label}
<Select
value={filters.channelFilters[field.key as "name" | "username"].condition}
onValueChange={(value: FilterCondition) =>
updateChannelFilter(field.key as "name" | "username", { condition: value })
}
>
<SelectTrigger className="h-7 w-36 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONDITION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Label>
<Input
placeholder={
filters.channelFilters[field.key as "name" | "username"].condition === "equals"
? "Точное значение"
: "Например: новости"
}
value={filters.channelFilters[field.key as "name" | "username"].value}
onChange={(e) =>
updateChannelFilter(field.key as "name" | "username", { value: e.target.value })
}
/>
</div>
))}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Мин. подписчиков</Label>
<Input
type="number"
placeholder="0"
value={filters.channelFilters.subscribersMin || ""}
onChange={(e) =>
setFilters({
...filters,
channelFilters: {
...filters.channelFilters,
subscribersMin: e.target.value ? parseInt(e.target.value) : null,
},
})
}
/>
</div>
<div className="space-y-2">
<Label>Макс. подписчиков</Label>
<Input
type="number"
placeholder="1000000"
value={filters.channelFilters.subscribersMax || ""}
onChange={(e) =>
setFilters({
...filters,
channelFilters: {
...filters.channelFilters,
subscribersMax: e.target.value ? parseInt(e.target.value) : null,
},
})
}
/>
</div>
</div>
</div>
)}
{activeTab === "placements" && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Статусы размещений</Label>
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto">
{STATUS_OPTIONS.map((status) => (
<label
key={status}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
filters.placementFilters.statuses.includes(status)
? "bg-primary/10 border-primary text-primary"
: "hover:bg-muted"
)}
>
<Checkbox
checked={filters.placementFilters.statuses.includes(status)}
onCheckedChange={() => toggleStatus(status)}
/>
{status}
</label>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Мин. стоимость ()</Label>
<Input
type="number"
placeholder="0"
value={filters.placementFilters.costMin || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
costMin: e.target.value ? parseFloat(e.target.value) : null,
},
})
}
/>
</div>
<div className="space-y-2">
<Label>Макс. стоимость ()</Label>
<Input
type="number"
placeholder="100000"
value={filters.placementFilters.costMax || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
costMax: e.target.value ? parseFloat(e.target.value) : null,
},
})
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Дата размещения от</Label>
<Input
type="date"
value={filters.placementFilters.dateFrom || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
dateFrom: e.target.value || null,
},
})
}
/>
</div>
<div className="space-y-2">
<Label>Дата размещения до</Label>
<Input
type="date"
value={filters.placementFilters.dateTo || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
dateTo: e.target.value || null,
},
})
}
/>
</div>
</div>
</div>
)}
{activeTab === "sort" && (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-2">
<Label>Сортировать по</Label>
<Select
value={filters.sort?.field || ""}
onValueChange={(field) =>
setFilters({
...filters,
sort: filters.sort
? { ...filters.sort, field }
: { field, direction: "desc" },
})
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите поле" />
</SelectTrigger>
<SelectContent>
{SORT_FIELDS.map((field) => (
<SelectItem key={field.key} value={field.key}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Направление</Label>
<div className="flex gap-1 p-1 border rounded-md">
<button
onClick={() =>
setFilters({
...filters,
sort: filters.sort
? { ...filters.sort, direction: "asc" }
: { field: "title", direction: "asc" },
})
}
className={cn(
"flex-1 flex items-center justify-center py-2 rounded-sm transition-colors",
filters.sort?.direction === "asc"
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
title="По возрастанию"
>
<ArrowUp className="h-4 w-4" />
<span className="ml-1 text-xs">A-Z</span>
</button>
<button
onClick={() =>
setFilters({
...filters,
sort: filters.sort
? { ...filters.sort, direction: "desc" }
: { field: "title", direction: "desc" },
})
}
className={cn(
"flex-1 flex items-center justify-center py-2 rounded-sm transition-colors",
filters.sort?.direction === "desc"
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
title="По убыванию"
>
<ArrowDown className="h-4 w-4" />
<span className="ml-1 text-xs">Z-A</span>
</button>
</div>
</div>
</div>
{filters.sort && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Текущая сортировка:</span>
<span className="font-medium">
{SORT_FIELDS.find((f) => f.key === filters.sort?.field)?.label ||
filters.sort?.field}
</span>
<span>
{filters.sort.direction === "asc" ? "↑" : "↓"} (
{filters.sort.direction === "asc" ? "A-Z" : "Z-A"})
</span>
</div>
)}
</div>
)}
</div>
<DialogFooter className="flex justify-between">
<Button variant="outline" onClick={handleReset}>
Сбросить
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button onClick={handleApply}>
Применить
{hasActiveFilters && (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-primary-foreground/20 rounded">
</span>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -16,7 +16,7 @@ import {
ClipboardCheck,
ClipboardList,
Filter,
Folder,
FolderKanban,
Gauge,
KanbanSquare,
LayoutDashboard,
@@ -27,12 +27,15 @@ import {
MessageCircle,
MonitorPlay,
NotebookPen,
Palette,
PlayCircle,
Settings,
ShoppingCart,
Sparkles,
Trash2,
Tv,
TrendingUp,
Upload,
BookOpen,
ChevronRight,
X,
@@ -41,6 +44,7 @@ import {
import { type NavGuideState, NavMain } from "@/components/nav-main";
import { NavUser } from "@/components/nav-user";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Sidebar,
SidebarContent,
@@ -97,10 +101,12 @@ interface NavItem {
icon?: LucideIcon;
isActive?: boolean;
requiredPermission?: "projects_read" | "placements_read" | "analytics_read" | "admin_full";
tooltip?: string;
items?: {
title: string;
url: string;
icon?: LucideIcon;
tooltip?: string;
}[];
}
@@ -338,7 +344,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const [showCreateDialog, setShowCreateDialog] = React.useState(false);
const [newWorkspaceName, setNewWorkspaceName] = React.useState("");
const [newWorkspaceAvatar, setNewWorkspaceAvatar] = React.useState<File | null>(null);
const [newWorkspaceAvatarPreview, setNewWorkspaceAvatarPreview] = React.useState<string | null>(null);
const [isCreating, setIsCreating] = React.useState(false);
const newWorkspaceAvatarInputRef = React.useRef<HTMLInputElement>(null);
const handleWorkspaceSelect = React.useCallback(
(workspaceId: string) => {
if (currentWorkspace?.id === workspaceId) return;
@@ -362,13 +371,15 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
url: buildRoute.dashboard(workspaceId),
icon: LayoutDashboard,
isActive: true,
tooltip: "Обзор ключевых метрик: расходы, охват, подписчики, CPF и CPM",
});
// 2. Проекты (всегда доступны)
items.push({
title: "Проекты",
url: buildRoute.projects(workspaceId),
icon: Tv,
icon: FolderKanban,
tooltip: "Управление рекламными кампаниями: статус, бюджет, ответственные",
});
// 3. Креативы (только с доступом к размещениям)
@@ -376,67 +387,62 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
items.push({
title: "Креативы",
url: buildRoute.creatives(workspaceId),
icon: Folder,
icon: Palette,
requiredPermission: "placements_read",
tooltip: "Шаблоны рекламных сообщений для закупов",
});
}
// 4. Каталог каналов (всегда доступен)
items.push({
title: "Каталог каналов",
url: buildRoute.channels(workspaceId),
icon: Building2,
});
// 5. Планы закупов (правка названия)
// 4. Размещения (ранее "Планы закупов")
if (hasPermission("placements_read")) {
items.push({
title: "Планы закупов",
url: buildRoute.purchasePlans(workspaceId),
icon: ClipboardList,
requiredPermission: "placements_read",
tooltip: "Черновики кампаний: сбор каналов, фиксация ставок, согласование бюджета",
});
}
// 6. Размещения
if (hasPermission("placements_read")) {
items.push({
title: "Размещения",
url: buildRoute.placements(workspaceId),
icon: ShoppingCart,
requiredPermission: "placements_read",
});
}
// 7. Аналитика — только два подраздела
// 5. Аналитика — только подразделы
if (hasPermission("analytics_read")) {
items.push({
title: "Аналитика",
url: buildRoute.analytics(workspaceId),
icon: BarChart3,
requiredPermission: "analytics_read",
tooltip: "Сводные отчеты: эффективность по проектам и креативам",
items: [
{
title: "Все размещения",
url: buildRoute.analyticsPlacements(workspaceId),
icon: ShoppingCart,
tooltip: "Сводная таблица по всем закупкам",
},
{
title: "По проектам",
url: buildRoute.analyticsProjects(workspaceId),
icon: Tv,
tooltip: "Метрики эффективности по каждому проекту",
},
{
title: "По креативам",
url: buildRoute.analyticsCreatives(workspaceId),
icon: Folder,
icon: Palette,
tooltip: "Анализ результатов по рекламным шаблонам",
},
],
});
}
// 8. Настройки (без подразделов, админ)
// 6. Настройки (без подразделов, админ)
if (isAdmin) {
items.push({
title: "Настройки",
url: buildRoute.settings(workspaceId),
icon: Settings,
requiredPermission: "admin_full",
tooltip: "Управление доступом и параметрами рабочего пространства",
});
}
@@ -503,7 +509,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}, [isTrainingOpen, currentTrainingStep]);
const isTrainingVisible = isTrainingOpen && Boolean(currentTrainingStep);
const channelLink = React.useMemo<SupportLink>(
type SimpleLink = { title: string; href: string; icon: LucideIcon; external?: boolean };
const channelLink = React.useMemo<SimpleLink>(
() => ({
title: "Наш канал",
href: "https://t.me/+xqHlRelJ2etkMGIy",
@@ -593,9 +601,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
try {
setIsCreating(true);
const workspace = await createWorkspace(newWorkspaceName.trim());
const workspace = await createWorkspace(newWorkspaceName.trim(), newWorkspaceAvatar);
setShowCreateDialog(false);
setNewWorkspaceName("");
setNewWorkspaceAvatar(null);
setNewWorkspaceAvatarPreview(null);
setCurrentWorkspace(workspace);
} catch (err) {
console.error("Failed to create workspace:", err);
@@ -604,6 +614,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}
};
const handleNewWorkspaceAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
return;
}
setNewWorkspaceAvatar(file);
const reader = new FileReader();
reader.onload = () => {
setNewWorkspaceAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
};
const handleNewWorkspaceAvatarRemove = () => {
setNewWorkspaceAvatar(null);
setNewWorkspaceAvatarPreview(null);
if (newWorkspaceAvatarInputRef.current) {
newWorkspaceAvatarInputRef.current.value = "";
}
};
return (
<>
<Sidebar collapsible="icon" {...props}>
@@ -663,7 +697,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarContent>
<SidebarFooter>
<SidebarGroup className="px-0">
<SidebarGroup className="px-0 pb-0">
<SidebarGroupContent>
<SidebarMenu>
{/* Наш канал */}
@@ -671,6 +705,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuButton
asChild
size="sm"
className="!p-1.5 !h-7"
tooltip={isSidebarCollapsed ? channelLink.title : undefined}
>
<a
@@ -679,9 +714,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<channelLink.icon className="size-4 text-muted-foreground/90" />
<span className="text-muted-foreground/90">{channelLink.title}</span>
<ArrowUpRight className="ml-auto size-3.5 text-muted-foreground/60" />
<channelLink.icon className="size-3.5 text-muted-foreground/90" />
<span className="text-xs text-muted-foreground/90">{channelLink.title}</span>
<ArrowUpRight className="ml-auto size-3 text-muted-foreground/60" />
</a>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -690,12 +725,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuItem>
<SidebarMenuButton
size="sm"
className="!p-1.5 !h-7"
tooltip={isSidebarCollapsed ? "Обучение" : undefined}
onClick={openTraining}
className="cursor-pointer"
>
<MonitorPlay className="size-4 text-muted-foreground/90" />
<span className="text-muted-foreground/90">Обучение</span>
<MonitorPlay className="size-3.5 text-muted-foreground/90" />
<span className="text-xs text-muted-foreground/90">Обучение</span>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -706,14 +741,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="sm"
className="!p-1.5 !h-7 justify-between"
tooltip="Помощь"
className="justify-between"
>
<div className="flex items-center gap-2">
<LifeBuoy className="size-4 text-muted-foreground/90" />
<span className="text-muted-foreground/90">Помощь</span>
<LifeBuoy className="size-3.5 text-muted-foreground/90" />
<span className="text-xs text-muted-foreground/90">Помощь</span>
</div>
<ChevronRight className="size-4 text-muted-foreground/60" />
<ChevronRight className="size-3 text-muted-foreground/60" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -735,20 +770,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<CollapsibleTrigger asChild>
<SidebarMenuButton
size="sm"
className="justify-between"
className="!p-1.5 !h-7 justify-between"
>
<div className="flex items-center gap-2">
<LifeBuoy className="size-4 text-muted-foreground/90" />
<span className="text-muted-foreground/90">Помощь</span>
<LifeBuoy className="size-3.5 text-muted-foreground/90" />
<span className="text-xs text-muted-foreground/90">Помощь</span>
</div>
<ChevronRight className="size-4 text-muted-foreground/60 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
<ChevronRight className="size-3 text-muted-foreground/60 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{helpSubItems.map((item) => (
<SidebarMenuSubItem key={item.title}>
<SidebarMenuSubButton asChild className="">
<SidebarMenuSubButton asChild className="!p-1.5 !h-7">
{renderHelpSubItem(item)}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
@@ -826,6 +861,47 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Avatar */}
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarImage src={newWorkspaceAvatarPreview || undefined} alt="Avatar" />
<AvatarFallback>
{newWorkspaceName.charAt(0).toUpperCase() || "W"}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => newWorkspaceAvatarInputRef.current?.click()}
disabled={isCreating}
>
<Upload className="h-4 w-4 mr-2" />
Загрузить
</Button>
{newWorkspaceAvatarPreview && (
<Button
variant="outline"
size="sm"
onClick={handleNewWorkspaceAvatarRemove}
disabled={isCreating}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<input
ref={newWorkspaceAvatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleNewWorkspaceAvatarChange}
/>
</div>
</div>
{/* Name */}
<div className="grid gap-2">
<Label htmlFor="workspace-name">Название</Label>
<Input
@@ -838,13 +914,19 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
handleCreateWorkspace();
}
}}
disabled={isCreating}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
onClick={() => {
setShowCreateDialog(false);
setNewWorkspaceName("");
setNewWorkspaceAvatar(null);
setNewWorkspaceAvatarPreview(null);
}}
>
Отмена
</Button>

View File

@@ -42,10 +42,12 @@ export function NavMain({
url: string;
icon?: LucideIcon;
isActive?: boolean;
tooltip?: string;
items?: {
title: string;
url: string;
icon?: LucideIcon;
tooltip?: string;
}[];
}[];
guideState?: NavGuideState;
@@ -60,13 +62,15 @@ export function NavMain({
<SidebarMenu>
{items.map((item) => {
const hasSubmenu = item.items && item.items.length > 0;
const isActive = pathname === item.url;
const isActive = hasSubmenu
? pathname.startsWith(item.url)
: pathname === item.url || (item.title === "Главная" ? false : pathname.startsWith(item.url + "/"));
const hasActiveSubmenu =
hasSubmenu && item.items!.some((sub) => pathname === sub.url);
const shouldForceOpen = guideState?.expandedItems?.includes(item.title);
const shouldHighlightButton = item.title === "Аналитика"
? false
: isActive;
: isActive || hasActiveSubmenu;
const isGuideHighlight = highlightedItem === item.title;
const highlightedButtonClass = isGuideHighlight
? "ring-2 ring-primary/70"
@@ -79,7 +83,7 @@ export function NavMain({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
tooltip={item.tooltip ? { children: item.tooltip, side: "right" } : item.title}
isActive={shouldHighlightButton}
className={cn(highlightedButtonClass)}
>
@@ -161,7 +165,7 @@ export function NavMain({
<>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
tooltip={item.tooltip ? { children: item.tooltip, side: "right" } : item.title}
isActive={shouldHighlightButton}
className={cn(highlightedButtonClass)}
>
@@ -234,7 +238,7 @@ export function NavMain({
) : (
<SidebarMenuButton
asChild
tooltip={item.title}
tooltip={item.tooltip ? { children: item.tooltip, side: "right" } : item.title}
isActive={shouldHighlightButton}
className={cn(highlightedButtonClass)}
>

View File

@@ -107,19 +107,23 @@ export function NavUser({
{workspaceSwitcher.workspaces.length > 0 ? (
workspaceSwitcher.workspaces.map((workspace) => {
const isDemo = workspace.id === DEMO_WORKSPACE_ID;
const isSelected = workspaceSwitcher.currentWorkspaceId === workspace.id;
return (
<DropdownMenuItem
key={workspace.id}
onClick={() => workspaceSwitcher.onSelect(workspace)}
className="gap-2 p-2 text-sm"
>
<div className="flex size-6 items-center justify-center rounded-sm border">
{isDemo ? (
<Sparkles className="size-4 shrink-0 text-purple-500" />
) : (
<Building2 className="size-4 shrink-0" />
)}
</div>
<Avatar className="h-6 w-6 rounded-sm">
<AvatarImage src={workspace.avatar_url || undefined} alt={workspace.name} />
<AvatarFallback className="rounded-sm text-xs">
{isDemo ? (
<Sparkles className="h-3 w-3 text-purple-500" />
) : (
workspace.name.charAt(0).toUpperCase()
)}
</AvatarFallback>
</Avatar>
<span className="flex-1 truncate">
{workspace.name}
{isDemo && (
@@ -128,7 +132,7 @@ export function NavUser({
</span>
)}
</span>
{workspaceSwitcher.currentWorkspaceId === workspace.id && (
{isSelected && (
<Check className="size-4 ml-auto" />
)}
</DropdownMenuItem>

View File

@@ -55,6 +55,8 @@ interface WorkspaceContextType {
permissions: Permission[];
hasPermission: (key: PermissionKey, projectId?: string) => boolean;
isAdmin: boolean;
// Check if user is admin in a specific workspace
isWorkspaceAdmin: (workspaceId: string) => Promise<boolean>;
// Loading states
isLoading: boolean;
@@ -64,7 +66,7 @@ interface WorkspaceContextType {
// Actions
refreshWorkspaces: () => Promise<Workspace[]>;
refreshProjects: () => Promise<void>;
createWorkspace: (name: string) => Promise<Workspace>;
createWorkspace: (name: string, avatarFile?: File | null) => Promise<Workspace>;
}
const WorkspaceContext = createContext<WorkspaceContextType | undefined>(
@@ -406,12 +408,49 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
}
}, [projects, saveSelectedProjects]);
// ============================================================================
// Check if user is admin in a specific workspace
// ============================================================================
const isWorkspaceAdmin = useCallback(async (workspaceId: string): Promise<boolean> => {
// Demo mode - user is admin
if (isDemoMode || workspaceId === DEMO_WORKSPACE_ID) {
return true;
}
// Check if this is the current workspace
if (currentWorkspace?.id === workspaceId) {
return isAdmin;
}
// Load permissions for the target workspace
try {
const me = await authApi.me();
const membersResponse = await membersApi.list(workspaceId, { size: 100 });
const member = membersResponse.items.find((m) => m.user.id === me.id);
if (!member) {
// Check if user is creator (members list is empty or this is first member)
if (membersResponse.items.length === 0) {
return true;
}
return false;
}
// Check if has admin_full permission
return member.permissions.some((p) => p.key === "admin_full");
} catch (err) {
console.error("Failed to check workspace permissions:", err);
return false;
}
}, [currentWorkspace, isAdmin, isDemoMode]);
// ============================================================================
// Create Workspace
// ============================================================================
const createWorkspace = useCallback(async (name: string): Promise<Workspace> => {
const workspace = await workspacesApi.create({ name });
const createWorkspace = useCallback(async (name: string, avatarFile?: File | null): Promise<Workspace> => {
const workspace = await workspacesApi.create({ name, avatar_file: avatarFile });
setWorkspaces((prev) => [...prev, workspace]);
return workspace;
}, []);
@@ -462,6 +501,7 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
permissions,
hasPermission,
isAdmin,
isWorkspaceAdmin,
// Loading states
isLoading,

View File

@@ -128,7 +128,7 @@ function SidebarProvider({
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<TooltipProvider delayDuration={500}>
<div
data-slot="sidebar-wrapper"
style={
@@ -533,12 +533,12 @@ function SidebarMenuButton({
}
return (
<Tooltip>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
hidden={isMobile}
{...tooltip}
/>
</Tooltip>
@@ -643,7 +643,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"flex min-w-0 flex-col gap-1 px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}

View File

@@ -19,10 +19,11 @@ function TooltipProvider({
}
function Tooltip({
delayDuration = 500,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipProvider delayDuration={delayDuration}>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
@@ -46,13 +47,13 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
"bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance shadow-md border border-border/50",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
<TooltipPrimitive.Arrow className="bg-popover fill-popover z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)