style: clean up code formatting and remove trailing whitespace
refactor: reorganize imports and fix type imports
This commit is contained in:
1 parent
d3704602fd
commit
47c04de6fe
232 files changed
+7140
-4798
No files matched your search
+35
-29
@@ -1,20 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import { WordRotate } from "@/components/ui/word-rotate";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { LanguageSwitcher } from "./language-switcher";
|
||||
import { MobileMenu } from "./mobile-menu";
|
||||
import { NotificationBell } from "./notifications/NotificationBell";
|
||||
import { MessagesBell } from "./messages/MessagesBell";
|
||||
import { LoginDialog } from "./auth/login-dialog";
|
||||
import { RegisterDialog } from "./auth/register-dialog";
|
||||
import { LogOut, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -24,14 +15,23 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import { WordRotate } from "@/components/ui/word-rotate";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { LogOut, User } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoginDialog } from "./auth/login-dialog";
|
||||
import { RegisterDialog } from "./auth/register-dialog";
|
||||
import { LanguageSwitcher } from "./language-switcher";
|
||||
import { MessagesBell } from "./messages/MessagesBell";
|
||||
import { MobileMenu } from "./mobile-menu";
|
||||
import { NotificationBell } from "./notifications/NotificationBell";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
export default function Header() {
|
||||
const t = useTranslations("header.nav");
|
||||
@@ -90,7 +90,7 @@ export default function Header() {
|
||||
navigationMenuTriggerStyle(),
|
||||
"bg-transparent",
|
||||
highlight &&
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
@@ -105,10 +105,10 @@ export default function Header() {
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
|
||||
|
||||
{/* Messages */}
|
||||
{mounted && isAuthenticated && <MessagesBell />}
|
||||
|
||||
|
||||
{/* Notifications */}
|
||||
{mounted && isAuthenticated && <NotificationBell />}
|
||||
{mounted && isAuthenticated && user?.subscriptionTier && (
|
||||
@@ -116,7 +116,7 @@ export default function Header() {
|
||||
{user.subscriptionTier.slug}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
|
||||
{/* Auth Section */}
|
||||
{!mounted ? (
|
||||
// Placeholder durante SSR para evitar mismatch
|
||||
@@ -126,7 +126,10 @@ export default function Header() {
|
||||
) : isAuthenticated && user ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative h-8 w-8 rounded-full"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
@@ -191,9 +194,12 @@ export default function Header() {
|
||||
|
||||
{/* Login Dialog */}
|
||||
<LoginDialog open={loginDialogOpen} onOpenChange={setLoginDialogOpen} />
|
||||
|
||||
|
||||
{/* Register Dialog */}
|
||||
<RegisterDialog open={registerDialogOpen} onOpenChange={setRegisterDialogOpen} />
|
||||
<RegisterDialog
|
||||
open={registerDialogOpen}
|
||||
onOpenChange={setRegisterDialogOpen}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminSubscriptionTab } from "@/components/admin/AdminSubscriptionTab";
|
||||
import { AdminItemManager, type AdminItemsResponse } from "@/components/admin/items/AdminItemManager";
|
||||
import {
|
||||
AdminItemManager,
|
||||
type AdminItemsResponse,
|
||||
} from "@/components/admin/items/AdminItemManager";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface AdminPanelTabsProps {
|
||||
adminUser: {
|
||||
@@ -28,9 +31,9 @@ export function AdminPanelTabs({
|
||||
locale,
|
||||
initialTab = "overview",
|
||||
}: AdminPanelTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "subscriptions" | "items">(
|
||||
initialTab
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"overview" | "subscriptions" | "items"
|
||||
>(initialTab);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
@@ -40,31 +43,39 @@ export function AdminPanelTabs({
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Visão Geral</TabsTrigger>
|
||||
<TabsTrigger value="subscriptions">Assinaturas & Permissões</TabsTrigger>
|
||||
<TabsTrigger value="subscriptions">
|
||||
Assinaturas & Permissões
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="items">Gerenciamento de Itens</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bem-vindo, {adminUser?.username ?? "Administrador"}</CardTitle>
|
||||
<CardTitle>
|
||||
Bem-vindo, {adminUser?.username ?? "Administrador"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Utilize as abas acima para administrar planos de assinatura, permissões
|
||||
e a curadoria de itens do marketplace. Esta área é exclusiva para
|
||||
operadores autorizados.
|
||||
Utilize as abas acima para administrar planos de assinatura,
|
||||
permissões e a curadoria de itens do marketplace. Esta área é
|
||||
exclusiva para operadores autorizados.
|
||||
</p>
|
||||
<p>
|
||||
Para solicitações críticas, lembre-se de registrar mudanças e validar
|
||||
com a equipe responsável antes de disponibilizar aos usuários finais.
|
||||
Para solicitações críticas, lembre-se de registrar mudanças e
|
||||
validar com a equipe responsável antes de disponibilizar aos
|
||||
usuários finais.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subscriptions">
|
||||
<AdminSubscriptionTab initialTiers={initialTiers} adminUser={adminUser} />
|
||||
<AdminSubscriptionTab
|
||||
initialTiers={initialTiers}
|
||||
adminUser={adminUser}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="items">
|
||||
@@ -73,4 +84,3 @@ export function AdminPanelTabs({
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ export function AdminSubscriptionTab({
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
Configure planos, recursos e vincule usuários às assinaturas
|
||||
disponíveis. Todas as alterações são aplicadas imediatamente
|
||||
após salvar.
|
||||
disponíveis. Todas as alterações são aplicadas imediatamente após
|
||||
salvar.
|
||||
</p>
|
||||
{adminUser?.subscription_tier && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -47,4 +47,3 @@ export function AdminSubscriptionTab({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm";
|
||||
import { ItemAiAssistant, type ItemAiPrefill } from "./ItemAiAssistant";
|
||||
@@ -13,7 +13,11 @@ interface AdminItemCreatePageProps {
|
||||
serverOptions: string[];
|
||||
}
|
||||
|
||||
export function AdminItemCreatePage({ locale, categories, serverOptions }: AdminItemCreatePageProps) {
|
||||
export function AdminItemCreatePage({
|
||||
locale,
|
||||
categories,
|
||||
serverOptions,
|
||||
}: AdminItemCreatePageProps) {
|
||||
const router = useRouter();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -54,14 +58,20 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({ error: "Erro ao criar item" }));
|
||||
const data = await response
|
||||
.json()
|
||||
.catch(() => ({ error: "Erro ao criar item" }));
|
||||
throw new Error(data?.error || data?.message || "Falha ao criar item");
|
||||
}
|
||||
|
||||
goBackToList();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : "Erro ao criar item. Tente novamente.");
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Erro ao criar item. Tente novamente.",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -73,8 +83,9 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Novo item</h1>
|
||||
<p className="text-muted-foreground max-w-2xl">
|
||||
Utilize a seção de IA abaixo para gerar automaticamente campos sugeridos a partir de prints e descrições.
|
||||
Ajuste o que for necessário antes de publicar.
|
||||
Utilize a seção de IA abaixo para gerar automaticamente campos
|
||||
sugeridos a partir de prints e descrições. Ajuste o que for
|
||||
necessário antes de publicar.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={goBackToList} className="gap-2">
|
||||
@@ -120,4 +131,3 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { AdminItemForm, type ItemFormPayload } from "./AdminItemForm";
|
||||
|
||||
interface AdminItemCreateViewProps {
|
||||
@@ -10,7 +10,11 @@ interface AdminItemCreateViewProps {
|
||||
serverOptions: string[];
|
||||
}
|
||||
|
||||
export function AdminItemCreateView({ locale, categories, serverOptions }: AdminItemCreateViewProps) {
|
||||
export function AdminItemCreateView({
|
||||
locale,
|
||||
categories,
|
||||
serverOptions,
|
||||
}: AdminItemCreateViewProps) {
|
||||
const router = useRouter();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -43,7 +47,9 @@ export function AdminItemCreateView({ locale, categories, serverOptions }: Admin
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => ({ error: "Erro ao criar item" }));
|
||||
const errorPayload = await response
|
||||
.json()
|
||||
.catch(() => ({ error: "Erro ao criar item" }));
|
||||
throw new Error(errorPayload?.error || "Falha ao criar item");
|
||||
}
|
||||
|
||||
@@ -75,4 +81,3 @@ export function AdminItemCreateView({ locale, categories, serverOptions }: Admin
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,19 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { RefreshCw, Plus, Pencil } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { ItemCreationModal, type ItemFormPayload } from "@/components/admin/items/ItemCreationModal";
|
||||
import { Pencil, Plus, RefreshCw } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ItemCreationModal,
|
||||
type ItemFormPayload,
|
||||
} from "@/components/admin/items/ItemCreationModal";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export interface AdminItemRecord {
|
||||
item_id: number;
|
||||
@@ -50,12 +65,20 @@ interface AdminItemManagerProps {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function AdminItemManager({ initialData, locale }: AdminItemManagerProps) {
|
||||
export function AdminItemManager({
|
||||
initialData,
|
||||
locale,
|
||||
}: AdminItemManagerProps) {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<AdminItemsResponse>(initialData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [apiStatus, setApiStatus] = useState<{ code: number | null; ok: boolean | null; durationMs: number | null; checkedAt: string | null }>({ code: null, ok: null, durationMs: null, checkedAt: null });
|
||||
const [apiStatus, setApiStatus] = useState<{
|
||||
code: number | null;
|
||||
ok: boolean | null;
|
||||
durationMs: number | null;
|
||||
checkedAt: string | null;
|
||||
}>({ code: null, ok: null, durationMs: null, checkedAt: null });
|
||||
|
||||
const [serverFilter, setServerFilter] = useState<string>("all");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
@@ -65,10 +88,12 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
const pageSize = initialData.pageSize ?? 50;
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(
|
||||
null,
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [activeServers, setActiveServers] = useState<string[]>([]);
|
||||
const [serversLoading, setServersLoading] = useState<boolean>(false);
|
||||
const [_serversLoading, setServersLoading] = useState<boolean>(false);
|
||||
|
||||
const searchDebounce = useRef<number | undefined>(undefined);
|
||||
|
||||
@@ -96,13 +121,18 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn("[AdminItemManager] falha ao buscar servidores ativos:", res.status);
|
||||
console.warn(
|
||||
"[AdminItemManager] falha ao buscar servidores ativos:",
|
||||
res.status,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
const names: string[] = Array.isArray(json?.servers)
|
||||
? json.servers
|
||||
.map((s: any) => (typeof s?.serverName === "string" ? s.serverName : null))
|
||||
.map((s: any) =>
|
||||
typeof s?.serverName === "string" ? s.serverName : null,
|
||||
)
|
||||
.filter((n: string | null): n is string => Boolean(n))
|
||||
: [];
|
||||
if (!cancelled) {
|
||||
@@ -147,8 +177,19 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
signal: controller.signal,
|
||||
});
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
setApiStatus({ code: res.status, ok: res.ok, durationMs, checkedAt: new Date().toISOString() });
|
||||
console.log("Status:", res.status, res.statusText, "Duration:", `${durationMs}ms`);
|
||||
setApiStatus({
|
||||
code: res.status,
|
||||
ok: res.ok,
|
||||
durationMs,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
console.log(
|
||||
"Status:",
|
||||
res.status,
|
||||
res.statusText,
|
||||
"Duration:",
|
||||
`${durationMs}ms`,
|
||||
);
|
||||
console.log("Headers:", {
|
||||
"content-type": res.headers.get("content-type"),
|
||||
"x-powered-by": res.headers.get("x-powered-by"),
|
||||
@@ -165,10 +206,12 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
res.status === 401
|
||||
? "Sessão expirada ou não autenticado. Faça login e tente novamente."
|
||||
: res.status === 403
|
||||
? "Acesso restrito ao administrador."
|
||||
: "Não foi possível carregar os itens.";
|
||||
? "Acesso restrito ao administrador."
|
||||
: "Não foi possível carregar os itens.";
|
||||
const apiMsg = errorPayload?.error || errorPayload?.message || null;
|
||||
const composed = apiMsg ? `${baseMsg} (${apiMsg})` : `${baseMsg} (HTTP ${res.status})`;
|
||||
const composed = apiMsg
|
||||
? `${baseMsg} (${apiMsg})`
|
||||
: `${baseMsg} (HTTP ${res.status})`;
|
||||
console.error("Erro na API:", composed, "Payload:", errorPayload);
|
||||
throw new Error(composed);
|
||||
}
|
||||
@@ -179,23 +222,42 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
throw new Error("Resposta inválida da API de itens.");
|
||||
}
|
||||
setData(json);
|
||||
console.log("Itens carregados:", { total: json.total, page: json.page, pageSize: json.pageSize });
|
||||
console.log("Itens carregados:", {
|
||||
total: json.total,
|
||||
page: json.page,
|
||||
pageSize: json.pageSize,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
setApiStatus((prev) => ({ code: prev.code, ok: false, durationMs, checkedAt: new Date().toISOString() }));
|
||||
setApiStatus((prev) => ({
|
||||
code: prev.code,
|
||||
ok: false,
|
||||
durationMs,
|
||||
checkedAt: new Date().toISOString(),
|
||||
}));
|
||||
if (err?.name === "AbortError") {
|
||||
console.error("Requisição abortada por timeout");
|
||||
setError("Tempo de resposta excedido. Tente novamente.");
|
||||
} else {
|
||||
console.error("Falha ao carregar itens:", err);
|
||||
setError(err instanceof Error ? err.message : "Falha ao carregar itens.");
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Falha ao carregar itens.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
console.groupEnd();
|
||||
window.clearTimeout(timeoutId);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, serverFilter, categoryFilter, rarityFilter, page, pageSize]);
|
||||
}, [
|
||||
search,
|
||||
serverFilter,
|
||||
categoryFilter,
|
||||
rarityFilter,
|
||||
page,
|
||||
pageSize,
|
||||
isAdminItemsResponse,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchDebounce.current !== undefined) {
|
||||
@@ -219,7 +281,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
set.add(item.rarity);
|
||||
}
|
||||
});
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
|
||||
return Array.from(set).sort((a, b) =>
|
||||
a.localeCompare(b, "pt-BR", { sensitivity: "base" }),
|
||||
);
|
||||
}, [data.items]);
|
||||
|
||||
const openEditModal = (item: AdminItemRecord) => {
|
||||
@@ -257,8 +321,12 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ error: "Erro desconhecido" }));
|
||||
throw new Error(error?.error || error?.message || "Falha ao salvar item");
|
||||
const error = await res
|
||||
.json()
|
||||
.catch(() => ({ error: "Erro desconhecido" }));
|
||||
throw new Error(
|
||||
error?.error || error?.message || "Falha ao salvar item",
|
||||
);
|
||||
}
|
||||
|
||||
await fetchItems();
|
||||
@@ -281,12 +349,18 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Todos os itens cadastrados</CardTitle>
|
||||
<CardDescription>
|
||||
Visualize e edite os registros unificados importados do database do Tibia Wiki.
|
||||
Itens específicos de servidores podem ser marcados através das tags.
|
||||
Visualize e edite os registros unificados importados do database
|
||||
do Tibia Wiki. Itens específicos de servidores podem ser marcados
|
||||
através das tags.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchItems} disabled={loading}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchItems}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" /> Atualizar
|
||||
</Button>
|
||||
<Button
|
||||
@@ -309,7 +383,13 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<Select value={categoryFilter} onValueChange={(value) => { setCategoryFilter(value); setPage(1); }}>
|
||||
<Select
|
||||
value={categoryFilter}
|
||||
onValueChange={(value) => {
|
||||
setCategoryFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Categoria" />
|
||||
</SelectTrigger>
|
||||
@@ -322,7 +402,13 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={rarityFilter} onValueChange={(value) => { setRarityFilter(value); setPage(1); }}>
|
||||
<Select
|
||||
value={rarityFilter}
|
||||
onValueChange={(value) => {
|
||||
setRarityFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Raridade" />
|
||||
</SelectTrigger>
|
||||
@@ -340,7 +426,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
{apiStatus.code !== null && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
API: {apiStatus.code} {apiStatus.ok ? "OK" : "Erro"}
|
||||
{apiStatus.durationMs !== null ? ` • ${apiStatus.durationMs}ms` : ""}
|
||||
{apiStatus.durationMs !== null
|
||||
? ` • ${apiStatus.durationMs}ms`
|
||||
: ""}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -350,7 +438,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/40 bg-destructive/10">
|
||||
<CardContent className="py-3 text-sm text-destructive">{error}</CardContent>
|
||||
<CardContent className="py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -382,26 +472,48 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
<table className="w-full table-auto border-collapse min-w-[960px]">
|
||||
<thead>
|
||||
<tr className="bg-muted/70 text-muted-foreground text-xs uppercase tracking-wide">
|
||||
<th className="border border-border px-3 py-2 text-left">Imagem</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Nome</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Categoria</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Raridade</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Origem</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Servidores</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Atualizado em</th>
|
||||
<th className="border border-border px-3 py-2 text-left">Ações</th>
|
||||
<th className="border border-border px-3 py-2 text-left">
|
||||
Imagem
|
||||
</th>
|
||||
<th className="border border-border px-3 py-2 text-left">
|
||||
Nome
|
||||
</th>
|
||||
<th className="border border-border px-3 py-2 text-left">
|
||||
Categoria
|
||||
</th>
|
||||
<th className="border border-border px-3 py-2 text-left">
|
||||
Raridade
|
||||
</th>
|
||||
<th className="border border-border px-3 py-2 text-left">
|
||||
Origem
|
||||
</th>
|
||||
<th className="border border-border px-3 py-2 text-left">
|
||||
Servidores
|
||||
</th>
|
||||
<th className="border border-border px-3 py-2 text-left">
|
||||
Atualizado em
|
||||
</th>
|
||||
<th className="border border-border px-3 py-2 text-left">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{itemsForDisplay.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="border border-border px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
<td
|
||||
colSpan={8}
|
||||
className="border border-border px-3 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
Nenhum item encontrado com os filtros atuais.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
itemsForDisplay.map((item) => (
|
||||
<tr key={item.item_id} className="odd:bg-background even:bg-muted/40 hover:bg-primary/5 transition-colors">
|
||||
<tr
|
||||
key={item.item_id}
|
||||
className="odd:bg-background even:bg-muted/40 hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<td className="border border-border px-3 py-2 align-middle">
|
||||
{item.image_url ? (
|
||||
<div className="relative h-12 w-12 overflow-hidden rounded border">
|
||||
@@ -413,7 +525,9 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Sem imagem</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Sem imagem
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 align-top">
|
||||
@@ -422,14 +536,21 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
#{item.item_id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 align-top text-sm">{item.category}</td>
|
||||
<td className="border border-border px-3 py-2 align-top text-sm">
|
||||
{item.category}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 align-top text-sm">
|
||||
{item.rarity ? (
|
||||
<Badge variant="outline" className="uppercase text-[10px]">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="uppercase text-[10px]"
|
||||
>
|
||||
{item.rarity}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 align-top text-[11px] text-muted-foreground">
|
||||
@@ -440,17 +561,27 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
{item.servers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.servers.map((serverTag) => (
|
||||
<Badge key={`${item.item_id}-${serverTag}`} variant="secondary" className="text-[10px] uppercase">
|
||||
<Badge
|
||||
key={`${item.item_id}-${serverTag}`}
|
||||
variant="secondary"
|
||||
className="text-[10px] uppercase"
|
||||
>
|
||||
{serverTag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">Global</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Global
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 align-top text-xs text-muted-foreground">
|
||||
{format(new Date(item.updated_at), "dd/MM/yyyy HH:mm", { locale: ptBR })}
|
||||
{format(
|
||||
new Date(item.updated_at),
|
||||
"dd/MM/yyyy HH:mm",
|
||||
{ locale: ptBR },
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-border px-3 py-2 align-top">
|
||||
<Button
|
||||
@@ -471,7 +602,8 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Página {page} • Total de {Math.max(1, Math.ceil(data.total / pageSize))}
|
||||
Página {page} • Total de{" "}
|
||||
{Math.max(1, Math.ceil(data.total / pageSize))}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -485,8 +617,16 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((prev) => (prev >= Math.ceil(data.total / pageSize) ? prev : prev + 1))}
|
||||
disabled={page >= Math.ceil(data.total / pageSize) || loading}
|
||||
onClick={() =>
|
||||
setPage((prev) =>
|
||||
prev >= Math.ceil(data.total / pageSize)
|
||||
? prev
|
||||
: prev + 1,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
page >= Math.ceil(data.total / pageSize) || loading
|
||||
}
|
||||
>
|
||||
Próxima
|
||||
</Button>
|
||||
@@ -510,4 +650,3 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ItemImageCapture, ProcessedImage } from "./ItemImageCapture";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ItemImageCapture, type ProcessedImage } from "./ItemImageCapture";
|
||||
|
||||
interface ItemAiAssistantProps {
|
||||
locale: string;
|
||||
@@ -86,7 +92,10 @@ export function ItemAiAssistant({
|
||||
const data = await response.json();
|
||||
const structure = data?.structure ?? data;
|
||||
const columns = structure?.columns;
|
||||
setLogs((prev) => [...prev, `Resposta recebida. Tamanho ~${JSON.stringify(structure).length} bytes.`]);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
`Resposta recebida. Tamanho ~${JSON.stringify(structure).length} bytes.`,
|
||||
]);
|
||||
|
||||
// Heurística: sugerir nomes com base em imagens quando não houver texto detectado
|
||||
const suggestNamesFromImages = (imgs: ProcessedImage[]): string[] => {
|
||||
@@ -96,7 +105,15 @@ export function ItemAiAssistant({
|
||||
.replace(/\.[a-zA-Z0-9]+$/, "")
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, "-");
|
||||
const common = new Set(["image", "img", "screenshot", "captura", "photo", "foto", "unknown"]);
|
||||
const common = new Set([
|
||||
"image",
|
||||
"img",
|
||||
"screenshot",
|
||||
"captura",
|
||||
"photo",
|
||||
"foto",
|
||||
"unknown",
|
||||
]);
|
||||
const orientation = first.height > first.width ? "retrato" : "paisagem";
|
||||
const suggestions: string[] = [];
|
||||
if (base && !common.has(base)) suggestions.push(base);
|
||||
@@ -105,12 +122,19 @@ export function ItemAiAssistant({
|
||||
return Array.from(new Set(suggestions)).slice(0, 3);
|
||||
};
|
||||
|
||||
const autoNameDetected = typeof columns?.name === "string" && Boolean(String(columns.name).trim());
|
||||
const autoDescDetected = typeof columns?.description === "string" && Boolean(String(columns.description).trim());
|
||||
const autoNameDetected =
|
||||
typeof columns?.name === "string" &&
|
||||
Boolean(String(columns.name).trim());
|
||||
const autoDescDetected =
|
||||
typeof columns?.description === "string" &&
|
||||
Boolean(String(columns.description).trim());
|
||||
|
||||
const origemDados: string = autoNameDetected || autoDescDetected ? "automatico" : "nao_detectado";
|
||||
const origemDados: string =
|
||||
autoNameDetected || autoDescDetected ? "automatico" : "nao_detectado";
|
||||
|
||||
const sugestoesNome = !autoNameDetected ? suggestNamesFromImages(images) : [];
|
||||
const sugestoesNome = !autoNameDetected
|
||||
? suggestNamesFromImages(images)
|
||||
: [];
|
||||
|
||||
// Estrutura final com campos obrigatórios, origem e sugestões
|
||||
const finalStructure = {
|
||||
@@ -125,18 +149,35 @@ export function ItemAiAssistant({
|
||||
? "Confirme o nome sugerido ou ajuste se necessário."
|
||||
: "Informe um nome para o item. Descrição é opcional, mas ajuda.",
|
||||
},
|
||||
sugestoes: sugestoesNome.length > 0 ? { nome: sugestoesNome } : undefined,
|
||||
sugestoes:
|
||||
sugestoesNome.length > 0 ? { nome: sugestoesNome } : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
setRawJson(JSON.stringify(finalStructure, null, 2));
|
||||
|
||||
const prefill: ItemAiPrefill = {
|
||||
name: autoNameDetected ? (columns!.name as string) : undefined,
|
||||
description: autoDescDetected ? (columns!.description as string) : undefined,
|
||||
metadata: typeof columns === "object" && columns !== null
|
||||
? { ...columns, origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined }
|
||||
: { origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined },
|
||||
name: autoNameDetected ? (columns?.name as string) : undefined,
|
||||
description: autoDescDetected
|
||||
? (columns?.description as string)
|
||||
: undefined,
|
||||
metadata:
|
||||
typeof columns === "object" && columns !== null
|
||||
? {
|
||||
...columns,
|
||||
origem_dados: origemDados,
|
||||
confirmar_nome: autoNameDetected ? false : undefined,
|
||||
sugestoes: sugestoesNome.length
|
||||
? { nome: sugestoesNome }
|
||||
: undefined,
|
||||
}
|
||||
: {
|
||||
origem_dados: origemDados,
|
||||
confirmar_nome: autoNameDetected ? false : undefined,
|
||||
sugestoes: sugestoesNome.length
|
||||
? { nome: sugestoesNome }
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
if (columns) {
|
||||
@@ -160,12 +201,20 @@ export function ItemAiAssistant({
|
||||
}
|
||||
}
|
||||
|
||||
setLogs((prev) => [...prev, "Sugestões de preenchimento geradas. Aguardando confirmação do usuário."]);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
"Sugestões de preenchimento geradas. Aguardando confirmação do usuário.",
|
||||
]);
|
||||
onGenerated({ json: finalStructure, prefill });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : "Erro ao processar análise de IA.");
|
||||
setLogs((prev) => [...prev, `Erro: ${err instanceof Error ? err.message : String(err)}`]);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Erro ao processar análise de IA.",
|
||||
);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
`Erro: ${err instanceof Error ? err.message : String(err)}`,
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLogs((prev) => [...prev, "Processo de geração concluído."]);
|
||||
@@ -193,8 +242,8 @@ export function ItemAiAssistant({
|
||||
Assistente IA para registro de item
|
||||
</h2>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Cole prints e descreva o item. A IA vai sugerir a tabela, campos e metadados. Você pode ajustar os dados
|
||||
antes de salvar.
|
||||
Cole prints e descreva o item. A IA vai sugerir a tabela, campos e
|
||||
metadados. Você pode ajustar os dados antes de salvar.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
@@ -216,7 +265,10 @@ export function ItemAiAssistant({
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Escopo</label>
|
||||
<Select value={scope} onValueChange={(value: "global" | "server") => setScope(value)}>
|
||||
<Select
|
||||
value={scope}
|
||||
onValueChange={(value: "global" | "server") => setScope(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -247,7 +299,9 @@ export function ItemAiAssistant({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Descrição detalhada para a IA</label>
|
||||
<label className="text-sm font-medium">
|
||||
Descrição detalhada para a IA
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
@@ -257,10 +311,14 @@ export function ItemAiAssistant({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Prints / evidências visuais</label>
|
||||
<label className="text-sm font-medium">
|
||||
Prints / evidências visuais
|
||||
</label>
|
||||
<ItemImageCapture onImagesChange={setImages} />
|
||||
{images.length > 0 && (
|
||||
<p className="text-xs text-emerald-600">Imagem recebida — pronta para processamento.</p>
|
||||
<p className="text-xs text-emerald-600">
|
||||
Imagem recebida — pronta para processamento.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -271,12 +329,17 @@ export function ItemAiAssistant({
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" onClick={handleGenerate} disabled={disabled || loading}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Gerar com IA
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A IA retornará um JSON com sugestões de campos. As alterações só serão aplicadas após sua confirmação.
|
||||
A IA retornará um JSON com sugestões de campos. As alterações só
|
||||
serão aplicadas após sua confirmação.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -292,16 +355,20 @@ export function ItemAiAssistant({
|
||||
</pre>
|
||||
) : (
|
||||
<p>
|
||||
Execute a análise para visualizar o JSON sugerido. Ele será usado para preencher automaticamente os
|
||||
campos do formulário.
|
||||
Execute a análise para visualizar o JSON sugerido. Ele será
|
||||
usado para preencher automaticamente os campos do formulário.
|
||||
</p>
|
||||
)}
|
||||
{logs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] text-muted-foreground">Logs do processo</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Logs do processo
|
||||
</p>
|
||||
<div className="rounded-md border bg-muted/30 p-2">
|
||||
{logs.map((line, idx) => (
|
||||
<p key={idx} className="text-[11px] leading-relaxed">• {line}</p>
|
||||
<p key={idx} className="text-[11px] leading-relaxed">
|
||||
• {line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,4 +379,3 @@ export function ItemAiAssistant({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,3 +41,5 @@ export function ItemCreationModal({
|
||||
);
|
||||
}
|
||||
|
||||
// Reexporta o tipo para permitir importação de ItemFormPayload a partir de ItemCreationModal
|
||||
export type { ItemFormPayload } from "./AdminItemForm";
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||
import { Upload, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resizeImageToDataURL } from "@/lib/image/resize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -225,4 +225,3 @@ export function ItemImageCapture({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { SubscriptionTierSummary } from "@/lib/prisma/queries/subscription-tiers";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { SubscriptionTierSummary } from "@/lib/prisma/queries/subscription-tiers";
|
||||
|
||||
type FeedbackState =
|
||||
| { type: "success"; message: string }
|
||||
@@ -38,7 +38,10 @@ function mapTierToFormState(tier: SubscriptionTierSummary): TierFormState {
|
||||
priceYearly: tier.price_yearly ? tier.price_yearly.toString() : "",
|
||||
isActive: tier.is_active,
|
||||
features: (tier.features ?? [])
|
||||
.map((feature) => `${feature.feature_key}|${feature.label}|${feature.description ?? ""}`)
|
||||
.map(
|
||||
(feature) =>
|
||||
`${feature.feature_key}|${feature.label}|${feature.description ?? ""}`,
|
||||
)
|
||||
.join("\n"),
|
||||
};
|
||||
}
|
||||
@@ -53,7 +56,9 @@ function parseFeatures(text: string) {
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line, index) => {
|
||||
const [featureKey, label, description] = line.split("|").map((part) => part?.trim() ?? "");
|
||||
const [featureKey, label, description] = line
|
||||
.split("|")
|
||||
.map((part) => part?.trim() ?? "");
|
||||
return {
|
||||
feature_key: featureKey,
|
||||
label: label || featureKey,
|
||||
@@ -79,8 +84,14 @@ export function AdminSubscriptionDashboard({
|
||||
isActive: true,
|
||||
features: "",
|
||||
});
|
||||
const [editForms, setEditForms] = useState<Record<number, TierFormState>>(() =>
|
||||
Object.fromEntries(initialTiers.map((tier) => [tier.subscription_tier_id, mapTierToFormState(tier)]))
|
||||
const [editForms, setEditForms] = useState<Record<number, TierFormState>>(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
initialTiers.map((tier) => [
|
||||
tier.subscription_tier_id,
|
||||
mapTierToFormState(tier),
|
||||
]),
|
||||
),
|
||||
);
|
||||
const [assignUserId, setAssignUserId] = useState("");
|
||||
const [assignTierId, setAssignTierId] = useState<string>("free");
|
||||
@@ -105,8 +116,8 @@ export function AdminSubscriptionDashboard({
|
||||
data.tiers.map((tier: SubscriptionTierSummary) => [
|
||||
tier.subscription_tier_id,
|
||||
mapTierToFormState(tier),
|
||||
])
|
||||
)
|
||||
]),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -119,8 +130,12 @@ export function AdminSubscriptionDashboard({
|
||||
slug: createForm.slug,
|
||||
description: createForm.description || null,
|
||||
level: Number.parseInt(createForm.level.toString(), 10),
|
||||
priceMonthly: createForm.priceMonthly ? Number.parseFloat(createForm.priceMonthly) : null,
|
||||
priceYearly: createForm.priceYearly ? Number.parseFloat(createForm.priceYearly) : null,
|
||||
priceMonthly: createForm.priceMonthly
|
||||
? Number.parseFloat(createForm.priceMonthly)
|
||||
: null,
|
||||
priceYearly: createForm.priceYearly
|
||||
? Number.parseFloat(createForm.priceYearly)
|
||||
: null,
|
||||
isActive: createForm.isActive,
|
||||
features: parseFeatures(createForm.features),
|
||||
};
|
||||
@@ -156,7 +171,9 @@ export function AdminSubscriptionDashboard({
|
||||
setFeedback({
|
||||
type: "error",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Não foi possível criar o plano.",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Não foi possível criar o plano.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -173,8 +190,12 @@ export function AdminSubscriptionDashboard({
|
||||
slug: form.slug,
|
||||
description: form.description || null,
|
||||
level: Number.parseInt(form.level.toString(), 10),
|
||||
priceMonthly: form.priceMonthly ? Number.parseFloat(form.priceMonthly) : null,
|
||||
priceYearly: form.priceYearly ? Number.parseFloat(form.priceYearly) : null,
|
||||
priceMonthly: form.priceMonthly
|
||||
? Number.parseFloat(form.priceMonthly)
|
||||
: null,
|
||||
priceYearly: form.priceYearly
|
||||
? Number.parseFloat(form.priceYearly)
|
||||
: null,
|
||||
isActive: form.isActive,
|
||||
features: parseFeatures(form.features),
|
||||
};
|
||||
@@ -194,12 +215,17 @@ export function AdminSubscriptionDashboard({
|
||||
}
|
||||
|
||||
await refreshTiers();
|
||||
setFeedback({ type: "success", message: "Plano atualizado com sucesso." });
|
||||
setFeedback({
|
||||
type: "success",
|
||||
message: "Plano atualizado com sucesso.",
|
||||
});
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
type: "error",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Não foi possível atualizar o plano.",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Não foi possível atualizar o plano.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -231,7 +257,9 @@ export function AdminSubscriptionDashboard({
|
||||
setFeedback({
|
||||
type: "error",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Não foi possível remover o plano.",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Não foi possível remover o plano.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -242,7 +270,10 @@ export function AdminSubscriptionDashboard({
|
||||
resetFeedback();
|
||||
|
||||
if (!assignUserId.trim()) {
|
||||
setFeedback({ type: "error", message: "Informe um ID de usuário válido." });
|
||||
setFeedback({
|
||||
type: "error",
|
||||
message: "Informe um ID de usuário válido.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -290,7 +321,7 @@ export function AdminSubscriptionDashboard({
|
||||
}
|
||||
return a.level - b.level;
|
||||
}),
|
||||
[tiers]
|
||||
[tiers],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -322,7 +353,10 @@ export function AdminSubscriptionDashboard({
|
||||
placeholder="Ex.: Ultra"
|
||||
value={createForm.name}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({ ...prev, name: event.target.value }))
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -335,7 +369,10 @@ export function AdminSubscriptionDashboard({
|
||||
placeholder="ex.: ultra"
|
||||
value={createForm.slug}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({ ...prev, slug: event.target.value }))
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
slug: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -365,7 +402,10 @@ export function AdminSubscriptionDashboard({
|
||||
variant={createForm.isActive ? "default" : "outline"}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCreateForm((prev) => ({ ...prev, isActive: !prev.isActive }))
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
isActive: !prev.isActive,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{createForm.isActive ? "Ativo" : "Inativo"}
|
||||
@@ -375,7 +415,10 @@ export function AdminSubscriptionDashboard({
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="tier-price-monthly">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="tier-price-monthly"
|
||||
>
|
||||
Preço mensal (opcional)
|
||||
</label>
|
||||
<Input
|
||||
@@ -385,12 +428,18 @@ export function AdminSubscriptionDashboard({
|
||||
step="0.01"
|
||||
value={createForm.priceMonthly}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({ ...prev, priceMonthly: event.target.value }))
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
priceMonthly: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="tier-price-yearly">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="tier-price-yearly"
|
||||
>
|
||||
Preço anual (opcional)
|
||||
</label>
|
||||
<Input
|
||||
@@ -400,7 +449,10 @@ export function AdminSubscriptionDashboard({
|
||||
step="0.01"
|
||||
value={createForm.priceYearly}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({ ...prev, priceYearly: event.target.value }))
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
priceYearly: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -415,7 +467,10 @@ export function AdminSubscriptionDashboard({
|
||||
rows={3}
|
||||
value={createForm.description}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({ ...prev, description: event.target.value }))
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
description: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -429,7 +484,10 @@ export function AdminSubscriptionDashboard({
|
||||
rows={4}
|
||||
value={createForm.features}
|
||||
onChange={(event) =>
|
||||
setCreateForm((prev) => ({ ...prev, features: event.target.value }))
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
features: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="premium.analytics|Analytics avançadas|Painéis detalhados"
|
||||
/>
|
||||
@@ -452,7 +510,8 @@ export function AdminSubscriptionDashboard({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{orderedTiers.map((tier) => {
|
||||
const formState = editForms[tier.subscription_tier_id] ?? mapTierToFormState(tier);
|
||||
const formState =
|
||||
editForms[tier.subscription_tier_id] ?? mapTierToFormState(tier);
|
||||
|
||||
return (
|
||||
<Card key={tier.subscription_tier_id} className="border-muted">
|
||||
@@ -461,7 +520,10 @@ export function AdminSubscriptionDashboard({
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
{tier.name}
|
||||
<Badge variant="secondary">{tier.slug}</Badge>
|
||||
<Badge variant={tier.is_active ? "default" : "outline"} className="uppercase">
|
||||
<Badge
|
||||
variant={tier.is_active ? "default" : "outline"}
|
||||
className="uppercase"
|
||||
>
|
||||
{tier.is_active ? "Ativo" : "Inativo"}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
@@ -514,7 +576,10 @@ export function AdminSubscriptionDashboard({
|
||||
...prev,
|
||||
[tier.subscription_tier_id]: {
|
||||
...prev[tier.subscription_tier_id],
|
||||
level: Number.parseInt(event.target.value || "0", 10),
|
||||
level: Number.parseInt(
|
||||
event.target.value || "0",
|
||||
10,
|
||||
),
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -530,7 +595,8 @@ export function AdminSubscriptionDashboard({
|
||||
...prev,
|
||||
[tier.subscription_tier_id]: {
|
||||
...prev[tier.subscription_tier_id],
|
||||
isActive: !prev[tier.subscription_tier_id].isActive,
|
||||
isActive:
|
||||
!prev[tier.subscription_tier_id].isActive,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -542,7 +608,9 @@ export function AdminSubscriptionDashboard({
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Preço mensal</label>
|
||||
<label className="text-sm font-medium">
|
||||
Preço mensal
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -618,7 +686,9 @@ export function AdminSubscriptionDashboard({
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleUpdateTier(tier.subscription_tier_id)}
|
||||
onClick={() =>
|
||||
handleUpdateTier(tier.subscription_tier_id)
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Salvar alterações
|
||||
@@ -640,7 +710,9 @@ export function AdminSubscriptionDashboard({
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteTier(tier.subscription_tier_id, tier.slug)}
|
||||
onClick={() =>
|
||||
handleDeleteTier(tier.subscription_tier_id, tier.slug)
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Remover plano
|
||||
@@ -654,7 +726,7 @@ export function AdminSubscriptionDashboard({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vincular plano a usuário</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -698,4 +770,3 @@ export function AdminSubscriptionDashboard({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
@@ -58,7 +58,7 @@ export function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
|
||||
} else {
|
||||
setError(data.error || "Erro ao fazer login");
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
setError("Erro ao conectar com o servidor");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -116,4 +116,3 @@ export function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,22 +12,29 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { useServers } from "@/lib/hooks/use-servers";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { formatPhone, formatInternationalPhone, unformatPhone, combinePhoneWithDialCode, getPhonePlaceholder, checkPasswordStrength, validatePasswordRequirements } from "@/lib/utils/formatters";
|
||||
import { COUNTRIES, DEFAULT_COUNTRY, type Country } from "@/lib/utils/countries";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { X, Plus, Check, XCircle, ChevronDown } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useServers } from "@/lib/hooks/use-servers";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import {
|
||||
COUNTRIES,
|
||||
type Country,
|
||||
DEFAULT_COUNTRY,
|
||||
} from "@/lib/utils/countries";
|
||||
import {
|
||||
checkPasswordStrength,
|
||||
combinePhoneWithDialCode,
|
||||
formatInternationalPhone,
|
||||
getPhonePlaceholder,
|
||||
unformatPhone,
|
||||
validatePasswordRequirements,
|
||||
} from "@/lib/utils/formatters";
|
||||
|
||||
interface RegisterDialogProps {
|
||||
open: boolean;
|
||||
@@ -39,7 +50,11 @@ interface ServerSelection {
|
||||
export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
const t = useTranslations("auth.register");
|
||||
const { refreshSession } = useSession();
|
||||
const { servers, isLoading: serversLoading, error: serversError } = useServers();
|
||||
const {
|
||||
servers,
|
||||
isLoading: serversLoading,
|
||||
error: serversError,
|
||||
} = useServers();
|
||||
|
||||
// Form fields
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -49,7 +64,8 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [selectedCountry, setSelectedCountry] = useState<Country>(DEFAULT_COUNTRY);
|
||||
const [selectedCountry, setSelectedCountry] =
|
||||
useState<Country>(DEFAULT_COUNTRY);
|
||||
const [countrySearch, setCountrySearch] = useState("");
|
||||
|
||||
// Servers selection
|
||||
@@ -61,7 +77,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [passwordStrength, setPasswordStrength] = useState<"weak" | "medium" | "strong">("weak");
|
||||
const [passwordStrength, setPasswordStrength] = useState<
|
||||
"weak" | "medium" | "strong"
|
||||
>("weak");
|
||||
|
||||
// Filtered servers based on search
|
||||
const filteredServers = useMemo(() => {
|
||||
@@ -70,7 +88,7 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
return servers.filter(
|
||||
(s) =>
|
||||
s.serverName.toLowerCase().includes(searchLower) &&
|
||||
!selectedServers.some((sel) => sel.serverName === s.serverName)
|
||||
!selectedServers.some((sel) => sel.serverName === s.serverName),
|
||||
);
|
||||
}, [serverSearch, servers, selectedServers]);
|
||||
|
||||
@@ -145,7 +163,11 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
};
|
||||
|
||||
const handlePhoneChange = (value: string) => {
|
||||
const formatted = formatInternationalPhone(value, selectedCountry.dialCode, selectedCountry.code);
|
||||
const formatted = formatInternationalPhone(
|
||||
value,
|
||||
selectedCountry.dialCode,
|
||||
selectedCountry.code,
|
||||
);
|
||||
setPhone(formatted);
|
||||
};
|
||||
|
||||
@@ -157,7 +179,7 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(searchLower) ||
|
||||
c.dialCode.includes(searchLower) ||
|
||||
c.code.toLowerCase().includes(searchLower)
|
||||
c.code.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}, [countrySearch]);
|
||||
|
||||
@@ -233,7 +255,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
password,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
phone: phone ? combinePhoneWithDialCode(selectedCountry.dialCode, phone) : undefined,
|
||||
phone: phone
|
||||
? combinePhoneWithDialCode(selectedCountry.dialCode, phone)
|
||||
: undefined,
|
||||
servers: selectedServers.map((s) => ({
|
||||
serverName: s.serverName,
|
||||
characterName: s.characterName.trim(),
|
||||
@@ -246,7 +270,10 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
const text = await response.text();
|
||||
console.error("Resposta não é JSON do registro:", text.substring(0, 200));
|
||||
console.error(
|
||||
"Resposta não é JSON do registro:",
|
||||
text.substring(0, 200),
|
||||
);
|
||||
setError("Erro: resposta inválida do servidor");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -270,7 +297,7 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
setError(data.error || "Erro ao criar conta");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
setError("Erro ao conectar com o servidor");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -313,7 +340,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
className={fieldErrors.username ? "border-destructive" : ""}
|
||||
/>
|
||||
{fieldErrors.username && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.username}</p>
|
||||
<p className="text-sm text-destructive">
|
||||
{fieldErrors.username}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -351,7 +380,12 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
<div
|
||||
className={`h-full ${getPasswordStrengthColor()} transition-all`}
|
||||
style={{
|
||||
width: passwordStrength === "weak" ? "33%" : passwordStrength === "medium" ? "66%" : "100%",
|
||||
width:
|
||||
passwordStrength === "weak"
|
||||
? "33%"
|
||||
: passwordStrength === "medium"
|
||||
? "66%"
|
||||
: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -362,7 +396,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
</div>
|
||||
)}
|
||||
{fieldErrors.password && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.password}</p>
|
||||
<p className="text-sm text-destructive">
|
||||
{fieldErrors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -375,10 +411,14 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className={fieldErrors.confirmPassword ? "border-destructive" : ""}
|
||||
className={
|
||||
fieldErrors.confirmPassword ? "border-destructive" : ""
|
||||
}
|
||||
/>
|
||||
{fieldErrors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.confirmPassword}</p>
|
||||
<p className="text-sm text-destructive">
|
||||
{fieldErrors.confirmPassword}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,7 +440,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
className={fieldErrors.firstName ? "border-destructive" : ""}
|
||||
/>
|
||||
{fieldErrors.firstName && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.firstName}</p>
|
||||
<p className="text-sm text-destructive">
|
||||
{fieldErrors.firstName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -416,7 +458,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
className={fieldErrors.lastName ? "border-destructive" : ""}
|
||||
/>
|
||||
{fieldErrors.lastName && (
|
||||
<p className="text-sm text-destructive">{fieldErrors.lastName}</p>
|
||||
<p className="text-sm text-destructive">
|
||||
{fieldErrors.lastName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -435,12 +479,17 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{selectedCountry.flag}</span>
|
||||
<span className="text-sm">{selectedCountry.dialCode}</span>
|
||||
<span className="text-sm">
|
||||
{selectedCountry.dialCode}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[300px] max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-[300px] max-h-[300px] overflow-y-auto"
|
||||
>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
@@ -463,7 +512,7 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
const reformatted = formatInternationalPhone(
|
||||
unformatted,
|
||||
country.dialCode,
|
||||
country.code
|
||||
country.code,
|
||||
);
|
||||
setPhone(reformatted);
|
||||
}
|
||||
@@ -473,7 +522,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
<span className="flex items-center gap-2 w-full">
|
||||
<span>{country.flag}</span>
|
||||
<span className="flex-1">{country.name}</span>
|
||||
<span className="text-muted-foreground text-sm">{country.dialCode}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{country.dialCode}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -504,7 +555,9 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{t("servers")}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t("serversDescription")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("serversDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -587,15 +640,23 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`character-${index}`}>{t("characterName")}</Label>
|
||||
<Label htmlFor={`character-${index}`}>
|
||||
{t("characterName")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`character-${index}`}
|
||||
type="text"
|
||||
placeholder={t("characterNamePlaceholder")}
|
||||
value={server.characterName}
|
||||
onChange={(e) => updateServerCharacter(index, e.target.value)}
|
||||
onChange={(e) =>
|
||||
updateServerCharacter(index, e.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
className={fieldErrors[`server_${index}`] ? "border-destructive" : ""}
|
||||
className={
|
||||
fieldErrors[`server_${index}`]
|
||||
? "border-destructive"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{fieldErrors[`server_${index}`] && (
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -624,7 +685,11 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading || serversLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || serversLoading}
|
||||
>
|
||||
{isLoading ? t("submitting") : t("submit")}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -632,4 +697,3 @@ export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Package, MapPin, DollarSign, Coins, User } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Coins, DollarSign, MapPin, Package, User } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Chat } from "@/lib/hooks/use-chat";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
@@ -17,20 +17,23 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
|
||||
chat.buyer_id === currentUserId ? chat.seller_cache : chat.buyer_cache;
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const formatPrice = (price: number | null | undefined, type: "pix" | "tc" | "gp"): string => {
|
||||
const formatPrice = (
|
||||
price: number | null | undefined,
|
||||
type: "pix" | "tc" | "gp",
|
||||
): string => {
|
||||
if (price === null || price === undefined) return "N/A";
|
||||
|
||||
|
||||
if (type === "pix") {
|
||||
return new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
|
||||
if (type === "tc") {
|
||||
return `${price.toLocaleString("pt-BR")} TC`;
|
||||
}
|
||||
|
||||
|
||||
if (type === "gp") {
|
||||
const gold = price / 1000000;
|
||||
if (gold >= 1) {
|
||||
@@ -38,7 +41,7 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
|
||||
}
|
||||
return `${price.toLocaleString("pt-BR")} gp`;
|
||||
}
|
||||
|
||||
|
||||
return price.toLocaleString("pt-BR");
|
||||
};
|
||||
|
||||
@@ -63,7 +66,9 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
|
||||
|
||||
{/* Detalhes do anúncio */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg truncate">{listing.item_name}</h3>
|
||||
<h3 className="font-semibold text-lg truncate">
|
||||
{listing.item_name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
@@ -102,9 +107,7 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
|
||||
{/* Informações do outro usuário */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Conversando com:{" "}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">Conversando com: </span>
|
||||
<span className="text-sm font-medium">
|
||||
{otherUser.character_name || otherUser.username}
|
||||
</span>
|
||||
@@ -112,4 +115,3 @@ export function ChatHeader({ chat, currentUserId }: ChatHeaderProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { MessageInput } from "./MessageInput";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useChat } from "@/lib/hooks/use-chat";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { MessageInput } from "./MessageInput";
|
||||
import { MessageList } from "./MessageList";
|
||||
|
||||
interface ChatModalProps {
|
||||
open: boolean;
|
||||
@@ -104,9 +104,7 @@ export function ChatModal({
|
||||
<DialogContent className="max-w-2xl h-[80vh] flex flex-col p-0 gap-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Chat com vendedor</DialogTitle>
|
||||
<DialogDescription>
|
||||
Conversa sobre o anúncio
|
||||
</DialogDescription>
|
||||
<DialogDescription>Conversa sobre o anúncio</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isCreatingChat || (isLoading && !chat) ? (
|
||||
@@ -144,4 +142,3 @@ export function ChatModal({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatMessage } from "@/lib/hooks/use-chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
@@ -31,7 +31,7 @@ export function MessageBubble({ message, isOwn }: MessageBubbleProps) {
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full mb-2",
|
||||
isOwn ? "justify-end" : "justify-start"
|
||||
isOwn ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -39,7 +39,7 @@ export function MessageBubble({ message, isOwn }: MessageBubbleProps) {
|
||||
"max-w-[70%] rounded-lg px-4 py-2",
|
||||
isOwn
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">
|
||||
@@ -48,16 +48,13 @@ export function MessageBubble({ message, isOwn }: MessageBubbleProps) {
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 mt-1 text-xs",
|
||||
isOwn ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||
isOwn ? "text-primary-foreground/70" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{formatTime(message.created_at)}</span>
|
||||
{isOwn && message.read_at && (
|
||||
<span className="opacity-70">✓✓</span>
|
||||
)}
|
||||
{isOwn && message.read_at && <span className="opacity-70">✓✓</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, KeyboardEvent } from "react";
|
||||
import { Loader2, Send } from "lucide-react";
|
||||
import { type KeyboardEvent, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Send, Loader2 } from "lucide-react";
|
||||
import { useChatTyping } from "@/lib/hooks/use-chat";
|
||||
import { useSocket } from "@/lib/hooks/use-socket";
|
||||
|
||||
@@ -25,7 +25,7 @@ export function MessageInput({
|
||||
const { handleTypingStart, handleTypingStop } = useChatTyping(
|
||||
socket,
|
||||
isConnected,
|
||||
chatId
|
||||
chatId,
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -46,7 +46,7 @@ export function MessageInput({
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setContent(value);
|
||||
|
||||
|
||||
if (value.trim().length > 0) {
|
||||
handleTypingStart();
|
||||
} else {
|
||||
@@ -85,4 +85,3 @@ export function MessageInput({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { ChatMessage } from "@/lib/hooks/use-chat";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: ChatMessage[];
|
||||
@@ -40,7 +40,7 @@ export function MessageList({
|
||||
if (messages.length > 0 && !isLoading) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
|
||||
}
|
||||
}, [isLoading]);
|
||||
}, [isLoading, messages.length]);
|
||||
|
||||
// Detectar scroll no topo para carregar mais
|
||||
const handleScroll = () => {
|
||||
@@ -50,7 +50,7 @@ export function MessageList({
|
||||
if (container.scrollTop === 0) {
|
||||
const previousScrollHeight = container.scrollHeight;
|
||||
onLoadMore();
|
||||
|
||||
|
||||
// Ajustar scroll após carregar mais mensagens
|
||||
setTimeout(() => {
|
||||
const newScrollHeight = container.scrollHeight;
|
||||
@@ -75,7 +75,9 @@ export function MessageList({
|
||||
<div className="flex items-center justify-center h-full text-center text-muted-foreground">
|
||||
<div>
|
||||
<p className="text-sm">Nenhuma mensagem ainda</p>
|
||||
<p className="text-xs mt-1">Envie a primeira mensagem para começar a conversa</p>
|
||||
<p className="text-xs mt-1">
|
||||
Envie a primeira mensagem para começar a conversa
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -115,4 +117,3 @@ export function MessageList({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import confetti from "canvas-confetti";
|
||||
import {
|
||||
Heart,
|
||||
Coffee,
|
||||
Pizza,
|
||||
Loader2,
|
||||
Copy,
|
||||
Check,
|
||||
Lock,
|
||||
User,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
DollarSign,
|
||||
Coffee,
|
||||
Copy,
|
||||
Heart,
|
||||
Loader2,
|
||||
Lock,
|
||||
Pizza,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DonationTier, DonationResponse } from "@/types/donation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { DonationResponse, DonationTier } from "@/types/donation";
|
||||
|
||||
interface DonationCardProps extends DonationTier {}
|
||||
|
||||
@@ -61,7 +60,7 @@ export function DonationCard({
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [timeRemaining, setTimeRemaining] = useState<string | null>(null);
|
||||
const [paymentConfirmed, setPaymentConfirmed] = useState(false);
|
||||
const [_paymentConfirmed, setPaymentConfirmed] = useState(false);
|
||||
const [showThankYouModal, setShowThankYouModal] = useState(false);
|
||||
|
||||
const t = useTranslations("donation");
|
||||
@@ -94,7 +93,7 @@ export function DonationCard({
|
||||
|
||||
const updateTimer = () => {
|
||||
try {
|
||||
const expires = new Date(pixData!.expiresAt!);
|
||||
const expires = new Date(pixData?.expiresAt!);
|
||||
const now = new Date();
|
||||
const diff = expires.getTime() - now.getTime();
|
||||
|
||||
@@ -118,7 +117,7 @@ export function DonationCard({
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/donations/status?pixId=${pixData.pixId}`
|
||||
`/api/donations/status?pixId=${pixData.pixId}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -346,7 +345,7 @@ export function DonationCard({
|
||||
const isValidCustomAmount = () => {
|
||||
const amount = parseFloat(customAmount);
|
||||
return (
|
||||
!isNaN(amount) &&
|
||||
!Number.isNaN(amount) &&
|
||||
amount >= customProduct.minAmount &&
|
||||
amount <= customProduct.maxAmount
|
||||
);
|
||||
@@ -363,12 +362,12 @@ export function DonationCard({
|
||||
// Garante apenas um separador decimal
|
||||
const parts = normalized.split(".");
|
||||
if (parts.length > 2) {
|
||||
return parts[0] + "." + parts.slice(1).join("");
|
||||
return `${parts[0]}.${parts.slice(1).join("")}`;
|
||||
}
|
||||
|
||||
// Limita 2 casas decimais
|
||||
if (parts[1]?.length > 2) {
|
||||
return parts[0] + "." + parts[1].substring(0, 2);
|
||||
return `${parts[0]}.${parts[1].substring(0, 2)}`;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
@@ -396,12 +395,12 @@ export function DonationCard({
|
||||
setError("Digite um valor");
|
||||
return;
|
||||
}
|
||||
const amount = parseFloat(customAmount);
|
||||
const _amount = parseFloat(customAmount);
|
||||
if (!isValidCustomAmount()) {
|
||||
setError(
|
||||
`Valor deve estar entre R$ ${customProduct.minAmount.toFixed(
|
||||
2
|
||||
)} e R$ ${customProduct.maxAmount.toFixed(2)}`
|
||||
2,
|
||||
)} e R$ ${customProduct.maxAmount.toFixed(2)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -463,10 +462,10 @@ export function DonationCard({
|
||||
<p className="text-xs text-red-500">
|
||||
{parseFloat(customAmount) < customProduct.minAmount
|
||||
? `${t(
|
||||
"form.minAmount"
|
||||
"form.minAmount",
|
||||
)} ${customProduct.minAmount.toFixed(2)}`
|
||||
: `${t(
|
||||
"form.maxAmount"
|
||||
"form.maxAmount",
|
||||
)} ${customProduct.maxAmount.toFixed(2)}`}
|
||||
</p>
|
||||
)}
|
||||
@@ -485,7 +484,7 @@ export function DonationCard({
|
||||
</>
|
||||
) : customAmount ? (
|
||||
`${t("form.donate")} ${parseFloat(
|
||||
customAmount || "0"
|
||||
customAmount || "0",
|
||||
).toFixed(2)}`
|
||||
) : (
|
||||
t("form.chooseAmount")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Heart, Coffee } from "lucide-react";
|
||||
import { BlurFade } from "@/components/ui/blur-fade";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Coffee, Heart } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BlurFade } from "@/components/ui/blur-fade";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function DonationFooterCard() {
|
||||
const t = useTranslations("donation");
|
||||
|
||||
+173
-147
@@ -1,45 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { TibiaItem } from "@/types/item";
|
||||
import { MagicCard } from "@/components/ui/magic-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PriceDisplay } from "@/components/ui/price-display";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Sword,
|
||||
Shield,
|
||||
Weight,
|
||||
Heart,
|
||||
Star,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Boxes,
|
||||
Clock,
|
||||
Coins,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Minus,
|
||||
Zap,
|
||||
Flame,
|
||||
Snowflake,
|
||||
Zap as Energy,
|
||||
Skull,
|
||||
Cross,
|
||||
Badge as BadgeIcon,
|
||||
Boxes,
|
||||
Gem,
|
||||
FlaskConical,
|
||||
Wrench,
|
||||
ScrollText,
|
||||
Leaf,
|
||||
Zap as Energy,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
Gem,
|
||||
Heart,
|
||||
Leaf,
|
||||
Minus,
|
||||
ScrollText,
|
||||
Shield,
|
||||
Skull,
|
||||
Snowflake,
|
||||
Star,
|
||||
Sword,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Weight,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
getRarityColor,
|
||||
getRarityBgColor,
|
||||
getRarityGlow,
|
||||
} from "@/lib/currency-utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MagicCard } from "@/components/ui/magic-card";
|
||||
import { PriceDisplay } from "@/components/ui/price-display";
|
||||
import { formatNumber } from "@/lib/utils/formatters";
|
||||
import type { TibiaItem } from "@/types/item";
|
||||
|
||||
interface ItemCardProps {
|
||||
item: TibiaItem;
|
||||
@@ -50,7 +44,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
const t = useTranslations("home.item");
|
||||
const locale = useLocale();
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [_isHovered, setIsHovered] = useState(false);
|
||||
const formatVocation = (v: string | undefined): string | undefined => {
|
||||
if (!v) return undefined;
|
||||
let out = String(v);
|
||||
@@ -64,14 +58,22 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
return out;
|
||||
};
|
||||
|
||||
const ScrollRow = ({ children, ariaLabel }: { children: React.ReactNode; ariaLabel: string }) => {
|
||||
const ScrollRow = ({
|
||||
children,
|
||||
ariaLabel,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
ariaLabel: string;
|
||||
}) => {
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
const [_canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [_canScrollRight, setCanScrollRight] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const autoScrollRef = useRef<number | null>(null);
|
||||
const drag = useRef<{ active: boolean; startX: number; startLeft: number }>({ active: false, startX: 0, startLeft: 0 });
|
||||
const drag = useRef<{ active: boolean; startX: number; startLeft: number }>(
|
||||
{ active: false, startX: 0, startLeft: 0 },
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const updateButtons = () => {
|
||||
@@ -114,9 +116,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
el.removeEventListener("mouseleave", onLeave);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [updateButtons]);
|
||||
|
||||
const scrollBy = (dx: number) => {
|
||||
const _scrollBy = (dx: number) => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const base = contentRef.current?.offsetWidth || 0;
|
||||
@@ -182,7 +184,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
if (!drag.current.active) return;
|
||||
drag.current.active = false;
|
||||
setIsDragging(false);
|
||||
try { el.releasePointerCapture(e.pointerId); } catch {}
|
||||
try {
|
||||
el.releasePointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
el.addEventListener("pointerdown", onPointerDown);
|
||||
@@ -204,7 +208,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
ref={scrollerRef}
|
||||
className={`overflow-x-auto no-scrollbar whitespace-nowrap pr-6 pl-6 select-none ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
style={{ scrollBehavior: "smooth" }}
|
||||
>
|
||||
>
|
||||
<div ref={contentRef} className="inline-flex items-center gap-2">
|
||||
{children}
|
||||
{children}
|
||||
@@ -213,10 +217,10 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const [showAllDroppers, setShowAllDroppers] = useState(false);
|
||||
|
||||
const rarityColors = {
|
||||
const [_showAllDroppers, _setShowAllDroppers] = useState(false);
|
||||
|
||||
const _rarityColors = {
|
||||
common: "bg-gray-500",
|
||||
uncommon: "bg-green-500",
|
||||
rare: "bg-blue-500",
|
||||
@@ -251,14 +255,20 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
(item as any).price.sell !== 0;
|
||||
const priceDifference = hasPrice
|
||||
? (((item as any).price.buy - (item as any).price.sell) /
|
||||
(item as any).price.sell) * 100
|
||||
(item as any).price.sell) *
|
||||
100
|
||||
: 0;
|
||||
const isHotDeal = hasPrice && priceDifference > 20; // Hot deal se diferença > 20%
|
||||
|
||||
// Determinar tendência de preço
|
||||
const getPriceTrend = () => {
|
||||
const ph = (item as any).priceHistory;
|
||||
if (!ph || typeof ph.highest !== "number" || typeof ph.lowest !== "number" || typeof ph.average !== "number") {
|
||||
if (
|
||||
!ph ||
|
||||
typeof ph.highest !== "number" ||
|
||||
typeof ph.lowest !== "number" ||
|
||||
typeof ph.average !== "number"
|
||||
) {
|
||||
return "stable";
|
||||
}
|
||||
if (ph.highest > ph.average * 1.1) return "up";
|
||||
@@ -273,8 +283,8 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
trend === "up"
|
||||
? "text-green-500"
|
||||
: trend === "down"
|
||||
? "text-red-500"
|
||||
: "text-gray-500";
|
||||
? "text-red-500"
|
||||
: "text-gray-500";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -323,9 +333,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-muted/50 rounded-xl flex items-center justify-center text-3xl backdrop-blur-sm">
|
||||
{typeof CategoryIcon === "string" ? (
|
||||
null
|
||||
) : (
|
||||
{typeof CategoryIcon === "string" ? null : (
|
||||
<CategoryIcon className="w-10 h-10" />
|
||||
)}
|
||||
</div>
|
||||
@@ -352,7 +360,8 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
{(() => {
|
||||
const catPt = (item as any).categoryPt as string | undefined;
|
||||
const catEn = (item as any).category as string | undefined;
|
||||
const catRaw = (catPt && String(catPt)) || (catEn && String(catEn)) || "";
|
||||
const catRaw =
|
||||
(catPt && String(catPt)) || (catEn && String(catEn)) || "";
|
||||
const display = catRaw
|
||||
? catRaw.charAt(0).toUpperCase() + catRaw.slice(1)
|
||||
: "";
|
||||
@@ -375,9 +384,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
{item.stats.attack && (
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Sword className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">
|
||||
{item.stats.attack} ATK
|
||||
</span>
|
||||
<span className="font-medium">{item.stats.attack} ATK</span>
|
||||
</div>
|
||||
)}
|
||||
{item.stats.defense && (
|
||||
@@ -391,21 +398,23 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
{item.stats.armor && (
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">
|
||||
{item.stats.armor} ARM
|
||||
</span>
|
||||
<span className="font-medium">{item.stats.armor} ARM</span>
|
||||
</div>
|
||||
)}
|
||||
{(item as any).volume && (
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Boxes className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">Volume {(item as any).volume}</span>
|
||||
<span className="font-medium">
|
||||
Volume {(item as any).volume}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(item as any).bonus_de_skill && (
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">Skill +{(item as any).bonus_de_skill}</span>
|
||||
<span className="font-medium">
|
||||
Skill +{(item as any).bonus_de_skill}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -457,84 +466,91 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(item as any).protecao && (() => {
|
||||
const raw = String((item as any).protecao || "");
|
||||
const tokens = raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.filter((s) => !/^nenhum(a)?\.?$/i.test(s.trim()));
|
||||
if (tokens.length === 0) return null;
|
||||
return (
|
||||
<ScrollRow ariaLabel="elemental protections">
|
||||
{tokens.map((raw: string, idx: number) => {
|
||||
const token = raw.trim();
|
||||
const match = token.match(/^([A-Za-zçÇáÁéÉíÍóÓúÚãÃõÕ ]+)(.*)$/);
|
||||
const type = (match?.[1] || token).trim().toLowerCase();
|
||||
const value = (match?.[2] || "").trim();
|
||||
let Icon: any = Shield;
|
||||
let chipBg = "bg-amber-500/10";
|
||||
let chipText = "text-amber-700";
|
||||
switch (type) {
|
||||
case "fire":
|
||||
case "fogo":
|
||||
Icon = Flame;
|
||||
chipBg = "bg-red-500/10";
|
||||
chipText = "text-red-600";
|
||||
break;
|
||||
case "ice":
|
||||
case "gelo":
|
||||
Icon = Snowflake;
|
||||
chipBg = "bg-blue-500/10";
|
||||
chipText = "text-blue-600";
|
||||
break;
|
||||
case "energy":
|
||||
case "energia":
|
||||
Icon = Energy;
|
||||
chipBg = "bg-yellow-500/10";
|
||||
chipText = "text-yellow-600";
|
||||
break;
|
||||
case "earth":
|
||||
case "terra":
|
||||
Icon = Leaf;
|
||||
chipBg = "bg-green-500/10";
|
||||
chipText = "text-green-600";
|
||||
break;
|
||||
case "death":
|
||||
case "morte":
|
||||
Icon = Skull;
|
||||
chipBg = "bg-zinc-500/10";
|
||||
chipText = "text-zinc-600";
|
||||
break;
|
||||
case "holy":
|
||||
case "sagrado":
|
||||
Icon = Cross;
|
||||
chipBg = "bg-amber-500/10";
|
||||
chipText = "text-amber-700";
|
||||
break;
|
||||
case "physical":
|
||||
case "fisico":
|
||||
Icon = Shield;
|
||||
chipBg = "bg-slate-500/10";
|
||||
chipText = "text-slate-600";
|
||||
break;
|
||||
default:
|
||||
Icon = Shield;
|
||||
chipBg = "bg-amber-500/10";
|
||||
chipText = "text-amber-700";
|
||||
}
|
||||
const prettyType = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
const label = `${prettyType.replace("fisico", "Físico")}${value ? " " + value : ""}`;
|
||||
return (
|
||||
<div key={`${type}-${idx}`} className={`flex items-center gap-1 text-xs ${chipBg} ${chipText} px-2 py-1 rounded`}>
|
||||
<Icon className="h-3 w-3" />
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollRow>
|
||||
);
|
||||
})()}
|
||||
{(item as any).protecao &&
|
||||
(() => {
|
||||
const raw = String((item as any).protecao || "");
|
||||
const tokens = raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.filter((s) => !/^nenhum(a)?\.?$/i.test(s.trim()));
|
||||
if (tokens.length === 0) return null;
|
||||
return (
|
||||
<ScrollRow ariaLabel="elemental protections">
|
||||
{tokens.map((raw: string, idx: number) => {
|
||||
const token = raw.trim();
|
||||
const match = token.match(
|
||||
/^([A-Za-zçÇáÁéÉíÍóÓúÚãÃõÕ ]+)(.*)$/,
|
||||
);
|
||||
const type = (match?.[1] || token).trim().toLowerCase();
|
||||
const value = (match?.[2] || "").trim();
|
||||
let Icon: any = Shield;
|
||||
let chipBg = "bg-amber-500/10";
|
||||
let chipText = "text-amber-700";
|
||||
switch (type) {
|
||||
case "fire":
|
||||
case "fogo":
|
||||
Icon = Flame;
|
||||
chipBg = "bg-red-500/10";
|
||||
chipText = "text-red-600";
|
||||
break;
|
||||
case "ice":
|
||||
case "gelo":
|
||||
Icon = Snowflake;
|
||||
chipBg = "bg-blue-500/10";
|
||||
chipText = "text-blue-600";
|
||||
break;
|
||||
case "energy":
|
||||
case "energia":
|
||||
Icon = Energy;
|
||||
chipBg = "bg-yellow-500/10";
|
||||
chipText = "text-yellow-600";
|
||||
break;
|
||||
case "earth":
|
||||
case "terra":
|
||||
Icon = Leaf;
|
||||
chipBg = "bg-green-500/10";
|
||||
chipText = "text-green-600";
|
||||
break;
|
||||
case "death":
|
||||
case "morte":
|
||||
Icon = Skull;
|
||||
chipBg = "bg-zinc-500/10";
|
||||
chipText = "text-zinc-600";
|
||||
break;
|
||||
case "holy":
|
||||
case "sagrado":
|
||||
Icon = Cross;
|
||||
chipBg = "bg-amber-500/10";
|
||||
chipText = "text-amber-700";
|
||||
break;
|
||||
case "physical":
|
||||
case "fisico":
|
||||
Icon = Shield;
|
||||
chipBg = "bg-slate-500/10";
|
||||
chipText = "text-slate-600";
|
||||
break;
|
||||
default:
|
||||
Icon = Shield;
|
||||
chipBg = "bg-amber-500/10";
|
||||
chipText = "text-amber-700";
|
||||
}
|
||||
const prettyType =
|
||||
type.charAt(0).toUpperCase() + type.slice(1);
|
||||
const label = `${prettyType.replace("fisico", "Físico")}${value ? ` ${value}` : ""}`;
|
||||
return (
|
||||
<div
|
||||
key={`${type}-${idx}`}
|
||||
className={`flex items-center gap-1 text-xs ${chipBg} ${chipText} px-2 py-1 rounded`}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollRow>
|
||||
);
|
||||
})()}
|
||||
|
||||
{item.stats.weight && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
@@ -552,7 +568,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(item as any).dropa_de && (
|
||||
{(item as any).dropa_de &&
|
||||
(() => {
|
||||
const raw = String((item as any).dropa_de || "");
|
||||
const normalized = raw.trim();
|
||||
@@ -588,8 +604,7 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
</ScrollRow>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
})()}
|
||||
|
||||
{mode === "market" && item.price && (
|
||||
<>
|
||||
@@ -649,7 +664,9 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("priceHistory")}
|
||||
</span>
|
||||
<div className={`flex items-center gap-1 text-xs ${trendColor}`}>
|
||||
<div
|
||||
className={`flex items-center gap-1 text-xs ${trendColor}`}
|
||||
>
|
||||
<TrendIcon className="h-3 w-3" />
|
||||
<span className="capitalize">{trend}</span>
|
||||
</div>
|
||||
@@ -661,21 +678,27 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
<span className="font-medium">{t("highest")}</span>
|
||||
</div>
|
||||
<div className="font-bold">{formatNumber(item.priceHistory.highest, locale)} gp</div>
|
||||
<div className="font-bold">
|
||||
{formatNumber(item.priceHistory.highest, locale)} gp
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<div className="flex items-center justify-center gap-1 text-red-600">
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
<span className="font-medium">{t("lowest")}</span>
|
||||
</div>
|
||||
<div className="font-bold">{formatNumber(item.priceHistory.lowest, locale)} gp</div>
|
||||
<div className="font-bold">
|
||||
{formatNumber(item.priceHistory.lowest, locale)} gp
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<div className="flex items-center justify-center gap-1 text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />
|
||||
<span className="font-medium">{t("average")}</span>
|
||||
</div>
|
||||
<div className="font-bold">{formatNumber(item.priceHistory.average, locale)} gp</div>
|
||||
<div className="font-bold">
|
||||
{formatNumber(item.priceHistory.average, locale)} gp
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -690,7 +713,10 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
|
||||
>
|
||||
{t("viewDetails")}
|
||||
</Button>
|
||||
<Button className="w-full bg-green-600 hover:bg-green-700 text-white" size="sm">
|
||||
<Button
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{t("buyNow")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Globe } from "lucide-react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,8 +11,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const languages = [
|
||||
{ code: "pt", name: "Português", flag: "🇧🇷" },
|
||||
@@ -37,7 +36,7 @@ export function LanguageSwitcher() {
|
||||
router.push(`/${newLocale}${pathWithoutLocale}`);
|
||||
};
|
||||
|
||||
const currentLanguage = languages.find((lang) => lang.code === locale);
|
||||
const _currentLanguage = languages.find((lang) => lang.code === locale);
|
||||
|
||||
// Renderizar placeholder durante SSR para evitar mismatch
|
||||
if (!mounted) {
|
||||
@@ -72,8 +71,3 @@ export function LanguageSwitcher() {
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Filter, Search, SortAsc, SortDesc, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
X,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -23,6 +13,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface MarketplaceFiltersProps {
|
||||
onSearch: (query: string) => void;
|
||||
@@ -48,7 +39,7 @@ export function MarketplaceFilters({
|
||||
showRarity = true,
|
||||
categoryOptionsOverride,
|
||||
}: MarketplaceFiltersProps) {
|
||||
const t = useTranslations("home");
|
||||
const _t = useTranslations("home");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeFilters, setActiveFilters] = useState<FilterState>({
|
||||
rarity: [],
|
||||
@@ -142,7 +133,9 @@ export function MarketplaceFilters({
|
||||
};
|
||||
|
||||
const activeFiltersCount =
|
||||
activeFilters.rarity.length + activeFilters.category.length + (activeFilters.vocation ? 1 : 0);
|
||||
activeFilters.rarity.length +
|
||||
activeFilters.category.length +
|
||||
(activeFilters.vocation ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -159,39 +152,39 @@ export function MarketplaceFilters({
|
||||
|
||||
{/* Filtros e ordenação */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Filtro por raridade (opcional) */}
|
||||
{showRarity && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Raridade
|
||||
{activeFilters.rarity.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{activeFilters.rarity.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48">
|
||||
<DropdownMenuLabel>Filtrar por Raridade</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{rarityOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => toggleRarityFilter(option.value)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className={`w-3 h-3 rounded-full ${option.color}`} />
|
||||
<span>{option.label}</span>
|
||||
{activeFilters.rarity.includes(option.value) && (
|
||||
<X className="h-3 w-3 ml-auto" />
|
||||
{/* Filtro por raridade (opcional) */}
|
||||
{showRarity && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Raridade
|
||||
{activeFilters.rarity.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{activeFilters.rarity.length}
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48">
|
||||
<DropdownMenuLabel>Filtrar por Raridade</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{rarityOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => toggleRarityFilter(option.value)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className={`w-3 h-3 rounded-full ${option.color}`} />
|
||||
<span>{option.label}</span>
|
||||
{activeFilters.rarity.includes(option.value) && (
|
||||
<X className="h-3 w-3 ml-auto" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Filtro por categoria */}
|
||||
<DropdownMenu>
|
||||
@@ -247,7 +240,9 @@ export function MarketplaceFilters({
|
||||
const newFilters = {
|
||||
...activeFilters,
|
||||
vocation:
|
||||
activeFilters.vocation === option.value ? null : option.value,
|
||||
activeFilters.vocation === option.value
|
||||
? null
|
||||
: option.value,
|
||||
};
|
||||
setActiveFilters(newFilters);
|
||||
onFilter(newFilters);
|
||||
@@ -322,7 +317,7 @@ export function MarketplaceFilters({
|
||||
})}
|
||||
{activeFilters.category.map((category) => {
|
||||
const option = categoryOptions.find(
|
||||
(opt) => opt.value === category
|
||||
(opt) => opt.value === category,
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
@@ -362,8 +357,3 @@ export function MarketplaceFilters({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+485
-278
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Clock,
|
||||
Eye,
|
||||
Heart,
|
||||
MessageSquare,
|
||||
MousePointerClick,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { TrendingUp, Eye, MousePointerClick, Heart, MessageSquare, Clock } from "lucide-react";
|
||||
|
||||
interface AdAnalytics {
|
||||
ad_id: number;
|
||||
@@ -37,13 +44,13 @@ export function AnalyticsDashboard() {
|
||||
if (user?.userId) {
|
||||
loadUserListings();
|
||||
}
|
||||
}, [user?.userId]);
|
||||
}, [user?.userId, loadUserListings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAd) {
|
||||
loadAnalytics(selectedAd);
|
||||
}
|
||||
}, [selectedAd]);
|
||||
}, [selectedAd, loadAnalytics]);
|
||||
|
||||
const loadUserListings = async () => {
|
||||
setLoading(true);
|
||||
@@ -266,4 +273,3 @@ export function AnalyticsDashboard() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AuctionCountdownProps {
|
||||
@@ -30,7 +30,7 @@ export function AuctionCountdown({
|
||||
|
||||
const calculateTimeRemaining = () => {
|
||||
const endTime = new Date(endsAt).getTime();
|
||||
const now = new Date().getTime();
|
||||
const now = Date.now();
|
||||
const total = endTime - now;
|
||||
|
||||
if (total <= 0) {
|
||||
@@ -61,19 +61,19 @@ export function AuctionCountdown({
|
||||
// Determinar cor baseada no tempo restante
|
||||
const getColorClass = () => {
|
||||
const totalMinutes = timeRemaining.hours * 60 + timeRemaining.minutes;
|
||||
|
||||
|
||||
if (timeRemaining.total <= 0) {
|
||||
return "text-red-600 dark:text-red-400";
|
||||
}
|
||||
|
||||
|
||||
if (totalMinutes < 5) {
|
||||
return "text-red-500 dark:text-red-400 font-semibold animate-pulse";
|
||||
}
|
||||
|
||||
|
||||
if (totalMinutes < 60) {
|
||||
return "text-yellow-600 dark:text-yellow-400";
|
||||
}
|
||||
|
||||
|
||||
return "text-green-600 dark:text-green-400";
|
||||
};
|
||||
|
||||
@@ -84,7 +84,7 @@ export function AuctionCountdown({
|
||||
}
|
||||
|
||||
const totalMinutes = timeRemaining.hours * 60 + timeRemaining.minutes;
|
||||
|
||||
|
||||
if (totalMinutes < 5) {
|
||||
return "Termina em breve";
|
||||
}
|
||||
@@ -102,13 +102,10 @@ export function AuctionCountdown({
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1.5", className)}>
|
||||
{showIcon && (
|
||||
<Clock className={cn("h-3.5 w-3.5", getColorClass())} />
|
||||
)}
|
||||
{showIcon && <Clock className={cn("h-3.5 w-3.5", getColorClass())} />}
|
||||
<span className={cn("text-xs font-medium", getColorClass())}>
|
||||
{getTimeText()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Loader2, TrendingUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Coins, DollarSign, Loader2, TrendingUp } from "lucide-react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
|
||||
interface Listing {
|
||||
@@ -45,20 +45,27 @@ interface BidModalProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalProps) {
|
||||
export function BidModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
listing,
|
||||
onSuccess,
|
||||
}: BidModalProps) {
|
||||
const { user } = useSession();
|
||||
const [bidAmount, setBidAmount] = useState<string>("");
|
||||
const [paymentMethod, setPaymentMethod] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bidHistory, setBidHistory] = useState<Array<{
|
||||
bid_id: number;
|
||||
bidder_id: number;
|
||||
bidder_name: string;
|
||||
bid_amount: number;
|
||||
bid_type: string | null;
|
||||
created_at: Date;
|
||||
}>>([]);
|
||||
const [bidHistory, setBidHistory] = useState<
|
||||
Array<{
|
||||
bid_id: number;
|
||||
bidder_id: number;
|
||||
bidder_name: string;
|
||||
bid_amount: number;
|
||||
bid_type: string | null;
|
||||
created_at: Date;
|
||||
}>
|
||||
>([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
|
||||
if (!listing) return null;
|
||||
@@ -67,41 +74,50 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
|
||||
const currentBid = listing.current_bid_amount || 0;
|
||||
const minIncrement = listing.min_bid_increment || 10;
|
||||
const minBid = currentBid + minIncrement;
|
||||
const initialPrice = listing.initial_price_pix || listing.initial_price_tibia_coins || listing.initial_price_gold_coins || 0;
|
||||
const initialPrice =
|
||||
listing.initial_price_pix ||
|
||||
listing.initial_price_tibia_coins ||
|
||||
listing.initial_price_gold_coins ||
|
||||
0;
|
||||
|
||||
// Determinar métodos de pagamento disponíveis para lance
|
||||
const availableMethods: string[] = [];
|
||||
if (listing.price_pix || listing.initial_price_pix) availableMethods.push("pix");
|
||||
if (listing.price_tibia_coins || listing.initial_price_tibia_coins) availableMethods.push("tc");
|
||||
if (listing.price_gold_coins || listing.initial_price_gold_coins) availableMethods.push("gp");
|
||||
if (listing.price_pix || listing.initial_price_pix)
|
||||
availableMethods.push("pix");
|
||||
if (listing.price_tibia_coins || listing.initial_price_tibia_coins)
|
||||
availableMethods.push("tc");
|
||||
if (listing.price_gold_coins || listing.initial_price_gold_coins)
|
||||
availableMethods.push("gp");
|
||||
|
||||
// Selecionar primeiro método disponível por padrão
|
||||
useEffect(() => {
|
||||
if (availableMethods.length > 0 && !paymentMethod) {
|
||||
setPaymentMethod(availableMethods[0]);
|
||||
}
|
||||
}, [availableMethods.length]);
|
||||
}, [paymentMethod]);
|
||||
|
||||
// Atualizar valor do lance quando mudar
|
||||
useEffect(() => {
|
||||
if (bidAmount === "" && minBid > 0) {
|
||||
setBidAmount(minBid.toFixed(2));
|
||||
}
|
||||
}, [minBid]);
|
||||
}, [minBid, bidAmount]);
|
||||
|
||||
// Buscar histórico de lances quando abrir modal
|
||||
useEffect(() => {
|
||||
if (open && listing) {
|
||||
fetchBidHistory();
|
||||
}
|
||||
}, [open, listing?.listing_id]);
|
||||
}, [open, listing?.listing_id, fetchBidHistory, listing]);
|
||||
|
||||
const fetchBidHistory = async () => {
|
||||
if (!listing) return;
|
||||
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const res = await fetch(`/api/listings/${listing.listing_id}/bids?limit=5`);
|
||||
const res = await fetch(
|
||||
`/api/listings/${listing.listing_id}/bids?limit=5`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
@@ -117,14 +133,16 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
|
||||
|
||||
const handleBid = async () => {
|
||||
const amount = parseFloat(bidAmount);
|
||||
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
|
||||
if (Number.isNaN(amount) || amount <= 0) {
|
||||
setError("Valor do lance inválido");
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount < minBid) {
|
||||
setError(`Lance mínimo é ${minBid.toFixed(2)} (lance atual: ${currentBid.toFixed(2)} + incremento: ${minIncrement.toFixed(2)})`);
|
||||
setError(
|
||||
`Lance mínimo é ${minBid.toFixed(2)} (lance atual: ${currentBid.toFixed(2)} + incremento: ${minIncrement.toFixed(2)})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -198,11 +216,17 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Preço Inicial</Label>
|
||||
<p className="text-lg font-semibold">{formatPrice(initialPrice)}</p>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Preço Inicial
|
||||
</Label>
|
||||
<p className="text-lg font-semibold">
|
||||
{formatPrice(initialPrice)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Lance Atual</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Lance Atual
|
||||
</Label>
|
||||
<p className="text-lg font-semibold text-primary">
|
||||
{currentBid > 0 ? formatPrice(currentBid) : "Sem lances"}
|
||||
</p>
|
||||
@@ -211,18 +235,28 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Incremento Mínimo</Label>
|
||||
<p className="text-sm font-medium">{formatPrice(minIncrement)}</p>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Incremento Mínimo
|
||||
</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{formatPrice(minIncrement)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Lance Mínimo Necessário</Label>
|
||||
<p className="text-sm font-semibold text-green-600">{formatPrice(minBid)}</p>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Lance Mínimo Necessário
|
||||
</Label>
|
||||
<p className="text-sm font-semibold text-green-600">
|
||||
{formatPrice(minBid)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listing.auction_ends_at && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Término do Leilão</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Término do Leilão
|
||||
</Label>
|
||||
<p className="text-sm">
|
||||
{new Date(listing.auction_ends_at).toLocaleString("pt-BR")}
|
||||
</p>
|
||||
@@ -235,7 +269,9 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
|
||||
{/* Histórico de lances */}
|
||||
{bidHistory.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Histórico de Lances (Últimos 5)</Label>
|
||||
<Label className="text-sm font-medium">
|
||||
Histórico de Lances (Últimos 5)
|
||||
</Label>
|
||||
<div className="bg-muted rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
||||
{bidHistory.map((bid, index) => {
|
||||
const isCurrentUser = user?.userId === bid.bidder_id;
|
||||
@@ -244,7 +280,9 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
|
||||
<div
|
||||
key={bid.bid_id}
|
||||
className={`p-2 rounded ${
|
||||
isCurrentUser ? "bg-primary/10 border border-primary/20" : ""
|
||||
isCurrentUser
|
||||
? "bg-primary/10 border border-primary/20"
|
||||
: ""
|
||||
} ${isHighest ? "border-l-2 border-l-primary" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -343,7 +381,12 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBid}
|
||||
disabled={loading || !bidAmount || parseFloat(bidAmount) < minBid || !paymentMethod}
|
||||
disabled={
|
||||
loading ||
|
||||
!bidAmount ||
|
||||
parseFloat(bidAmount) < minBid ||
|
||||
!paymentMethod
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
@@ -363,4 +406,3 @@ export function BidModal({ open, onOpenChange, listing, onSuccess }: BidModalPro
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Package } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,12 +11,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -21,9 +20,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { ItemSearch } from "./ItemSearch";
|
||||
import { ArrowRight, Package } from "lucide-react";
|
||||
|
||||
interface Item {
|
||||
id: number;
|
||||
@@ -47,7 +47,11 @@ interface Server {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalProps) {
|
||||
export function CreateAdModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: CreateAdModalProps) {
|
||||
const { user } = useSession();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -97,7 +101,7 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
|
||||
|
||||
if (!hasPrice && (!acceptTrade || !tradeItem)) {
|
||||
setError(
|
||||
"Informe pelo menos um preço (PIX, TC ou GP) ou configure uma troca por item"
|
||||
"Informe pelo menos um preço (PIX, TC ou GP) ou configure uma troca por item",
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -130,9 +134,10 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
|
||||
description: formData.description || null,
|
||||
trade_item_id: acceptTrade && tradeItem ? tradeItem.id : null,
|
||||
trade_item_name: acceptTrade && tradeItem ? tradeItem.name : null,
|
||||
trade_item_quantity: acceptTrade && tradeItem
|
||||
? parseInt(formData.trade_item_quantity, 10)
|
||||
: null,
|
||||
trade_item_quantity:
|
||||
acceptTrade && tradeItem
|
||||
? parseInt(formData.trade_item_quantity, 10)
|
||||
: null,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -155,10 +160,14 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
|
||||
auction_ends_at: "Término do leilão",
|
||||
};
|
||||
|
||||
const validationMessages = data.details.map((detail: { field?: string; message?: string }) => {
|
||||
const fieldLabel = detail.field ? fieldLabels[detail.field] || detail.field : "Campo";
|
||||
return `${fieldLabel}: ${detail.message ?? "preenchimento obrigatório"}`;
|
||||
});
|
||||
const validationMessages = data.details.map(
|
||||
(detail: { field?: string; message?: string }) => {
|
||||
const fieldLabel = detail.field
|
||||
? fieldLabels[detail.field] || detail.field
|
||||
: "Campo";
|
||||
return `${fieldLabel}: ${detail.message ?? "preenchimento obrigatório"}`;
|
||||
},
|
||||
);
|
||||
|
||||
setError(validationMessages.join(" "));
|
||||
} else {
|
||||
@@ -199,7 +208,7 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
|
||||
if (open) {
|
||||
loadServers();
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, loadServers]);
|
||||
|
||||
const loadServers = async () => {
|
||||
setLoadingServers(true);
|
||||
@@ -295,7 +304,10 @@ export function CreateAdModal({ open, onOpenChange, onSuccess }: CreateAdModalPr
|
||||
</SelectItem>
|
||||
) : (
|
||||
servers.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverName}>
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverName}
|
||||
>
|
||||
{server.serverName}
|
||||
{server.location && ` (${server.location})`}
|
||||
</SelectItem>
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
ShoppingBag,
|
||||
ShoppingCart,
|
||||
User,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,24 +22,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
ShoppingCart,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
MessageSquare,
|
||||
User,
|
||||
Loader2,
|
||||
ShoppingBag,
|
||||
} from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface PurchaseIntention {
|
||||
intention_id: number;
|
||||
@@ -72,21 +72,30 @@ function getStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400"
|
||||
>
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Pendente
|
||||
</Badge>
|
||||
);
|
||||
case "accepted":
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-green-500/20 text-green-700 dark:text-green-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-green-500/20 text-green-700 dark:text-green-400"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Aceita
|
||||
</Badge>
|
||||
);
|
||||
case "rejected":
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-red-500/20 text-red-700 dark:text-red-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-red-500/20 text-red-700 dark:text-red-400"
|
||||
>
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Rejeitada
|
||||
</Badge>
|
||||
@@ -96,7 +105,9 @@ function getStatusBadge(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatCustomOffer(intention: PurchaseIntention): { value: string; date: string | null } | null {
|
||||
function formatCustomOffer(
|
||||
intention: PurchaseIntention,
|
||||
): { value: string; date: string | null } | null {
|
||||
if (
|
||||
intention.custom_offer_amount === null ||
|
||||
intention.custom_offer_amount === undefined ||
|
||||
@@ -142,7 +153,10 @@ export function IntentionDetailModal({
|
||||
const [sellerResponse, setSellerResponse] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [listing, setListing] = useState<{ seller_id: number; listing_status: string } | null>(null);
|
||||
const [listing, setListing] = useState<{
|
||||
seller_id: number;
|
||||
listing_status: string;
|
||||
} | null>(null);
|
||||
const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
|
||||
const [markingSold, setMarkingSold] = useState(false);
|
||||
|
||||
@@ -161,7 +175,11 @@ export function IntentionDetailModal({
|
||||
}, [open, listingId]);
|
||||
|
||||
const isOwner = user && listing && user.userId === listing.seller_id;
|
||||
const canMarkAsSold = isOwner && listing && (listing.listing_status === "active" || listing.listing_status === "in_escrow");
|
||||
const canMarkAsSold =
|
||||
isOwner &&
|
||||
listing &&
|
||||
(listing.listing_status === "active" ||
|
||||
listing.listing_status === "in_escrow");
|
||||
const canAcceptOrReject = intention.status === "pending";
|
||||
const customOffer = formatCustomOffer(intention);
|
||||
|
||||
@@ -169,7 +187,7 @@ export function IntentionDetailModal({
|
||||
if (open) {
|
||||
setSellerResponse(intention.seller_response ?? "");
|
||||
}
|
||||
}, [open, intention.intention_id, intention.seller_response]);
|
||||
}, [open, intention.seller_response]);
|
||||
|
||||
const handleAccept = async () => {
|
||||
setLoading(true);
|
||||
@@ -185,7 +203,7 @@ export function IntentionDetailModal({
|
||||
status: "accepted",
|
||||
seller_response: sellerResponse || undefined,
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
@@ -217,7 +235,7 @@ export function IntentionDetailModal({
|
||||
status: "rejected",
|
||||
seller_response: sellerResponse || undefined,
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
@@ -229,7 +247,9 @@ export function IntentionDetailModal({
|
||||
if (onUpdate) onUpdate();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Erro ao rejeitar intenção");
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Erro ao rejeitar intenção",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -241,7 +261,7 @@ export function IntentionDetailModal({
|
||||
const res = await fetch(`/api/listings/${listingId}/mark-sold`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Erro ao marcar como vendido");
|
||||
@@ -250,13 +270,15 @@ export function IntentionDetailModal({
|
||||
// Invalidar cache para atualizar a lista
|
||||
queryClient.invalidateQueries({ queryKey: ["listings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
|
||||
|
||||
|
||||
if (onUpdate) onUpdate();
|
||||
setShowMarkSoldDialog(false);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Erro ao marcar como vendido:", error);
|
||||
alert(error instanceof Error ? error.message : "Erro ao marcar como vendido");
|
||||
alert(
|
||||
error instanceof Error ? error.message : "Erro ao marcar como vendido",
|
||||
);
|
||||
} finally {
|
||||
setMarkingSold(false);
|
||||
}
|
||||
@@ -271,7 +293,8 @@ export function IntentionDetailModal({
|
||||
{getStatusBadge(intention.status)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Detalhes da intenção de compra de {intention.buyer?.username || "usuário"}
|
||||
Detalhes da intenção de compra de{" "}
|
||||
{intention.buyer?.username || "usuário"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -304,7 +327,9 @@ export function IntentionDetailModal({
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Método de Pagamento</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Método de Pagamento
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
@@ -313,7 +338,9 @@ export function IntentionDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Data de Criação</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Data de Criação
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
@@ -328,9 +355,13 @@ export function IntentionDetailModal({
|
||||
<Label className="text-xs text-primary uppercase tracking-wide">
|
||||
Oferta do Comprador
|
||||
</Label>
|
||||
<div className="text-base font-semibold text-primary">{customOffer.value}</div>
|
||||
<div className="text-base font-semibold text-primary">
|
||||
{customOffer.value}
|
||||
</div>
|
||||
{customOffer.date && (
|
||||
<p className="text-[11px] text-primary/70">Enviada em {customOffer.date}</p>
|
||||
<p className="text-[11px] text-primary/70">
|
||||
Enviada em {customOffer.date}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -398,7 +429,11 @@ export function IntentionDetailModal({
|
||||
Marcar como Vendido
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading || markingSold}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading || markingSold}
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
{canAcceptOrReject && (
|
||||
@@ -420,7 +455,10 @@ export function IntentionDetailModal({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={handleAccept} disabled={loading || markingSold}>
|
||||
<Button
|
||||
onClick={handleAccept}
|
||||
disabled={loading || markingSold}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
@@ -445,14 +483,18 @@ export function IntentionDetailModal({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Marcar anúncio como vendido?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ao marcar este anúncio como vendido, as seguintes ações serão executadas:
|
||||
Ao marcar este anúncio como vendido, as seguintes ações serão
|
||||
executadas:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 pt-2">
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
||||
<li>O anúncio será marcado como "Vendido"</li>
|
||||
<li>O anúncio continuará visível na lista por mais 1 hora</li>
|
||||
<li>Após 1 hora, o anúncio sairá automaticamente da lista de anúncios ativos</li>
|
||||
<li>
|
||||
Após 1 hora, o anúncio sairá automaticamente da lista de
|
||||
anúncios ativos
|
||||
</li>
|
||||
<li>Nenhuma interação (comprar, dar lance) será permitida</li>
|
||||
</ul>
|
||||
<p className="pt-2 font-medium text-foreground">
|
||||
@@ -480,4 +522,3 @@ export function IntentionDetailModal({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Loader2, Search, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, X, Loader2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Item {
|
||||
@@ -38,7 +38,9 @@ export function ItemSearch({
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [previewItem, setPreviewItem] = useState<Item | null>(selectedItem || null);
|
||||
const [previewItem, setPreviewItem] = useState<Item | null>(
|
||||
selectedItem || null,
|
||||
);
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
@@ -58,7 +60,7 @@ export function ItemSearch({
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/items?q=${encodeURIComponent(searchQuery)}&pageSize=10`
|
||||
`/api/items?q=${encodeURIComponent(searchQuery)}&pageSize=10`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
@@ -76,7 +78,10 @@ export function ItemSearch({
|
||||
// Fechar dropdown ao clicar fora
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
searchRef.current &&
|
||||
!searchRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
@@ -159,7 +164,9 @@ export function ItemSearch({
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{item.name}</p>
|
||||
{item.category && (
|
||||
<p className="text-xs text-muted-foreground">{item.category}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.category}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{item.rarity && (
|
||||
@@ -200,24 +207,27 @@ export function ItemSearch({
|
||||
{previewItem.rarity && (
|
||||
<Badge variant="secondary">{previewItem.rarity}</Badge>
|
||||
)}
|
||||
{previewItem.level !== undefined && previewItem.level !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">Level:</span>
|
||||
{previewItem.level}
|
||||
</span>
|
||||
)}
|
||||
{previewItem.weight !== undefined && previewItem.weight !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">Peso:</span>
|
||||
{previewItem.weight} oz
|
||||
</span>
|
||||
)}
|
||||
{previewItem.npc_price !== undefined && previewItem.npc_price !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">Preço NPC:</span>
|
||||
{previewItem.npc_price.toLocaleString()} gp
|
||||
</span>
|
||||
)}
|
||||
{previewItem.level !== undefined &&
|
||||
previewItem.level !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">Level:</span>
|
||||
{previewItem.level}
|
||||
</span>
|
||||
)}
|
||||
{previewItem.weight !== undefined &&
|
||||
previewItem.weight !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">Peso:</span>
|
||||
{previewItem.weight} oz
|
||||
</span>
|
||||
)}
|
||||
{previewItem.npc_price !== undefined &&
|
||||
previewItem.npc_price !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">Preço NPC:</span>
|
||||
{previewItem.npc_price.toLocaleString()} gp
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,4 +237,3 @@ export function ItemSearch({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Eye,
|
||||
Heart,
|
||||
MapPin,
|
||||
Pause,
|
||||
ShoppingBag,
|
||||
Star,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Eye,
|
||||
Heart,
|
||||
Clock,
|
||||
MapPin,
|
||||
DollarSign,
|
||||
Coins,
|
||||
TrendingUp,
|
||||
Star,
|
||||
Pause,
|
||||
Trash2,
|
||||
X,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ShoppingBag,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface Listing {
|
||||
listing_id: number;
|
||||
@@ -76,10 +74,11 @@ interface ListingDetailsModalProps {
|
||||
|
||||
function formatTimeAgo(date: Date | string): string {
|
||||
const dateObj = typeof date === "string" ? new Date(date) : date;
|
||||
const seconds = Math.floor((new Date().getTime() - dateObj.getTime()) / 1000);
|
||||
const seconds = Math.floor((Date.now() - dateObj.getTime()) / 1000);
|
||||
if (seconds < 60) return "há alguns segundos";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
|
||||
if (minutes < 60)
|
||||
return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
|
||||
const days = Math.floor(hours / 24);
|
||||
@@ -92,14 +91,40 @@ function formatTimeAgo(date: Date | string): string {
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const statusConfig = {
|
||||
active: { label: "Ativo", variant: "default" as const, icon: CheckCircle2, className: "bg-green-500 hover:bg-green-600" },
|
||||
sold: { label: "Vendido", variant: "secondary" as const, icon: CheckCircle2, className: "bg-blue-500 hover:bg-blue-600 text-white" },
|
||||
cancelled: { label: "Cancelado", variant: "destructive" as const, icon: X, className: "bg-red-500 hover:bg-red-600 text-white" },
|
||||
in_escrow: { label: "Em Escrow", variant: "default" as const, icon: AlertCircle, className: "bg-yellow-500 hover:bg-yellow-600 text-white" },
|
||||
expired: { label: "Expirado", variant: "secondary" as const, icon: Clock, className: "bg-gray-500 hover:bg-gray-600 text-white" },
|
||||
active: {
|
||||
label: "Ativo",
|
||||
variant: "default" as const,
|
||||
icon: CheckCircle2,
|
||||
className: "bg-green-500 hover:bg-green-600",
|
||||
},
|
||||
sold: {
|
||||
label: "Vendido",
|
||||
variant: "secondary" as const,
|
||||
icon: CheckCircle2,
|
||||
className: "bg-blue-500 hover:bg-blue-600 text-white",
|
||||
},
|
||||
cancelled: {
|
||||
label: "Cancelado",
|
||||
variant: "destructive" as const,
|
||||
icon: X,
|
||||
className: "bg-red-500 hover:bg-red-600 text-white",
|
||||
},
|
||||
in_escrow: {
|
||||
label: "Em Escrow",
|
||||
variant: "default" as const,
|
||||
icon: AlertCircle,
|
||||
className: "bg-yellow-500 hover:bg-yellow-600 text-white",
|
||||
},
|
||||
expired: {
|
||||
label: "Expirado",
|
||||
variant: "secondary" as const,
|
||||
icon: Clock,
|
||||
className: "bg-gray-500 hover:bg-gray-600 text-white",
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.active;
|
||||
const config =
|
||||
statusConfig[status as keyof typeof statusConfig] || statusConfig.active;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
@@ -123,7 +148,7 @@ export function ListingDetailsModal({
|
||||
const [itemImageUrl, setItemImageUrl] = useState<string>("");
|
||||
const [showMarkSoldDialog, setShowMarkSoldDialog] = useState(false);
|
||||
const [markingSold, setMarkingSold] = useState(false);
|
||||
|
||||
|
||||
// Verificar se o usuário é o dono do anúncio
|
||||
const isOwner = user && user.userId === listing.seller_id;
|
||||
|
||||
@@ -134,13 +159,19 @@ export function ListingDetailsModal({
|
||||
} else if (listing.image_local_path) {
|
||||
const normalizedPath = listing.image_local_path.replace(/\\/g, "/");
|
||||
if (normalizedPath.startsWith("data/")) {
|
||||
setItemImageUrl(`/api/images?path=${encodeURIComponent(normalizedPath)}`);
|
||||
setItemImageUrl(
|
||||
`/api/images?path=${encodeURIComponent(normalizedPath)}`,
|
||||
);
|
||||
} else {
|
||||
const category = listing.item_category || "other";
|
||||
setItemImageUrl(`/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`);
|
||||
setItemImageUrl(
|
||||
`/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`,
|
||||
);
|
||||
}
|
||||
} else if (listing.item_id_unified) {
|
||||
const params = new URLSearchParams({ item: listing.item_id_unified.toString() });
|
||||
const params = new URLSearchParams({
|
||||
item: listing.item_id_unified.toString(),
|
||||
});
|
||||
if (listing.item_name) {
|
||||
params.set("name", listing.item_name);
|
||||
}
|
||||
@@ -158,27 +189,52 @@ export function ListingDetailsModal({
|
||||
const prices: Array<{ type: string; label: string; value: string }> = [];
|
||||
|
||||
if (listing.price_pix) {
|
||||
const price = typeof listing.price_pix === "string" ? parseFloat(listing.price_pix) : listing.price_pix;
|
||||
if (!isNaN(price)) {
|
||||
prices.push({ type: "pix", label: "PIX", value: `R$ ${price.toFixed(2)}` });
|
||||
const price =
|
||||
typeof listing.price_pix === "string"
|
||||
? parseFloat(listing.price_pix)
|
||||
: listing.price_pix;
|
||||
if (!Number.isNaN(price)) {
|
||||
prices.push({
|
||||
type: "pix",
|
||||
label: "PIX",
|
||||
value: `R$ ${price.toFixed(2)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (listing.price_tibia_coins) {
|
||||
const price = typeof listing.price_tibia_coins === "string" ? parseInt(listing.price_tibia_coins, 10) : listing.price_tibia_coins;
|
||||
if (!isNaN(price)) {
|
||||
prices.push({ type: "tibia_coins", label: "Tibia Coins", value: `${price.toLocaleString()} TC` });
|
||||
const price =
|
||||
typeof listing.price_tibia_coins === "string"
|
||||
? parseInt(listing.price_tibia_coins, 10)
|
||||
: listing.price_tibia_coins;
|
||||
if (!Number.isNaN(price)) {
|
||||
prices.push({
|
||||
type: "tibia_coins",
|
||||
label: "Tibia Coins",
|
||||
value: `${price.toLocaleString()} TC`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (listing.price_gold_coins) {
|
||||
const price = typeof listing.price_gold_coins === "string" ? parseInt(listing.price_gold_coins, 10) : listing.price_gold_coins;
|
||||
if (!isNaN(price)) {
|
||||
prices.push({ type: "gold_coins", label: "Gold Coins", value: `${price.toLocaleString()} GP` });
|
||||
const price =
|
||||
typeof listing.price_gold_coins === "string"
|
||||
? parseInt(listing.price_gold_coins, 10)
|
||||
: listing.price_gold_coins;
|
||||
if (!Number.isNaN(price)) {
|
||||
prices.push({
|
||||
type: "gold_coins",
|
||||
label: "Gold Coins",
|
||||
value: `${price.toLocaleString()} GP`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity) {
|
||||
if (
|
||||
listing.trade_item_id &&
|
||||
listing.trade_item_name &&
|
||||
listing.trade_item_quantity
|
||||
) {
|
||||
prices.push({
|
||||
type: "trade",
|
||||
label: "Troca",
|
||||
@@ -220,7 +276,10 @@ export function ListingDetailsModal({
|
||||
const canPause = isOwner && listing.listing_status === "active";
|
||||
const canActivate = isOwner && listing.listing_status === "cancelled";
|
||||
const canCancel = isOwner && listing.listing_status === "active";
|
||||
const canMarkAsSold = isOwner && (listing.listing_status === "active" || listing.listing_status === "in_escrow");
|
||||
const canMarkAsSold =
|
||||
isOwner &&
|
||||
(listing.listing_status === "active" ||
|
||||
listing.listing_status === "in_escrow");
|
||||
|
||||
const handleMarkAsSold = async () => {
|
||||
setMarkingSold(true);
|
||||
@@ -228,7 +287,7 @@ export function ListingDetailsModal({
|
||||
const res = await fetch(`/api/listings/${listing.listing_id}/mark-sold`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Erro ao marcar como vendido");
|
||||
@@ -237,12 +296,14 @@ export function ListingDetailsModal({
|
||||
// Invalidar cache para atualizar a lista
|
||||
queryClient.invalidateQueries({ queryKey: ["listings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["my-listings"] });
|
||||
|
||||
|
||||
setShowMarkSoldDialog(false);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Erro ao marcar como vendido:", error);
|
||||
alert(error instanceof Error ? error.message : "Erro ao marcar como vendido");
|
||||
alert(
|
||||
error instanceof Error ? error.message : "Erro ao marcar como vendido",
|
||||
);
|
||||
} finally {
|
||||
setMarkingSold(false);
|
||||
}
|
||||
@@ -256,7 +317,9 @@ export function ListingDetailsModal({
|
||||
Detalhes do Anúncio
|
||||
{getStatusBadge(listing.listing_status)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Informações completas do seu anúncio</DialogDescription>
|
||||
<DialogDescription>
|
||||
Informações completas do seu anúncio
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
@@ -294,22 +357,30 @@ export function ListingDetailsModal({
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Visualizações</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Visualizações
|
||||
</div>
|
||||
<div className="font-semibold">{listing.views_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Favoritos</div>
|
||||
<div className="font-semibold">{listing.favorites_count || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Favoritos
|
||||
</div>
|
||||
<div className="font-semibold">
|
||||
{listing.favorites_count || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Criado</div>
|
||||
<div className="font-semibold text-xs">{formatTimeAgo(listing.created_at)}</div>
|
||||
<div className="font-semibold text-xs">
|
||||
{formatTimeAgo(listing.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -349,7 +420,9 @@ export function ListingDetailsModal({
|
||||
: "bg-purple-500/10 border-purple-500/20"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground mb-1">{price.label}</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{price.label}
|
||||
</div>
|
||||
<div className="font-semibold">{price.value}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -370,26 +443,44 @@ export function ListingDetailsModal({
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Lance Atual:</span>
|
||||
<span className="font-semibold text-lg text-primary">
|
||||
R$ {typeof listing.current_bid_amount === "number" ? listing.current_bid_amount.toFixed(2) : parseFloat(String(listing.current_bid_amount)).toFixed(2)}
|
||||
R${" "}
|
||||
{typeof listing.current_bid_amount === "number"
|
||||
? listing.current_bid_amount.toFixed(2)
|
||||
: parseFloat(
|
||||
String(listing.current_bid_amount),
|
||||
).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{listing.min_bid_increment && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Incremento Mínimo:</span>
|
||||
<span className="text-muted-foreground">
|
||||
Incremento Mínimo:
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
R$ {typeof listing.min_bid_increment === "number" ? listing.min_bid_increment.toFixed(2) : parseFloat(String(listing.min_bid_increment)).toFixed(2)}
|
||||
R${" "}
|
||||
{typeof listing.min_bid_increment === "number"
|
||||
? listing.min_bid_increment.toFixed(2)
|
||||
: parseFloat(
|
||||
String(listing.min_bid_increment),
|
||||
).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{listing.auction_ends_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Termina em:</span>
|
||||
<span className="font-medium">{new Date(listing.auction_ends_at).toLocaleString("pt-BR")}</span>
|
||||
<span className="font-medium">
|
||||
{new Date(listing.auction_ends_at).toLocaleString(
|
||||
"pt-BR",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-2">Sem lances ainda</div>
|
||||
<div className="text-center text-muted-foreground py-2">
|
||||
Sem lances ainda
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -398,7 +489,9 @@ export function ListingDetailsModal({
|
||||
{listing.description && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">Descrição</h4>
|
||||
<p className="text-sm text-muted-foreground p-3 bg-muted rounded-lg">{listing.description}</p>
|
||||
<p className="text-sm text-muted-foreground p-3 bg-muted rounded-lg">
|
||||
{listing.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -410,13 +503,19 @@ export function ListingDetailsModal({
|
||||
</div>
|
||||
{listing.item_id_unified && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">ID do Item (Unificado):</span>
|
||||
<span className="ml-2 font-mono">{listing.item_id_unified}</span>
|
||||
<span className="text-muted-foreground">
|
||||
ID do Item (Unificado):
|
||||
</span>
|
||||
<span className="ml-2 font-mono">
|
||||
{listing.item_id_unified}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-muted-foreground">Criado em:</span>
|
||||
<span className="ml-2">{new Date(listing.created_at).toLocaleString("pt-BR")}</span>
|
||||
<span className="ml-2">
|
||||
{new Date(listing.created_at).toLocaleString("pt-BR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -424,9 +523,9 @@ export function ListingDetailsModal({
|
||||
{(canPause || canActivate || canCancel || canMarkAsSold) && (
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
{canMarkAsSold && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setShowMarkSoldDialog(true)}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setShowMarkSoldDialog(true)}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<ShoppingBag className="h-4 w-4 mr-2" />
|
||||
@@ -440,13 +539,21 @@ export function ListingDetailsModal({
|
||||
</Button>
|
||||
)}
|
||||
{canActivate && onActivate && (
|
||||
<Button variant="default" onClick={onActivate} className="flex-1">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onActivate}
|
||||
className="flex-1"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
Reativar Anúncio
|
||||
</Button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Button variant="destructive" onClick={onCancel} className="flex-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Cancelar Anúncio
|
||||
</Button>
|
||||
@@ -462,14 +569,18 @@ export function ListingDetailsModal({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Marcar anúncio como vendido?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ao marcar este anúncio como vendido, as seguintes ações serão executadas:
|
||||
Ao marcar este anúncio como vendido, as seguintes ações serão
|
||||
executadas:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 pt-2">
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
||||
<li>O anúncio será marcado como "Vendido"</li>
|
||||
<li>O anúncio continuará visível na lista por mais 1 hora</li>
|
||||
<li>Após 1 hora, o anúncio sairá automaticamente da lista de anúncios ativos</li>
|
||||
<li>
|
||||
Após 1 hora, o anúncio sairá automaticamente da lista de
|
||||
anúncios ativos
|
||||
</li>
|
||||
<li>Nenhuma interação (comprar, dar lance) será permitida</li>
|
||||
</ul>
|
||||
<p className="pt-2 font-medium text-foreground">
|
||||
@@ -497,4 +608,3 @@ export function ListingDetailsModal({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
ShoppingCart,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { ShoppingCart, Clock, CheckCircle2, XCircle, MessageSquare } from "lucide-react";
|
||||
import { IntentionDetailModal } from "./IntentionDetailModal";
|
||||
|
||||
interface PurchaseIntention {
|
||||
@@ -32,10 +38,11 @@ interface MyAdsIntentionListProps {
|
||||
}
|
||||
|
||||
function formatTimeAgo(date: Date): string {
|
||||
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return "há alguns segundos";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
|
||||
if (minutes < 60)
|
||||
return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
|
||||
const days = Math.floor(hours / 24);
|
||||
@@ -46,21 +53,30 @@ function getStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400"
|
||||
>
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Pendente
|
||||
</Badge>
|
||||
);
|
||||
case "accepted":
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-green-500/20 text-green-700 dark:text-green-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-green-500/20 text-green-700 dark:text-green-400"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Aceita
|
||||
</Badge>
|
||||
);
|
||||
case "rejected":
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-red-500/20 text-red-700 dark:text-red-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-red-500/20 text-red-700 dark:text-red-400"
|
||||
>
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Rejeitada
|
||||
</Badge>
|
||||
@@ -107,15 +123,21 @@ function formatCustomOffer(intention: PurchaseIntention): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListProps) {
|
||||
export function MyAdsIntentionList({
|
||||
listingId,
|
||||
sellerId,
|
||||
}: MyAdsIntentionListProps) {
|
||||
const [intentions, setIntentions] = useState<PurchaseIntention[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIntention, setSelectedIntention] = useState<PurchaseIntention | null>(null);
|
||||
const [filter, setFilter] = useState<"all" | "pending" | "accepted" | "rejected">("all");
|
||||
const [selectedIntention, setSelectedIntention] =
|
||||
useState<PurchaseIntention | null>(null);
|
||||
const [filter, setFilter] = useState<
|
||||
"all" | "pending" | "accepted" | "rejected"
|
||||
>("all");
|
||||
|
||||
useEffect(() => {
|
||||
loadIntentions();
|
||||
}, [listingId, filter]);
|
||||
}, [loadIntentions]);
|
||||
|
||||
const loadIntentions = async () => {
|
||||
setLoading(true);
|
||||
@@ -124,7 +146,9 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
|
||||
if (filter !== "all") {
|
||||
params.set("status", filter);
|
||||
}
|
||||
const res = await fetch(`/api/listings/${listingId}/intentions?${params.toString()}`);
|
||||
const res = await fetch(
|
||||
`/api/listings/${listingId}/intentions?${params.toString()}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
@@ -216,19 +240,26 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||
<ShoppingCart className="h-3 w-3" />
|
||||
<span>{getPaymentMethodLabel(intention.payment_method)}</span>
|
||||
<span>
|
||||
{getPaymentMethodLabel(intention.payment_method)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTimeAgo(new Date(intention.created_at))}</span>
|
||||
</div>
|
||||
{formatCustomOffer(intention) && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="border-primary text-primary">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-primary text-primary"
|
||||
>
|
||||
Oferta: {formatCustomOffer(intention)}
|
||||
</Badge>
|
||||
{intention.custom_offer_created_at && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(intention.custom_offer_created_at).toLocaleString("pt-BR")}
|
||||
{new Date(
|
||||
intention.custom_offer_created_at,
|
||||
).toLocaleString("pt-BR")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -261,4 +292,3 @@ export function MyAdsIntentionList({ listingId, sellerId }: MyAdsIntentionListPr
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Pause,
|
||||
Play,
|
||||
Trash2,
|
||||
Edit,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
Heart,
|
||||
TrendingUp,
|
||||
ChevronDown,
|
||||
Pause,
|
||||
Play,
|
||||
ShoppingCart,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useMyListings } from "@/lib/hooks/use-listings";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { useMyListings, type Listing } from "@/lib/hooks/use-listings";
|
||||
import { AdCard } from "./AdCard";
|
||||
import { MyAdsIntentionList } from "./MyAdsIntentionList";
|
||||
|
||||
export function MyAdsPanel() {
|
||||
const { user } = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<string>("active");
|
||||
const [expandedIntentions, setExpandedIntentions] = useState<Set<number>>(new Set());
|
||||
const [expandedIntentions, setExpandedIntentions] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Determinar status baseado na aba ativa
|
||||
const status =
|
||||
activeTab === "active" ? "active" :
|
||||
activeTab === "paused" ? "cancelled" : // Backend usa 'cancelled' para pausados
|
||||
activeTab === "sold" ? "sold" :
|
||||
undefined; // "all" busca todos
|
||||
const status =
|
||||
activeTab === "active"
|
||||
? "active"
|
||||
: activeTab === "paused"
|
||||
? "cancelled"
|
||||
: // Backend usa 'cancelled' para pausados
|
||||
activeTab === "sold"
|
||||
? "sold"
|
||||
: undefined; // "all" busca todos
|
||||
|
||||
// Usar React Query para buscar meus anúncios com atualização automática
|
||||
// O queryKey muda quando status muda, então recarrega automaticamente
|
||||
@@ -145,9 +149,7 @@ export function MyAdsPanel() {
|
||||
</div>
|
||||
) : listings.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
Nenhum anúncio encontrado
|
||||
</p>
|
||||
<p className="text-muted-foreground">Nenhum anúncio encontrado</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -180,21 +182,25 @@ export function MyAdsPanel() {
|
||||
<span>
|
||||
{(() => {
|
||||
if (listing.price_pix) {
|
||||
const price = typeof listing.price_pix === 'string'
|
||||
? parseFloat(listing.price_pix)
|
||||
: listing.price_pix;
|
||||
const price =
|
||||
typeof listing.price_pix === "string"
|
||||
? parseFloat(listing.price_pix)
|
||||
: listing.price_pix;
|
||||
return `R$ ${price.toFixed(2)}`;
|
||||
}
|
||||
if (listing.price_tibia_coins) {
|
||||
const price = typeof listing.price_tibia_coins === 'string'
|
||||
? parseInt(listing.price_tibia_coins, 10)
|
||||
: listing.price_tibia_coins;
|
||||
const price =
|
||||
typeof listing.price_tibia_coins ===
|
||||
"string"
|
||||
? parseInt(listing.price_tibia_coins, 10)
|
||||
: listing.price_tibia_coins;
|
||||
return `${price.toLocaleString()} TC`;
|
||||
}
|
||||
if (listing.price_gold_coins) {
|
||||
const price = typeof listing.price_gold_coins === 'string'
|
||||
? parseInt(listing.price_gold_coins, 10)
|
||||
: listing.price_gold_coins;
|
||||
const price =
|
||||
typeof listing.price_gold_coins === "string"
|
||||
? parseInt(listing.price_gold_coins, 10)
|
||||
: listing.price_gold_coins;
|
||||
return `${price.toLocaleString()} GP`;
|
||||
}
|
||||
return "Preço não informado";
|
||||
@@ -236,42 +242,43 @@ export function MyAdsPanel() {
|
||||
</div>
|
||||
|
||||
{/* Seção de Intenções de Compra */}
|
||||
{listing.listing_status === "active" && !listing.is_auction && (
|
||||
<div className="border-t pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newExpanded = new Set(expandedIntentions);
|
||||
if (isExpanded) {
|
||||
newExpanded.delete(listing.listing_id);
|
||||
} else {
|
||||
newExpanded.add(listing.listing_id);
|
||||
}
|
||||
setExpandedIntentions(newExpanded);
|
||||
}}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
<span>Intenções de Compra</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
isExpanded ? "transform rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
{isExpanded && (
|
||||
<div className="mt-4">
|
||||
<MyAdsIntentionList
|
||||
listingId={listing.listing_id}
|
||||
sellerId={listing.seller_id}
|
||||
{listing.listing_status === "active" &&
|
||||
!listing.is_auction && (
|
||||
<div className="border-t pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newExpanded = new Set(expandedIntentions);
|
||||
if (isExpanded) {
|
||||
newExpanded.delete(listing.listing_id);
|
||||
} else {
|
||||
newExpanded.add(listing.listing_id);
|
||||
}
|
||||
setExpandedIntentions(newExpanded);
|
||||
}}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
<span>Intenções de Compra</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
isExpanded ? "transform rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
{isExpanded && (
|
||||
<div className="mt-4">
|
||||
<MyAdsIntentionList
|
||||
listingId={listing.listing_id}
|
||||
sellerId={listing.seller_id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -282,4 +289,3 @@ export function MyAdsPanel() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
MessageSquare,
|
||||
ShoppingCart,
|
||||
MapPin,
|
||||
Package,
|
||||
User,
|
||||
ExternalLink,
|
||||
AlertCircle,
|
||||
DollarSign,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Coins,
|
||||
DollarSign,
|
||||
ExternalLink,
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
Package,
|
||||
RefreshCw,
|
||||
ShoppingCart,
|
||||
User,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useMyPurchaseIntentions, type PurchaseIntention, type PurchaseIntentionStatus } from "@/lib/hooks/use-purchase-intentions";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ChatModal } from "@/components/chat/ChatModal";
|
||||
import { ChangePaymentMethodButton } from "@/components/purchase-intentions/ChangePaymentMethodButton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -33,14 +30,22 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ChatModal } from "@/components/chat/ChatModal";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
type PurchaseIntention,
|
||||
type PurchaseIntentionStatus,
|
||||
useMyPurchaseIntentions,
|
||||
} from "@/lib/hooks/use-purchase-intentions";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatTimeAgo(date: Date | string): string {
|
||||
const dateObj = typeof date === "string" ? new Date(date) : date;
|
||||
const seconds = Math.floor((new Date().getTime() - dateObj.getTime()) / 1000);
|
||||
const seconds = Math.floor((Date.now() - dateObj.getTime()) / 1000);
|
||||
if (seconds < 60) return "há alguns segundos";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
|
||||
if (minutes < 60)
|
||||
return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
|
||||
const days = Math.floor(hours / 24);
|
||||
@@ -55,35 +60,50 @@ function getStatusBadge(status: PurchaseIntentionStatus) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-yellow-500/20 text-yellow-700 dark:text-yellow-400"
|
||||
>
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Pendente
|
||||
</Badge>
|
||||
);
|
||||
case "accepted":
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-green-500/20 text-green-700 dark:text-green-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-green-500/20 text-green-700 dark:text-green-400"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Aceita
|
||||
</Badge>
|
||||
);
|
||||
case "rejected":
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-red-500/20 text-red-700 dark:text-red-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-red-500/20 text-red-700 dark:text-red-400"
|
||||
>
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Rejeitada
|
||||
</Badge>
|
||||
);
|
||||
case "expired":
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-gray-500/20 text-gray-700 dark:text-gray-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-gray-500/20 text-gray-700 dark:text-gray-400"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Expirada
|
||||
</Badge>
|
||||
);
|
||||
case "completed":
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-blue-500/20 text-blue-700 dark:text-blue-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-blue-500/20 text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Concluída
|
||||
</Badge>
|
||||
@@ -110,18 +130,18 @@ function getPaymentMethodLabel(method: string): string {
|
||||
|
||||
function formatPrice(price: number | null, method: string): string {
|
||||
if (price === null) return "N/A";
|
||||
|
||||
|
||||
if (method === "pix") {
|
||||
return new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
|
||||
if (method === "tibia_coins") {
|
||||
return `${price.toLocaleString("pt-BR")} TC`;
|
||||
}
|
||||
|
||||
|
||||
if (method === "gold_coins") {
|
||||
const gold = price / 1000000;
|
||||
if (gold >= 1) {
|
||||
@@ -129,11 +149,14 @@ function formatPrice(price: number | null, method: string): string {
|
||||
}
|
||||
return `${price.toLocaleString("pt-BR")} gp`;
|
||||
}
|
||||
|
||||
|
||||
return price.toLocaleString("pt-BR");
|
||||
}
|
||||
|
||||
function formatCustomOffer(amount: number | null | undefined, currency: string | null | undefined): string | null {
|
||||
function formatCustomOffer(
|
||||
amount: number | null | undefined,
|
||||
currency: string | null | undefined,
|
||||
): string | null {
|
||||
if (amount === null || amount === undefined || !currency) {
|
||||
return null;
|
||||
}
|
||||
@@ -171,7 +194,9 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg">Anúncio não disponível</h3>
|
||||
<h3 className="font-semibold text-lg">
|
||||
Anúncio não disponível
|
||||
</h3>
|
||||
{getStatusBadge(intention.status)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -203,7 +228,9 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
|
||||
return `/api/images?path=${encodeURIComponent(`data/images/${category}/${normalizedPath}`)}`;
|
||||
}
|
||||
if (listing.item_id_unified) {
|
||||
const params = new URLSearchParams({ item: listing.item_id_unified.toString() });
|
||||
const params = new URLSearchParams({
|
||||
item: listing.item_id_unified.toString(),
|
||||
});
|
||||
if (listing.item_name) {
|
||||
params.set("name", listing.item_name);
|
||||
}
|
||||
@@ -223,24 +250,27 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
|
||||
? `${listing.listing_id}_${intention.buyer_id}_${listing.seller_id}`
|
||||
: null);
|
||||
|
||||
const price = listing.price_pix && intention.payment_method === "pix"
|
||||
? listing.price_pix
|
||||
: listing.price_tibia_coins && intention.payment_method === "tibia_coins"
|
||||
? listing.price_tibia_coins
|
||||
: listing.price_gold_coins && intention.payment_method === "gold_coins"
|
||||
? listing.price_gold_coins
|
||||
: null;
|
||||
const price =
|
||||
listing.price_pix && intention.payment_method === "pix"
|
||||
? listing.price_pix
|
||||
: listing.price_tibia_coins && intention.payment_method === "tibia_coins"
|
||||
? listing.price_tibia_coins
|
||||
: listing.price_gold_coins && intention.payment_method === "gold_coins"
|
||||
? listing.price_gold_coins
|
||||
: null;
|
||||
const customOfferLabel = formatCustomOffer(
|
||||
typeof intention.custom_offer_amount === "number" ? intention.custom_offer_amount : null,
|
||||
intention.custom_offer_currency ?? null
|
||||
typeof intention.custom_offer_amount === "number"
|
||||
? intention.custom_offer_amount
|
||||
: null,
|
||||
intention.custom_offer_currency ?? null,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4 flex-1 min-w-0">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4 flex-1 min-w-0">
|
||||
<div className="relative w-16 h-16 rounded-lg bg-muted flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
{itemImageUrl ? (
|
||||
<Image
|
||||
@@ -254,160 +284,172 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg truncate">{listing.item_name}</h3>
|
||||
{getStatusBadge(intention.status)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{listing.server_name}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg truncate">
|
||||
{listing.item_name}
|
||||
</h3>
|
||||
{getStatusBadge(intention.status)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Package className="h-3 w-3" />
|
||||
<span>Qtd: {listing.quantity}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ShoppingCart className="h-3 w-3" />
|
||||
<span>{getPaymentMethodLabel(intention.payment_method)}</span>
|
||||
{price !== null && (
|
||||
<span className="font-medium text-foreground ml-1">
|
||||
{formatPrice(price, intention.payment_method)}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{listing.server_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Package className="h-3 w-3" />
|
||||
<span>Qtd: {listing.quantity}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ShoppingCart className="h-3 w-3" />
|
||||
<span>
|
||||
{getPaymentMethodLabel(intention.payment_method)}
|
||||
</span>
|
||||
{price !== null && (
|
||||
<span className="font-medium text-foreground ml-1">
|
||||
{formatPrice(price, intention.payment_method)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{customOfferLabel && (
|
||||
<div className="flex items-center gap-1 text-foreground">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-primary text-primary"
|
||||
>
|
||||
Sua oferta: {customOfferLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{customOfferLabel && (
|
||||
<div className="flex items-center gap-1 text-foreground">
|
||||
<Badge variant="outline" className="border-primary text-primary">
|
||||
Sua oferta: {customOfferLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{/* Informações do vendedor */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Vendedor:</span>
|
||||
<span className="font-medium">
|
||||
{listing.seller_character_name || listing.seller_username || "Desconhecido"}
|
||||
</span>
|
||||
</div>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Informações do vendedor */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Vendedor:</span>
|
||||
<span className="font-medium">
|
||||
{listing.seller_character_name ||
|
||||
listing.seller_username ||
|
||||
"Desconhecido"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Mensagem do comprador */}
|
||||
{intention.buyer_message && (
|
||||
<div className="bg-muted/50 rounded-md p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground mb-1">Sua mensagem:</p>
|
||||
<p className="text-sm">{intention.buyer_message}</p>
|
||||
{/* Mensagem do comprador */}
|
||||
{intention.buyer_message && (
|
||||
<div className="bg-muted/50 rounded-md p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
Sua mensagem:
|
||||
</p>
|
||||
<p className="text-sm">{intention.buyer_message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Resposta do vendedor */}
|
||||
{intention.seller_response && (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md p-3 border",
|
||||
intention.status === "accepted"
|
||||
? "bg-green-500/10 border-green-500/20"
|
||||
: intention.status === "rejected"
|
||||
? "bg-red-500/10 border-red-500/20"
|
||||
: "bg-blue-500/10 border-blue-500/20"
|
||||
{/* Resposta do vendedor */}
|
||||
{intention.seller_response && (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md p-3 border",
|
||||
intention.status === "accepted"
|
||||
? "bg-green-500/10 border-green-500/20"
|
||||
: intention.status === "rejected"
|
||||
? "bg-red-500/10 border-red-500/20"
|
||||
: "bg-blue-500/10 border-blue-500/20",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"h-4 w-4 mt-0.5 flex-shrink-0",
|
||||
intention.status === "accepted"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: intention.status === "rejected"
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium mb-1">
|
||||
{intention.status === "accepted"
|
||||
? "Resposta do vendedor (Aceita):"
|
||||
: intention.status === "rejected"
|
||||
? "Resposta do vendedor (Rejeitada):"
|
||||
: "Resposta do vendedor:"}
|
||||
</p>
|
||||
<p className="text-sm">{intention.seller_response}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Criada {formatTimeAgo(intention.created_at)}</span>
|
||||
</div>
|
||||
{intention.updated_at !== intention.created_at && (
|
||||
<span>Atualizada {formatTimeAgo(intention.updated_at)}</span>
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"h-4 w-4 mt-0.5 flex-shrink-0",
|
||||
intention.status === "accepted"
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: intention.status === "rejected"
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-blue-600 dark:text-blue-400"
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium mb-1">
|
||||
{intention.status === "accepted"
|
||||
? "Resposta do vendedor (Aceita):"
|
||||
: intention.status === "rejected"
|
||||
? "Resposta do vendedor (Rejeitada):"
|
||||
: "Resposta do vendedor:"}
|
||||
</p>
|
||||
<p className="text-sm">{intention.seller_response}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Criada {formatTimeAgo(intention.created_at)}</span>
|
||||
</div>
|
||||
{intention.updated_at !== intention.created_at && (
|
||||
<span>Atualizada {formatTimeAgo(intention.updated_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2 flex-wrap">
|
||||
{intention.status === "pending" &&
|
||||
intention.payment_method_change_count < 1 && (
|
||||
<ChangePaymentMethodButton
|
||||
intentionId={intention.intention_id}
|
||||
currentPaymentMethod={intention.payment_method}
|
||||
availableMethods={[
|
||||
...(listing.price_pix ? ["pix"] : []),
|
||||
...(listing.price_tibia_coins ? ["tibia_coins"] : []),
|
||||
...(listing.price_gold_coins ? ["gold_coins"] : []),
|
||||
...(listing.trade_item_id ? ["trade"] : []),
|
||||
]}
|
||||
canChange={true}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowListingModal(true)}
|
||||
className="flex-1"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Ver Anúncio
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setShowChatModal(true)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Abrir Chat
|
||||
</Button>
|
||||
{intention.status === "accepted" && listing.listing_status === "active" && (
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2 flex-wrap">
|
||||
{intention.status === "pending" &&
|
||||
intention.payment_method_change_count < 1 && (
|
||||
<ChangePaymentMethodButton
|
||||
intentionId={intention.intention_id}
|
||||
currentPaymentMethod={intention.payment_method}
|
||||
availableMethods={[
|
||||
...(listing.price_pix ? ["pix"] : []),
|
||||
...(listing.price_tibia_coins ? ["tibia_coins"] : []),
|
||||
...(listing.price_gold_coins ? ["gold_coins"] : []),
|
||||
...(listing.trade_item_id ? ["trade"] : []),
|
||||
]}
|
||||
canChange={true}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowListingModal(true)}
|
||||
className="flex-1"
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
Comprar Agora
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Ver Anúncio
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setShowChatModal(true)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Abrir Chat
|
||||
</Button>
|
||||
{intention.status === "accepted" &&
|
||||
listing.listing_status === "active" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowListingModal(true)}
|
||||
className="flex-1"
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
Comprar Agora
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={showListingModal} onOpenChange={setShowListingModal}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
@@ -434,30 +476,48 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{listing.item_name}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{listing.description ?? "Sem descrição fornecida para este anúncio."}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{listing.description ??
|
||||
"Sem descrição fornecida para este anúncio."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span>Servidor: <span className="font-medium text-foreground">{listing.server_name}</span></span>
|
||||
<span>
|
||||
Servidor:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{listing.server_name}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Package className="h-4 w-4" />
|
||||
<span>Quantidade: <span className="font-medium text-foreground">{listing.quantity}</span></span>
|
||||
<span>
|
||||
Quantidade:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{listing.quantity}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
<span>Vendedor:{" "}
|
||||
<span>
|
||||
Vendedor:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{listing.seller_character_name || listing.seller_username || "Desconhecido"}
|
||||
{listing.seller_character_name ||
|
||||
listing.seller_username ||
|
||||
"Desconhecido"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Condições de pagamento</h4>
|
||||
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Condições de pagamento
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{listing.price_pix && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
@@ -477,18 +537,23 @@ function IntentionCard({ intention, locale = "pt" }: IntentionCardProps) {
|
||||
{formatPrice(listing.price_gold_coins, "gold_coins")}
|
||||
</Badge>
|
||||
)}
|
||||
{listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{listing.trade_item_quantity}x {listing.trade_item_name}
|
||||
</Badge>
|
||||
)}
|
||||
{listing.trade_item_id &&
|
||||
listing.trade_item_name &&
|
||||
listing.trade_item_quantity && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{listing.trade_item_quantity}x {listing.trade_item_name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/${locale}/market?listing=${listing.listing_id}`} target="_blank">
|
||||
<Link
|
||||
href={`/${locale}/market?listing=${listing.listing_id}`}
|
||||
target="_blank"
|
||||
>
|
||||
Abrir no marketplace
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -521,17 +586,25 @@ interface MyPurchaseIntentionsPanelProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntentionsPanelProps) {
|
||||
export function MyPurchaseIntentionsPanel({
|
||||
locale = "pt",
|
||||
}: MyPurchaseIntentionsPanelProps) {
|
||||
const { user } = useSession();
|
||||
const [filter, setFilter] = useState<PurchaseIntentionStatus | "all">("all");
|
||||
const { intentions, isLoading } = useMyPurchaseIntentions();
|
||||
|
||||
// Contar por status
|
||||
const pendingCount = intentions.filter((i) => i.status === "pending").length;
|
||||
const acceptedCount = intentions.filter((i) => i.status === "accepted").length;
|
||||
const rejectedCount = intentions.filter((i) => i.status === "rejected").length;
|
||||
const acceptedCount = intentions.filter(
|
||||
(i) => i.status === "accepted",
|
||||
).length;
|
||||
const rejectedCount = intentions.filter(
|
||||
(i) => i.status === "rejected",
|
||||
).length;
|
||||
const expiredCount = intentions.filter((i) => i.status === "expired").length;
|
||||
const completedCount = intentions.filter((i) => i.status === "completed").length;
|
||||
const completedCount = intentions.filter(
|
||||
(i) => i.status === "completed",
|
||||
).length;
|
||||
const filteredIntentions =
|
||||
filter === "all"
|
||||
? intentions
|
||||
@@ -540,7 +613,9 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Faça login para ver suas intenções de compra</p>
|
||||
<p className="text-muted-foreground">
|
||||
Faça login para ver suas intenções de compra
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -614,7 +689,9 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
|
||||
) : filteredIntentions.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg">
|
||||
<ShoppingCart className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">Nenhuma intenção de compra encontrada</p>
|
||||
<p className="text-lg font-medium mb-2">
|
||||
Nenhuma intenção de compra encontrada
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{filter === "all"
|
||||
? "Quando você manifestar interesse em comprar um item, ele aparecerá aqui."
|
||||
@@ -627,11 +704,14 @@ export function MyPurchaseIntentionsPanel({ locale = "pt" }: MyPurchaseIntention
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredIntentions.map((intention) => (
|
||||
<IntentionCard key={intention.intention_id} intention={intention} locale={locale} />
|
||||
<IntentionCard
|
||||
key={intention.intention_id}
|
||||
intention={intention}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Coins, DollarSign, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,13 +11,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DollarSign, Coins, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface Listing {
|
||||
listing_id: number;
|
||||
@@ -37,7 +37,12 @@ interface PurchaseModalProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: PurchaseModalProps) {
|
||||
export function PurchaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
listing,
|
||||
onSuccess,
|
||||
}: PurchaseModalProps) {
|
||||
const { user } = useSession();
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -53,7 +58,10 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedMethod && !["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)) {
|
||||
if (
|
||||
selectedMethod &&
|
||||
!["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)
|
||||
) {
|
||||
setCustomOfferValue("");
|
||||
}
|
||||
}, [selectedMethod]);
|
||||
@@ -61,13 +69,19 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
if (!listing) return null;
|
||||
|
||||
// Determinar métodos de pagamento disponíveis
|
||||
const availableMethods: Array<{ type: string; label: string; value: string; icon: any }> = [];
|
||||
|
||||
const availableMethods: Array<{
|
||||
type: string;
|
||||
label: string;
|
||||
value: string;
|
||||
icon: any;
|
||||
}> = [];
|
||||
|
||||
if (listing.price_pix) {
|
||||
const price = typeof listing.price_pix === 'string'
|
||||
? parseFloat(listing.price_pix)
|
||||
: listing.price_pix;
|
||||
if (!isNaN(price)) {
|
||||
const price =
|
||||
typeof listing.price_pix === "string"
|
||||
? parseFloat(listing.price_pix)
|
||||
: listing.price_pix;
|
||||
if (!Number.isNaN(price)) {
|
||||
availableMethods.push({
|
||||
type: "pix",
|
||||
label: "PIX",
|
||||
@@ -78,10 +92,11 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
}
|
||||
|
||||
if (listing.price_tibia_coins) {
|
||||
const price = typeof listing.price_tibia_coins === 'string'
|
||||
? parseInt(listing.price_tibia_coins, 10)
|
||||
: listing.price_tibia_coins;
|
||||
if (!isNaN(price)) {
|
||||
const price =
|
||||
typeof listing.price_tibia_coins === "string"
|
||||
? parseInt(listing.price_tibia_coins, 10)
|
||||
: listing.price_tibia_coins;
|
||||
if (!Number.isNaN(price)) {
|
||||
availableMethods.push({
|
||||
type: "tibia_coins",
|
||||
label: "Tibia Coins",
|
||||
@@ -92,10 +107,11 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
}
|
||||
|
||||
if (listing.price_gold_coins) {
|
||||
const price = typeof listing.price_gold_coins === 'string'
|
||||
? parseInt(listing.price_gold_coins, 10)
|
||||
: listing.price_gold_coins;
|
||||
if (!isNaN(price)) {
|
||||
const price =
|
||||
typeof listing.price_gold_coins === "string"
|
||||
? parseInt(listing.price_gold_coins, 10)
|
||||
: listing.price_gold_coins;
|
||||
if (!Number.isNaN(price)) {
|
||||
availableMethods.push({
|
||||
type: "gold_coins",
|
||||
label: "Gold Coins",
|
||||
@@ -105,7 +121,11 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
}
|
||||
}
|
||||
|
||||
if (listing.trade_item_id && listing.trade_item_name && listing.trade_item_quantity) {
|
||||
if (
|
||||
listing.trade_item_id &&
|
||||
listing.trade_item_name &&
|
||||
listing.trade_item_quantity
|
||||
) {
|
||||
availableMethods.push({
|
||||
type: "trade",
|
||||
label: "Troca",
|
||||
@@ -114,7 +134,10 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
});
|
||||
}
|
||||
|
||||
const formatOffer = (amount: number | string | null, currency: string | null) => {
|
||||
const formatOffer = (
|
||||
amount: number | string | null,
|
||||
currency: string | null,
|
||||
) => {
|
||||
if (amount === null || amount === undefined || !currency) {
|
||||
return null;
|
||||
}
|
||||
@@ -136,7 +159,7 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
|
||||
const highestOfferLabel = formatOffer(
|
||||
listing.highest_custom_offer_amount ?? null,
|
||||
listing.highest_custom_offer_currency ?? null
|
||||
listing.highest_custom_offer_currency ?? null,
|
||||
);
|
||||
|
||||
// Se houver apenas um método, selecionar automaticamente
|
||||
@@ -167,20 +190,28 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
payment_method: selectedMethod,
|
||||
};
|
||||
|
||||
if (hasCustomOffer && ["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)) {
|
||||
if (
|
||||
hasCustomOffer &&
|
||||
["pix", "tibia_coins", "gold_coins"].includes(selectedMethod)
|
||||
) {
|
||||
const offerNumber = Number(customOfferValue);
|
||||
payload.custom_offer_amount =
|
||||
selectedMethod === "pix" ? Number(offerNumber.toFixed(2)) : Math.floor(offerNumber);
|
||||
selectedMethod === "pix"
|
||||
? Number(offerNumber.toFixed(2))
|
||||
: Math.floor(offerNumber);
|
||||
payload.custom_offer_currency = selectedMethod;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/listings/${listing.listing_id}/purchase`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
const response = await fetch(
|
||||
`/api/listings/${listing.listing_id}/purchase`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -236,10 +267,16 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Valores anunciados pelo vendedor</p>
|
||||
<p className="text-sm font-medium">
|
||||
Valores anunciados pelo vendedor
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableMethods.map((method) => (
|
||||
<Badge key={`original-${method.type}`} variant="secondary" className="gap-1">
|
||||
<Badge
|
||||
key={`original-${method.type}`}
|
||||
variant="secondary"
|
||||
className="gap-1"
|
||||
>
|
||||
{method.label}: {method.value}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -287,15 +324,21 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
) : availableMethods.length === 1 ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Método de Pagamento</Label>
|
||||
<div className={`p-4 rounded-lg border-2 ${getMethodClassName(availableMethods[0].type)}`}>
|
||||
<div
|
||||
className={`p-4 rounded-lg border-2 ${getMethodClassName(availableMethods[0].type)}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{(() => {
|
||||
const Icon = availableMethods[0].icon;
|
||||
return <Icon className="h-4 w-4" />;
|
||||
})()}
|
||||
<span className="font-medium">{availableMethods[0].label}</span>
|
||||
<span className="font-medium">
|
||||
{availableMethods[0].label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm opacity-90">{availableMethods[0].value}</p>
|
||||
<p className="text-sm opacity-90">
|
||||
{availableMethods[0].value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -308,7 +351,8 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
["pix", "tibia_coins", "gold_coins"].includes(selectedMethod) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
Propor valor personalizado ({selectedMethod === "pix" ? "R$" : "quantidade"})
|
||||
Propor valor personalizado (
|
||||
{selectedMethod === "pix" ? "R$" : "quantidade"})
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -316,11 +360,13 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
step={selectedMethod === "pix" ? "0.01" : "1"}
|
||||
value={customOfferValue}
|
||||
onChange={(event) => setCustomOfferValue(event.target.value)}
|
||||
placeholder={selectedMethod === "pix" ? "Ex.: 199.90" : "Ex.: 1200"}
|
||||
placeholder={
|
||||
selectedMethod === "pix" ? "Ex.: 199.90" : "Ex.: 1200"
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Esse valor será enviado ao vendedor como sua proposta. Ele pode aceitar, negar ou
|
||||
responder.
|
||||
Esse valor será enviado ao vendedor como sua proposta. Ele
|
||||
pode aceitar, negar ou responder.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -344,7 +390,9 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePurchase}
|
||||
disabled={loading || !selectedMethod || availableMethods.length === 0}
|
||||
disabled={
|
||||
loading || !selectedMethod || availableMethods.length === 0
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
@@ -361,4 +409,3 @@ export function PurchaseModal({ open, onOpenChange, listing, onSuccess }: Purcha
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, User } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { User, CheckCircle2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useMarketTelemetry } from "@/lib/hooks/use-telemetry";
|
||||
|
||||
interface SellerInfoProps {
|
||||
@@ -27,14 +27,10 @@ export function SellerInfo({
|
||||
|
||||
// Tracking de visualização de vendedor
|
||||
useEffect(() => {
|
||||
trackCustomEvent(
|
||||
"seller_profile_viewed",
|
||||
listingId,
|
||||
{
|
||||
seller_id: sellerId,
|
||||
seller_name: sellerName,
|
||||
}
|
||||
);
|
||||
trackCustomEvent("seller_profile_viewed", listingId, {
|
||||
seller_id: sellerId,
|
||||
seller_name: sellerName,
|
||||
});
|
||||
}, [sellerId, sellerName, listingId, trackCustomEvent]);
|
||||
|
||||
// Gerar iniciais do nome
|
||||
@@ -75,4 +71,3 @@ export function SellerInfo({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Package, Clock } from "lucide-react";
|
||||
import { Clock, Package } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Chat } from "@/lib/hooks/use-chat";
|
||||
import { formatTimeAgo } from "@/lib/utils";
|
||||
|
||||
@@ -20,9 +20,7 @@ export function ChatListItem({
|
||||
onChatClick,
|
||||
}: ChatListItemProps) {
|
||||
const otherUser =
|
||||
chat.buyer_id === currentUserId
|
||||
? chat.seller_cache
|
||||
: chat.buyer_cache;
|
||||
chat.buyer_id === currentUserId ? chat.seller_cache : chat.buyer_cache;
|
||||
|
||||
const unreadCount =
|
||||
chat.buyer_id === currentUserId
|
||||
@@ -53,7 +51,10 @@ export function ChatListItem({
|
||||
{otherUser.character_name || otherUser.username}
|
||||
</h4>
|
||||
{unreadCount > 0 && (
|
||||
<Badge variant="destructive" className="h-5 min-w-5 px-1.5 text-xs">
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="h-5 min-w-5 px-1.5 text-xs"
|
||||
>
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { MessagesPanel } from "./MessagesPanel";
|
||||
|
||||
@@ -46,14 +46,14 @@ export function MessagesBell({ className }: MessagesBellProps) {
|
||||
}, 30000); // 30 segundos
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [user]);
|
||||
}, [user, fetchUnreadCount]);
|
||||
|
||||
// Atualizar contagem quando abrir/fechar
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchUnreadCount();
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, fetchUnreadCount]);
|
||||
|
||||
// Marcar como visto quando abrir
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
@@ -82,7 +82,7 @@ export function MessagesBell({ className }: MessagesBellProps) {
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<MessagesPanel
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChatModal } from "@/components/chat/ChatModal";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -8,11 +9,10 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ChatListItem } from "./ChatListItem";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { ChatModal } from "@/components/chat/ChatModal";
|
||||
import type { Chat } from "@/lib/hooks/use-chat";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { ChatListItem } from "./ChatListItem";
|
||||
|
||||
interface MessagesPanelProps {
|
||||
open: boolean;
|
||||
@@ -44,7 +44,7 @@ export function MessagesPanel({
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setChats(data.chats || []);
|
||||
|
||||
|
||||
// Calcular contagem de usuários únicos com não lidas
|
||||
const uniqueUsersWithUnread = new Set<number>();
|
||||
data.chats.forEach((chat: Chat) => {
|
||||
@@ -53,15 +53,13 @@ export function MessagesPanel({
|
||||
? chat.buyer_unread_count
|
||||
: chat.seller_unread_count;
|
||||
const otherUserId =
|
||||
chat.buyer_id === user.userId
|
||||
? chat.seller_id
|
||||
: chat.buyer_id;
|
||||
|
||||
chat.buyer_id === user.userId ? chat.seller_id : chat.buyer_id;
|
||||
|
||||
if (unreadCount > 0) {
|
||||
uniqueUsersWithUnread.add(otherUserId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
onUnreadCountChange(uniqueUsersWithUnread.size);
|
||||
}
|
||||
}
|
||||
@@ -78,7 +76,12 @@ export function MessagesPanel({
|
||||
// Marcar todos como lidos ao abrir
|
||||
markAllAsRead();
|
||||
}
|
||||
}, [open, user]);
|
||||
}, [
|
||||
open,
|
||||
user,
|
||||
fetchChats, // Marcar todos como lidos ao abrir
|
||||
markAllAsRead,
|
||||
]);
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
@@ -94,7 +97,7 @@ export function MessagesPanel({
|
||||
chat.buyer_id === user?.userId ? 0 : chat.buyer_unread_count,
|
||||
seller_unread_count:
|
||||
chat.seller_id === user?.userId ? 0 : chat.seller_unread_count,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
onUnreadCountChange(0);
|
||||
} catch (error) {
|
||||
@@ -102,7 +105,7 @@ export function MessagesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatClick = (chatId: string, listingId: number) => {
|
||||
const handleChatClick = (chatId: string, _listingId: number) => {
|
||||
setSelectedChatId(chatId);
|
||||
setShowChatModal(true);
|
||||
onOpenChange(false);
|
||||
@@ -123,7 +126,10 @@ export function MessagesPanel({
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-4 px-4 overflow-y-auto" style={{ maxHeight: "calc(100vh - 120px)" }}>
|
||||
<div
|
||||
className="mt-4 px-4 overflow-y-auto"
|
||||
style={{ maxHeight: "calc(100vh - 120px)" }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
@@ -144,7 +150,9 @@ export function MessagesPanel({
|
||||
key={chat.chat_id}
|
||||
chat={chat}
|
||||
currentUserId={user.userId}
|
||||
onChatClick={() => handleChatClick(chat.chat_id, chat.listing_id)}
|
||||
onChatClick={() =>
|
||||
handleChatClick(chat.chat_id, chat.listing_id)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Menu } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { LanguageSwitcher } from "./language-switcher";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
interface MobileMenuProps {
|
||||
links: Array<{ href: string; label: string }>;
|
||||
@@ -77,8 +77,3 @@ export function MobileMenu({ links }: MobileMenuProps) {
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Bell } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotificationDropdown } from "./NotificationDropdown";
|
||||
import { useSession } from "@/lib/hooks/use-session";
|
||||
import { NotificationDropdown } from "./NotificationDropdown";
|
||||
|
||||
interface Notification {
|
||||
notification_id: number;
|
||||
@@ -32,7 +32,7 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [_loading, setLoading] = useState(false);
|
||||
const rawUnreadRef = useRef(0);
|
||||
const baselineUnreadRef = useRef(0);
|
||||
const baselineLoadedRef = useRef(false);
|
||||
@@ -122,7 +122,7 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
|
||||
baselineUnreadRef.current = 0;
|
||||
baselineLoadedRef.current = false;
|
||||
}
|
||||
}, [effectiveUserId]);
|
||||
}, [effectiveUserId, loadBaselineFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveUserId) return;
|
||||
@@ -138,7 +138,7 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
|
||||
}, 30000); // 30 segundos
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [effectiveUserId, isOpen]);
|
||||
}, [effectiveUserId, isOpen, fetchNotifications, fetchUnreadCount]);
|
||||
|
||||
// Atualizar quando abrir popover
|
||||
useEffect(() => {
|
||||
@@ -147,7 +147,7 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
|
||||
persistBaseline(rawUnreadRef.current);
|
||||
setUnreadCount(0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, fetchNotifications, persistBaseline]);
|
||||
|
||||
const handleMarkAsRead = async (notificationId: number) => {
|
||||
try {
|
||||
@@ -159,14 +159,14 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
|
||||
// Atualizar estado local
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) =>
|
||||
n.notification_id === notificationId ? { ...n, is_read: true } : n
|
||||
)
|
||||
n.notification_id === notificationId ? { ...n, is_read: true } : n,
|
||||
),
|
||||
);
|
||||
// Atualizar contagem
|
||||
rawUnreadRef.current = Math.max(0, rawUnreadRef.current - 1);
|
||||
const newBaseline = Math.min(
|
||||
baselineUnreadRef.current,
|
||||
rawUnreadRef.current
|
||||
rawUnreadRef.current,
|
||||
);
|
||||
persistBaseline(newBaseline);
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
@@ -209,4 +209,3 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bell,
|
||||
ExternalLink,
|
||||
MessageSquare,
|
||||
ShoppingCart,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useLocale } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
// ScrollArea não disponível, usando div com overflow
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Bell, ShoppingCart, TrendingUp, MessageSquare, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useLocale } from "next-intl";
|
||||
|
||||
interface Notification {
|
||||
notification_id: number;
|
||||
@@ -24,10 +30,11 @@ interface NotificationDropdownProps {
|
||||
}
|
||||
|
||||
function formatTimeAgo(date: Date): string {
|
||||
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return "há alguns segundos";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
|
||||
if (minutes < 60)
|
||||
return `há ${minutes} ${minutes === 1 ? "minuto" : "minutos"}`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `há ${hours} ${hours === 1 ? "hora" : "horas"}`;
|
||||
const days = Math.floor(hours / 24);
|
||||
@@ -118,14 +125,16 @@ export function NotificationDropdown({
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`${getNotificationColor(
|
||||
notification.notification_type
|
||||
notification.notification_type,
|
||||
)} rounded-full p-2 text-white flex-shrink-0`}
|
||||
>
|
||||
{getNotificationIcon(notification.notification_type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="font-medium text-sm">{notification.title}</h4>
|
||||
<h4 className="font-medium text-sm">
|
||||
{notification.title}
|
||||
</h4>
|
||||
{!notification.is_read && (
|
||||
<div className="h-2 w-2 bg-primary rounded-full flex-shrink-0 mt-1" />
|
||||
)}
|
||||
@@ -148,7 +157,9 @@ export function NotificationDropdown({
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{index < notifications.length - 1 && <Separator className="my-2" />}
|
||||
{index < notifications.length - 1 && (
|
||||
<Separator className="my-2" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -156,4 +167,3 @@ export function NotificationDropdown({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { MarketOffer } from "@/types/item";
|
||||
import { Clock, Coins, Heart, Shield, Sword } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PriceDisplay } from "@/components/ui/price-display";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Clock,
|
||||
User,
|
||||
Coins,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Star,
|
||||
Heart,
|
||||
Shield,
|
||||
Sword,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { getRarityColor, getRarityBgColor } from "@/lib/currency-utils";
|
||||
import type { MarketOffer } from "@/types/item";
|
||||
|
||||
interface OfferCardProps {
|
||||
offer: MarketOffer;
|
||||
@@ -28,7 +17,7 @@ export function OfferCard({ offer }: OfferCardProps) {
|
||||
const t = useTranslations("home.stats");
|
||||
const tButtons = useTranslations("home.buttons");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [_isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const formatTimeAgo = (timestamp: Date) => {
|
||||
const now = new Date();
|
||||
@@ -81,8 +70,7 @@ export function OfferCard({ offer }: OfferCardProps) {
|
||||
};
|
||||
|
||||
const isHotDeal = offer.price < offer.item.priceHistory.average * 0.8;
|
||||
const isNewOffer =
|
||||
new Date().getTime() - offer.timestamp.getTime() < 1000 * 60 * 30; // 30 min
|
||||
const isNewOffer = Date.now() - offer.timestamp.getTime() < 1000 * 60 * 30; // 30 min
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -128,7 +116,7 @@ export function OfferCard({ offer }: OfferCardProps) {
|
||||
</div>
|
||||
<Badge
|
||||
className={`${getRarityColor(
|
||||
offer.item.rarity
|
||||
offer.item.rarity,
|
||||
)} text-white absolute -top-2 -right-2 text-xs shadow-lg`}
|
||||
>
|
||||
{offer.item.rarity}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -16,9 +19,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RefreshCw, Loader2 } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface ChangePaymentMethodButtonProps {
|
||||
intentionId: number;
|
||||
@@ -49,14 +49,15 @@ export function ChangePaymentMethodButton({
|
||||
canChange,
|
||||
}: ChangePaymentMethodButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedMethod, setSelectedMethod] = useState<string>(currentPaymentMethod);
|
||||
const [selectedMethod, setSelectedMethod] =
|
||||
useState<string>(currentPaymentMethod);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Filtrar métodos disponíveis (excluir o atual)
|
||||
const availableOptions = availableMethods.filter(
|
||||
(method) => method !== currentPaymentMethod
|
||||
(method) => method !== currentPaymentMethod,
|
||||
);
|
||||
|
||||
const handleChange = async () => {
|
||||
@@ -75,7 +76,7 @@ export function ChangePaymentMethodButton({
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ payment_method: selectedMethod }),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const data = await res.json();
|
||||
@@ -92,7 +93,11 @@ export function ChangePaymentMethodButton({
|
||||
setOpen(false);
|
||||
// Opcional: mostrar toast de sucesso
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Erro ao trocar método de pagamento");
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Erro ao trocar método de pagamento",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -123,7 +128,8 @@ export function ChangePaymentMethodButton({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Trocar Método de Pagamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Você pode trocar o método de pagamento apenas uma vez. Escolha o novo método:
|
||||
Você pode trocar o método de pagamento apenas uma vez. Escolha o
|
||||
novo método:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -158,10 +164,17 @@ export function ChangePaymentMethodButton({
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleChange} disabled={loading || selectedMethod === currentPaymentMethod}>
|
||||
<Button
|
||||
onClick={handleChange}
|
||||
disabled={loading || selectedMethod === currentPaymentMethod}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
@@ -178,4 +191,3 @@ export function ChangePaymentMethodButton({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Coins, Shield } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Shield, Coins } from "lucide-react";
|
||||
|
||||
export function EscrowBanner() {
|
||||
const t = useTranslations("home.escrow");
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
CheckCircle,
|
||||
Handshake,
|
||||
Lock,
|
||||
Shield,
|
||||
Wallet,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BlurFade } from "@/components/ui/blur-fade";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BlurFade } from "@/components/ui/blur-fade";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { NumberTicker } from "@/components/ui/number-ticker";
|
||||
import {
|
||||
Wallet,
|
||||
Handshake,
|
||||
CheckCircle,
|
||||
Shield,
|
||||
Zap,
|
||||
BarChart,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
|
||||
export function EscrowExplanation() {
|
||||
const t = useTranslations("home.escrowExplanation");
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ItemCard } from "@/components/item-card";
|
||||
import { MarketplaceFilters } from "@/components/marketplace-filters";
|
||||
import { featuredItems } from "@/lib/mock-data";
|
||||
import { BlurFade } from "@/components/ui/blur-fade";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { featuredItems } from "@/lib/mock-data";
|
||||
|
||||
export function FeaturedItems() {
|
||||
const t = useTranslations("home.featured");
|
||||
@@ -21,7 +21,7 @@ export function FeaturedItems() {
|
||||
priceRange: { min: 0, max: 100000 },
|
||||
});
|
||||
const [sortBy, setSortBy] = useState("name");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoading, _setIsLoading] = useState(false);
|
||||
|
||||
// Filtrar e ordenar itens
|
||||
const filteredItems = useMemo(() => {
|
||||
@@ -30,21 +30,21 @@ export function FeaturedItems() {
|
||||
// Filtro por busca
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter((item) =>
|
||||
item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
item.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
// Filtro por raridade
|
||||
if (filters.rarity.length > 0) {
|
||||
filtered = filtered.filter((item) =>
|
||||
filters.rarity.includes(item.rarity)
|
||||
filters.rarity.includes(item.rarity),
|
||||
);
|
||||
}
|
||||
|
||||
// Filtro por categoria
|
||||
if (filters.category.length > 0) {
|
||||
filtered = filtered.filter((item) =>
|
||||
filters.category.includes(item.category)
|
||||
filters.category.includes(item.category),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function FeaturedItems() {
|
||||
filtered = filtered.filter(
|
||||
(item) =>
|
||||
item.price.buy >= filters.priceRange.min &&
|
||||
item.price.buy <= filters.priceRange.max
|
||||
item.price.buy <= filters.priceRange.max,
|
||||
);
|
||||
|
||||
// Ordenação
|
||||
@@ -64,7 +64,7 @@ export function FeaturedItems() {
|
||||
return a.price.buy - b.price.buy;
|
||||
case "price-desc":
|
||||
return b.price.buy - a.price.buy;
|
||||
case "rarity":
|
||||
case "rarity": {
|
||||
const rarityOrder = {
|
||||
common: 0,
|
||||
uncommon: 1,
|
||||
@@ -73,6 +73,7 @@ export function FeaturedItems() {
|
||||
legendary: 4,
|
||||
};
|
||||
return rarityOrder[a.rarity] - rarityOrder[b.rarity];
|
||||
}
|
||||
case "trending":
|
||||
return b.priceHistory.highest - a.priceHistory.highest;
|
||||
case "recent":
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AnimatedGradientText } from "@/components/ui/animated-gradient-text";
|
||||
import { ShimmerButton } from "@/components/ui/shimmer-button";
|
||||
import { BlurFade } from "@/components/ui/blur-fade";
|
||||
import Link from "next/link";
|
||||
import { ShimmerButton } from "@/components/ui/shimmer-button";
|
||||
|
||||
export function HeroSection() {
|
||||
const t = useTranslations("home.hero");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Coins, Shield, ShoppingBag } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BentoGrid, BentoCard } from "@/components/ui/bento-grid";
|
||||
import { ShoppingBag, Coins, Shield } from "lucide-react";
|
||||
import { BentoCard, BentoGrid } from "@/components/ui/bento-grid";
|
||||
|
||||
export function HowItWorks() {
|
||||
const t = useTranslations("home.howItWorks");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ItemCard } from "@/components/item-card";
|
||||
import { BlurFade } from "@/components/ui/blur-fade";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -38,7 +38,7 @@ export function MarketExample() {
|
||||
// Buscar mix de categorias para variedade visual
|
||||
const categories = ["weapon", "armor", "helmet"];
|
||||
const response = await fetch(
|
||||
`/api/items?category=${categories.join(",")}&pageSize=8&sort=name`
|
||||
`/api/items?category=${categories.join(",")}&pageSize=8&sort=name`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -116,9 +116,7 @@ export function MarketExample() {
|
||||
<div className="container">
|
||||
<BlurFade delay={0.2}>
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold mb-4">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<h2 className="text-4xl font-bold mb-4">{t("title")}</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
@@ -131,9 +129,7 @@ export function MarketExample() {
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">⚠️</div>
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
{t("errorTitle")}
|
||||
</h3>
|
||||
<h3 className="text-xl font-semibold mb-2">{t("errorTitle")}</h3>
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -153,9 +149,7 @@ export function MarketExample() {
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
Nenhum item encontrado
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Tente novamente mais tarde
|
||||
</p>
|
||||
<p className="text-muted-foreground">Tente novamente mais tarde</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AnimatedList } from "@/components/ui/animated-list";
|
||||
import { OfferCard } from "@/components/offer-card";
|
||||
import { buyOffers, sellOffers } from "@/lib/mock-data";
|
||||
import { AnimatedList } from "@/components/ui/animated-list";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { buyOffers, sellOffers } from "@/lib/mock-data";
|
||||
|
||||
export function MarketOffers() {
|
||||
const t = useTranslations("home.offers");
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { NumberTicker } from "@/components/ui/number-ticker";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
DollarSign,
|
||||
ShoppingCart,
|
||||
TrendingUp,
|
||||
Users,
|
||||
DollarSign,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
import { AreaChart, Area, ResponsiveContainer } from "recharts";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Area, AreaChart, ResponsiveContainer } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { NumberTicker } from "@/components/ui/number-ticker";
|
||||
import { buyOffers, sellOffers } from "@/lib/mock-data";
|
||||
|
||||
interface StatData {
|
||||
@@ -130,14 +130,14 @@ export function MarketStats() {
|
||||
const t = useTranslations("home.stats");
|
||||
|
||||
// Calcular volume total baseado nos dados mock
|
||||
const calculateTotalVolume = () => {
|
||||
const _calculateTotalVolume = () => {
|
||||
const buyVolume = buyOffers.reduce(
|
||||
(sum, offer) => sum + offer.price * offer.quantity,
|
||||
0
|
||||
0,
|
||||
);
|
||||
const sellVolume = sellOffers.reduce(
|
||||
(sum, offer) => sum + offer.price * offer.quantity,
|
||||
0
|
||||
0,
|
||||
);
|
||||
return Math.round((buyVolume + sellVolume) / 1000); // Dividir por 1000 para número mais legível
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ComponentProps } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type ThemeProviderProps = ComponentProps<typeof NextThemesProvider>;
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
@@ -58,8 +58,3 @@ export function ThemeToggle() {
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
@@ -28,7 +28,7 @@ const AccordionTrigger = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ComponentPropsWithoutRef } from "react"
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AnimatedGradientTextProps
|
||||
extends ComponentPropsWithoutRef<"div"> {
|
||||
speed?: number
|
||||
colorFrom?: string
|
||||
colorTo?: string
|
||||
speed?: number;
|
||||
colorFrom?: string;
|
||||
colorTo?: string;
|
||||
}
|
||||
|
||||
export function AnimatedGradientText({
|
||||
@@ -28,11 +28,11 @@ export function AnimatedGradientText({
|
||||
}
|
||||
className={cn(
|
||||
`animate-gradient inline bg-gradient-to-r from-[var(--color-from)] via-[var(--color-to)] to-[var(--color-from)] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, type MotionProps, motion } from "motion/react";
|
||||
import React, {
|
||||
ComponentPropsWithoutRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { AnimatePresence, motion, MotionProps } from "motion/react"
|
||||
} from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AnimatedListItem({ children }: { children: React.ReactNode }) {
|
||||
const animations: MotionProps = {
|
||||
@@ -16,42 +16,42 @@ export function AnimatedListItem({ children }: { children: React.ReactNode }) {
|
||||
animate: { scale: 1, opacity: 1, originY: 0 },
|
||||
exit: { scale: 0, opacity: 0 },
|
||||
transition: { type: "spring", stiffness: 350, damping: 40 },
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div {...animations} layout className="mx-auto w-full">
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export interface AnimatedListProps extends ComponentPropsWithoutRef<"div"> {
|
||||
children: React.ReactNode
|
||||
delay?: number
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export const AnimatedList = React.memo(
|
||||
({ children, className, delay = 1000, ...props }: AnimatedListProps) => {
|
||||
const [index, setIndex] = useState(0)
|
||||
const [index, setIndex] = useState(0);
|
||||
const childrenArray = useMemo(
|
||||
() => React.Children.toArray(children),
|
||||
[children]
|
||||
)
|
||||
[children],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (index < childrenArray.length - 1) {
|
||||
const timeout = setTimeout(() => {
|
||||
setIndex((prevIndex) => (prevIndex + 1) % childrenArray.length)
|
||||
}, delay)
|
||||
setIndex((prevIndex) => (prevIndex + 1) % childrenArray.length);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [index, delay, childrenArray.length])
|
||||
}, [index, delay, childrenArray.length]);
|
||||
|
||||
const itemsToShow = useMemo(() => {
|
||||
const result = childrenArray.slice(0, index + 1).reverse()
|
||||
return result
|
||||
}, [index, childrenArray])
|
||||
const result = childrenArray.slice(0, index + 1).reverse();
|
||||
return result;
|
||||
}, [index, childrenArray]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -66,8 +66,8 @@ export const AnimatedList = React.memo(
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AnimatedList.displayName = "AnimatedList"
|
||||
AnimatedList.displayName = "AnimatedList";
|
||||
+10
-10
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
@@ -14,11 +14,11 @@ function Avatar({
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
@@ -31,7 +31,7 @@ function AvatarImage({
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
@@ -43,11 +43,11 @@ function AvatarFallback({
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
@@ -22,8 +22,8 @@ const badgeVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
@@ -32,7 +32,7 @@ function Badge({
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -40,7 +40,7 @@ function Badge({
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { ArrowRightIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BentoGridProps extends ComponentPropsWithoutRef<"div"> {
|
||||
children: ReactNode;
|
||||
@@ -26,7 +25,7 @@ const BentoGrid = ({ children, className, ...props }: BentoGridProps) => {
|
||||
"grid w-full gap-6",
|
||||
"grid-cols-1 md:grid-cols-3",
|
||||
"auto-rows-[minmax(280px,auto)]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: "1fr 2fr",
|
||||
@@ -60,7 +59,7 @@ const BentoCard = ({
|
||||
// dark styles
|
||||
"dark:bg-background dark:border-border/50",
|
||||
"dark:shadow-sm dark:hover:shadow-xl",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
+25
-25
@@ -1,31 +1,31 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react"
|
||||
import {
|
||||
AnimatePresence,
|
||||
type MotionProps,
|
||||
motion,
|
||||
MotionProps,
|
||||
type UseInViewOptions,
|
||||
useInView,
|
||||
UseInViewOptions,
|
||||
Variants,
|
||||
} from "motion/react"
|
||||
type Variants,
|
||||
} from "motion/react";
|
||||
import { useRef } from "react";
|
||||
|
||||
type MarginType = UseInViewOptions["margin"]
|
||||
type MarginType = UseInViewOptions["margin"];
|
||||
|
||||
interface BlurFadeProps extends MotionProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: {
|
||||
hidden: { y: number }
|
||||
visible: { y: number }
|
||||
}
|
||||
duration?: number
|
||||
delay?: number
|
||||
offset?: number
|
||||
direction?: "up" | "down" | "left" | "right"
|
||||
inView?: boolean
|
||||
inViewMargin?: MarginType
|
||||
blur?: string
|
||||
hidden: { y: number };
|
||||
visible: { y: number };
|
||||
};
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
offset?: number;
|
||||
direction?: "up" | "down" | "left" | "right";
|
||||
inView?: boolean;
|
||||
inViewMargin?: MarginType;
|
||||
blur?: string;
|
||||
}
|
||||
|
||||
export function BlurFade({
|
||||
@@ -41,9 +41,9 @@ export function BlurFade({
|
||||
blur = "6px",
|
||||
...props
|
||||
}: BlurFadeProps) {
|
||||
const ref = useRef(null)
|
||||
const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
|
||||
const isInView = !inView || inViewResult
|
||||
const ref = useRef(null);
|
||||
const inViewResult = useInView(ref, { once: true, margin: inViewMargin });
|
||||
const isInView = !inView || inViewResult;
|
||||
const defaultVariants: Variants = {
|
||||
hidden: {
|
||||
[direction === "left" || direction === "right" ? "x" : "y"]:
|
||||
@@ -56,8 +56,8 @@ export function BlurFade({
|
||||
opacity: 1,
|
||||
filter: `blur(0px)`,
|
||||
},
|
||||
}
|
||||
const combinedVariants = variant || defaultVariants
|
||||
};
|
||||
const combinedVariants = variant || defaultVariants;
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
@@ -77,5 +77,5 @@ export function BlurFade({
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
);
|
||||
}
|
||||
+10
-10
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
@@ -33,8 +33,8 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@@ -44,9 +44,9 @@ function Button({
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -54,7 +54,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -89,4 +89,4 @@ export {
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@@ -14,7 +14,7 @@ const Checkbox = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -24,8 +24,7 @@ const Checkbox = React.forwardRef<
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
@@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -39,7 +39,7 @@ const DialogContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -60,7 +60,7 @@ const DialogHeader = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -74,7 +74,7 @@ const DialogFooter = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -89,7 +89,7 @@ const DialogTitle = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useId, useRef, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { motion } from "motion/react";
|
||||
import type React from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* DotPattern Component Props
|
||||
@@ -19,16 +20,16 @@ import { cn } from "@/lib/utils"
|
||||
* @param {boolean} [glow=false] - Whether dots should have a glowing animation effect
|
||||
*/
|
||||
interface DotPatternProps extends React.SVGProps<SVGSVGElement> {
|
||||
width?: number
|
||||
height?: number
|
||||
x?: number
|
||||
y?: number
|
||||
cx?: number
|
||||
cy?: number
|
||||
cr?: number
|
||||
className?: string
|
||||
glow?: boolean
|
||||
[key: string]: unknown
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
cx?: number;
|
||||
cy?: number;
|
||||
cr?: number;
|
||||
className?: string;
|
||||
glow?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,22 +74,22 @@ export function DotPattern({
|
||||
glow = false,
|
||||
...props
|
||||
}: DotPatternProps) {
|
||||
const id = useId()
|
||||
const containerRef = useRef<SVGSVGElement>(null)
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
||||
const id = useId();
|
||||
const containerRef = useRef<SVGSVGElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
const { width, height } = containerRef.current.getBoundingClientRect()
|
||||
setDimensions({ width, height })
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
setDimensions({ width, height });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateDimensions()
|
||||
window.addEventListener("resize", updateDimensions)
|
||||
return () => window.removeEventListener("resize", updateDimensions)
|
||||
}, [])
|
||||
updateDimensions();
|
||||
window.addEventListener("resize", updateDimensions);
|
||||
return () => window.removeEventListener("resize", updateDimensions);
|
||||
}, []);
|
||||
|
||||
const dots = Array.from(
|
||||
{
|
||||
@@ -97,16 +98,16 @@ export function DotPattern({
|
||||
Math.ceil(dimensions.height / height),
|
||||
},
|
||||
(_, i) => {
|
||||
const col = i % Math.ceil(dimensions.width / width)
|
||||
const row = Math.floor(i / Math.ceil(dimensions.width / width))
|
||||
const col = i % Math.ceil(dimensions.width / width);
|
||||
const row = Math.floor(i / Math.ceil(dimensions.width / width));
|
||||
return {
|
||||
x: col * width + cx,
|
||||
y: row * height + cy,
|
||||
delay: Math.random() * 5,
|
||||
duration: Math.random() * 3 + 2,
|
||||
}
|
||||
}
|
||||
)
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -114,7 +115,7 @@ export function DotPattern({
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full text-neutral-400/80",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -124,7 +125,7 @@ export function DotPattern({
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{dots.map((dot, index) => (
|
||||
{dots.map((dot, _index) => (
|
||||
<motion.circle
|
||||
key={`${dot.x}-${dot.y}`}
|
||||
cx={dot.x}
|
||||
@@ -154,5 +155,5 @@ export function DotPattern({
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,15 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root
|
||||
data-slot="dropdown-menu"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
@@ -22,7 +17,7 @@ function DropdownMenuPortal({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
@@ -33,7 +28,7 @@ function DropdownMenuTrigger({
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
@@ -48,12 +43,12 @@ function DropdownMenuContent({
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
@@ -61,7 +56,7 @@ function DropdownMenuGroup({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
@@ -70,8 +65,8 @@ function DropdownMenuItem({
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@@ -80,11 +75,11 @@ function DropdownMenuItem({
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@@ -98,7 +93,7 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -110,7 +105,7 @@ function DropdownMenuCheckboxItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
@@ -121,7 +116,7 @@ function DropdownMenuRadioGroup({
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@@ -134,7 +129,7 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -145,7 +140,7 @@ function DropdownMenuRadioItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@@ -153,7 +148,7 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
@@ -161,11 +156,11 @@ function DropdownMenuLabel({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
@@ -178,7 +173,7 @@ function DropdownMenuSeparator({
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
@@ -190,17 +185,17 @@ function DropdownMenuShortcut({
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -209,7 +204,7 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@@ -217,14 +212,14 @@ function DropdownMenuSubTrigger({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@@ -236,11 +231,11 @@ function DropdownMenuSubContent({
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -259,4 +254,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
};
|
||||
+42
-42
@@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import createGlobe, { COBEOptions } from "cobe"
|
||||
import { useMotionValue, useSpring } from "motion/react"
|
||||
import createGlobe, { type COBEOptions } from "cobe";
|
||||
import { useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MOVEMENT_DAMPING = 1400
|
||||
const MOVEMENT_DAMPING = 1400;
|
||||
|
||||
const GLOBE_CONFIG: COBEOptions = {
|
||||
width: 800,
|
||||
@@ -34,87 +34,87 @@ const GLOBE_CONFIG: COBEOptions = {
|
||||
{ location: [34.6937, 135.5022], size: 0.05 },
|
||||
{ location: [41.0082, 28.9784], size: 0.06 },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
export function Globe({
|
||||
className,
|
||||
config = GLOBE_CONFIG,
|
||||
}: {
|
||||
className?: string
|
||||
config?: COBEOptions
|
||||
className?: string;
|
||||
config?: COBEOptions;
|
||||
}) {
|
||||
let phi = 0
|
||||
let width = 0
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const pointerInteracting = useRef<number | null>(null)
|
||||
const pointerInteractionMovement = useRef(0)
|
||||
let phi = 0;
|
||||
let width = 0;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointerInteracting = useRef<number | null>(null);
|
||||
const pointerInteractionMovement = useRef(0);
|
||||
|
||||
const r = useMotionValue(0)
|
||||
const r = useMotionValue(0);
|
||||
const rs = useSpring(r, {
|
||||
mass: 1,
|
||||
damping: 30,
|
||||
stiffness: 100,
|
||||
})
|
||||
});
|
||||
|
||||
const updatePointerInteraction = (value: number | null) => {
|
||||
pointerInteracting.current = value
|
||||
pointerInteracting.current = value;
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab"
|
||||
canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateMovement = (clientX: number) => {
|
||||
if (pointerInteracting.current !== null) {
|
||||
const delta = clientX - pointerInteracting.current
|
||||
pointerInteractionMovement.current = delta
|
||||
r.set(r.get() + delta / MOVEMENT_DAMPING)
|
||||
const delta = clientX - pointerInteracting.current;
|
||||
pointerInteractionMovement.current = delta;
|
||||
r.set(r.get() + delta / MOVEMENT_DAMPING);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (canvasRef.current) {
|
||||
width = canvasRef.current.offsetWidth
|
||||
width = canvasRef.current.offsetWidth;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onResize)
|
||||
onResize()
|
||||
window.addEventListener("resize", onResize);
|
||||
onResize();
|
||||
|
||||
const globe = createGlobe(canvasRef.current!, {
|
||||
...config,
|
||||
width: width * 2,
|
||||
height: width * 2,
|
||||
onRender: (state) => {
|
||||
if (!pointerInteracting.current) phi += 0.005
|
||||
state.phi = phi + rs.get()
|
||||
state.width = width * 2
|
||||
state.height = width * 2
|
||||
if (!pointerInteracting.current) phi += 0.005;
|
||||
state.phi = phi + rs.get();
|
||||
state.width = width * 2;
|
||||
state.height = width * 2;
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
setTimeout(() => (canvasRef.current!.style.opacity = "1"), 0)
|
||||
setTimeout(() => (canvasRef.current?.style.opacity = "1"), 0);
|
||||
return () => {
|
||||
globe.destroy()
|
||||
window.removeEventListener("resize", onResize)
|
||||
}
|
||||
}, [rs, config])
|
||||
globe.destroy();
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [rs, config, phi, width]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 mx-auto aspect-[1/1] w-full max-w-[600px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<canvas
|
||||
className={cn(
|
||||
"size-full opacity-0 transition-opacity duration-500 [contain:layout_paint_size]"
|
||||
"size-full opacity-0 transition-opacity duration-500 [contain:layout_paint_size]",
|
||||
)}
|
||||
ref={canvasRef}
|
||||
onPointerDown={(e) => {
|
||||
pointerInteracting.current = e.clientX
|
||||
updatePointerInteraction(e.clientX)
|
||||
pointerInteracting.current = e.clientX;
|
||||
updatePointerInteraction(e.clientX);
|
||||
}}
|
||||
onPointerUp={() => updatePointerInteraction(null)}
|
||||
onPointerOut={() => updatePointerInteraction(null)}
|
||||
@@ -124,5 +124,5 @@ export function Globe({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
@@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
@@ -23,4 +23,3 @@ const Label = React.forwardRef<
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect } from "react"
|
||||
import { motion, useMotionTemplate, useMotionValue } from "motion/react"
|
||||
import { motion, useMotionTemplate, useMotionValue } from "motion/react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MagicCardProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
gradientSize?: number
|
||||
gradientColor?: string
|
||||
gradientOpacity?: number
|
||||
gradientFrom?: string
|
||||
gradientTo?: string
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
gradientSize?: number;
|
||||
gradientColor?: string;
|
||||
gradientOpacity?: number;
|
||||
gradientFrom?: string;
|
||||
gradientTo?: string;
|
||||
}
|
||||
|
||||
export function MagicCard({
|
||||
@@ -24,49 +25,49 @@ export function MagicCard({
|
||||
gradientFrom = "#9E7AFF",
|
||||
gradientTo = "#FE8BBB",
|
||||
}: MagicCardProps) {
|
||||
const mouseX = useMotionValue(-gradientSize)
|
||||
const mouseY = useMotionValue(-gradientSize)
|
||||
const mouseX = useMotionValue(-gradientSize);
|
||||
const mouseY = useMotionValue(-gradientSize);
|
||||
const reset = useCallback(() => {
|
||||
mouseX.set(-gradientSize)
|
||||
mouseY.set(-gradientSize)
|
||||
}, [gradientSize, mouseX, mouseY])
|
||||
mouseX.set(-gradientSize);
|
||||
mouseY.set(-gradientSize);
|
||||
}, [gradientSize, mouseX, mouseY]);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
mouseX.set(e.clientX - rect.left)
|
||||
mouseY.set(e.clientY - rect.top)
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
mouseX.set(e.clientX - rect.left);
|
||||
mouseY.set(e.clientY - rect.top);
|
||||
},
|
||||
[mouseX, mouseY]
|
||||
)
|
||||
[mouseX, mouseY],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [reset])
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalPointerOut = (e: PointerEvent) => {
|
||||
if (!e.relatedTarget) {
|
||||
reset()
|
||||
reset();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
reset()
|
||||
reset();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointerout", handleGlobalPointerOut)
|
||||
window.addEventListener("blur", reset)
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
window.addEventListener("pointerout", handleGlobalPointerOut);
|
||||
window.addEventListener("blur", reset);
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointerout", handleGlobalPointerOut)
|
||||
window.removeEventListener("blur", reset)
|
||||
document.removeEventListener("visibilitychange", handleVisibility)
|
||||
}
|
||||
}, [reset])
|
||||
window.removeEventListener("pointerout", handleGlobalPointerOut);
|
||||
window.removeEventListener("blur", reset);
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
};
|
||||
}, [reset]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -99,5 +100,5 @@ export function MagicCard({
|
||||
/>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,103 +1,103 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const morphTime = 1.5
|
||||
const cooldownTime = 0.5
|
||||
const morphTime = 1.5;
|
||||
const cooldownTime = 0.5;
|
||||
|
||||
const useMorphingText = (texts: string[]) => {
|
||||
const textIndexRef = useRef(0)
|
||||
const morphRef = useRef(0)
|
||||
const cooldownRef = useRef(0)
|
||||
const timeRef = useRef(new Date())
|
||||
const textIndexRef = useRef(0);
|
||||
const morphRef = useRef(0);
|
||||
const cooldownRef = useRef(0);
|
||||
const timeRef = useRef(new Date());
|
||||
|
||||
const text1Ref = useRef<HTMLSpanElement>(null)
|
||||
const text2Ref = useRef<HTMLSpanElement>(null)
|
||||
const text1Ref = useRef<HTMLSpanElement>(null);
|
||||
const text2Ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const setStyles = useCallback(
|
||||
(fraction: number) => {
|
||||
const [current1, current2] = [text1Ref.current, text2Ref.current]
|
||||
if (!current1 || !current2) return
|
||||
const [current1, current2] = [text1Ref.current, text2Ref.current];
|
||||
if (!current1 || !current2) return;
|
||||
|
||||
current2.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`
|
||||
current2.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`
|
||||
current2.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
|
||||
current2.style.opacity = `${fraction ** 0.4 * 100}%`;
|
||||
|
||||
const invertedFraction = 1 - fraction
|
||||
const invertedFraction = 1 - fraction;
|
||||
current1.style.filter = `blur(${Math.min(
|
||||
8 / invertedFraction - 8,
|
||||
100
|
||||
)}px)`
|
||||
current1.style.opacity = `${Math.pow(invertedFraction, 0.4) * 100}%`
|
||||
100,
|
||||
)}px)`;
|
||||
current1.style.opacity = `${invertedFraction ** 0.4 * 100}%`;
|
||||
|
||||
current1.textContent = texts[textIndexRef.current % texts.length]
|
||||
current2.textContent = texts[(textIndexRef.current + 1) % texts.length]
|
||||
current1.textContent = texts[textIndexRef.current % texts.length];
|
||||
current2.textContent = texts[(textIndexRef.current + 1) % texts.length];
|
||||
},
|
||||
[texts]
|
||||
)
|
||||
[texts],
|
||||
);
|
||||
|
||||
const doMorph = useCallback(() => {
|
||||
morphRef.current -= cooldownRef.current
|
||||
cooldownRef.current = 0
|
||||
morphRef.current -= cooldownRef.current;
|
||||
cooldownRef.current = 0;
|
||||
|
||||
let fraction = morphRef.current / morphTime
|
||||
let fraction = morphRef.current / morphTime;
|
||||
|
||||
if (fraction > 1) {
|
||||
cooldownRef.current = cooldownTime
|
||||
fraction = 1
|
||||
cooldownRef.current = cooldownTime;
|
||||
fraction = 1;
|
||||
}
|
||||
|
||||
setStyles(fraction)
|
||||
setStyles(fraction);
|
||||
|
||||
if (fraction === 1) {
|
||||
textIndexRef.current++
|
||||
textIndexRef.current++;
|
||||
}
|
||||
}, [setStyles])
|
||||
}, [setStyles]);
|
||||
|
||||
const doCooldown = useCallback(() => {
|
||||
morphRef.current = 0
|
||||
const [current1, current2] = [text1Ref.current, text2Ref.current]
|
||||
morphRef.current = 0;
|
||||
const [current1, current2] = [text1Ref.current, text2Ref.current];
|
||||
if (current1 && current2) {
|
||||
current2.style.filter = "none"
|
||||
current2.style.opacity = "100%"
|
||||
current1.style.filter = "none"
|
||||
current1.style.opacity = "0%"
|
||||
current2.style.filter = "none";
|
||||
current2.style.opacity = "100%";
|
||||
current1.style.filter = "none";
|
||||
current1.style.opacity = "0%";
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrameId: number
|
||||
let animationFrameId: number;
|
||||
|
||||
const animate = () => {
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
|
||||
const newTime = new Date()
|
||||
const dt = (newTime.getTime() - timeRef.current.getTime()) / 1000
|
||||
timeRef.current = newTime
|
||||
const newTime = new Date();
|
||||
const dt = (newTime.getTime() - timeRef.current.getTime()) / 1000;
|
||||
timeRef.current = newTime;
|
||||
|
||||
cooldownRef.current -= dt
|
||||
cooldownRef.current -= dt;
|
||||
|
||||
if (cooldownRef.current <= 0) doMorph()
|
||||
else doCooldown()
|
||||
}
|
||||
if (cooldownRef.current <= 0) doMorph();
|
||||
else doCooldown();
|
||||
};
|
||||
|
||||
animate()
|
||||
animate();
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
}, [doMorph, doCooldown])
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [doMorph, doCooldown]);
|
||||
|
||||
return { text1Ref, text2Ref }
|
||||
}
|
||||
return { text1Ref, text2Ref };
|
||||
};
|
||||
|
||||
interface MorphingTextProps {
|
||||
className?: string
|
||||
texts: string[]
|
||||
className?: string;
|
||||
texts: string[];
|
||||
}
|
||||
|
||||
const Texts: React.FC<Pick<MorphingTextProps, "texts">> = ({ texts }) => {
|
||||
const { text1Ref, text2Ref } = useMorphingText(texts)
|
||||
const { text1Ref, text2Ref } = useMorphingText(texts);
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
@@ -109,8 +109,8 @@ const Texts: React.FC<Pick<MorphingTextProps, "texts">> = ({ texts }) => {
|
||||
ref={text2Ref}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const SvgFilters: React.FC = () => (
|
||||
<svg
|
||||
@@ -131,7 +131,7 @@ const SvgFilters: React.FC = () => (
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const MorphingText: React.FC<MorphingTextProps> = ({
|
||||
texts,
|
||||
@@ -140,10 +140,10 @@ export const MorphingText: React.FC<MorphingTextProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto h-16 w-full max-w-screen-md text-center font-sans text-[40pt] leading-none font-bold [filter:url(#threshold)_blur(0.6px)] md:h-24 lg:text-[6rem]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Texts texts={texts} />
|
||||
<SvgFilters />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
@@ -11,7 +11,7 @@ function NavigationMenu({
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
viewport?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
@@ -19,14 +19,14 @@ function NavigationMenu({
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
@@ -38,11 +38,11 @@ function NavigationMenuList({
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
@@ -55,12 +55,12 @@ function NavigationMenuItem({
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
)
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
@@ -79,7 +79,7 @@ function NavigationMenuTrigger({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
@@ -92,11 +92,11 @@ function NavigationMenuContent({
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
@@ -106,19 +106,19 @@ function NavigationMenuViewport({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center",
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
@@ -130,11 +130,11 @@ function NavigationMenuLink({
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
@@ -146,13 +146,13 @@ function NavigationMenuIndicator({
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -165,4 +165,4 @@ export {
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
};
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
type CSSProperties,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
} from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NeonColorsProps {
|
||||
firstColor: string
|
||||
secondColor: string
|
||||
firstColor: string;
|
||||
secondColor: string;
|
||||
}
|
||||
|
||||
interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -23,14 +23,14 @@ interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
* @description
|
||||
* The component to be rendered as the card
|
||||
* */
|
||||
as?: ReactElement
|
||||
as?: ReactElement;
|
||||
/**
|
||||
* @default ""
|
||||
* @type string
|
||||
* @description
|
||||
* The className of the card
|
||||
*/
|
||||
className?: string
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* @default ""
|
||||
@@ -38,7 +38,7 @@ interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
* @description
|
||||
* The children of the card
|
||||
* */
|
||||
children?: ReactNode
|
||||
children?: ReactNode;
|
||||
|
||||
/**
|
||||
* @default 5
|
||||
@@ -46,7 +46,7 @@ interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
* @description
|
||||
* The size of the border in pixels
|
||||
* */
|
||||
borderSize?: number
|
||||
borderSize?: number;
|
||||
|
||||
/**
|
||||
* @default 20
|
||||
@@ -54,7 +54,7 @@ interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
* @description
|
||||
* The size of the radius in pixels
|
||||
* */
|
||||
borderRadius?: number
|
||||
borderRadius?: number;
|
||||
|
||||
/**
|
||||
* @default "{ firstColor: '#ff00aa', secondColor: '#00FFF1' }"
|
||||
@@ -62,7 +62,7 @@ interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
* @description
|
||||
* The colors of the neon gradient
|
||||
* */
|
||||
neonColors?: NeonColorsProps
|
||||
neonColors?: NeonColorsProps;
|
||||
}
|
||||
|
||||
export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
|
||||
@@ -76,31 +76,31 @@ export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
|
||||
},
|
||||
...props
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
const { offsetWidth, offsetHeight } = containerRef.current
|
||||
setDimensions({ width: offsetWidth, height: offsetHeight })
|
||||
const { offsetWidth, offsetHeight } = containerRef.current;
|
||||
setDimensions({ width: offsetWidth, height: offsetHeight });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateDimensions()
|
||||
window.addEventListener("resize", updateDimensions)
|
||||
updateDimensions();
|
||||
window.addEventListener("resize", updateDimensions);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateDimensions)
|
||||
}
|
||||
}, [])
|
||||
window.removeEventListener("resize", updateDimensions);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const { offsetWidth, offsetHeight } = containerRef.current
|
||||
setDimensions({ width: offsetWidth, height: offsetHeight })
|
||||
const { offsetWidth, offsetHeight } = containerRef.current;
|
||||
setDimensions({ width: offsetWidth, height: offsetHeight });
|
||||
}
|
||||
}, [children])
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -122,7 +122,7 @@ export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
|
||||
}
|
||||
className={cn(
|
||||
"relative z-10 size-full rounded-[var(--border-radius)]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -138,11 +138,11 @@ export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
|
||||
"after:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] after:bg-[length:100%_200%] after:opacity-80",
|
||||
"after:animate-background-position-spin",
|
||||
"dark:bg-neutral-900",
|
||||
"break-words"
|
||||
"break-words",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { ComponentPropsWithoutRef, useEffect, useRef } from "react"
|
||||
import { useInView, useMotionValue, useSpring } from "motion/react"
|
||||
import { useInView, useMotionValue, useSpring } from "motion/react";
|
||||
import { type ComponentPropsWithoutRef, useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> {
|
||||
value: number
|
||||
startValue?: number
|
||||
direction?: "up" | "down"
|
||||
delay?: number
|
||||
decimalPlaces?: number
|
||||
value: number;
|
||||
startValue?: number;
|
||||
direction?: "up" | "down";
|
||||
delay?: number;
|
||||
decimalPlaces?: number;
|
||||
}
|
||||
|
||||
export function NumberTicker({
|
||||
@@ -22,22 +22,22 @@ export function NumberTicker({
|
||||
decimalPlaces = 0,
|
||||
...props
|
||||
}: NumberTickerProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
const motionValue = useMotionValue(direction === "down" ? value : startValue)
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const motionValue = useMotionValue(direction === "down" ? value : startValue);
|
||||
const springValue = useSpring(motionValue, {
|
||||
damping: 60,
|
||||
stiffness: 100,
|
||||
})
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" })
|
||||
});
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView) {
|
||||
const timer = setTimeout(() => {
|
||||
motionValue.set(direction === "down" ? startValue : value)
|
||||
}, delay * 1000)
|
||||
return () => clearTimeout(timer)
|
||||
motionValue.set(direction === "down" ? startValue : value);
|
||||
}, delay * 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [motionValue, isInView, delay, value, direction, startValue])
|
||||
}, [motionValue, isInView, delay, value, direction, startValue]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
@@ -46,22 +46,22 @@ export function NumberTicker({
|
||||
ref.current.textContent = Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
}).format(Number(latest.toFixed(decimalPlaces)))
|
||||
}).format(Number(latest.toFixed(decimalPlaces)));
|
||||
}
|
||||
}),
|
||||
[springValue, decimalPlaces]
|
||||
)
|
||||
[springValue, decimalPlaces],
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-block tracking-wider text-black tabular-nums dark:text-white",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{startValue}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
+147
-146
@@ -1,81 +1,82 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
ComponentPropsWithoutRef,
|
||||
import type React from "react";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
} from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MousePosition {
|
||||
x: number
|
||||
y: number
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function MousePosition(): MousePosition {
|
||||
const [mousePosition, setMousePosition] = useState<MousePosition>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
setMousePosition({ x: event.clientX, y: event.clientY })
|
||||
}
|
||||
setMousePosition({ x: event.clientX, y: event.clientY });
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
}
|
||||
}, [])
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return mousePosition
|
||||
return mousePosition;
|
||||
}
|
||||
|
||||
interface ParticlesProps extends ComponentPropsWithoutRef<"div"> {
|
||||
className?: string
|
||||
quantity?: number
|
||||
staticity?: number
|
||||
ease?: number
|
||||
size?: number
|
||||
refresh?: boolean
|
||||
color?: string
|
||||
vx?: number
|
||||
vy?: number
|
||||
className?: string;
|
||||
quantity?: number;
|
||||
staticity?: number;
|
||||
ease?: number;
|
||||
size?: number;
|
||||
refresh?: boolean;
|
||||
color?: string;
|
||||
vx?: number;
|
||||
vy?: number;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): number[] {
|
||||
hex = hex.replace("#", "")
|
||||
hex = hex.replace("#", "");
|
||||
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split("")
|
||||
.map((char) => char + char)
|
||||
.join("")
|
||||
.join("");
|
||||
}
|
||||
|
||||
const hexInt = parseInt(hex, 16)
|
||||
const red = (hexInt >> 16) & 255
|
||||
const green = (hexInt >> 8) & 255
|
||||
const blue = hexInt & 255
|
||||
return [red, green, blue]
|
||||
const hexInt = parseInt(hex, 16);
|
||||
const red = (hexInt >> 16) & 255;
|
||||
const green = (hexInt >> 8) & 255;
|
||||
const blue = hexInt & 255;
|
||||
return [red, green, blue];
|
||||
}
|
||||
|
||||
type Circle = {
|
||||
x: number
|
||||
y: number
|
||||
translateX: number
|
||||
translateY: number
|
||||
size: number
|
||||
alpha: number
|
||||
targetAlpha: number
|
||||
dx: number
|
||||
dy: number
|
||||
magnetism: number
|
||||
}
|
||||
x: number;
|
||||
y: number;
|
||||
translateX: number;
|
||||
translateY: number;
|
||||
size: number;
|
||||
alpha: number;
|
||||
targetAlpha: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
magnetism: number;
|
||||
};
|
||||
|
||||
export const Particles: React.FC<ParticlesProps> = ({
|
||||
className = "",
|
||||
@@ -89,104 +90,104 @@ export const Particles: React.FC<ParticlesProps> = ({
|
||||
vy = 0,
|
||||
...props
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
const context = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const circles = useRef<Circle[]>([])
|
||||
const mousePosition = MousePosition()
|
||||
const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 })
|
||||
const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1
|
||||
const rafID = useRef<number | null>(null)
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||||
const context = useRef<CanvasRenderingContext2D | null>(null);
|
||||
const circles = useRef<Circle[]>([]);
|
||||
const mousePosition = MousePosition();
|
||||
const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
|
||||
const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1;
|
||||
const rafID = useRef<number | null>(null);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
context.current = canvasRef.current.getContext("2d")
|
||||
context.current = canvasRef.current.getContext("2d");
|
||||
}
|
||||
initCanvas()
|
||||
animate()
|
||||
initCanvas();
|
||||
animate();
|
||||
|
||||
const handleResize = () => {
|
||||
if (resizeTimeout.current) {
|
||||
clearTimeout(resizeTimeout.current)
|
||||
clearTimeout(resizeTimeout.current);
|
||||
}
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
initCanvas()
|
||||
}, 200)
|
||||
}
|
||||
initCanvas();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize)
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
if (rafID.current != null) {
|
||||
window.cancelAnimationFrame(rafID.current)
|
||||
window.cancelAnimationFrame(rafID.current);
|
||||
}
|
||||
if (resizeTimeout.current) {
|
||||
clearTimeout(resizeTimeout.current)
|
||||
clearTimeout(resizeTimeout.current);
|
||||
}
|
||||
window.removeEventListener("resize", handleResize)
|
||||
}
|
||||
}, [color])
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [animate, initCanvas]);
|
||||
|
||||
useEffect(() => {
|
||||
onMouseMove()
|
||||
}, [mousePosition.x, mousePosition.y])
|
||||
onMouseMove();
|
||||
}, [onMouseMove]);
|
||||
|
||||
useEffect(() => {
|
||||
initCanvas()
|
||||
}, [refresh])
|
||||
initCanvas();
|
||||
}, [initCanvas]);
|
||||
|
||||
const initCanvas = () => {
|
||||
resizeCanvas()
|
||||
drawParticles()
|
||||
}
|
||||
resizeCanvas();
|
||||
drawParticles();
|
||||
};
|
||||
|
||||
const onMouseMove = () => {
|
||||
if (canvasRef.current) {
|
||||
const rect = canvasRef.current.getBoundingClientRect()
|
||||
const { w, h } = canvasSize.current
|
||||
const x = mousePosition.x - rect.left - w / 2
|
||||
const y = mousePosition.y - rect.top - h / 2
|
||||
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const { w, h } = canvasSize.current;
|
||||
const x = mousePosition.x - rect.left - w / 2;
|
||||
const y = mousePosition.y - rect.top - h / 2;
|
||||
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2;
|
||||
if (inside) {
|
||||
mouse.current.x = x
|
||||
mouse.current.y = y
|
||||
mouse.current.x = x;
|
||||
mouse.current.y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resizeCanvas = () => {
|
||||
if (canvasContainerRef.current && canvasRef.current && context.current) {
|
||||
canvasSize.current.w = canvasContainerRef.current.offsetWidth
|
||||
canvasSize.current.h = canvasContainerRef.current.offsetHeight
|
||||
canvasSize.current.w = canvasContainerRef.current.offsetWidth;
|
||||
canvasSize.current.h = canvasContainerRef.current.offsetHeight;
|
||||
|
||||
canvasRef.current.width = canvasSize.current.w * dpr
|
||||
canvasRef.current.height = canvasSize.current.h * dpr
|
||||
canvasRef.current.style.width = `${canvasSize.current.w}px`
|
||||
canvasRef.current.style.height = `${canvasSize.current.h}px`
|
||||
context.current.scale(dpr, dpr)
|
||||
canvasRef.current.width = canvasSize.current.w * dpr;
|
||||
canvasRef.current.height = canvasSize.current.h * dpr;
|
||||
canvasRef.current.style.width = `${canvasSize.current.w}px`;
|
||||
canvasRef.current.style.height = `${canvasSize.current.h}px`;
|
||||
context.current.scale(dpr, dpr);
|
||||
|
||||
// Clear existing particles and create new ones with exact quantity
|
||||
circles.current = []
|
||||
circles.current = [];
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const circle = circleParams()
|
||||
drawCircle(circle)
|
||||
const circle = circleParams();
|
||||
drawCircle(circle);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const circleParams = (): Circle => {
|
||||
const x = Math.floor(Math.random() * canvasSize.current.w)
|
||||
const y = Math.floor(Math.random() * canvasSize.current.h)
|
||||
const translateX = 0
|
||||
const translateY = 0
|
||||
const pSize = Math.floor(Math.random() * 2) + size
|
||||
const alpha = 0
|
||||
const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1))
|
||||
const dx = (Math.random() - 0.5) * 0.1
|
||||
const dy = (Math.random() - 0.5) * 0.1
|
||||
const magnetism = 0.1 + Math.random() * 4
|
||||
const x = Math.floor(Math.random() * canvasSize.current.w);
|
||||
const y = Math.floor(Math.random() * canvasSize.current.h);
|
||||
const translateX = 0;
|
||||
const translateY = 0;
|
||||
const pSize = Math.floor(Math.random() * 2) + size;
|
||||
const alpha = 0;
|
||||
const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1));
|
||||
const dx = (Math.random() - 0.5) * 0.1;
|
||||
const dy = (Math.random() - 0.5) * 0.1;
|
||||
const magnetism = 0.1 + Math.random() * 4;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
@@ -198,26 +199,26 @@ export const Particles: React.FC<ParticlesProps> = ({
|
||||
dx,
|
||||
dy,
|
||||
magnetism,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const rgb = hexToRgb(color)
|
||||
const rgb = hexToRgb(color);
|
||||
|
||||
const drawCircle = (circle: Circle, update = false) => {
|
||||
if (context.current) {
|
||||
const { x, y, translateX, translateY, size, alpha } = circle
|
||||
context.current.translate(translateX, translateY)
|
||||
context.current.beginPath()
|
||||
context.current.arc(x, y, size, 0, 2 * Math.PI)
|
||||
context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`
|
||||
context.current.fill()
|
||||
context.current.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
const { x, y, translateX, translateY, size, alpha } = circle;
|
||||
context.current.translate(translateX, translateY);
|
||||
context.current.beginPath();
|
||||
context.current.arc(x, y, size, 0, 2 * Math.PI);
|
||||
context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`;
|
||||
context.current.fill();
|
||||
context.current.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
if (!update) {
|
||||
circles.current.push(circle)
|
||||
circles.current.push(circle);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearContext = () => {
|
||||
if (context.current) {
|
||||
@@ -225,34 +226,34 @@ export const Particles: React.FC<ParticlesProps> = ({
|
||||
0,
|
||||
0,
|
||||
canvasSize.current.w,
|
||||
canvasSize.current.h
|
||||
)
|
||||
canvasSize.current.h,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const drawParticles = () => {
|
||||
clearContext()
|
||||
const particleCount = quantity
|
||||
clearContext();
|
||||
const particleCount = quantity;
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const circle = circleParams()
|
||||
drawCircle(circle)
|
||||
const circle = circleParams();
|
||||
drawCircle(circle);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const remapValue = (
|
||||
value: number,
|
||||
start1: number,
|
||||
end1: number,
|
||||
start2: number,
|
||||
end2: number
|
||||
end2: number,
|
||||
): number => {
|
||||
const remapped =
|
||||
((value - start1) * (end2 - start2)) / (end1 - start1) + start2
|
||||
return remapped > 0 ? remapped : 0
|
||||
}
|
||||
((value - start1) * (end2 - start2)) / (end1 - start1) + start2;
|
||||
return remapped > 0 ? remapped : 0;
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
clearContext()
|
||||
clearContext();
|
||||
circles.current.forEach((circle: Circle, i: number) => {
|
||||
// Handle the alpha value
|
||||
const edge = [
|
||||
@@ -260,29 +261,29 @@ export const Particles: React.FC<ParticlesProps> = ({
|
||||
canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge
|
||||
circle.y + circle.translateY - circle.size, // distance from top edge
|
||||
canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
|
||||
]
|
||||
const closestEdge = edge.reduce((a, b) => Math.min(a, b))
|
||||
];
|
||||
const closestEdge = edge.reduce((a, b) => Math.min(a, b));
|
||||
const remapClosestEdge = parseFloat(
|
||||
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2)
|
||||
)
|
||||
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2),
|
||||
);
|
||||
if (remapClosestEdge > 1) {
|
||||
circle.alpha += 0.02
|
||||
circle.alpha += 0.02;
|
||||
if (circle.alpha > circle.targetAlpha) {
|
||||
circle.alpha = circle.targetAlpha
|
||||
circle.alpha = circle.targetAlpha;
|
||||
}
|
||||
} else {
|
||||
circle.alpha = circle.targetAlpha * remapClosestEdge
|
||||
circle.alpha = circle.targetAlpha * remapClosestEdge;
|
||||
}
|
||||
circle.x += circle.dx + vx
|
||||
circle.y += circle.dy + vy
|
||||
circle.x += circle.dx + vx;
|
||||
circle.y += circle.dy + vy;
|
||||
circle.translateX +=
|
||||
(mouse.current.x / (staticity / circle.magnetism) - circle.translateX) /
|
||||
ease
|
||||
ease;
|
||||
circle.translateY +=
|
||||
(mouse.current.y / (staticity / circle.magnetism) - circle.translateY) /
|
||||
ease
|
||||
ease;
|
||||
|
||||
drawCircle(circle, true)
|
||||
drawCircle(circle, true);
|
||||
|
||||
// circle gets out of the canvas
|
||||
if (
|
||||
@@ -292,14 +293,14 @@ export const Particles: React.FC<ParticlesProps> = ({
|
||||
circle.y > canvasSize.current.h + circle.size
|
||||
) {
|
||||
// remove the circle from the array
|
||||
circles.current.splice(i, 1)
|
||||
circles.current.splice(i, 1);
|
||||
// create a new circle
|
||||
const newCircle = circleParams()
|
||||
drawCircle(newCircle)
|
||||
const newCircle = circleParams();
|
||||
drawCircle(newCircle);
|
||||
}
|
||||
})
|
||||
rafID.current = window.requestAnimationFrame(animate)
|
||||
}
|
||||
});
|
||||
rafID.current = window.requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -310,5 +311,5 @@ export const Particles: React.FC<ParticlesProps> = ({
|
||||
>
|
||||
<canvas ref={canvasRef} className="size-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Currency } from "@/types/currency";
|
||||
import { formatPrice, formatPriceWithTc } from "@/lib/currency-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Currency } from "@/types/currency";
|
||||
|
||||
interface PriceDisplayProps {
|
||||
value: number;
|
||||
|
||||
+29
-30
@@ -1,16 +1,16 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
@@ -20,7 +20,7 @@ const SelectTrigger = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -29,8 +29,8 @@ const SelectTrigger = React.forwardRef<
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
@@ -40,14 +40,14 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
@@ -57,15 +57,15 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
@@ -78,7 +78,7 @@ const SelectContent = React.forwardRef<
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
@@ -88,7 +88,7 @@ const SelectContent = React.forwardRef<
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -96,8 +96,8 @@ const SelectContent = React.forwardRef<
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
@@ -108,8 +108,8 @@ const SelectLabel = React.forwardRef<
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
@@ -119,7 +119,7 @@ const SelectItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -131,8 +131,8 @@ const SelectItem = React.forwardRef<
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
@@ -143,8 +143,8 @@ const SelectSeparator = React.forwardRef<
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
@@ -157,5 +157,4 @@ export {
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
@@ -18,11 +18,11 @@ function Separator({
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
+19
-24
@@ -1,36 +1,31 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return (
|
||||
<SheetPrimitive.Root
|
||||
data-slot="sheet"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
@@ -42,11 +37,11 @@ function SheetOverlay({
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
@@ -55,7 +50,7 @@ function SheetContent({
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
@@ -72,7 +67,7 @@ function SheetContent({
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -83,7 +78,7 @@ function SheetContent({
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -93,7 +88,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -103,7 +98,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
@@ -116,7 +111,7 @@ function SheetTitle({
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
@@ -129,7 +124,7 @@ function SheetDescription({
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -141,4 +136,4 @@ export {
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
};
|
||||
@@ -1,15 +1,18 @@
|
||||
import React, { ComponentPropsWithoutRef, CSSProperties } from "react"
|
||||
import React, {
|
||||
type ComponentPropsWithoutRef,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ShimmerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
shimmerColor?: string
|
||||
shimmerSize?: string
|
||||
borderRadius?: string
|
||||
shimmerDuration?: string
|
||||
background?: string
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
shimmerColor?: string;
|
||||
shimmerSize?: string;
|
||||
borderRadius?: string;
|
||||
shimmerDuration?: string;
|
||||
background?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ShimmerButton = React.forwardRef<
|
||||
@@ -27,7 +30,7 @@ export const ShimmerButton = React.forwardRef<
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
@@ -44,7 +47,7 @@ export const ShimmerButton = React.forwardRef<
|
||||
className={cn(
|
||||
"group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden [border-radius:var(--radius)] border border-white/10 px-6 py-3 whitespace-nowrap text-white [background:var(--bg)]",
|
||||
"transform-gpu transition-transform duration-300 ease-in-out active:translate-y-px",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
@@ -53,7 +56,7 @@ export const ShimmerButton = React.forwardRef<
|
||||
<div
|
||||
className={cn(
|
||||
"-z-30 blur-[2px]",
|
||||
"[container-type:size] absolute inset-0 overflow-visible"
|
||||
"[container-type:size] absolute inset-0 overflow-visible",
|
||||
)}
|
||||
>
|
||||
{/* spark */}
|
||||
@@ -78,19 +81,19 @@ export const ShimmerButton = React.forwardRef<
|
||||
"group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]",
|
||||
|
||||
// on click
|
||||
"group-active:shadow-[inset_0_-10px_10px_#ffffff3f]"
|
||||
"group-active:shadow-[inset_0_-10px_10px_#ffffff3f]",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* backdrop */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute [inset:var(--cut)] -z-20 [border-radius:var(--radius)] [background:var(--bg)]"
|
||||
"absolute [inset:var(--cut)] -z-20 [border-radius:var(--radius)] [background:var(--bg)]",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ShimmerButton.displayName = "ShimmerButton"
|
||||
ShimmerButton.displayName = "ShimmerButton";
|
||||
@@ -1,25 +1,25 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Width of the border in pixels
|
||||
* @default 1
|
||||
*/
|
||||
borderWidth?: number
|
||||
borderWidth?: number;
|
||||
/**
|
||||
* Duration of the animation in seconds
|
||||
* @default 14
|
||||
*/
|
||||
duration?: number
|
||||
duration?: number;
|
||||
/**
|
||||
* Color of the border, can be a single color or an array of colors
|
||||
* @default "#000000"
|
||||
*/
|
||||
shineColor?: string | string[]
|
||||
shineColor?: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,9 +55,9 @@ export function ShineBorder({
|
||||
}
|
||||
className={cn(
|
||||
"motion-safe:animate-shine pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,3 @@ function Skeleton({
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+11
-11
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
@@ -15,7 +15,7 @@ function Tabs({
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
@@ -27,11 +27,11 @@ function TabsList({
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
@@ -43,11 +43,11 @@ function TabsTrigger({
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
@@ -60,7 +60,7 @@ function TabsContent({
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -10,13 +10,13 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { AnimatePresence, motion, MotionProps } from "motion/react"
|
||||
import { AnimatePresence, type MotionProps, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface WordRotateProps {
|
||||
words: string[]
|
||||
duration?: number
|
||||
motionProps?: MotionProps
|
||||
className?: string
|
||||
words: string[];
|
||||
duration?: number;
|
||||
motionProps?: MotionProps;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WordRotate({
|
||||
@@ -23,16 +23,16 @@ export function WordRotate({
|
||||
},
|
||||
className,
|
||||
}: WordRotateProps) {
|
||||
const [index, setIndex] = useState(0)
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setIndex((prevIndex) => (prevIndex + 1) % words.length)
|
||||
}, duration)
|
||||
setIndex((prevIndex) => (prevIndex + 1) % words.length);
|
||||
}, duration);
|
||||
|
||||
// Clean up interval on unmount
|
||||
return () => clearInterval(interval)
|
||||
}, [words, duration])
|
||||
return () => clearInterval(interval);
|
||||
}, [words, duration]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden py-2">
|
||||
@@ -46,5 +46,5 @@ export function WordRotate({
|
||||
</motion.h1>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Construction, Home, ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Construction, Home } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { BlurFade } from "@/components/ui/blur-fade";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DotPattern } from "@/components/ui/dot-pattern";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -23,7 +23,7 @@ export function UnderConstruction({
|
||||
{/* Background Pattern */}
|
||||
<DotPattern
|
||||
className={cn(
|
||||
"absolute inset-0 h-full w-full text-muted-foreground/20"
|
||||
"absolute inset-0 h-full w-full text-muted-foreground/20",
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
Reference in new issue
Block a user