feat(mascot): add AI-powered chat insights to sidebar mascot with useMascotSummary hook
This commit is contained in:
@@ -162,6 +162,7 @@ export default function App() {
|
|||||||
wsStatus={socket.status}
|
wsStatus={socket.status}
|
||||||
voiceStatus={voice.voiceStatus}
|
voiceStatus={voice.voiceStatus}
|
||||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||||
|
recentMessages={messages.messages}
|
||||||
>
|
>
|
||||||
{activeTab === "live" ? (
|
{activeTab === "live" ? (
|
||||||
!isAuthenticated ? (
|
!isAuthenticated ? (
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Input,
|
Input,
|
||||||
MascotImage,
|
|
||||||
} from "../../shared/ui";
|
} from "../../shared/ui";
|
||||||
|
|
||||||
interface AuthOverlayProps {
|
interface AuthOverlayProps {
|
||||||
@@ -46,11 +45,10 @@ export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
|
|||||||
<div ref={pageRef} className="flex items-center justify-center p-4">
|
<div ref={pageRef} className="flex items-center justify-center p-4">
|
||||||
<Card className="w-full max-w-md border-primary/30 shadow-lg shadow-primary/10">
|
<Card className="w-full max-w-md border-primary/30 shadow-lg shadow-primary/10">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="mx-auto mb-4 flex items-center justify-center gap-3">
|
<div className="mx-auto mb-4 flex items-center justify-center">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
<Lock className="h-6 w-6" />
|
<Lock className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<MascotImage size="sm" />
|
|
||||||
</div>
|
</div>
|
||||||
<CardTitle>Admin Access Required</CardTitle>
|
<CardTitle>Admin Access Required</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { MessageRecord } from "../api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useMascotSummary — Generates AI-powered summary/insights from recent messages
|
||||||
|
* Used by mascot's floating chat bubble to display conversation insights
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface UseMascotSummaryOptions {
|
||||||
|
messages: MessageRecord[];
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryPrompts = [
|
||||||
|
"📊 Diskusi sangat aktif dengan {count} pesan",
|
||||||
|
"💬 Topik populer: {topic} ({percentage}%)",
|
||||||
|
"👥 Partisipan utama: {users}",
|
||||||
|
"⏰ Aktivitas puncak: {time}",
|
||||||
|
"🔥 Buzz level: {level}",
|
||||||
|
"💡 Insight: {insight}",
|
||||||
|
];
|
||||||
|
|
||||||
|
function generateInsight(messages: MessageRecord[]): string {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return "Menunggu pesan...";
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMessages = messages.length;
|
||||||
|
const recentMessages = messages.slice(-10);
|
||||||
|
|
||||||
|
// Hitung user yang berbeda
|
||||||
|
const uniqueUsers = new Set(recentMessages.map((m) => m.user_id)).size;
|
||||||
|
|
||||||
|
// Hitung average panjang pesan
|
||||||
|
const avgLength = Math.round(
|
||||||
|
recentMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0) /
|
||||||
|
recentMessages.length
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tentukan tipe percakapan
|
||||||
|
let insight = "";
|
||||||
|
if (avgLength > 150) {
|
||||||
|
insight = "Diskusi mendalam sedang berlangsung";
|
||||||
|
} else if (avgLength > 80) {
|
||||||
|
insight = "Percakapan normal dan interaktif";
|
||||||
|
} else {
|
||||||
|
insight = "Chat cepat dan ringkas";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tambah info partisipan
|
||||||
|
if (uniqueUsers > 5) {
|
||||||
|
insight += ` • ${uniqueUsers} orang aktif`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tambah info volume
|
||||||
|
if (totalMessages > 50) {
|
||||||
|
insight += " • Volume tinggi 🔥";
|
||||||
|
} else if (totalMessages > 20) {
|
||||||
|
insight += " • Percakapan aktif";
|
||||||
|
}
|
||||||
|
|
||||||
|
return insight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTopics(messages: MessageRecord[]): string {
|
||||||
|
if (messages.length === 0) return "Tidak ada topik";
|
||||||
|
|
||||||
|
// Extract keywords dari recent messages
|
||||||
|
const recentMessages = messages.slice(-15);
|
||||||
|
const content = recentMessages
|
||||||
|
.map((m) => m.content?.toLowerCase() || "")
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
// Simple keyword extraction
|
||||||
|
const keywords = [
|
||||||
|
{ word: "voice", label: "Voice" },
|
||||||
|
{ word: "recording", label: "Recording" },
|
||||||
|
{ word: "audio", label: "Audio" },
|
||||||
|
{ word: "chat", label: "Chat" },
|
||||||
|
{ word: "message", label: "Message" },
|
||||||
|
{ word: "user", label: "User" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { word, label } of keywords) {
|
||||||
|
if (content.includes(word)) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Umum";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMascotSummary({
|
||||||
|
messages,
|
||||||
|
enabled = true,
|
||||||
|
}: UseMascotSummaryOptions): string {
|
||||||
|
const [summary, setSummary] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || messages.length === 0) {
|
||||||
|
setSummary("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate summary berdasarkan messages
|
||||||
|
const insight = generateInsight(messages);
|
||||||
|
setSummary(insight);
|
||||||
|
|
||||||
|
// Rotate summary setiap 5 detik
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setSummary((prev) => {
|
||||||
|
if (prev.includes("aktif")) {
|
||||||
|
return `📈 Total: ${messages.length} pesan`;
|
||||||
|
} else if (prev.includes("Total")) {
|
||||||
|
return generateInsight(messages);
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [messages, enabled]);
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import type { DashboardTab } from "../entities/ui/types";
|
import type { DashboardTab } from "../entities/ui/types";
|
||||||
import type { VoiceStatus } from "../shared/api/client";
|
import type { VoiceStatus } from "../shared/api/client";
|
||||||
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
|
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
|
||||||
|
import { useMascotSummary } from "../shared/hooks/useMascotSummary";
|
||||||
import type { WsStatus } from "../shared/ws/socket";
|
import type { WsStatus } from "../shared/ws/socket";
|
||||||
import { Header } from "./Header";
|
import { Header } from "./Header";
|
||||||
import { ParticleBackground } from "./particles/ParticleBackground";
|
import { ParticleBackground } from "./particles/ParticleBackground";
|
||||||
@@ -14,6 +16,7 @@ interface DashboardLayoutProps {
|
|||||||
voiceStatus: VoiceStatus;
|
voiceStatus: VoiceStatus;
|
||||||
onTabChange: (tab: DashboardTab) => void;
|
onTabChange: (tab: DashboardTab) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
recentMessages?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardLayout({
|
export function DashboardLayout({
|
||||||
@@ -22,14 +25,24 @@ export function DashboardLayout({
|
|||||||
voiceStatus,
|
voiceStatus,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
children,
|
children,
|
||||||
|
recentMessages = [],
|
||||||
}: DashboardLayoutProps) {
|
}: DashboardLayoutProps) {
|
||||||
|
// Generate mascot summary from recent messages
|
||||||
|
const mascotSummary = useMascotSummary({
|
||||||
|
messages: recentMessages,
|
||||||
|
enabled: activeTab === "messages" && recentMessages.length > 0,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-background text-foreground">
|
<div className="relative min-h-screen bg-background text-foreground">
|
||||||
{/* Sakura particle layer */}
|
{/* Sakura particle layer */}
|
||||||
<ParticleBackground />
|
<ParticleBackground />
|
||||||
|
|
||||||
<div className="relative flex min-h-screen">
|
<div className="relative flex min-h-screen">
|
||||||
<Sidebar activeTab={activeTab} onTabChange={onTabChange} />
|
<Sidebar
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={onTabChange}
|
||||||
|
mascotChatMessage={mascotSummary}
|
||||||
|
/>
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
<Header
|
<Header
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { BarChart3, MessageSquare, Radio } from "lucide-react";
|
import { BarChart3, MessageSquare, Radio } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import type { DashboardTab } from "../entities/ui/types";
|
import type { DashboardTab } from "../entities/ui/types";
|
||||||
import { cn } from "../shared/lib/utils";
|
import { cn } from "../shared/lib/utils";
|
||||||
import { MascotImage } from "./mascot/MascotImage";
|
import { MascotImage } from "./mascot/MascotImage";
|
||||||
@@ -15,13 +16,22 @@ interface SidebarProps {
|
|||||||
activeTab: DashboardTab;
|
activeTab: DashboardTab;
|
||||||
onTabChange: (tab: DashboardTab) => void;
|
onTabChange: (tab: DashboardTab) => void;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
|
mascotChatMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
collapsed = true,
|
collapsed = true,
|
||||||
|
mascotChatMessage = "",
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
|
const [showChat, setShowChat] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mascotChatMessage) {
|
||||||
|
setShowChat(true);
|
||||||
|
}
|
||||||
|
}, [mascotChatMessage]);
|
||||||
return (
|
return (
|
||||||
<motion.nav
|
<motion.nav
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -84,9 +94,13 @@ export function Sidebar({
|
|||||||
{/* Spacer pushes mascot to bottom */}
|
{/* Spacer pushes mascot to bottom */}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Mascot PNG */}
|
{/* Mascot PNG with chat bubble */}
|
||||||
<div className="flex justify-center pb-4">
|
<div className="flex justify-center pb-4">
|
||||||
<MascotImage size="sm" />
|
<MascotImage
|
||||||
|
size="sm"
|
||||||
|
showChat={showChat && !collapsed}
|
||||||
|
chatMessage={mascotChatMessage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.nav>
|
</motion.nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,895 +0,0 @@
|
|||||||
import { cn } from "../../shared/lib/utils";
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type MascotVariant =
|
|
||||||
| "idle"
|
|
||||||
| "waving"
|
|
||||||
| "sleeping"
|
|
||||||
| "thinking"
|
|
||||||
| "peeking"
|
|
||||||
| "crying";
|
|
||||||
|
|
||||||
interface ChibiMascotProps {
|
|
||||||
variant?: MascotVariant;
|
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Size map ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const sizes = { sm: 48, md: 80, lg: 120 } as const;
|
|
||||||
|
|
||||||
// ─── Keyframes ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const keyframes = `
|
|
||||||
@keyframes cb-bob {
|
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
50% { transform: translateY(-3px); }
|
|
||||||
}
|
|
||||||
@keyframes cb-wave {
|
|
||||||
0%, 100% { transform: rotate(0deg); }
|
|
||||||
25% { transform: rotate(18deg); }
|
|
||||||
75% { transform: rotate(-8deg); }
|
|
||||||
}
|
|
||||||
@keyframes cb-sleep {
|
|
||||||
0%, 100% { transform: scaleY(1); }
|
|
||||||
50% { transform: scaleY(0.94); }
|
|
||||||
}
|
|
||||||
@keyframes cb-think {
|
|
||||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
|
||||||
50% { transform: translateY(-3px) rotate(4deg); }
|
|
||||||
}
|
|
||||||
@keyframes cb-cry {
|
|
||||||
0%, 100% { transform: translateY(0) scale(1); }
|
|
||||||
25% { transform: translateY(-2px) scale(1.03); }
|
|
||||||
75% { transform: translateY(2px) scale(0.97); }
|
|
||||||
}
|
|
||||||
@keyframes cb-peek {
|
|
||||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
|
||||||
50% { transform: translateX(-4px) rotate(-4deg); }
|
|
||||||
}
|
|
||||||
@keyframes cb-teardrop {
|
|
||||||
0% { opacity: 1; transform: translateY(0) scaleX(1); }
|
|
||||||
100% { opacity: 0; transform: translateY(14px) scaleX(0.4); }
|
|
||||||
}
|
|
||||||
@keyframes cb-blink {
|
|
||||||
0%, 85%, 100% { transform: scaleY(1); }
|
|
||||||
90% { transform: scaleY(0.08); }
|
|
||||||
}
|
|
||||||
@keyframes cb-zzz {
|
|
||||||
0% { opacity: 0; transform: translateX(0) translateY(0); }
|
|
||||||
40% { opacity: 1; transform: translateX(5px) translateY(-5px); }
|
|
||||||
100% { opacity: 0; transform: translateX(14px) translateY(-14px); }
|
|
||||||
}
|
|
||||||
@keyframes cb-dots {
|
|
||||||
0% { opacity: 0; transform: scale(0.6); }
|
|
||||||
50% { opacity: 1; transform: scale(1); }
|
|
||||||
100% { opacity: 0; transform: scale(0.6); }
|
|
||||||
}
|
|
||||||
@keyframes cb-tear-wobble {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
25% { transform: translateX(-2px); }
|
|
||||||
75% { transform: translateX(2px); }
|
|
||||||
}
|
|
||||||
@keyframes cb-ear-twitch {
|
|
||||||
0%, 100% { transform: rotate(0deg); }
|
|
||||||
20% { transform: rotate(12deg); }
|
|
||||||
40% { transform: rotate(-6deg); }
|
|
||||||
60% { transform: rotate(8deg); }
|
|
||||||
}
|
|
||||||
@keyframes cb-arm-chin {
|
|
||||||
0%, 100% { transform: rotate(0deg); }
|
|
||||||
50% { transform: rotate(6deg); }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ─── Variant animation styles ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function getAnimation(variant: MascotVariant): React.CSSProperties {
|
|
||||||
const map: Record<MascotVariant, string> = {
|
|
||||||
idle: "cb-bob 2.5s ease-in-out infinite",
|
|
||||||
waving: "cb-bob 2.5s ease-in-out infinite",
|
|
||||||
sleeping: "cb-sleep 3.5s ease-in-out infinite",
|
|
||||||
thinking: "cb-think 2.2s ease-in-out infinite",
|
|
||||||
peeking: "cb-peek 2.4s ease-in-out infinite",
|
|
||||||
crying: "cb-cry 1.4s ease-in-out infinite",
|
|
||||||
};
|
|
||||||
return { animation: map[variant] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function ChibiMascot({
|
|
||||||
variant = "idle",
|
|
||||||
size = "md",
|
|
||||||
className,
|
|
||||||
}: ChibiMascotProps) {
|
|
||||||
const px = sizes[size];
|
|
||||||
// Base design dimension is 80px, we scale everything from 0-100 percentage canvas
|
|
||||||
const ch = (pct: number) => (pct / 100) * px;
|
|
||||||
|
|
||||||
// Colors
|
|
||||||
const skin = "#FFE4D6";
|
|
||||||
const skinShadow = "#F5D5C3";
|
|
||||||
const hair = "#FFB7C5";
|
|
||||||
const hairDark = "#F59CB0";
|
|
||||||
const eyeColor = "#7EC8E3";
|
|
||||||
const eyeShine = "#FFFFFF";
|
|
||||||
const blush = "#FFB7C5";
|
|
||||||
const outfit = "#7EC8E3";
|
|
||||||
const outfitDark = "#6BB5D0";
|
|
||||||
const outline = "#E8C4C9";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<style>{keyframes}</style>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex items-center justify-center overflow-visible",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
width: px,
|
|
||||||
height: px,
|
|
||||||
...getAnimation(variant),
|
|
||||||
}}
|
|
||||||
aria-label={`Chibi mascot (${variant})`}
|
|
||||||
role="img"
|
|
||||||
>
|
|
||||||
{/* Inner container — everything is positioned relative to this */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* ─── PEEKING variant: a surface to peek over ─── */}
|
|
||||||
{variant === "peeking" && (
|
|
||||||
<div
|
|
||||||
key="peek-surface"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "28%",
|
|
||||||
left: "-10%",
|
|
||||||
width: "120%",
|
|
||||||
height: "38%",
|
|
||||||
background: "linear-gradient(180deg, #E0F2FE 0%, #BAE6FD 100%)",
|
|
||||||
borderRadius: "30% 30% 0 0",
|
|
||||||
border: `2px solid #7DD3FC`,
|
|
||||||
borderBottom: "none",
|
|
||||||
zIndex: 15,
|
|
||||||
boxShadow: "inset 0 -2px 4px rgba(0,0,0,0.06)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Surface highlight */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "20%",
|
|
||||||
left: "15%",
|
|
||||||
width: "70%",
|
|
||||||
height: "30%",
|
|
||||||
background: "rgba(255,255,255,0.4)",
|
|
||||||
borderRadius: "50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ─── Body ─── */}
|
|
||||||
<div
|
|
||||||
key="body"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "18%",
|
|
||||||
left: "50%",
|
|
||||||
marginLeft: ch(-20),
|
|
||||||
width: ch(40),
|
|
||||||
height: ch(28),
|
|
||||||
background: `linear-gradient(180deg, ${outfit} 0%, ${outfitDark} 100%)`,
|
|
||||||
borderRadius: `${ch(8)} ${ch(8)} ${ch(5)} ${ch(5)}`,
|
|
||||||
border: `2px solid ${outline}`,
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* dress/collar detail */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "0%",
|
|
||||||
left: "10%",
|
|
||||||
width: "80%",
|
|
||||||
height: "30%",
|
|
||||||
background: "rgba(255,255,255,0.15)",
|
|
||||||
borderRadius: "0 0 50% 50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─── Arms ─── */}
|
|
||||||
{/* Left arm (always visible, resting) */}
|
|
||||||
<div
|
|
||||||
key="arm-left"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "24%",
|
|
||||||
left: "50%",
|
|
||||||
marginLeft: ch(-38),
|
|
||||||
width: ch(7),
|
|
||||||
height: ch(18),
|
|
||||||
background: `linear-gradient(180deg, ${skinShadow} 0%, ${skin} 100%)`,
|
|
||||||
borderRadius: ch(4),
|
|
||||||
border: `1.5px solid ${outline}`,
|
|
||||||
transformOrigin: "bottom center",
|
|
||||||
transform: "rotate(12deg)",
|
|
||||||
zIndex: 2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Right arm — varies by variant */}
|
|
||||||
{variant === "waving" && (
|
|
||||||
<div
|
|
||||||
key="arm-right-waving"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "26%",
|
|
||||||
right: "50%",
|
|
||||||
marginRight: ch(-36),
|
|
||||||
width: ch(7),
|
|
||||||
height: ch(18),
|
|
||||||
background: `linear-gradient(180deg, ${skin} 0%, ${skinShadow} 100%)`,
|
|
||||||
borderRadius: ch(4),
|
|
||||||
border: `1.5px solid ${outline}`,
|
|
||||||
transformOrigin: "bottom center",
|
|
||||||
animation: "cb-wave 0.5s ease-in-out infinite",
|
|
||||||
zIndex: 2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{variant === "thinking" && (
|
|
||||||
<div
|
|
||||||
key="arm-right-thinking"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "36%",
|
|
||||||
right: "50%",
|
|
||||||
marginRight: ch(-32),
|
|
||||||
width: ch(6),
|
|
||||||
height: ch(15),
|
|
||||||
background: `linear-gradient(180deg, ${skin} 0%, ${skinShadow} 100%)`,
|
|
||||||
borderRadius: ch(3),
|
|
||||||
border: `1.5px solid ${outline}`,
|
|
||||||
transformOrigin: "bottom center",
|
|
||||||
transform: "rotate(-20deg)",
|
|
||||||
animation: "cb-arm-chin 2.2s ease-in-out infinite",
|
|
||||||
zIndex: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* tiny hand */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: -ch(2),
|
|
||||||
left: "50%",
|
|
||||||
marginLeft: ch(-2.5),
|
|
||||||
width: ch(5),
|
|
||||||
height: ch(4),
|
|
||||||
background: skin,
|
|
||||||
borderRadius: "50%",
|
|
||||||
border: `1px solid ${outline}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{variant === "crying" && (
|
|
||||||
<div
|
|
||||||
key="arm-right-crying"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "24%",
|
|
||||||
right: "50%",
|
|
||||||
marginRight: ch(-36),
|
|
||||||
width: ch(7),
|
|
||||||
height: ch(16),
|
|
||||||
background: `linear-gradient(180deg, ${skin} 0%, ${skinShadow} 100%)`,
|
|
||||||
borderRadius: ch(4),
|
|
||||||
border: `1.5px solid ${outline}`,
|
|
||||||
transformOrigin: "bottom center",
|
|
||||||
transform: "rotate(-8deg)",
|
|
||||||
animation: "cb-tear-wobble 1.4s ease-in-out infinite",
|
|
||||||
zIndex: 2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{variant === "idle" ||
|
|
||||||
variant === "sleeping" ||
|
|
||||||
variant === "peeking" ? (
|
|
||||||
<div
|
|
||||||
key="arm-right-rest"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "24%",
|
|
||||||
right: "50%",
|
|
||||||
marginRight: ch(-36),
|
|
||||||
width: ch(7),
|
|
||||||
height: ch(16),
|
|
||||||
background: `linear-gradient(180deg, ${skin} 0%, ${skinShadow} 100%)`,
|
|
||||||
borderRadius: ch(4),
|
|
||||||
border: `1.5px solid ${outline}`,
|
|
||||||
transformOrigin: "bottom center",
|
|
||||||
transform: "rotate(-8deg)",
|
|
||||||
zIndex: 2,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* ─── Head (circle) ─── */}
|
|
||||||
<div
|
|
||||||
key="head"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "10%",
|
|
||||||
left: "50%",
|
|
||||||
marginLeft: ch(-30),
|
|
||||||
width: ch(60),
|
|
||||||
height: ch(60),
|
|
||||||
background: skin,
|
|
||||||
borderRadius: "50%",
|
|
||||||
border: `2.5px solid ${outline}`,
|
|
||||||
zIndex: 3,
|
|
||||||
overflow: "visible",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* ─── SKIN SHADOW (subtle) ─── */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: "15%",
|
|
||||||
width: "70%",
|
|
||||||
height: "25%",
|
|
||||||
background: skinShadow,
|
|
||||||
borderRadius: "0 0 50% 50%",
|
|
||||||
opacity: 0.4,
|
|
||||||
zIndex: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ─── EARS ─── */}
|
|
||||||
{/* Left ear */}
|
|
||||||
<div
|
|
||||||
key="ear-left"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: ch(-5),
|
|
||||||
left: ch(-7),
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderLeft: `${ch(6)}px solid transparent`,
|
|
||||||
borderRight: `${ch(6)}px solid transparent`,
|
|
||||||
borderBottom: `${ch(14)}px solid ${hair}`,
|
|
||||||
zIndex: 4,
|
|
||||||
filter: `drop-shadow(0 1px 0 ${outline})`,
|
|
||||||
transformOrigin: "bottom center",
|
|
||||||
...(variant === "waving"
|
|
||||||
? { animation: "cb-ear-twitch 1.2s ease-in-out infinite" }
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Inner ear */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: ch(2),
|
|
||||||
left: ch(-3.5),
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderLeft: `${ch(3.5)}px solid transparent`,
|
|
||||||
borderRight: `${ch(3.5)}px solid transparent`,
|
|
||||||
borderBottom: `${ch(8)}px solid #FFB7C5`,
|
|
||||||
zIndex: 5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right ear */}
|
|
||||||
<div
|
|
||||||
key="ear-right"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: ch(-5),
|
|
||||||
right: ch(-7),
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderLeft: `${ch(6)}px solid transparent`,
|
|
||||||
borderRight: `${ch(6)}px solid transparent`,
|
|
||||||
borderBottom: `${ch(14)}px solid ${hair}`,
|
|
||||||
zIndex: 4,
|
|
||||||
filter: `drop-shadow(0 1px 0 ${outline})`,
|
|
||||||
transformOrigin: "bottom center",
|
|
||||||
...(variant === "waving"
|
|
||||||
? {
|
|
||||||
animation:
|
|
||||||
"cb-ear-twitch 1.2s 0.15s ease-in-out infinite",
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Inner ear */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: ch(2),
|
|
||||||
left: ch(-3.5),
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderLeft: `${ch(3.5)}px solid transparent`,
|
|
||||||
borderRight: `${ch(3.5)}px solid transparent`,
|
|
||||||
borderBottom: `${ch(8)}px solid #FFB7C5`,
|
|
||||||
zIndex: 5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─── HAIR BANG ─── */}
|
|
||||||
<div
|
|
||||||
key="hair-bang"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: ch(-1),
|
|
||||||
left: "12%",
|
|
||||||
width: "76%",
|
|
||||||
height: "36%",
|
|
||||||
background: hair,
|
|
||||||
borderRadius: "50% 50% 20% 20%",
|
|
||||||
border: `1.5px solid ${hairDark}`,
|
|
||||||
zIndex: 6,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Hair shine */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "15%",
|
|
||||||
left: "20%",
|
|
||||||
width: "35%",
|
|
||||||
height: "40%",
|
|
||||||
background: "rgba(255,255,255,0.3)",
|
|
||||||
borderRadius: "50%",
|
|
||||||
transform: "rotate(-15deg)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Side wisps */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "10%",
|
|
||||||
left: "-5%",
|
|
||||||
width: "30%",
|
|
||||||
height: "50%",
|
|
||||||
background: hair,
|
|
||||||
borderRadius: "0 0 50% 50%",
|
|
||||||
borderLeft: `1px solid ${hairDark}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "10%",
|
|
||||||
right: "-5%",
|
|
||||||
width: "30%",
|
|
||||||
height: "50%",
|
|
||||||
background: hair,
|
|
||||||
borderRadius: "0 0 50% 50%",
|
|
||||||
borderRight: `1px solid ${hairDark}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─── EYES ─── */}
|
|
||||||
{(variant === "sleeping" || variant === "crying") && (
|
|
||||||
<>
|
|
||||||
{/* Closed / squeezed eyes */}
|
|
||||||
<div
|
|
||||||
key="eye-left"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "40%",
|
|
||||||
left: "22%",
|
|
||||||
width: "24%",
|
|
||||||
height: variant === "sleeping" ? "4%" : "8%",
|
|
||||||
background: "#555",
|
|
||||||
borderRadius: variant === "sleeping" ? "2px" : "50%",
|
|
||||||
zIndex: 7,
|
|
||||||
animation:
|
|
||||||
variant === "sleeping"
|
|
||||||
? "cb-sleep 3.5s ease-in-out infinite"
|
|
||||||
: "cb-blink 3s 2s infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
key="eye-right"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "40%",
|
|
||||||
right: "22%",
|
|
||||||
width: "24%",
|
|
||||||
height: variant === "sleeping" ? "4%" : "8%",
|
|
||||||
background: "#555",
|
|
||||||
borderRadius: variant === "sleeping" ? "2px" : "50%",
|
|
||||||
zIndex: 7,
|
|
||||||
animation:
|
|
||||||
variant === "sleeping"
|
|
||||||
? "cb-sleep 3.5s ease-in-out infinite"
|
|
||||||
: "cb-blink 3s 2s infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{variant !== "sleeping" && variant !== "crying" && (
|
|
||||||
<>
|
|
||||||
{/* Normal big eyes */}
|
|
||||||
<div
|
|
||||||
key="eye-left"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "38%",
|
|
||||||
left: "20%",
|
|
||||||
width: "26%",
|
|
||||||
height: "34%",
|
|
||||||
background: eyeColor,
|
|
||||||
borderRadius: "50%",
|
|
||||||
border: `1.5px solid #555`,
|
|
||||||
zIndex: 7,
|
|
||||||
animation: "cb-blink 4s 1s infinite",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Pupil */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "10%",
|
|
||||||
left: "20%",
|
|
||||||
width: "60%",
|
|
||||||
height: "60%",
|
|
||||||
background: "#5BA3C7",
|
|
||||||
borderRadius: "50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Highlight dot */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "15%",
|
|
||||||
left: "20%",
|
|
||||||
width: "35%",
|
|
||||||
height: "35%",
|
|
||||||
background: eyeShine,
|
|
||||||
borderRadius: "50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Small secondary highlight */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "25%",
|
|
||||||
right: "15%",
|
|
||||||
width: "15%",
|
|
||||||
height: "15%",
|
|
||||||
background: "rgba(255,255,255,0.6)",
|
|
||||||
borderRadius: "50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
key="eye-right"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "38%",
|
|
||||||
right: "20%",
|
|
||||||
width: "26%",
|
|
||||||
height: "34%",
|
|
||||||
background: eyeColor,
|
|
||||||
borderRadius: "50%",
|
|
||||||
border: `1.5px solid #555`,
|
|
||||||
zIndex: 7,
|
|
||||||
animation: "cb-blink 4s 1s infinite",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Pupil */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "10%",
|
|
||||||
left: "20%",
|
|
||||||
width: "60%",
|
|
||||||
height: "60%",
|
|
||||||
background: "#5BA3C7",
|
|
||||||
borderRadius: "50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Highlight dot */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "15%",
|
|
||||||
left: "20%",
|
|
||||||
width: "35%",
|
|
||||||
height: "35%",
|
|
||||||
background: eyeShine,
|
|
||||||
borderRadius: "50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Small secondary highlight */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: "25%",
|
|
||||||
right: "15%",
|
|
||||||
width: "15%",
|
|
||||||
height: "15%",
|
|
||||||
background: "rgba(255,255,255,0.6)",
|
|
||||||
borderRadius: "50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ─── NOSE (pink) ─── */}
|
|
||||||
<div
|
|
||||||
key="nose"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "58%",
|
|
||||||
left: "50%",
|
|
||||||
marginLeft: ch(-1.5),
|
|
||||||
width: ch(3),
|
|
||||||
height: ch(2.2),
|
|
||||||
background: "#FF9EBB",
|
|
||||||
borderRadius: "50% 50% 50% 50% / 60% 60% 40% 40%",
|
|
||||||
zIndex: 9,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ─── MOUTH ─── */}
|
|
||||||
<div
|
|
||||||
key="mouth"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "64%",
|
|
||||||
left: "50%",
|
|
||||||
marginLeft: ch(-3),
|
|
||||||
width: ch(6),
|
|
||||||
height: ch(2.5),
|
|
||||||
borderBottom: `2px solid ${outline}`,
|
|
||||||
borderLeft: "1px solid transparent",
|
|
||||||
borderRight: "1px solid transparent",
|
|
||||||
borderRadius: "0 0 50% 50%",
|
|
||||||
zIndex: 8,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ─── BLUSH ─── */}
|
|
||||||
<div
|
|
||||||
key="blush-left"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "52%",
|
|
||||||
left: "6%",
|
|
||||||
width: "16%",
|
|
||||||
height: "12%",
|
|
||||||
background: blush,
|
|
||||||
borderRadius: "50%",
|
|
||||||
opacity: 0.45,
|
|
||||||
zIndex: 7,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
key="blush-right"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "52%",
|
|
||||||
right: "6%",
|
|
||||||
width: "16%",
|
|
||||||
height: "12%",
|
|
||||||
background: blush,
|
|
||||||
borderRadius: "50%",
|
|
||||||
opacity: 0.45,
|
|
||||||
zIndex: 7,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ─── CRYING: Teardrops ─── */}
|
|
||||||
{variant === "crying" && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
key="tear-left"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "70%",
|
|
||||||
left: "14%",
|
|
||||||
width: "6%",
|
|
||||||
height: "12%",
|
|
||||||
background:
|
|
||||||
"linear-gradient(180deg, rgba(137,207,240,0.9) 0%, rgba(137,207,240,0.3) 100%)",
|
|
||||||
borderRadius: "50% 50% 50% 50% / 60% 60% 40% 40%",
|
|
||||||
animation: "cb-teardrop 1.6s ease-in infinite",
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
key="tear-right"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "72%",
|
|
||||||
right: "14%",
|
|
||||||
width: "6%",
|
|
||||||
height: "12%",
|
|
||||||
background:
|
|
||||||
"linear-gradient(180deg, rgba(137,207,240,0.9) 0%, rgba(137,207,240,0.3) 100%)",
|
|
||||||
borderRadius: "50% 50% 50% 50% / 60% 60% 40% 40%",
|
|
||||||
animation: "cb-teardrop 1.6s 0.3s ease-in infinite",
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─── VARIANT EXTRAS (outside head) ─── */}
|
|
||||||
|
|
||||||
{/* Thinking dots */}
|
|
||||||
{variant === "thinking" && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
key="think-dot1"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "4%",
|
|
||||||
right: "12%",
|
|
||||||
width: ch(3.5),
|
|
||||||
height: ch(3.5),
|
|
||||||
background: "#A0A0A0",
|
|
||||||
borderRadius: "50%",
|
|
||||||
animation: "cb-dots 1.6s ease-in-out infinite",
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
key="think-dot2"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "-1%",
|
|
||||||
right: "6%",
|
|
||||||
width: ch(4.5),
|
|
||||||
height: ch(4.5),
|
|
||||||
background: "#909090",
|
|
||||||
borderRadius: "50%",
|
|
||||||
animation: "cb-dots 1.6s 0.3s ease-in-out infinite",
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
key="think-dot3"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "-7%",
|
|
||||||
right: "3%",
|
|
||||||
width: ch(5.5),
|
|
||||||
height: ch(5.5),
|
|
||||||
background: "#808080",
|
|
||||||
borderRadius: "50%",
|
|
||||||
animation: "cb-dots 1.6s 0.6s ease-in-out infinite",
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sleeping zzz */}
|
|
||||||
{variant === "sleeping" && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
key="zzz1"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "3%",
|
|
||||||
right: "6%",
|
|
||||||
fontSize: ch(8),
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#7EC8E3",
|
|
||||||
animation: "cb-zzz 2.8s ease-in-out infinite",
|
|
||||||
zIndex: 10,
|
|
||||||
fontFamily: "sans-serif",
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
z
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
key="zzz2"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "-4%",
|
|
||||||
right: "12%",
|
|
||||||
fontSize: ch(10),
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#7EC8E3",
|
|
||||||
animation: "cb-zzz 2.8s 0.5s ease-in-out infinite",
|
|
||||||
zIndex: 10,
|
|
||||||
fontFamily: "sans-serif",
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
z
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
key="zzz3"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "-12%",
|
|
||||||
right: "16%",
|
|
||||||
fontSize: ch(13),
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#5BA3C7",
|
|
||||||
animation: "cb-zzz 2.8s 1s ease-in-out infinite",
|
|
||||||
zIndex: 10,
|
|
||||||
fontFamily: "sans-serif",
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Z
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Empty state with mascot ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface EmptyStateMascotProps {
|
|
||||||
variant?: MascotVariant;
|
|
||||||
message: string;
|
|
||||||
action?: { label: string; onClick: () => void };
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmptyStateMascot({
|
|
||||||
variant = "idle",
|
|
||||||
message,
|
|
||||||
action,
|
|
||||||
className,
|
|
||||||
}: EmptyStateMascotProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-sky-200 bg-white/60 p-8 text-center",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ChibiMascot variant={variant} size="md" />
|
|
||||||
<p className="text-sm text-muted-foreground">{message}</p>
|
|
||||||
{action && (
|
|
||||||
<button
|
|
||||||
onClick={action.onClick}
|
|
||||||
className="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-primary/90 active:scale-95"
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { MessageCircle } from "lucide-react";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MascotImage — Anime mascot PNG from GitHub CDN
|
* MascotImage — Anime mascot PNG from GitHub CDN
|
||||||
* Replaces ChibiMascot SVG component with external PNG asset
|
* Replaces ChibiMascot SVG component with external PNG asset
|
||||||
|
* Now includes optional floating chat bubble with AI insights
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface MascotImageProps {
|
interface MascotImageProps {
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
showChat?: boolean;
|
||||||
|
chatMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
@@ -14,15 +21,66 @@ const sizeMap = {
|
|||||||
lg: "w-48 h-auto",
|
lg: "w-48 h-auto",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MascotImage({ size = "md", className = "" }: MascotImageProps) {
|
const chatSizeMap = {
|
||||||
|
sm: "max-w-xs",
|
||||||
|
md: "max-w-sm",
|
||||||
|
lg: "max-w-md",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MascotImage({
|
||||||
|
size = "md",
|
||||||
|
className = "",
|
||||||
|
showChat = false,
|
||||||
|
chatMessage = "",
|
||||||
|
}: MascotImageProps) {
|
||||||
const sizeClass = sizeMap[size];
|
const sizeClass = sizeMap[size];
|
||||||
|
const chatSizeClass = chatSizeMap[size];
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showChat && chatMessage) {
|
||||||
|
setIsVisible(true);
|
||||||
|
const timer = setTimeout(() => setIsVisible(false), 8000); // Auto hide after 8s
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [showChat, chatMessage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<div className="relative inline-block">
|
||||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
|
<motion.img
|
||||||
alt="Mascot"
|
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
|
||||||
className={`object-contain drop-shadow-md ${sizeClass} ${className}`}
|
alt="Mascot"
|
||||||
/>
|
className={`object-contain drop-shadow-md ${sizeClass} ${className}`}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating Chat Bubble */}
|
||||||
|
{isVisible && chatMessage && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 10, scale: 0.8 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||||
|
className={`absolute -top-2 -right-2 ${chatSizeClass} pointer-events-none`}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{/* Chat bubble */}
|
||||||
|
<div className="bg-gradient-to-br from-primary/90 to-primary/80 text-white rounded-2xl px-4 py-2.5 shadow-lg backdrop-blur-sm border border-primary/50">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<MessageCircle className="h-4 w-4 shrink-0 mt-0.5 text-white/80" />
|
||||||
|
<p className="text-xs leading-relaxed font-medium line-clamp-3">
|
||||||
|
{chatMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat bubble tail */}
|
||||||
|
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-primary/80 rounded-full opacity-70" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user