Initial commit

This commit is contained in:
zavolo
2026-06-05 00:53:35 +03:00
commit e59e4424a4
7686 changed files with 352420 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/star.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Star Gifts Admin Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Outfit:wght@500;700;900&display=swap"
rel="stylesheet">
<script>
// Set theme BEFORE first paint to avoid flash/jump
(function () {
try {
var t = localStorage.getItem('void-theme');
if (t) document.documentElement.setAttribute('data-theme', t);
} catch (e) { }
})();
</script>
</head>
<body>
<div id="custom-cursor"></div>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
{
"name": "stargift-admin-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5",
"axios": "^1.6.2",
"lottie-react": "^2.4.0",
"lucide-react": "^0.303.0",
"pako": "^2.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.2",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.21.1",
"recharts": "^2.10.3",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"vite": "^5.0.8"
}
}
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+114
View File
@@ -0,0 +1,114 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import GiftsList from './pages/GiftsList'
import CreateGift from './pages/CreateGift'
import EditGift from './pages/EditGift'
import SendGift from './pages/SendGift'
import Attributes from './pages/Attributes'
import Users from './pages/Users'
import UserManagement from './pages/UserManagement'
import FrozenIconSettings from './pages/FrozenIconSettings'
import Statistics from './pages/Statistics'
import EmojiPacks from './pages/EmojiPacks'
import CreateEmojiPack from './pages/CreateEmojiPack'
import ManageEmojiPack from './pages/ManageEmojiPack'
import BulkUploadEmojis from './pages/BulkUploadEmojis'
import StickerPacks from './pages/StickerPacks'
import CreateStickerPack from './pages/CreateStickerPack'
import ManageStickerPack from './pages/ManageStickerPack'
import BulkUploadStickers from './pages/BulkUploadStickers'
import BulkUploadPacks from './pages/BulkUploadPacks'
import BulkUploadStickerPacks from './pages/BulkUploadStickerPacks'
import FeaturedStickerPacks from './pages/FeaturedStickerPacks'
import Verification from './pages/Verification'
import Reactions from './pages/Reactions'
import CreateReaction from './pages/CreateReaction'
import BulkUploadReactions from './pages/BulkUploadReactions'
import FeaturedEmojiPacks from './FeaturedEmojiPacks'
import EmojiPacksBackup from './pages/EmojiPacksBackup'
import SponsoredMessages from './pages/SponsoredMessages'
import ServiceNotifications from './pages/ServiceNotifications'
import CreateServiceNotification from './pages/CreateServiceNotification'
import IssueStars from './pages/IssueStars'
import FrozenAccounts from './pages/FrozenAccounts'
import FrozenSettings from './pages/FrozenSettings'
function App() {
return (
<BrowserRouter>
<Toaster
position="top-right"
toastOptions={{
duration: 3000,
style: {
background: '#0f0f0f',
color: '#fff',
border: '1px solid rgba(255,255,255,0.1)',
},
success: {
duration: 3000,
iconTheme: {
primary: '#00ff88',
secondary: '#000',
},
},
error: {
duration: 4000,
iconTheme: {
primary: '#ef4444',
secondary: '#fff',
},
},
}}
/>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="gifts" element={<GiftsList />} />
<Route path="gifts/create" element={<CreateGift />} />
<Route path="gifts/edit/:giftId" element={<EditGift />} />
<Route path="gifts/send" element={<SendGift />} />
<Route path="gifts/attributes" element={<Attributes />} />
<Route path="users" element={<Users />} />
<Route path="user-management" element={<UserManagement />} />
<Route path="frozen-icon-settings" element={<FrozenIconSettings />} />
<Route path="statistics" element={<Statistics />} />
<Route path="emojipacks" element={<EmojiPacks />} />
<Route path="emojipacks/create" element={<CreateEmojiPack />} />
<Route path="emojipacks/bulk-upload" element={<BulkUploadEmojis />} />
<Route path="emojipacks/featured" element={<FeaturedEmojiPacks />} />
<Route path="emojipacks/backup" element={<EmojiPacksBackup />} />
<Route path="emojipacks/:id" element={<ManageEmojiPack />} />
<Route path="emojipacks/:id/edit" element={<ManageEmojiPack />} />
<Route path="emojipacks/:packId/bulk-upload" element={<BulkUploadEmojis />} />
<Route path="stickerpacks" element={<StickerPacks />} />
<Route path="stickerpacks/create" element={<CreateStickerPack />} />
<Route path="stickerpacks/bulk-upload" element={<BulkUploadStickers />} />
<Route path="stickerpacks/bulk-upload-nested" element={<BulkUploadPacks />} />
<Route path="stickerpacks/bulk-upload-stickers" element={<BulkUploadStickerPacks />} />
<Route path="stickerpacks/featured" element={<FeaturedStickerPacks />} />
<Route path="stickerpacks/:id" element={<ManageStickerPack />} />
<Route path="stickerpacks/:id/edit" element={<ManageStickerPack />} />
<Route path="stickerpacks/:packId/bulk-upload" element={<BulkUploadStickers />} />
<Route path="verification" element={<Verification />} />
<Route path="reactions" element={<Reactions />} />
<Route path="reactions/create" element={<CreateReaction />} />
<Route path="reactions/bulk-upload" element={<BulkUploadReactions />} />
<Route path="sponsored-messages" element={<SponsoredMessages />} />
<Route path="service-notifications" element={<ServiceNotifications />} />
<Route path="service-notifications/create" element={<CreateServiceNotification />} />
<Route path="service-notifications/edit/:id" element={<CreateServiceNotification />} />
<Route path="stars/issue" element={<IssueStars />} />
<Route path="frozen-accounts" element={<FrozenAccounts />} />
<Route path="frozen-settings" element={<FrozenSettings />} />
</Route>
</Routes>
</BrowserRouter>
)
}
export default App
@@ -0,0 +1,239 @@
/* Telegram Dark Theme */
.featured-packs-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.header h1 {
margin: 0;
font-size: 28px;
color: #ffffff;
}
.btn-add {
padding: 12px 24px;
background: #5288c1;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
transition: background 0.2s;
}
.btn-add:hover {
background: #3d5a7a;
}
.featured-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.featured-pack-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #17212b;
border: 1px solid #2b5278;
border-radius: 8px;
transition: all 0.2s;
}
.featured-pack-item:hover {
border-color: #5288c1;
background: #1f2c38;
}
.pack-order {
font-size: 20px;
font-weight: bold;
color: #5288c1;
min-width: 40px;
text-align: center;
}
.pack-info {
flex: 1;
}
.pack-info h3 {
margin: 0 0 4px 0;
font-size: 18px;
color: #ffffff;
}
.pack-meta {
margin: 0;
font-size: 14px;
color: #8b98a5;
}
.pack-actions {
display: flex;
gap: 8px;
}
.btn-icon {
width: 36px;
height: 36px;
border: 1px solid #2b5278;
background: #0e1621;
color: #ffffff;
border-radius: 6px;
cursor: pointer;
font-size: 18px;
transition: all 0.2s;
}
.btn-icon:hover:not(:disabled) {
background: #2b5278;
border-color: #5288c1;
}
.btn-icon:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-remove {
width: 36px;
height: 36px;
border: 1px solid rgba(255, 68, 68, 0.3);
background: rgba(255, 68, 68, 0.2);
color: #ff6b6b;
border-radius: 6px;
cursor: pointer;
font-size: 18px;
transition: all 0.2s;
}
.btn-remove:hover {
background: #ff4444;
color: white;
border-color: #ff4444;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #8b98a5;
}
.empty-state p {
font-size: 16px;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #17212b;
border: 1px solid #2b5278;
border-radius: 12px;
padding: 24px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-content h2 {
margin: 0 0 20px 0;
font-size: 24px;
color: #ffffff;
}
.pack-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.pack-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid #2b5278;
background: #0e1621;
border-radius: 8px;
transition: all 0.2s;
}
.pack-list-item:hover {
background: #1f2c38;
border-color: #5288c1;
}
.pack-list-item h4 {
margin: 0 0 4px 0;
font-size: 16px;
color: #ffffff;
}
.btn-add-small {
padding: 8px 16px;
background: #5288c1;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.btn-add-small:hover {
background: #3d5a7a;
}
.btn-close {
width: 100%;
padding: 12px;
background: #2b5278;
border: 1px solid #2b5278;
color: #ffffff;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.btn-close:hover {
background: #3d5a7a;
}
.loading, .error {
text-align: center;
padding: 40px;
font-size: 18px;
color: #8b98a5;
}
.error {
color: #ff6b6b;
}
@@ -0,0 +1,226 @@
import React, { useState, useEffect } from 'react';
import './FeaturedEmojiPacks.css';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
function FeaturedEmojiPacks() {
const [featuredPacks, setFeaturedPacks] = useState([]);
const [allPacks, setAllPacks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showAddModal, setShowAddModal] = useState(false);
const [selectedPack, setSelectedPack] = useState(null);
useEffect(() => {
loadFeaturedPacks();
loadAllPacks();
}, []);
const loadFeaturedPacks = async () => {
try {
const response = await fetch(`${API_URL}/api/emojipacks/featured`);
const data = await response.json();
if (data.success) {
setFeaturedPacks(data.packs);
}
} catch (err) {
setError('Failed to load featured packs');
console.error(err);
} finally {
setLoading(false);
}
};
const loadAllPacks = async () => {
try {
const response = await fetch(`${API_URL}/api/emojipacks?limit=1000&emojis=true`);
const data = await response.json();
if (data.success) {
// Filter only emoji packs (Emojis === true)
setAllPacks(data.packs.filter(p => p.Emojis === true));
}
} catch (err) {
console.error('Failed to load all packs:', err);
}
};
const addToFeatured = async (stickerSetId) => {
try {
const maxOrder = Math.max(...featuredPacks.map(p => p.FeaturedOrder || 0), 0);
const response = await fetch(`${API_URL}/api/emojipacks/${stickerSetId}/featured`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isFeatured: true, featuredOrder: maxOrder + 1 })
});
const data = await response.json();
if (data.success) {
await loadFeaturedPacks();
setShowAddModal(false);
} else {
alert('Failed to add to featured: ' + data.error);
}
} catch (err) {
alert('Error: ' + err.message);
}
};
const removeFromFeatured = async (stickerSetId) => {
if (!confirm('Remove this pack from featured?')) return;
try {
const response = await fetch(`${API_URL}/api/emojipacks/${stickerSetId}/featured`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isFeatured: false })
});
const data = await response.json();
if (data.success) {
await loadFeaturedPacks();
} else {
alert('Failed to remove: ' + data.error);
}
} catch (err) {
alert('Error: ' + err.message);
}
};
const moveUp = async (pack, index) => {
if (index === 0) return;
const newPacks = [...featuredPacks];
[newPacks[index], newPacks[index - 1]] = [newPacks[index - 1], newPacks[index]];
await reorderPacks(newPacks);
};
const moveDown = async (pack, index) => {
if (index === featuredPacks.length - 1) return;
const newPacks = [...featuredPacks];
[newPacks[index], newPacks[index + 1]] = [newPacks[index + 1], newPacks[index]];
await reorderPacks(newPacks);
};
const reorderPacks = async (newPacks) => {
const reorderedPacks = newPacks.map((pack, index) => ({
stickerSetId: pack.StickerSetId,
featuredOrder: index + 1
}));
try {
const response = await fetch(`${API_URL}/api/emojipacks/featured/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ packs: reorderedPacks })
});
const data = await response.json();
if (data.success) {
await loadFeaturedPacks();
}
} catch (err) {
alert('Error reordering: ' + err.message);
}
};
if (loading) return <div className="loading">Loading...</div>;
if (error) return <div className="error">{error}</div>;
const availablePacks = allPacks.filter(
p => !featuredPacks.some(fp => fp.StickerSetId === p.StickerSetId)
);
return (
<div className="featured-packs-container">
<div className="header">
<h1>🌟 Featured Emoji Packs</h1>
<button className="btn-add" onClick={() => setShowAddModal(true)}>
+ Add Featured Pack
</button>
</div>
<div className="featured-list">
{featuredPacks.length === 0 ? (
<div className="empty-state">
<p>No featured packs yet. Add some to get started!</p>
</div>
) : (
featuredPacks.map((pack, index) => (
<div key={pack.StickerSetId} className="featured-pack-item">
<div className="pack-order">#{index + 1}</div>
<div className="pack-info">
<h3>{pack.Title}</h3>
<p className="pack-meta">
{pack.ShortName} {pack.Count} emojis ID: {pack.StickerSetId}
</p>
</div>
<div className="pack-actions">
<button
onClick={() => moveUp(pack, index)}
disabled={index === 0}
className="btn-icon"
title="Move up"
>
</button>
<button
onClick={() => moveDown(pack, index)}
disabled={index === featuredPacks.length - 1}
className="btn-icon"
title="Move down"
>
</button>
<button
onClick={() => removeFromFeatured(pack.StickerSetId)}
className="btn-remove"
title="Remove from featured"
>
</button>
</div>
</div>
))
)}
</div>
{showAddModal && (
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>Add Pack to Featured</h2>
<div className="pack-list">
{availablePacks.length === 0 ? (
<p>All emoji packs are already featured!</p>
) : (
availablePacks.map(pack => (
<div key={pack.StickerSetId} className="pack-list-item">
<div>
<h4>{pack.Title}</h4>
<p className="pack-meta">
{pack.ShortName} {pack.Count} emojis
</p>
</div>
<button
onClick={() => addToFeatured(pack.StickerSetId)}
className="btn-add-small"
>
Add
</button>
</div>
))
)}
</div>
<button onClick={() => setShowAddModal(false)} className="btn-close">
Close
</button>
</div>
</div>
)}
</div>
);
}
export default FeaturedEmojiPacks;
@@ -0,0 +1,34 @@
import { useEffect } from 'react'
export default function CustomCursor() {
useEffect(() => {
const cursor = document.getElementById('custom-cursor')
const moveCursor = (e) => {
if (cursor) {
cursor.style.left = `${e.clientX}px`
cursor.style.top = `${e.clientY}px`
}
}
const handleMouseDown = () => {
if (cursor) cursor.classList.add('scale-75')
}
const handleMouseUp = () => {
if (cursor) cursor.classList.remove('scale-75')
}
window.addEventListener('mousemove', moveCursor)
window.addEventListener('mousedown', handleMouseDown)
window.addEventListener('mouseup', handleMouseUp)
return () => {
window.removeEventListener('mousemove', moveCursor)
window.removeEventListener('mousedown', handleMouseDown)
window.removeEventListener('mouseup', handleMouseUp)
}
}, [])
return null
}
@@ -0,0 +1,195 @@
import { Outlet, NavLink, useLocation } from 'react-router-dom'
import { LayoutDashboard, Gift, Plus, Send, Users, BarChart3, Sparkles, Package, Sticker, Shield, Smile, Star, UserCog, Snowflake, Megaphone, ChevronRight, Menu, X, Bell, Coins, AlertCircle, Search, LogOut } from 'lucide-react'
import { useState, useEffect } from 'react'
export default function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(window.innerWidth >= 1024)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const location = useLocation()
// Close mobile menu on route change
useEffect(() => {
setMobileMenuOpen(false)
}, [location.pathname])
const menuSections = [
{
title: 'Overview',
items: [
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard', badge: null }
]
},
{
title: 'Star Gifts',
items: [
{ to: '/gifts', icon: Gift, label: 'All Gifts', badge: null },
{ to: '/gifts/create', icon: Plus, label: 'Create Gift', badge: null },
{ to: '/gifts/send', icon: Send, label: 'Send to User', badge: null },
{ to: '/gifts/attributes', icon: Sparkles, label: 'Attributes', badge: 'New' },
{ to: '/stars/issue', icon: Coins, label: 'Issue Stars', badge: 'New' }
]
},
{
title: 'Content',
items: [
{ to: '/emojipacks', icon: Package, label: 'Emoji Packs', badge: null },
{ to: '/emojipacks/featured', icon: Star, label: 'Featured Packs', badge: 'New' },
{ to: '/stickerpacks', icon: Sticker, label: 'Sticker Packs', badge: null },
{ to: '/reactions', icon: Smile, label: 'Reactions', badge: null },
{ to: '/reactions/bulk-upload', icon: Package, label: 'Bulk Upload Reactions', badge: null }
]
},
{
title: 'Advertising',
items: [
{ to: '/sponsored-messages', icon: Megaphone, label: 'Sponsored Ads', badge: 'Hot' },
{ to: '/service-notifications', icon: Bell, label: 'Notifications', badge: 'New' }
]
},
{
title: 'Users',
items: [
{ to: '/users', icon: Users, label: 'All Users', badge: null },
{ to: '/user-management', icon: UserCog, label: 'Management', badge: null },
{ to: '/verification', icon: Shield, label: 'Verification', badge: null },
{ to: '/frozen-accounts', icon: AlertCircle, label: 'Frozen Accounts', badge: 'New' },
{ to: '/frozen-icon-settings', icon: Snowflake, label: 'Frozen Icon', badge: null },
{ to: '/frozen-settings', icon: Snowflake, label: 'Frozen Settings', badge: 'New' }
]
},
{
title: 'Analytics',
items: [
{ to: '/statistics', icon: BarChart3, label: 'Statistics', badge: null }
]
}
]
// Breadcrumb logic
const currentSection = menuSections.find(s => s.items.some(i => i.to === location.pathname))?.title || 'Dashboard'
const currentPage = menuSections.flatMap(s => s.items).find(i => i.to === location.pathname)?.label || 'Overview'
return (
<div className="flex h-screen w-screen overflow-hidden bg-bg-app text-fg font-sans selection:bg-accent selection:text-black">
{/* Mobile Menu Overlay */}
{mobileMenuOpen && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
/>
)}
{/* Sidebar */}
<aside className={`
fixed lg:static inset-y-0 left-0 z-50
w-[280px] flex-shrink-0 bg-bg-side border-r border-border
transform transition-transform duration-300 ease-in-out
${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
flex flex-col
`}>
{/* Logo */}
<div className="h-[70px] flex items-center px-6 border-b border-border">
<NavLink to="/" className="flex items-center gap-3 font-heading font-black text-xl tracking-tight hover:opacity-80 transition-opacity">
<div className="bg-accent/10 p-2 rounded-lg border border-accent/20 shadow-[0_0_10px_rgba(0,242,255,0.1)]">
<Sparkles className="w-5 h-5 text-accent" />
</div>
<span>
MY<span className="text-accent">TELEGRAM</span>
</span>
</NavLink>
</div>
{/* Search (Visual Placeholder) */}
<div className="p-5 pb-0">
<div className="relative group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-fg-muted group-focus-within:text-accent transition-colors" />
<input
type="text"
placeholder="Search..."
className="w-full bg-bg-panel border border-border rounded-lg pl-9 pr-4 py-2.5 text-sm text-fg placeholder-fg-muted focus:border-accent focus:shadow-[0_0_15px_var(--accent-glow)] outline-none transition-all"
/>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-5 px-3 space-y-6 scrollbar-thin scrollbar-thumb-bg-panel">
{menuSections.map((section, idx) => (
<div key={idx}>
<h3 className="px-4 mb-2 text-xs font-bold text-fg-muted uppercase tracking-wider">
{section.title}
</h3>
<div className="space-y-1">
{section.items.map(({ to, icon: Icon, label, badge }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 border border-transparent
${isActive
? 'bg-accent/10 text-accent border-accent/20 shadow-[0_0_10px_rgba(0,242,255,0.1)]'
: 'text-fg-muted hover:text-fg hover:bg-bg-panel hover:border-border'
}`
}
>
<Icon className="w-4 h-4" />
<span className="flex-1">{label}</span>
{badge && (
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide border
${badge === 'Hot'
? 'bg-red-500/10 text-red-500 border-red-500/20'
: 'bg-accent/10 text-accent border-accent/20'
}`}>
{badge}
</span>
)}
</NavLink>
))}
</div>
</div>
))}
</nav>
</aside>
{/* Main Content Wrapper */}
<div className="flex-1 flex flex-col min-w-0 bg-bg-app relative">
{/* Background Ambient Glow */}
<div className="absolute top-0 left-0 w-full h-[500px] bg-accent/5 rounded-full blur-[120px] pointer-events-none -translate-y-1/2 opacity-50" />
{/* Top Header */}
<header className="h-[70px] flex items-center justify-between px-6 lg:px-8 border-b border-border bg-bg-app/80 backdrop-blur-md sticky top-0 z-30">
{/* Left: Breadcrumbs / Mobile Menu Toggle */}
<div className="flex items-center gap-4">
<button
onClick={() => setMobileMenuOpen(true)}
className="lg:hidden p-2 text-fg hover:bg-bg-panel rounded-lg transition-colors"
>
<Menu className="w-5 h-5" />
</button>
<nav className="hidden sm:flex items-center text-sm font-medium text-fg-muted">
<span className="hover:text-fg transition-colors cursor-pointer">Admin</span>
<ChevronRight className="w-4 h-4 mx-2 opacity-50" />
<span className="hover:text-fg transition-colors cursor-pointer">{currentSection}</span>
<ChevronRight className="w-4 h-4 mx-2 opacity-50" />
<span className="text-accent font-semibold">{currentPage}</span>
</nav>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-4">
{/* Notifications */}
<button className="relative p-2 text-fg-muted hover:text-accent hover:bg-bg-panel rounded-lg transition-all group">
<Bell className="w-5 h-5" />
<span className="absolute top-2 right-2 w-2 h-2 bg-accent rounded-full border-2 border-bg-app shadow-[0_0_10px_var(--accent)]" />
</button>
</div>
</header>
{/* Content Area */}
<main className="flex-1 overflow-y-auto p-4 lg:p-8 relative z-0 scrollbar-thin scrollbar-thumb-bg-panel">
<Outlet />
</main>
</div>
</div>
)
}
@@ -0,0 +1,208 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function SendNotificationModal({ template, onClose }) {
const [sendToAll, setSendToAll] = useState(false);
const [userIds, setUserIds] = useState('');
const [sending, setSending] = useState(false);
const handleSend = async () => {
// Validate input
if (!sendToAll && !userIds.trim()) {
toast.error('Please enter user IDs or select "Send to All Users"');
return;
}
// Parse user IDs
let userIdArray = [];
if (!sendToAll) {
userIdArray = userIds
.split(/[,\s\n]+/)
.map(id => id.trim())
.filter(id => id && !isNaN(id))
.map(id => parseInt(id));
if (userIdArray.length === 0) {
toast.error('No valid user IDs found');
return;
}
}
// Confirm action
const confirmMessage = sendToAll
? 'Are you sure you want to send this notification to ALL users?'
: `Are you sure you want to send this notification to ${userIdArray.length} user(s)?`;
if (!confirm(confirmMessage)) {
return;
}
try {
setSending(true);
const response = await fetch(`${API_URL}/api/service-notifications/${template.Id}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sendToAll,
userIds: sendToAll ? [] : userIdArray
})
});
const data = await response.json();
if (data.success) {
toast.success(`Notification queued for ${data.targetUserCount} user(s)`);
onClose();
} else {
toast.error(data.error || 'Failed to send notification');
}
} catch (error) {
console.error('Error sending notification:', error);
toast.error('Error sending notification');
} finally {
setSending(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="p-6 border-b border-gray-200">
<h2 className="text-2xl font-bold text-gray-900">Send Notification</h2>
<p className="mt-1 text-sm text-gray-600">
Send "{template.Title}" to users
</p>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Template Preview */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-900 mb-2">Template Preview</h3>
<div className="space-y-2 text-sm">
<div>
<span className="font-medium text-gray-700">Type:</span>{' '}
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 rounded text-xs">
{template.Type}
</span>
</div>
<div>
<span className="font-medium text-gray-700">Title:</span>{' '}
<span className="text-gray-900">{template.Title}</span>
</div>
<div>
<span className="font-medium text-gray-700">Message:</span>{' '}
<div className="mt-1 text-gray-600 whitespace-pre-wrap">
{template.Message}
</div>
</div>
<div>
<span className="font-medium text-gray-700">Display:</span>{' '}
<span className={`px-2 py-0.5 rounded text-xs ${
template.IsPopup
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-800'
}`}>
{template.IsPopup ? 'Popup' : 'Message'}
</span>
</div>
</div>
</div>
{/* Send Options */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Send To
</label>
<div className="space-y-3">
{/* Send to All */}
<label className="flex items-start p-3 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="radio"
checked={sendToAll}
onChange={() => setSendToAll(true)}
className="mt-1 mr-3"
/>
<div>
<div className="font-medium text-gray-900">All Users</div>
<div className="text-sm text-gray-600">
Send to every registered user in the system
</div>
</div>
</label>
{/* Send to Specific Users */}
<label className="flex items-start p-3 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="radio"
checked={!sendToAll}
onChange={() => setSendToAll(false)}
className="mt-1 mr-3"
/>
<div className="flex-1">
<div className="font-medium text-gray-900">Specific Users</div>
<div className="text-sm text-gray-600 mb-2">
Enter user IDs (comma or newline separated)
</div>
{!sendToAll && (
<textarea
value={userIds}
onChange={(e) => setUserIds(e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
placeholder="2010001, 2010002, 2010003&#10;or one per line"
/>
)}
</div>
</label>
</div>
</div>
{/* Warning */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">
Important
</h3>
<div className="mt-2 text-sm text-yellow-700">
<ul className="list-disc list-inside space-y-1">
<li>This action cannot be undone</li>
<li>Users will receive the notification immediately</li>
<li>Popup notifications require user interaction to dismiss</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="p-6 border-t border-gray-200 flex justify-end space-x-3">
<button
onClick={onClose}
disabled={sending}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={handleSend}
disabled={sending}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{sending ? 'Sending...' : '📤 Send Now'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
import { useState, useEffect } from 'react'
import Lottie from 'lottie-react'
import { AlertCircle } from 'lucide-react'
import pako from 'pako'
export default function StickerPreview({ stickerBase64, documentId, size = 120 }) {
const [animationData, setAnimationData] = useState(null)
const [error, setError] = useState(false)
const [loading, setLoading] = useState(false)
useEffect(() => {
// If documentId is provided (new format), load from MinIO
if (documentId && typeof documentId === 'number') {
loadStickerFromMinIO(documentId)
return
}
// Legacy: If stickerBase64 is provided (old format)
if (!stickerBase64) {
setAnimationData(null)
return
}
try {
let jsonString
// Check if it's a Buffer object (from MongoDB Binary)
if (typeof stickerBase64 === 'object' && stickerBase64.type === 'Buffer' && Array.isArray(stickerBase64.data)) {
// Convert Buffer array to string
jsonString = String.fromCharCode.apply(null, stickerBase64.data)
} else if (typeof stickerBase64 === 'string') {
// Try to decode as base64
try {
jsonString = atob(stickerBase64)
} catch {
// If it fails, assume it's already a JSON string
jsonString = stickerBase64
}
} else {
throw new Error('Unknown sticker format')
}
const data = JSON.parse(jsonString)
setAnimationData(data)
setError(false)
} catch (err) {
console.error('Failed to parse sticker data:', err)
setError(true)
}
}, [stickerBase64, documentId])
const loadStickerFromMinIO = async (docId) => {
setLoading(true)
setError(false)
try {
// For now, show placeholder since we can't directly access MinIO from frontend
// In production, you'd need a backend endpoint to proxy MinIO files
console.log(`Sticker DocumentId: ${docId}`)
// Show placeholder emoji for now
setAnimationData(null)
setError(false)
} catch (err) {
console.error('Failed to load sticker from MinIO:', err)
setError(true)
} finally {
setLoading(false)
}
}
if (!stickerBase64 && !documentId) {
return (
<div
className="flex items-center justify-center bg-gray-100 rounded-lg"
style={{ width: size, height: size }}
>
<span className="text-gray-400 text-2xl">🎁</span>
</div>
)
}
if (error) {
return (
<div
className="flex flex-col items-center justify-center bg-red-50 rounded-lg border border-red-200"
style={{ width: size, height: size }}
>
<AlertCircle className="w-6 h-6 text-red-500 mb-1" />
<span className="text-xs text-red-600">Invalid sticker</span>
</div>
)
}
if (!animationData) {
return (
<div
className="flex items-center justify-center bg-gray-100 rounded-lg"
style={{ width: size, height: size }}
>
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
)
}
return (
<div
className="flex items-center justify-center bg-gradient-to-br from-primary-50 to-purple-50 rounded-lg"
style={{ width: size, height: size }}
>
<Lottie
animationData={animationData}
loop={true}
style={{ width: size * 0.9, height: size * 0.9 }}
/>
</div>
)
}
+155
View File
@@ -0,0 +1,155 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg-app: #050505;
--bg-side: #0a0a0a;
--bg-panel: #0f0f0f;
--fg: #ffffff;
--fg-muted: #555555;
--accent: #00f2ff;
--accent-glow: rgba(0, 242, 255, 0.2);
--border: rgba(255, 255, 255, 0.05);
--glass: rgba(255, 255, 255, 0.02);
--success: #00ff88;
}
[data-theme="light"] {
--bg-app: #f3f4f6;
--bg-side: #ffffff;
--bg-panel: #ffffff;
--fg: #111827;
--fg-muted: #4b5563;
--accent: #0891b2;
--accent-glow: rgba(8, 145, 178, 0.1);
--border: rgba(0, 0, 0, 0.1);
--glass: rgba(0, 0, 0, 0.05);
--success: #059669;
}
@layer base {
body {
@apply bg-bg-app text-fg font-sans;
line-height: 1.6;
letter-spacing: -0.01em;
height: 100vh;
overflow: hidden;
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-app);
}
::-webkit-scrollbar-thumb {
background: var(--bg-panel);
border-radius: 10px;
border: 1px solid var(--border);
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent);
box-shadow: 0 0 10px var(--accent-glow);
}
::selection {
background: var(--accent);
color: #000;
}
}
/* Re-enable default cursor on touch devices */
@media (hover: none) and (pointer: coarse) {
* {
cursor: auto !important;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-heading text-fg;
line-height: 1.3;
}
}
/* Custom Cursor */
#custom-cursor {
width: 32px;
height: 32px;
position: fixed;
pointer-events: none;
z-index: 9999;
top: 0;
left: 0;
background: url('/Pointer.cur') center/contain no-repeat;
transform: translate(-50%, -50%);
/* Center cursor */
}
[data-theme="light"] #custom-cursor {
background: url('/Pointer1.cur') center/contain no-repeat;
}
@media (hover: none) and (pointer: coarse) {
#custom-cursor {
display: none !important;
}
}
@layer components {
/* Modern Cards with VOIDPAY style */
.card {
@apply bg-bg-panel border border-border rounded-2xl p-6 transition-all duration-300;
}
.card:hover {
@apply border-accent shadow-[0_0_15px_var(--accent-glow)];
}
/* Buttons */
.btn {
@apply px-6 py-3 rounded-lg font-bold font-heading transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center;
font-size: 0.9375rem;
}
.btn-primary {
@apply bg-fg text-bg-app hover:bg-accent hover:text-black hover:shadow-[0_0_15px_var(--accent-glow)];
}
.btn-secondary {
@apply bg-glass text-fg border border-border hover:bg-fg hover:text-bg-app;
backdrop-filter: blur(10px);
}
/* Inputs */
.input {
@apply w-full px-4 py-3 bg-bg-panel border border-border rounded-lg text-fg placeholder-fg-muted outline-none transition-all;
}
.input:focus {
@apply border-accent shadow-[0_0_15px_var(--accent-glow)];
}
/* Sidebar */
.void-sidebar {
@apply w-[280px] bg-bg-side border-r border-border flex flex-col flex-shrink-0;
}
.sidebar-link {
@apply block px-6 py-3 text-fg-muted font-medium text-sm transition-all border-l-2 border-transparent;
}
.sidebar-link.active {
@apply bg-[rgba(0, 242, 255, 0.05)] text-fg border-accent;
}
}
+34
View File
@@ -0,0 +1,34 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
// Gifts API
export const giftsApi = {
getAll: (params) => api.get('/gifts', { params }),
getById: (giftId) => api.get(`/gifts/${giftId}`),
create: (formData) => api.post('/gifts', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}),
update: (giftId, formData) => api.put(`/gifts/${giftId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}),
delete: (giftId) => api.delete(`/gifts/${giftId}`),
markSoldOut: (giftId, soldOut) => api.patch(`/gifts/${giftId}/soldout`, { soldOut }),
purchase: (giftId) => api.patch(`/gifts/${giftId}/purchase`),
}
// Stats API
export const statsApi = {
getOverview: () => api.get('/stats'),
getSentStats: () => api.get('/stats/sent'),
}
// Health check
export const healthCheck = () => api.get('/health')
export default api
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
@@ -0,0 +1,553 @@
import { useState, useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { Trash2, Upload, Sparkles, Image as ImageIcon, FileArchive, ChevronDown, Package, Gift, Settings } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const Attributes = () => {
const [gifts, setGifts] = useState([]);
const [selectedGiftId, setSelectedGiftId] = useState('');
const [attributeSets, setAttributeSets] = useState([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
// Form state
const [name, setName] = useState('');
const [modelFile, setModelFile] = useState(null);
const [patternFile, setPatternFile] = useState(null);
const [modelName, setModelName] = useState('');
const [patternName, setPatternName] = useState('');
const [backdropName, setBackdropName] = useState(''); // Kept for logic, even if auto-generated
const [modelRarity, setModelRarity] = useState(1000);
const [patternRarity, setPatternRarity] = useState(1000);
const [backdropRarity, setBackdropRarity] = useState(1000); // Kept for logic
// ZIP upload state
const [zipFile, setZipFile] = useState(null);
const [zipGiftId, setZipGiftId] = useState('');
const [uploadResult, setUploadResult] = useState(null);
useEffect(() => {
fetchGifts();
fetchAttributeSets();
}, []);
const fetchGifts = async () => {
try {
const response = await fetch(`${API_URL}/api/gifts`);
const data = await response.json();
setGifts(data);
} catch (error) {
console.error('Error fetching gifts:', error);
toast.error('Failed to load gifts');
}
};
const fetchAttributeSets = async () => {
try {
setLoading(true);
const response = await fetch(`${API_URL}/api/attributes`);
const data = await response.json();
setAttributeSets(data);
} catch (error) {
console.error('Error fetching attribute sets:', error);
toast.error('Failed to fetch attribute sets');
} finally {
setLoading(false);
}
};
const handleZipUpload = async () => {
if (!zipFile || !zipGiftId) {
toast.error('Please select a ZIP file and enter Gift ID');
return;
}
try {
setUploading(true);
setUploadResult(null);
const formData = new FormData();
formData.append('file', zipFile);
formData.append('giftId', zipGiftId);
const response = await fetch(`${API_URL}/api/attributes/upload-zip`, {
method: 'POST',
body: formData
});
const data = await response.json();
setUploadResult(data);
if (data.success) {
toast.success(`Successfully processed ${data.upgradesProcessed} upgrades!`);
setZipFile(null);
setZipGiftId('');
await fetchAttributeSets();
} else {
toast.error(data.error || 'Upload failed');
}
} catch (error) {
console.error('ZIP upload error:', error);
setUploadResult({
success: false,
error: error.message
});
toast.error('ZIP upload failed: ' + error.message);
} finally {
setUploading(false);
}
};
const handleUpload = async (e) => {
e.preventDefault();
if (!selectedGiftId) {
toast.error('Please select a Gift first!');
return;
}
if (!name) {
toast.error('Name is required');
return;
}
try {
setUploading(true);
const formData = new FormData();
formData.append('giftId', selectedGiftId);
formData.append('name', name);
formData.append('modelName', modelName || `${name} Model`);
formData.append('patternName', patternName || `${name} Pattern`);
formData.append('backdropName', backdropName || `${name} Backdrop`);
formData.append('modelRarity', modelRarity);
formData.append('patternRarity', patternRarity);
formData.append('backdropRarity', backdropRarity);
if (modelFile) formData.append('model', modelFile);
if (patternFile) formData.append('pattern', patternFile);
const response = await fetch(`${API_URL}/api/attributes`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to upload attribute set');
}
// Reset form
setName('');
setModelFile(null);
setPatternFile(null);
setModelName('');
setPatternName('');
setBackdropName('');
setModelRarity(1000);
setPatternRarity(1000);
setBackdropRarity(1000);
// Refresh list
fetchAttributeSets();
toast.success('Attribute set uploaded successfully!');
} catch (error) {
console.error('Error uploading attribute set:', error);
toast.error(error.message);
} finally {
setUploading(false);
}
};
const handleDelete = async (id) => {
if (!confirm('Are you sure you want to delete this attribute set?')) return;
try {
const response = await fetch(`${API_URL}/api/attributes/${id}`, {
method: 'DELETE'
});
if (response.ok) {
setAttributeSets(attributeSets.filter(s => s._id !== id));
toast.success('Attribute set deleted');
} else {
throw new Error('Failed to delete');
}
} catch (error) {
console.error('Error deleting attribute set:', error);
toast.error('Failed to delete attribute set');
}
};
const selectedGift = gifts.find(g => g.GiftId?.toString() === selectedGiftId);
return (
<div className="space-y-6 animate-fade-in">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<div className="p-2.5 bg-yellow/10 rounded-xl rounded-tr-none">
<Sparkles className="w-8 h-8 text-yellow" />
</div>
Gift Upgrade Attributes
</h1>
<p className="text-fg-muted mt-2">Manage models, patterns, and backdrops for gift upgrades</p>
</div>
</div>
{/* ZIP Upload Section */}
<div className="card bg-gradient-to-br from-bg-panel to-blue/5 border-blue/20">
<div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-heading font-bold mb-2 flex items-center gap-2 text-fg">
<Package className="text-blue" />
Bulk Upload from ZIP
</h2>
<p className="text-fg-muted text-sm max-w-2xl">
Upload a ZIP file containing <code className="bg-bg-app px-1.5 py-0.5 rounded text-xs">info.json</code> and <code className="bg-bg-app px-1.5 py-0.5 rounded text-xs">.tgs</code> files to automatically import multiple upgrades at once.
</p>
</div>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<div>
<label className="block text-xs font-bold text-fg-muted uppercase tracking-wider mb-2">Gift ID</label>
<input
type="number"
value={zipGiftId}
onChange={(e) => setZipGiftId(e.target.value)}
placeholder="e.g., 23"
className="input"
/>
</div>
<div>
<label className="block text-xs font-bold text-fg-muted uppercase tracking-wider mb-2">ZIP File</label>
<input
type="file"
accept=".zip"
onChange={(e) => setZipFile(e.target.files[0])}
className="w-full text-sm text-fg-muted
file:mr-4 file:py-2.5 file:px-4
file:rounded-lg file:border-0
file:text-sm file:font-semibold
file:bg-blue/10 file:text-blue
hover:file:bg-blue/20
cursor-pointer"
/>
</div>
<button
onClick={handleZipUpload}
disabled={!zipFile || !zipGiftId || uploading}
className="btn btn-primary h-[46px]"
>
{uploading ? 'Processing...' : 'Upload ZIP Archive'}
</button>
</div>
{zipFile && (
<div className="mt-3 flex items-center gap-2 text-sm text-fg-muted bg-bg-app/50 p-2 rounded-lg w-fit">
<FileArchive className="w-4 h-4 text-accent" />
<span className="font-medium text-fg">{zipFile.name}</span>
<span>({(zipFile.size / 1024 / 1024).toFixed(2)} MB)</span>
</div>
)}
{uploadResult && (
<div className={`mt-4 p-4 rounded-xl border ${uploadResult.success ? 'bg-success/10 border-success/20' : 'bg-red-500/10 border-red-500/20'} animate-fade-in`}>
{uploadResult.success ? (
<div className="flex items-center gap-3">
<div className="p-2 bg-success/20 rounded-full text-success">
<Package className="w-5 h-5" />
</div>
<div>
<p className="font-bold text-success">Import Successful</p>
<p className="text-sm text-fg-muted">Processed <strong>{uploadResult.upgradesProcessed}</strong> upgrades from {uploadResult.title}</p>
</div>
</div>
) : (
<div className="flex items-center gap-3">
<div className="p-2 bg-red-500/20 rounded-full text-red-500">
<Trash2 className="w-5 h-5" />
</div>
<div>
<p className="font-bold text-red-500">Import Failed</p>
<p className="text-sm text-fg-muted opacity-80">{uploadResult.error}</p>
</div>
</div>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Upload Form */}
<div className="card h-fit lg:sticky lg:top-6">
<h2 className="text-xl font-heading font-bold mb-6 flex items-center gap-2 text-fg">
<Settings className="w-5 h-5 text-accent" />
Manual Creation
</h2>
<form onSubmit={handleUpload} className="space-y-6">
{/* Gift Selection */}
<div className="pb-6 border-b border-border">
<label className="block text-sm font-bold text-accent mb-2">1. Select Gift for Upgrade</label>
<div className="relative">
<select
value={selectedGiftId}
onChange={(e) => setSelectedGiftId(e.target.value)}
className="input appearance-none cursor-pointer"
required
>
<option value="">-- Choose a Gift --</option>
{gifts.map((gift) => (
<option key={gift._id} value={gift.GiftId}>
{gift.Title || `Gift #${gift.GiftId}`} ({gift.Stars} )
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 text-fg-muted pointer-events-none" size={18} />
</div>
{selectedGift && (
<div className="mt-3 p-3 bg-accent/5 border border-accent/10 rounded-xl">
<div className="flex items-center gap-2 mb-1">
<Gift className="w-4 h-4 text-accent" />
<span className="text-fg font-bold text-sm">{selectedGift.Title || `Gift #${selectedGift.GiftId}`}</span>
</div>
<div className="text-xs text-fg-muted pl-6">{selectedGift.Stars} Stars Upgrade Cost: {selectedGift.UpgradeStars} Stars</div>
</div>
)}
</div>
<div>
<label className="block text-sm font-bold text-accent mb-4">2. Configure Upgrade Attributes</label>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-fg-muted mb-1.5">Set Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="input"
placeholder="e.g., Golden Rare Set"
required
disabled={!selectedGiftId}
/>
</div>
{/* Model (TGS) */}
<div className="bg-bg-app/50 border border-border rounded-xl p-4">
<h3 className="font-bold text-sm text-fg mb-3 flex items-center gap-2">
<FileArchive size={16} className="text-blue" />
Model (Upgraded Gift TGS)
</h3>
<div className="space-y-3">
<div className="relative group">
<input
type="file"
onChange={(e) => setModelFile(e.target.files[0])}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed z-10"
accept=".tgs"
disabled={!selectedGiftId}
/>
<div className={`border-2 border-dashed rounded-lg p-4 text-center transition-colors ${modelFile ? 'border-success bg-success/5' : 'border-border group-hover:border-blue group-hover:bg-blue/5'
}`}>
{modelFile ? (
<span className="text-success text-xs font-bold truncate block">{modelFile.name}</span>
) : (
<div className="flex flex-col items-center text-fg-muted">
<Upload size={16} className="mb-1" />
<span className="text-[10px] uppercase font-bold tracking-wider">Upload TGS</span>
</div>
)}
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-fg-muted uppercase mb-1">Model Name</label>
<input
type="text"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
className="input py-2 text-sm"
placeholder={`${name || 'Gift'} Model`}
disabled={!selectedGiftId}
/>
</div>
<div>
<label className="block text-[10px] font-bold text-fg-muted uppercase mb-1">Rarity ()</label>
<input
type="number"
value={modelRarity}
onChange={(e) => setModelRarity(e.target.value)}
className="input py-2 text-sm"
placeholder="1000"
disabled={!selectedGiftId}
/>
</div>
</div>
</div>
{/* Pattern (PNG or TGS) */}
<div className="bg-bg-app/50 border border-border rounded-xl p-4">
<h3 className="font-bold text-sm text-fg mb-3 flex items-center gap-2">
<ImageIcon size={16} className="text-purple" />
Pattern (PNG or TGS)
</h3>
<div className="space-y-3">
<div className="relative group">
<input
type="file"
onChange={(e) => setPatternFile(e.target.files[0])}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed z-10"
accept=".png,.tgs"
disabled={!selectedGiftId}
/>
<div className={`border-2 border-dashed rounded-lg p-4 text-center transition-colors ${patternFile ? 'border-success bg-success/5' : 'border-border group-hover:border-purple group-hover:bg-purple/5'
}`}>
{patternFile ? (
<span className="text-success text-xs font-bold truncate block">{patternFile.name}</span>
) : (
<div className="flex flex-col items-center text-fg-muted">
<Upload size={16} className="mb-1" />
<span className="text-[10px] uppercase font-bold tracking-wider">Upload PNG/TGS</span>
</div>
)}
</div>
</div>
<div>
<label className="block text-[10px] font-bold text-fg-muted uppercase mb-1">Pattern Name</label>
<input
type="text"
value={patternName}
onChange={(e) => setPatternName(e.target.value)}
className="input py-2 text-sm"
placeholder={`${name || 'Gift'} Pattern`}
disabled={!selectedGiftId}
/>
</div>
<div>
<label className="block text-[10px] font-bold text-fg-muted uppercase mb-1">Rarity ()</label>
<input
type="number"
value={patternRarity}
onChange={(e) => setPatternRarity(e.target.value)}
className="input py-2 text-sm"
placeholder="1000"
disabled={!selectedGiftId}
/>
</div>
</div>
</div>
{/* Backdrop Info */}
<div className="bg-blue/5 border border-blue/20 rounded-xl p-4">
<h3 className="font-bold text-sm mb-2 flex items-center gap-2 text-blue">
<Sparkles size={16} />
Backdrop (Auto-generated)
</h3>
<p className="text-xs text-fg-muted leading-relaxed">
Backdrop colors are <strong>automatically generated</strong> using a deterministic algorithm based on the upgrade instance ID.
</p>
</div>
<button
type="submit"
disabled={uploading || !selectedGiftId}
className="btn btn-primary w-full"
>
{uploading ? 'Uploading...' : 'Create Attribute Set'}
</button>
</div>
</div>
</form>
</div>
{/* List */}
<div className="lg:col-span-2">
<div className="card overflow-hidden p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-bg-app border-b border-border">
<tr>
<th className="p-4 text-xs font-bold text-fg-muted uppercase tracking-wider">Gift</th>
<th className="p-4 text-xs font-bold text-fg-muted uppercase tracking-wider">Name</th>
<th className="p-4 text-xs font-bold text-fg-muted uppercase tracking-wider">Model</th>
<th className="p-4 text-xs font-bold text-fg-muted uppercase tracking-wider">Pattern</th>
<th className="p-4 text-xs font-bold text-fg-muted uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
<tr>
<td colSpan="5" className="p-12 text-center text-fg-muted">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
Loading attributes...
</td>
</tr>
) : attributeSets.length === 0 ? (
<tr>
<td colSpan="5" className="p-12 text-center text-fg-muted">
No attribute sets found. Create one to get started.
</td>
</tr>
) : (
attributeSets.map((set) => (
<tr key={set._id} className="hover:bg-bg-app/50 transition-colors">
<td className="p-4">
<div className="inline-flex items-center px-2 py-1 rounded bg-accent/10 border border-accent/20 text-accent font-mono text-xs">
#{set.GiftId}
</div>
</td>
<td className="p-4 font-medium text-fg">{set.Name}</td>
<td className="p-4">
{set.Model ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5 text-xs text-blue font-medium">
<FileArchive size={12} />
TGS
</div>
<span className="text-[10px] px-1.5 py-0.5 bg-bg-app rounded text-fg-muted w-fit">
{set.Model.RarityPermille}
</span>
</div>
) : (
<span className="text-fg-muted opacity-50">-</span>
)}
</td>
<td className="p-4">
{set.Pattern ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5 text-xs text-purple font-medium">
<ImageIcon size={12} />
TGS/PNG
</div>
<span className="text-[10px] px-1.5 py-0.5 bg-bg-app rounded text-fg-muted w-fit">
{set.Pattern.RarityPermille}
</span>
</div>
) : (
<span className="text-fg-muted opacity-50">-</span>
)}
</td>
<td className="p-4 text-right">
<button
onClick={() => handleDelete(set._id)}
className="p-2 text-fg-muted hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
title="Delete Attribute Set"
>
<Trash2 size={18} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
};
export default Attributes;
@@ -0,0 +1,266 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Upload, FileArchive, ArrowLeft, CheckCircle, XCircle, Loader } from 'lucide-react';
import { Link } from 'react-router-dom';
import { toast } from 'react-hot-toast';
export default function BulkUploadEmojis() {
const navigate = useNavigate();
const { packId } = useParams();
const [loading, setLoading] = useState(false);
const [zipFile, setZipFile] = useState(null);
const [uploadProgress, setUploadProgress] = useState([]);
const [completed, setCompleted] = useState(false);
const [packs, setPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState(packId || '');
const [loadingPacks, setLoadingPacks] = useState(false);
useEffect(() => {
fetchPacks();
}, []);
const fetchPacks = async () => {
try {
setLoadingPacks(true);
const response = await fetch('/api/emojipacks?limit=100');
const data = await response.json();
setPacks(data.packs || []);
if (packId) {
setSelectedPackId(packId);
}
} catch (error) {
console.error('Error fetching packs:', error);
toast.error('Failed to load emoji packs');
} finally {
setLoadingPacks(false);
}
};
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file && file.name.endsWith('.zip')) {
setZipFile(file);
setUploadProgress([]);
setCompleted(false);
} else {
toast.error('Please select a ZIP file');
}
};
const handleUpload = async () => {
if (!zipFile) {
toast.error('Please select a ZIP file first');
return;
}
if (!selectedPackId) {
toast.error('Please select an emoji pack');
return;
}
setLoading(true);
setUploadProgress([]);
try {
const formData = new FormData();
formData.append('zipFile', zipFile);
formData.append('packId', selectedPackId);
const response = await fetch('/api/emojipacks/bulk-upload', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
setUploadProgress(data.results || []);
setCompleted(true);
toast.success('Upload completed successfully');
if (data.packId) {
setTimeout(() => {
navigate(`/emojipacks/${data.packId}`);
}, 2000);
}
} else {
const error = await response.json();
toast.error(`Error: ${error.error}`);
}
} catch (error) {
console.error('Error uploading:', error);
toast.error('Failed to upload ZIP file');
} finally {
setLoading(false);
}
};
return (
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
{/* Header */}
<div>
<Link
to={packId ? `/emojipacks/${packId}` : '/emojipacks'}
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
>
<ArrowLeft className="w-5 h-5 mr-2" />
Back
</Link>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<FileArchive className="w-8 h-8 text-purple" />
Bulk Upload Emojis from ZIP
</h1>
<p className="text-fg-muted mt-1">
Upload a ZIP file containing TGS, WebP, or PNG emoji files. Random emojis will be assigned automatically.
</p>
</div>
{/* Upload Form */}
<div className="card p-6 space-y-6">
{/* Pack Selection */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
Select Emoji Pack <span className="text-red">*</span>
</label>
<select
value={selectedPackId}
onChange={(e) => setSelectedPackId(e.target.value)}
disabled={loading || loadingPacks || !!packId}
className="input w-full focus:ring-2 focus:ring-purple/50 disabled:opacity-50"
>
<option value="">-- Select a pack --</option>
{packs.map((pack) => (
<option key={pack.StickerSetId || pack.stickerset_id} value={pack.StickerSetId || pack.stickerset_id}>
{pack.Title || pack.title} ({pack.ShortName || pack.short_name})
</option>
))}
</select>
<p className="mt-2 text-sm text-fg-muted">
{packId ? 'Pack is pre-selected from URL' : 'Choose which pack to add emojis to'}
</p>
{!packId && (
<Link
to="/emojipacks/create"
className="mt-2 inline-flex items-center text-sm text-purple hover:text-purple/80"
>
+ Create new emoji pack first
</Link>
)}
</div>
{/* File Input */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
Select ZIP File <span className="text-red">*</span>
</label>
<div className="flex items-center gap-4">
<input
type="file"
accept=".zip"
onChange={handleFileChange}
disabled={loading}
className="input flex-1 p-2 focus:ring-2 focus:ring-purple/50"
/>
{zipFile && (
<span className="text-sm text-fg-muted">
{zipFile.name} ({(zipFile.size / 1024 / 1024).toFixed(2)} MB)
</span>
)}
</div>
<p className="mt-2 text-sm text-fg-muted">
ZIP file should contain .tgs, .webp, or .png files
</p>
</div>
{/* Info Box */}
<div className="bg-blue/5 border border-blue/20 rounded-lg p-4">
<h4 className="font-semibold text-blue mb-2"> How it works</h4>
<ul className="list-disc list-inside text-sm text-blue/80 space-y-1">
<li>Upload a ZIP file containing TGS, WebP, or PNG emoji files</li>
<li>Each file will be extracted and processed</li>
<li>Random emoji will be assigned as fallback (alt) for each file</li>
<li>All emojis will be added to the selected pack</li>
<li>You can edit emoji assignments later</li>
</ul>
</div>
{/* Upload Button */}
<button
onClick={handleUpload}
disabled={!zipFile || !selectedPackId || loading}
className="btn btn-primary w-full py-3 text-lg font-semibold flex items-center justify-center gap-2"
>
{loading ? (
<>
<Loader className="w-5 h-5 animate-spin" />
Processing...
</>
) : (
<>
<Upload className="w-5 h-5" />
Upload and Process
</>
)}
</button>
{/* Progress */}
{uploadProgress.length > 0 && (
<div className="mt-6 space-y-2 animate-fade-in">
<h3 className="font-semibold text-fg">Upload Results:</h3>
<div className="max-h-96 overflow-y-auto space-y-2 pr-2 custom-scrollbar">
{uploadProgress.map((item, index) => (
<div
key={index}
className={`flex items-center gap-3 p-3 rounded-lg border ${item.success
? 'bg-success/5 border-success/20'
: 'bg-red/5 border-red/20'
}`}
>
{item.success ? (
<CheckCircle className="w-5 h-5 text-success flex-shrink-0" />
) : (
<XCircle className="w-5 h-5 text-red flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-fg truncate">{item.filename}</div>
{item.emoji && (
<div className="text-sm text-fg-muted truncate">
Emoji: {item.emoji} | Document ID: {item.documentId}
</div>
)}
{item.error && (
<div className="text-sm text-red">{item.error}</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Success Message */}
{completed && (
<div className="bg-success/5 border border-success/20 rounded-lg p-4 animate-scale-in">
<h4 className="font-semibold text-success mb-2 flex items-center gap-2">
<CheckCircle className="w-5 h-5" />
Upload Complete!
</h4>
<p className="text-sm text-success/80">
{uploadProgress.filter(p => p.success).length} emojis uploaded successfully.
{packId && ' Redirecting to pack...'}
</p>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,219 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Upload, FileArchive, CheckCircle, XCircle } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function BulkUploadPacks() {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0, pack: '' });
const [result, setResult] = useState(null);
const navigate = useNavigate();
const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
if (selectedFile && selectedFile.name.endsWith('.zip')) {
setFile(selectedFile);
} else {
toast.error('Please select a ZIP file');
setFile(null);
}
};
const handleUpload = async () => {
if (!file) {
toast.error('Please select a file first');
return;
}
setUploading(true);
setResult(null);
setProgress({ current: 0, total: 0, pack: 'Starting upload...' });
const formData = new FormData();
formData.append('zipFile', file);
try {
const response = await fetch('/api/stickerpacks/bulk-upload-nested', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (response.ok) {
setResult(data);
setProgress({ current: data.createdPacks, total: data.totalPacks, pack: 'Complete!' });
toast.success('Bulk upload completed!');
} else {
toast.error(data.error || 'Upload failed');
}
} catch (err) {
toast.error(err.message || 'Network error');
} finally {
setUploading(false);
}
};
return (
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
{/* Header */}
<div>
<button
onClick={() => navigate('/stickerpacks')}
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
>
<ArrowLeft className="w-5 h-5 mr-2" />
Back to Sticker Packs
</button>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<FileArchive className="w-8 h-8 text-purple" />
Bulk Upload Sticker Packs
</h1>
<p className="text-fg-muted mt-1">
Upload a ZIP file containing multiple pack ZIPs with .tgs files
</p>
</div>
{/* Upload Card */}
<div className="card p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-fg mb-4">
📦 Select Main ZIP File
</h2>
<p className="text-sm text-fg-muted mb-4">
Structure: <code className="bg-muted px-2 py-1 rounded">featured_packs.zip</code>
<code className="bg-muted px-2 py-1 rounded ml-2">DuckEmoji.zip</code>
<code className="bg-muted px-2 py-1 rounded ml-2">🦆_12345.tgs</code>
</p>
<input
type="file"
accept=".zip"
onChange={handleFileChange}
disabled={uploading}
className="input w-full p-2 cursor-pointer"
/>
{file && (
<div className="mt-3 p-3 bg-purple/10 border border-purple/20 rounded-lg">
<p className="text-sm text-purple">
Selected: <strong>{file.name}</strong> ({(file.size / 1024 / 1024).toFixed(2)} MB)
</p>
</div>
)}
</div>
<button
onClick={handleUpload}
disabled={!file || uploading}
className="btn btn-primary w-full py-3 text-lg font-semibold flex items-center justify-center gap-2"
>
{uploading ? (
<>
<Upload className="w-5 h-5 animate-spin" />
Processing...
</>
) : (
<>
<Upload className="w-5 h-5" />
Upload and Process
</>
)}
</button>
</div>
{/* Progress */}
{uploading && (
<div className="card p-6">
<h3 className="text-lg font-semibold text-fg mb-4">Processing...</h3>
<div className="space-y-3">
<div className="flex justify-between text-sm text-fg-muted">
<span>{progress.pack}</span>
<span>{progress.current}/{progress.total}</span>
</div>
<div className="w-full bg-muted rounded-full h-3">
<div
className="bg-purple h-3 rounded-full transition-all duration-300"
style={{ width: progress.total > 0 ? `${(progress.current / progress.total) * 100}%` : '0%' }}
/>
</div>
</div>
</div>
)}
{/* Results */}
{result && (
<div className="card p-6 animate-fade-in">
<h3 className="text-2xl font-bold text-fg mb-6 flex items-center gap-2">
<CheckCircle className="w-8 h-8 text-success" />
Upload Complete!
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-success/5 border border-success/20 rounded-xl p-4 text-center">
<div className="text-3xl font-bold text-success">{result.createdPacks}</div>
<div className="text-sm text-fg-muted mt-1">Packs Created</div>
</div>
<div className="bg-blue/5 border border-blue/20 rounded-xl p-4 text-center">
<div className="text-3xl font-bold text-blue">{result.totalStickers}</div>
<div className="text-sm text-fg-muted mt-1">Stickers Uploaded</div>
</div>
<div className="bg-purple/5 border border-purple/20 rounded-xl p-4 text-center">
<div className="text-3xl font-bold text-purple">{result.totalPacks}</div>
<div className="text-sm text-fg-muted mt-1">Total Packs</div>
</div>
<div className="bg-red/5 border border-red/20 rounded-xl p-4 text-center">
<div className="text-3xl font-bold text-red">{result.failedPacks}</div>
<div className="text-sm text-fg-muted mt-1">Failed</div>
</div>
</div>
{/* Created Packs List */}
{result.packs && result.packs.length > 0 && (
<div className="mb-6">
<h4 className="font-semibold text-fg mb-3">Created Packs:</h4>
<div className="max-h-96 overflow-y-auto space-y-2 custom-scrollbar pr-2">
{result.packs.map((pack, idx) => (
<div key={idx} className="flex justify-between items-center p-3 border border-border rounded-lg bg-card hover:border-purple transition-colors">
<div>
<div className="font-medium text-fg">{pack.title}</div>
<div className="text-sm text-fg-muted">
{pack.shortName} ID: {pack.stickersetId}
</div>
</div>
<div className="text-sm font-semibold text-purple">
{pack.stickersCount} stickers
</div>
</div>
))}
</div>
</div>
)}
{/* Failed Packs */}
{result.failures && result.failures.length > 0 && (
<div>
<h4 className="font-semibold text-red mb-3">Failed Packs:</h4>
<div className="max-h-48 overflow-y-auto space-y-2 custom-scrollbar pr-2">
{result.failures.map((fail, idx) => (
<div key={idx} className="p-3 bg-red/5 border border-red/20 rounded-lg">
<div className="font-medium text-fg">{fail.pack}</div>
<div className="text-sm text-red">{fail.reason}</div>
</div>
))}
</div>
</div>
)}
<button
onClick={() => navigate('/stickerpacks')}
className="mt-6 w-full btn btn-secondary"
>
View All Sticker Packs
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,183 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Paper,
Button,
Alert,
CircularProgress,
List,
ListItem,
ListItemText,
ListItemIcon,
Checkbox,
FormControlLabel,
Divider
} from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import FolderIcon from '@mui/icons-material/Folder';
const BulkUploadReactions = () => {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [results, setResults] = useState(null);
const [error, setError] = useState(null);
const [premium, setPremium] = useState(false);
const handleFileChange = (event) => {
if (event.target.files && event.target.files[0]) {
setFile(event.target.files[0]);
setResults(null);
setError(null);
}
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
setError(null);
setResults(null);
const formData = new FormData();
formData.append('zipFile', file);
formData.append('premium', premium);
try {
const response = await fetch('http://localhost:3001/api/reactions/bulk-upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Upload failed');
}
setResults(data.results);
} catch (err) {
setError(err.message);
} finally {
setUploading(false);
}
};
return (
<Box sx={{ p: 3 }}>
<Typography variant="h4" gutterBottom>
Bulk Upload Reactions
</Typography>
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="body1" paragraph>
Upload a ZIP file containing folders named after emojis (e.g., "🙏", "❤️").
Each folder should contain the reaction assets (static_icon.png, appear_animation.tgs, etc.).
</Typography>
<Box sx={{ mb: 3 }}>
<FormControlLabel
control={
<Checkbox
checked={premium}
onChange={(e) => setPremium(e.target.checked)}
/>
}
label="Mark as Premium Reactions"
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Button
variant="contained"
component="label"
startIcon={<CloudUploadIcon />}
>
Select ZIP File
<input
type="file"
hidden
accept=".zip"
onChange={handleFileChange}
/>
</Button>
{file && (
<Typography variant="body2">
Selected: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
</Typography>
)}
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Button
variant="contained"
color="primary"
onClick={handleUpload}
disabled={!file || uploading}
fullWidth
>
{uploading ? <CircularProgress size={24} /> : 'Upload Reactions'}
</Button>
</Paper>
{results && (
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Upload Results
</Typography>
<List>
{results.map((result, index) => (
<React.Fragment key={index}>
<ListItem>
<ListItemIcon>
{result.success ? (
<CheckCircleIcon color="success" />
) : (
<ErrorIcon color="error" />
)}
</ListItemIcon>
<ListItemText
primary={
<Box display="flex" alignItems="center" gap={1}>
{result.success ? (
<>
<Typography variant="h6">{result.emoji}</Typography>
<Typography variant="body2" color="textSecondary">
({result.assets?.length || 0} assets)
</Typography>
</>
) : (
<Typography color="error">
{result.emoji || 'Unknown'} - {result.error}
</Typography>
)}
</Box>
}
secondary={
result.success && (
<Typography variant="caption" display="block">
Assets: {result.assets?.join(', ')}
</Typography>
)
}
/>
</ListItem>
{index < results.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
</Paper>
)}
</Box>
);
};
export default BulkUploadReactions;
@@ -0,0 +1,219 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Upload, FileArchive, CheckCircle, XCircle } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function BulkUploadStickerPacks() {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0, pack: '' });
const [result, setResult] = useState(null);
const navigate = useNavigate();
const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
if (selectedFile && selectedFile.name.endsWith('.zip')) {
setFile(selectedFile);
} else {
toast.error('Please select a ZIP file');
setFile(null);
}
};
const handleUpload = async () => {
if (!file) {
toast.error('Please select a file first');
return;
}
setUploading(true);
setResult(null);
setProgress({ current: 0, total: 0, pack: 'Starting upload...' });
const formData = new FormData();
formData.append('zipFile', file);
try {
const response = await fetch('/api/stickerpacks/bulk-upload-stickers', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (response.ok) {
setResult(data);
setProgress({ current: data.createdPacks, total: data.totalPacks, pack: 'Complete!' });
toast.success('Bulk upload completed!');
} else {
toast.error(data.error || 'Upload failed');
}
} catch (err) {
toast.error(err.message || 'Network error');
} finally {
setUploading(false);
}
};
return (
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
{/* Header */}
<div>
<button
onClick={() => navigate('/stickerpacks')}
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
>
<ArrowLeft className="w-5 h-5 mr-2" />
Back to Sticker Packs
</button>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<FileArchive className="w-8 h-8 text-purple" />
Bulk Upload Regular Sticker Packs
</h1>
<p className="text-fg-muted mt-1">
Upload a ZIP file containing multiple pack ZIPs with .tgs sticker files
</p>
</div>
{/* Upload Card */}
<div className="card p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-fg mb-4">
📦 Select Main ZIP File
</h2>
<p className="text-sm text-fg-muted mb-4">
Structure: <code className="bg-muted px-2 py-1 rounded">featured_stickers.zip</code>
<code className="bg-muted px-2 py-1 rounded ml-2">Funny_Cats.zip</code>
<code className="bg-muted px-2 py-1 rounded ml-2">😀_12345.tgs</code>
</p>
<input
type="file"
accept=".zip"
onChange={handleFileChange}
disabled={uploading}
className="input w-full p-2 cursor-pointer"
/>
{file && (
<div className="mt-3 p-3 bg-purple/10 border border-purple/20 rounded-lg">
<p className="text-sm text-purple">
Selected: <strong>{file.name}</strong> ({(file.size / 1024 / 1024).toFixed(2)} MB)
</p>
</div>
)}
</div>
<button
onClick={handleUpload}
disabled={!file || uploading}
className="btn btn-primary w-full py-3 text-lg font-semibold flex items-center justify-center gap-2"
>
{uploading ? (
<>
<Upload className="w-5 h-5 animate-spin" />
Processing...
</>
) : (
<>
<Upload className="w-5 h-5" />
Upload and Process
</>
)}
</button>
</div>
{/* Progress */}
{uploading && (
<div className="card p-6">
<h3 className="text-lg font-semibold text-fg mb-4">Processing...</h3>
<div className="space-y-3">
<div className="flex justify-between text-sm text-fg-muted">
<span>{progress.pack}</span>
<span>{progress.current}/{progress.total}</span>
</div>
<div className="w-full bg-muted rounded-full h-3">
<div
className="bg-purple h-3 rounded-full transition-all duration-300"
style={{ width: progress.total > 0 ? `${(progress.current / progress.total) * 100}%` : '0%' }}
/>
</div>
</div>
</div>
)}
{/* Results */}
{result && (
<div className="card p-6 animate-fade-in">
<h3 className="text-2xl font-bold text-fg mb-6 flex items-center gap-2">
<CheckCircle className="w-8 h-8 text-success" />
Upload Complete!
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-success/5 border border-success/20 rounded-xl p-4 text-center">
<div className="text-3xl font-bold text-success">{result.createdPacks}</div>
<div className="text-sm text-fg-muted mt-1">Packs Created</div>
</div>
<div className="bg-blue/5 border border-blue/20 rounded-xl p-4 text-center">
<div className="text-3xl font-bold text-blue">{result.totalStickers}</div>
<div className="text-sm text-fg-muted mt-1">Stickers Uploaded</div>
</div>
<div className="bg-purple/5 border border-purple/20 rounded-xl p-4 text-center">
<div className="text-3xl font-bold text-purple">{result.totalPacks}</div>
<div className="text-sm text-fg-muted mt-1">Total Packs</div>
</div>
<div className="bg-red/5 border border-red/20 rounded-xl p-4 text-center">
<div className="text-3xl font-bold text-red">{result.failedPacks}</div>
<div className="text-sm text-fg-muted mt-1">Failed</div>
</div>
</div>
{/* Created Packs List */}
{result.packs && result.packs.length > 0 && (
<div className="mb-6">
<h4 className="font-semibold text-fg mb-3">Created Packs:</h4>
<div className="max-h-96 overflow-y-auto space-y-2 custom-scrollbar pr-2">
{result.packs.map((pack, idx) => (
<div key={idx} className="flex justify-between items-center p-3 border border-border rounded-lg bg-card hover:border-purple transition-colors">
<div>
<div className="font-medium text-fg">{pack.title}</div>
<div className="text-sm text-fg-muted">
{pack.shortName} ID: {pack.stickersetId}
</div>
</div>
<div className="text-sm font-semibold text-purple">
{pack.stickersCount} stickers
</div>
</div>
))}
</div>
</div>
)}
{/* Failed Packs */}
{result.failures && result.failures.length > 0 && (
<div>
<h4 className="font-semibold text-red mb-3">Failed Packs:</h4>
<div className="max-h-48 overflow-y-auto space-y-2 custom-scrollbar pr-2">
{result.failures.map((fail, idx) => (
<div key={idx} className="p-3 bg-red/5 border border-red/20 rounded-lg">
<div className="font-medium text-fg">{fail.pack}</div>
<div className="text-sm text-red">{fail.reason}</div>
</div>
))}
</div>
</div>
)}
<button
onClick={() => navigate('/stickerpacks')}
className="mt-6 w-full btn btn-secondary"
>
View All Sticker Packs
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,277 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Upload, FileArchive, ArrowLeft, CheckCircle, XCircle, Loader } from 'lucide-react';
import { Link } from 'react-router-dom';
import { toast } from 'react-hot-toast';
export default function BulkUploadStickers() {
const navigate = useNavigate();
const { packId } = useParams();
const [loading, setLoading] = useState(false);
const [zipFile, setZipFile] = useState(null);
const [uploadProgress, setUploadProgress] = useState([]);
const [completed, setCompleted] = useState(false);
const [uploadStats, setUploadStats] = useState(null);
const [packs, setPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState(packId || '');
const [loadingPacks, setLoadingPacks] = useState(false);
useEffect(() => {
fetchPacks();
}, []);
const fetchPacks = async () => {
try {
setLoadingPacks(true);
const response = await fetch('/api/stickerpacks?limit=100');
const data = await response.json();
setPacks(data.packs || []);
if (packId) {
setSelectedPackId(packId);
}
} catch (error) {
console.error('Error fetching packs:', error);
toast.error('Failed to load sticker packs');
} finally {
setLoadingPacks(false);
}
};
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file && file.name.endsWith('.zip')) {
setZipFile(file);
setUploadProgress([]);
setCompleted(false);
} else {
toast.error('Please select a ZIP file');
}
};
const handleUpload = async () => {
if (!zipFile) {
toast.error('Please select a ZIP file first');
return;
}
if (!selectedPackId) {
toast.error('Please select a sticker pack');
return;
}
setLoading(true);
setUploadProgress([]);
try {
const formData = new FormData();
formData.append('zipFile', zipFile);
formData.append('packId', selectedPackId);
const response = await fetch('/api/stickerpacks/bulk-upload', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
setUploadProgress(data.results || []);
setUploadStats({
total: data.successCount || 0,
premium: data.premiumCount || 0,
regular: data.regularCount || 0
});
setCompleted(true);
toast.success('Upload completed successfully');
if (data.packId) {
setTimeout(() => {
navigate(`/stickerpacks/${data.packId}`);
}, 2000);
}
} else {
const error = await response.json();
toast.error(`Error: ${error.error}`);
}
} catch (error) {
console.error('Error uploading:', error);
toast.error('Failed to upload ZIP file');
} finally {
setLoading(false);
}
};
return (
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
{/* Header */}
<div>
<Link
to={packId ? `/stickerpacks/${packId}` : '/stickerpacks'}
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
>
<ArrowLeft className="w-5 h-5 mr-2" />
Back
</Link>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<FileArchive className="w-8 h-8 text-blue" />
Bulk Upload Stickers from ZIP
</h1>
<p className="text-fg-muted mt-1">
Upload a ZIP file containing TGS/WEBP/PNG/WEBM sticker files. Random emojis will be assigned automatically.
</p>
<div className="mt-4 p-4 bg-blue/5 border border-blue/20 rounded-lg">
<p className="text-sm text-blue font-medium"> Premium Stickers Support:</p>
<p className="text-xs text-blue/80 mt-1">
Name files as <code className="bg-black/30 px-1 rounded">000_main.tgs</code> + <code className="bg-black/30 px-1 rounded">000_effect.tgs</code> for premium stickers with effects
</p>
</div>
</div>
{/* Upload Form */}
<div className="card p-6 space-y-6">
{/* Pack Selection */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
Select Sticker Pack <span className="text-red">*</span>
</label>
<select
value={selectedPackId}
onChange={(e) => setSelectedPackId(e.target.value)}
disabled={loading || loadingPacks || !!packId}
className="input w-full focus:ring-2 focus:ring-purple/50 disabled:opacity-50"
>
<option value="">-- Select a pack --</option>
{packs.map((pack) => (
<option key={pack.StickerSetId || pack.stickerset_id} value={pack.StickerSetId || pack.stickerset_id}>
{pack.Title || pack.title} ({pack.ShortName || pack.short_name})
</option>
))}
</select>
<p className="mt-2 text-sm text-fg-muted">
{packId ? 'Pack is pre-selected from URL' : 'Choose which pack to add stickers to'}
</p>
{!packId && (
<Link
to="/stickerpacks/create"
className="mt-2 inline-flex items-center text-sm text-purple hover:text-purple/80"
>
+ Create new sticker pack first
</Link>
)}
</div>
{/* File Input */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
Select ZIP File <span className="text-red">*</span>
</label>
<div className="flex items-center gap-4">
<input
type="file"
accept=".zip"
onChange={handleFileChange}
disabled={loading}
className="input flex-1 p-2 focus:ring-2 focus:ring-purple/50"
/>
{zipFile && (
<span className="text-sm text-fg-muted">
{zipFile.name} ({(zipFile.size / 1024 / 1024).toFixed(2)} MB)
</span>
)}
</div>
<p className="mt-2 text-sm text-fg-muted">
ZIP file should contain .tgs, .webp, .png or .webm files
</p>
</div>
{/* Info Box */}
<div className="bg-purple/5 border border-purple/20 rounded-lg p-4">
<h4 className="font-semibold text-purple mb-2"> How it works</h4>
<ul className="list-disc list-inside text-sm text-purple/80 space-y-1">
<li>Upload a ZIP file containing TGS, WEBP, PNG or WEBM sticker files</li>
<li>Each file will be extracted and processed</li>
<li>Random emoji will be assigned as fallback (alt) for each sticker</li>
<li>All stickers will be added to the selected pack</li>
<li>You can edit emoji assignments later</li>
<li>Supported formats: TGS (animated), WEBP (recommended), PNG</li>
<li className="text-purple font-medium"> Premium: Use <code className="bg-black/30 px-1 rounded">XXX_main.tgs</code> + <code className="bg-black/30 px-1 rounded">XXX_effect.tgs</code> naming</li>
</ul>
</div>
{/* Upload Button */}
<button
onClick={handleUpload}
disabled={!zipFile || !selectedPackId || loading}
className="btn btn-primary w-full py-3 text-lg font-semibold flex items-center justify-center gap-2"
>
{loading ? (
<>
<Loader className="w-5 h-5 animate-spin" />
Processing...
</>
) : (
<>
<Upload className="w-5 h-5" />
Upload and Process
</>
)}
</button>
{/* Progress */}
{uploadProgress.length > 0 && (
<div className="mt-6 space-y-2 animate-fade-in">
<h3 className="font-semibold text-fg">Upload Results:</h3>
<div className="max-h-96 overflow-y-auto space-y-2 pr-2 custom-scrollbar">
{uploadProgress.map((item, index) => (
<div
key={index}
className={`flex items-center gap-3 p-3 rounded-lg border ${item.success
? 'bg-success/5 border-success/20'
: 'bg-red/5 border-red/20'
}`}
>
{item.success ? (
<CheckCircle className="w-5 h-5 text-success flex-shrink-0" />
) : (
<XCircle className="w-5 h-5 text-red flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-fg truncate">{item.filename}</div>
{item.emoji && (
<div className="text-sm text-fg-muted truncate">
Emoji: {item.emoji} | Document ID: {item.documentId}
{item.premium && <span className="ml-2 px-2 py-0.5 bg-blue/10 text-blue text-[10px] rounded border border-blue/20"> PREMIUM</span>}
</div>
)}
{item.error && (
<div className="text-sm text-red">{item.error}</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Success Message */}
{completed && uploadStats && (
<div className="bg-success/5 border border-success/20 rounded-lg p-4 animate-scale-in">
<h4 className="font-semibold text-success mb-2 flex items-center gap-2">
<CheckCircle className="w-5 h-5" />
Upload Complete!
</h4>
<div className="text-sm text-success/80 space-y-1">
<p> Total: {uploadStats.total} stickers uploaded successfully</p>
{uploadStats.premium > 0 && (
<p> Premium: {uploadStats.premium} stickers with effects</p>
)}
{uploadStats.regular > 0 && (
<p>📦 Regular: {uploadStats.regular} stickers</p>
)}
{packId && <p className="mt-2 font-medium">Redirecting to pack...</p>}
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,210 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Package, ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import { toast } from 'react-hot-toast';
export default function CreateEmojiPack() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
title: '',
short_name: '',
text_color: false,
channel_emoji_status: false,
creator_id: ''
});
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch('/api/emojipacks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (response.ok) {
const data = await response.json();
toast.success('Emoji pack created successfully!');
navigate(`/emojipacks/${data.pack.StickerSetId}`);
} else {
const error = await response.json();
toast.error(`Error: ${error.error}`);
}
} catch (error) {
console.error('Error creating pack:', error);
toast.error('Failed to create pack');
} finally {
setLoading(false);
}
};
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<div className="max-w-3xl mx-auto space-y-6 animate-fade-in">
{/* Header */}
<div>
<Link
to="/emojipacks"
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
>
<ArrowLeft className="w-5 h-5 mr-2" />
Back to Packs
</Link>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<Package className="w-8 h-8 text-purple" />
Create Emoji Pack
</h1>
<p className="text-fg-muted mt-1">Create a new custom emoji sticker set</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="card p-6 space-y-6">
{/* Title */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
Title <span className="text-red">*</span>
</label>
<input
type="text"
name="title"
value={formData.title}
onChange={handleChange}
required
placeholder="My Awesome Emojis"
className="input w-full focus:ring-2 focus:ring-purple/50"
/>
<p className="mt-1 text-sm text-fg-muted">
Display name for the emoji pack
</p>
</div>
{/* Short Name */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
Short Name <span className="text-red">*</span>
</label>
<div className="flex items-center gap-2">
<span className="text-fg-muted">t.me/addemoji/</span>
<input
type="text"
name="short_name"
value={formData.short_name}
onChange={handleChange}
required
pattern="[a-zA-Z0-9_]+"
placeholder="my_emojis"
className="input flex-1 focus:ring-2 focus:ring-purple/50"
/>
</div>
<p className="mt-1 text-sm text-fg-muted">
Unique identifier (letters, numbers, and underscores only)
</p>
</div>
{/* Creator ID */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
Creator User ID
</label>
<input
type="number"
name="creator_id"
value={formData.creator_id}
onChange={handleChange}
placeholder="2000001"
className="input w-full focus:ring-2 focus:ring-purple/50"
/>
<p className="mt-1 text-sm text-fg-muted">
Optional: MyTelegram user ID of the pack creator
</p>
</div>
{/* Options */}
<div className="space-y-4 pt-2 border-t border-border">
<h3 className="text-lg font-semibold text-fg">Options</h3>
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
name="text_color"
checked={formData.text_color}
onChange={handleChange}
className="mt-1 w-4 h-4 text-purple rounded border-input focus:ring-purple/50 bg-muted"
/>
<div>
<div className="font-medium text-fg group-hover:text-purple transition-colors">Text Color Support</div>
<div className="text-sm text-fg-muted">
Emojis will change color based on context (text color, status, etc.)
</div>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
name="channel_emoji_status"
checked={formData.channel_emoji_status}
onChange={handleChange}
className="mt-1 w-4 h-4 text-purple rounded border-input focus:ring-purple/50 bg-muted"
/>
<div>
<div className="font-medium text-fg group-hover:text-purple transition-colors">Channel Emoji Status</div>
<div className="text-sm text-fg-muted">
Allow using emojis as channel status icons
</div>
</div>
</label>
</div>
{/* Info Box */}
<div className="bg-purple/5 border border-purple/20 rounded-lg p-4">
<h4 className="font-semibold text-purple mb-2">📝 Next Steps</h4>
<ol className="list-decimal list-inside text-sm text-purple/80 space-y-1">
<li>Create the pack with a unique short name</li>
<li>Upload TGS/WEBM files for each emoji</li>
<li>Assign fallback emoji (alt) for each uploaded file</li>
<li>Share the pack link with users</li>
</ol>
</div>
{/* Submit */}
<div className="flex gap-4 pt-4">
<button
type="submit"
disabled={loading}
className="flex-1 btn btn-primary font-semibold text-lg"
>
{loading ? 'Creating...' : 'Create Pack'}
</button>
<Link
to="/emojipacks"
className="btn btn-secondary font-semibold"
>
Cancel
</Link>
</div>
</form>
</div>
);
}
@@ -0,0 +1,315 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { Upload, ArrowLeft, Sparkles } from 'lucide-react'
import useGiftsStore from '../store/useGiftsStore'
import Lottie from 'lottie-react'
import pako from 'pako'
export default function CreateGift() {
const navigate = useNavigate()
const { createGift } = useGiftsStore()
const { register, handleSubmit, watch, formState: { errors } } = useForm()
const [stickerFile, setStickerFile] = useState(null)
const [stickerPreview, setStickerPreview] = useState(null)
const [submitting, setSubmitting] = useState(false)
const limited = watch('limited')
const handleFileChange = (e) => {
const file = e.target.files[0]
if (file) {
setStickerFile(file)
// Read file for preview (TGS files are gzipped JSON)
const reader = new FileReader()
reader.onload = (event) => {
try {
const arrayBuffer = event.target.result
const uint8Array = new Uint8Array(arrayBuffer)
// Try to decompress as gzip (TGS format)
try {
const decompressed = pako.inflate(uint8Array, { to: 'string' })
const jsonData = JSON.parse(decompressed)
setStickerPreview(jsonData)
} catch (gzipErr) {
// If not gzipped, try to parse as plain JSON
const text = new TextDecoder().decode(uint8Array)
const jsonData = JSON.parse(text)
setStickerPreview(jsonData)
}
} catch (err) {
console.error('Failed to parse JSON:', err)
setStickerPreview(null)
// toast.error('Invalid TGS file format')
}
}
reader.readAsArrayBuffer(file)
}
}
const onSubmit = async (rawData) => {
setSubmitting(true)
try {
// Process data to ensure correct types
const stars = Number(rawData.stars);
const data = {
...rawData,
giftId: Number(rawData.giftId),
stars: stars,
convertStars: rawData.limited
? Number(rawData.convertStars)
: Math.floor(stars * 0.7) || 1,
upgradeStars: rawData.limited
? Number(rawData.upgradeStars)
: null, // Explicitly null for non-limited
stickerId: rawData.stickerId ? rawData.stickerId : undefined
}
if (rawData.limited) {
data.availabilityTotal = Number(rawData.availabilityTotal)
data.availabilityRemains = rawData.availabilityRemains
? Number(rawData.availabilityRemains)
: Number(rawData.availabilityTotal)
} else {
delete data.availabilityTotal
delete data.availabilityRemains
}
const formData = new FormData()
formData.append('data', JSON.stringify(data))
if (stickerFile) {
formData.append('sticker', stickerFile)
console.log('Uploading sticker:', stickerFile.name, stickerFile.size, 'bytes')
} else {
console.warn('No sticker file selected')
}
await createGift(formData)
navigate('/gifts')
} catch (error) {
console.error('Create gift error:', error)
console.error('Error response:', error.response?.data)
} finally {
setSubmitting(false)
}
}
return (
<div className="max-w-3xl mx-auto">
<button onClick={() => navigate('/gifts')} className="btn btn-secondary mb-6 flex items-center gap-2">
<ArrowLeft className="w-4 h-4" />
Back to Gifts
</button>
<div className="card">
<div className="flex items-center gap-3 mb-6">
<div className="bg-gradient-to-br from-primary-500 to-primary-600 p-3 rounded-lg">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold">Create New Gift</h1>
<p className="text-[#8b98a5]">Add a new gift to the catalog</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Gift ID */}
<div>
<label className="label">Gift ID *</label>
<input
type="number"
className="input"
{...register('giftId', { required: 'Gift ID is required', min: 1 })}
/>
{errors.giftId && <p className="text-red-500 text-sm mt-1">{errors.giftId.message}</p>}
</div>
{/* Title */}
<div>
<label className="label">Title</label>
<input
type="text"
className="input"
placeholder="e.g., Golden Star, Blue Diamond..."
{...register('title')}
/>
</div>
{/* Sticker Upload */}
<div>
<label className="label">Sticker Animation (JSON/TGS)</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${stickerFile ? 'border-green-500 bg-green-500/20' : 'border-[#2b5278] hover:border-primary-500'
}`}>
<input
type="file"
accept=".json,.tgs"
onChange={handleFileChange}
className="hidden"
id="sticker-upload"
/>
<label htmlFor="sticker-upload" className="cursor-pointer">
<Upload className={`w-12 h-12 mx-auto mb-2 ${stickerFile ? 'text-green-600' : 'text-[#8b98a5]'}`} />
<p className={`text-[#8b98a5] font-medium ${stickerFile ? 'text-green-400' : ''}`}>
{stickerFile ? `${stickerFile.name}` : 'Click to upload sticker'}
</p>
{stickerFile && (
<p className="text-sm text-green-600 mt-1">
{(stickerFile.size / 1024).toFixed(2)} KB
</p>
)}
<p className="text-sm text-[#8b98a5] mt-1">JSON or TGS (Max: 10MB)</p>
</label>
</div>
{/* Preview */}
{stickerPreview && (
<div className="border border-border rounded-lg p-6 flex items-center justify-center bg-bg-app shadow-inner">
<Lottie
animationData={stickerPreview}
loop={true}
style={{ width: 150, height: 150 }}
/>
</div>
)}
</div>
{/* Manual Sticker ID */}
<div className="mt-4">
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Or Enter Existing Sticker ID (Debug)</label>
<input
type="text"
className="input font-mono text-sm"
placeholder="e.g. 17673446594580884"
{...register('stickerId')}
/>
<p className="text-xs text-fg-muted mt-1">Overrides file upload if set. Use to reuse existing stickers.</p>
</div>
</div>
{/* Stars Pricing */}
{/* Stars Pricing */}
<div className={`grid grid-cols-1 ${limited ? 'md:grid-cols-3' : 'md:grid-cols-1'} gap-6`}>
<div>
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Price (Stars) *</label>
<input
type="number"
className="input text-yellow-500 font-bold"
{...register('stars', { required: true, min: 1 })}
/>
</div>
{limited && (
<>
<div className="animate-fade-in">
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Convert Stars *</label>
<input
type="number"
className="input"
{...register('convertStars', { required: 'Convert price is required for limited gifts', min: 1 })}
/>
</div>
<div className="animate-fade-in">
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Upgrade Stars</label>
<input
type="number"
className="input text-purple-400"
{...register('upgradeStars', { min: 0 })}
/>
</div>
</>
)}
</div>
{/* Gift Type */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="limited"
className="w-4 h-4 text-primary-600 rounded"
{...register('limited')}
/>
<label htmlFor="limited" className="text-sm font-medium">Limited Edition</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="birthday"
className="w-4 h-4 text-primary-600 rounded"
{...register('birthday')}
/>
<label htmlFor="birthday" className="text-sm font-medium">Birthday Gift</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="soldOut"
className="w-4 h-4 text-primary-600 rounded"
{...register('soldOut')}
/>
<label htmlFor="soldOut" className="text-sm font-medium">Mark as Sold Out</label>
</div>
</div>
{/* Limited Edition Supply */}
{limited && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h3 className="font-medium text-purple-900 mb-3">Limited Edition Settings</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Total Supply *</label>
<input
type="number"
className="input"
{...register('availabilityTotal', { min: 1 })}
/>
</div>
<div>
<label className="label">Remaining Supply</label>
<input
type="number"
className="input"
placeholder="Leave empty to use total"
{...register('availabilityRemains', { min: 0 })}
/>
</div>
</div>
</div>
)}
{/* Buttons */}
<div className="flex gap-4">
<button
type="submit"
disabled={submitting}
className="btn btn-primary flex-1"
>
{submitting ? 'Creating...' : 'Create Gift'}
</button>
<button
type="button"
onClick={() => navigate('/gifts')}
className="btn btn-secondary"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,251 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { Upload, ArrowLeft, Smile } from 'lucide-react';
import Lottie from 'lottie-react';
import pako from 'pako';
export default function CreateReaction() {
const navigate = useNavigate();
const { register, handleSubmit, watch, formState: { errors } } = useForm();
const [files, setFiles] = useState({
staticIcon: null,
appearAnimation: null,
selectAnimation: null,
activateAnimation: null,
effectAnimation: null,
aroundAnimation: null,
centerIcon: null
});
const [previews, setPreviews] = useState({});
const [submitting, setSubmitting] = useState(false);
const premium = watch('premium');
const handleFileChange = (field, e) => {
const file = e.target.files[0];
if (file) {
setFiles(prev => ({ ...prev, [field]: file }));
// Read file for preview
const reader = new FileReader();
reader.onload = (event) => {
try {
const arrayBuffer = event.target.result;
const uint8Array = new Uint8Array(arrayBuffer);
try {
const decompressed = pako.inflate(uint8Array, { to: 'string' });
const jsonData = JSON.parse(decompressed);
setPreviews(prev => ({ ...prev, [field]: jsonData }));
} catch (gzipErr) {
const text = new TextDecoder().decode(uint8Array);
const jsonData = JSON.parse(text);
setPreviews(prev => ({ ...prev, [field]: jsonData }));
}
} catch (err) {
console.error('Failed to parse JSON:', err);
}
};
reader.readAsArrayBuffer(file);
}
};
const onSubmit = async (data) => {
setSubmitting(true);
try {
const formData = new FormData();
formData.append('data', JSON.stringify(data));
// Append all animation files
Object.entries(files).forEach(([key, file]) => {
if (file) {
formData.append(key, file);
}
});
const response = await fetch('/api/reactions', {
method: 'POST',
body: formData
});
if (response.ok) {
navigate('/reactions');
} else {
const error = await response.json();
alert(`Error: ${error.error}`);
}
} catch (error) {
console.error('Create reaction error:', error);
alert('Failed to create reaction');
} finally {
setSubmitting(false);
}
};
const AnimationUpload = ({ field, label, required = false }) => (
<div>
<label className="label">
{label} {required && <span className="text-red-500">*</span>}
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className={`border-2 border-dashed rounded-lg p-4 text-center transition-colors ${
files[field] ? 'border-green-500 bg-green-500/20' : 'border-[#2b5278] hover:border-purple-500'
}`}>
<input
type="file"
accept=".json,.tgs"
onChange={(e) => handleFileChange(field, e)}
className="hidden"
id={`${field}-upload`}
/>
<label htmlFor={`${field}-upload`} className="cursor-pointer">
<Upload className={`w-10 h-10 mx-auto mb-2 ${files[field] ? 'text-green-600' : 'text-[#8b98a5]'}`} />
<p className={`text-sm font-medium ${files[field] ? 'text-green-400' : 'text-[#8b98a5]'}`}>
{files[field] ? `${files[field].name}` : 'Click to upload'}
</p>
{files[field] && (
<p className="text-xs text-green-600 mt-1">
{(files[field].size / 1024).toFixed(2)} KB
</p>
)}
<p className="text-xs text-[#8b98a5] mt-1">TGS or JSON</p>
</label>
</div>
{previews[field] && (
<div className="border-2 border-[#2b5278] rounded-lg p-4 flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-50">
<Lottie
animationData={previews[field]}
loop={true}
style={{ width: 100, height: 100 }}
/>
</div>
)}
</div>
</div>
);
return (
<div className="max-w-4xl mx-auto">
<button onClick={() => navigate('/reactions')} className="btn btn-secondary mb-6 flex items-center gap-2">
<ArrowLeft className="w-4 h-4" />
Back to Reactions
</button>
<div className="card">
<div className="flex items-center gap-3 mb-6">
<div className="bg-gradient-to-br from-purple-500 to-pink-600 p-3 rounded-lg">
<Smile className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold">Create New Reaction</h1>
<p className="text-[#8b98a5]">Add a new reaction with animations</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Emoji *</label>
<input
type="text"
className="input"
placeholder="👍"
{...register('emoji', { required: 'Emoji is required' })}
/>
{errors.emoji && <p className="text-red-500 text-sm mt-1">{errors.emoji.message}</p>}
</div>
<div>
<label className="label">Title *</label>
<input
type="text"
className="input"
placeholder="Like, Heart, Fire..."
{...register('title', { required: 'Title is required' })}
/>
{errors.title && <p className="text-red-500 text-sm mt-1">{errors.title.message}</p>}
</div>
</div>
{/* Flags */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="premium"
className="w-4 h-4 text-purple-600 rounded"
{...register('premium')}
/>
<label htmlFor="premium" className="text-sm font-medium">Premium Only</label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="inactive"
className="w-4 h-4 text-[#8b98a5] rounded"
{...register('inactive')}
/>
<label htmlFor="inactive" className="text-sm font-medium">Mark as Inactive</label>
</div>
</div>
{/* Animations */}
<div className="space-y-6">
<h3 className="text-lg font-semibold border-b pb-2">Animations</h3>
<AnimationUpload field="staticIcon" label="Static Icon" required />
<AnimationUpload field="appearAnimation" label="Appear Animation" required />
<AnimationUpload field="selectAnimation" label="Select Animation (Hover)" required />
<AnimationUpload field="activateAnimation" label="Activate Animation" required />
<AnimationUpload field="effectAnimation" label="Effect Animation (Background)" required />
<AnimationUpload field="aroundAnimation" label="Around Animation" />
<AnimationUpload field="centerIcon" label="Center Icon" />
</div>
{/* Info */}
<div className="bg-blue-50 border border-blue-500/30 rounded-lg p-4">
<h4 className="font-semibold text-blue-900 mb-2"> Animation Guide</h4>
<ul className="list-disc list-inside text-sm text-blue-300 space-y-1">
<li><strong>Static Icon:</strong> Small icon shown in reaction picker</li>
<li><strong>Appear Animation:</strong> Plays when reaction picker opens</li>
<li><strong>Select Animation:</strong> Plays on hover over reaction</li>
<li><strong>Activate Animation:</strong> Plays when reaction is clicked</li>
<li><strong>Effect Animation:</strong> Background effect during activation</li>
<li><strong>Around/Center:</strong> Optional animations for existing reactions</li>
</ul>
</div>
{/* Buttons */}
<div className="flex gap-4">
<button
type="submit"
disabled={submitting}
className="btn btn-primary flex-1"
>
{submitting ? 'Creating...' : 'Create Reaction'}
</button>
<button
type="button"
onClick={() => navigate('/reactions')}
className="btn btn-secondary"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,362 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import toast from 'react-hot-toast';
import { Bell, ArrowLeft, Save, AlertCircle, FileText, CheckCircle2 } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function CreateServiceNotification() {
const navigate = useNavigate();
const { id } = useParams();
const isEdit = !!id;
const [formData, setFormData] = useState({
type: '',
title: '',
message: '',
mediaUrl: '',
mediaType: '',
isPopup: true,
isActive: true
});
const [loading, setLoading] = useState(false);
const [loadingTemplate, setLoadingTemplate] = useState(isEdit);
useEffect(() => {
if (isEdit) {
loadTemplate();
}
}, [id]);
const loadTemplate = async () => {
try {
setLoadingTemplate(true);
const response = await fetch(`${API_URL}/api/service-notifications/${id}`);
const data = await response.json();
if (data.success) {
setFormData({
type: data.template.Type || '',
title: data.template.Title || '',
message: data.template.Message || '',
mediaUrl: data.template.MediaUrl || '',
mediaType: data.template.MediaType || '',
isPopup: data.template.IsPopup !== undefined ? data.template.IsPopup : true,
isActive: data.template.IsActive !== undefined ? data.template.IsActive : true
});
} else {
toast.error('Failed to load template');
navigate('/service-notifications');
}
} catch (error) {
console.error('Error loading template:', error);
toast.error('Error loading template');
navigate('/service-notifications');
} finally {
setLoadingTemplate(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.type || !formData.title || !formData.message) {
toast.error('Please fill in all required fields');
return;
}
try {
setLoading(true);
const url = isEdit
? `${API_URL}/api/service-notifications/${id}`
: `${API_URL}/api/service-notifications`;
const method = isEdit ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const data = await response.json();
if (data.success) {
toast.success(isEdit ? 'Template updated successfully' : 'Template created successfully');
navigate('/service-notifications');
} else {
toast.error(data.error || 'Failed to save template');
}
} catch (error) {
console.error('Error saving template:', error);
toast.error('Error saving template');
} finally {
setLoading(false);
}
};
if (loadingTemplate) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple"></div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/service-notifications')}
className="p-2 -ml-2 text-fg-muted hover:text-fg rounded-full transition-colors"
>
<ArrowLeft className="w-6 h-6" />
</button>
<div>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<Bell className="w-8 h-8 text-purple" />
{isEdit ? 'Edit' : 'Create'} Notification Template
</h1>
<p className="mt-1 text-fg-muted">
{isEdit ? 'Update' : 'Create a new'} system notification template
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Form */}
<div className="lg:col-span-2">
<form onSubmit={handleSubmit} className="bg-card border border-border rounded-xl shadow-lg p-6 space-y-6">
{/* Type */}
<div>
<label className="block text-sm font-medium text-fg mb-1.5">
Type <span className="text-red">*</span>
</label>
<input
type="text"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="input w-full"
placeholder="e.g., ads, premium_promo, system_update"
required
/>
<p className="mt-1 text-xs text-fg-muted">
Used for deduplication. Same type won't show twice within 15 minutes.
</p>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-fg mb-1.5">
Title <span className="text-red">*</span>
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="input w-full"
placeholder="e.g., Premium Discount!"
required
/>
</div>
{/* Message */}
<div>
<label className="block text-sm font-medium text-fg mb-1.5">
Message <span className="text-red">*</span>
</label>
<textarea
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
rows={4}
className="input w-full resize-none"
placeholder="e.g., 🎉 Get Premium with 50% discount! Limited time offer."
required
/>
<p className="mt-1 text-xs text-fg-muted">
Supports emoji and text formatting
</p>
</div>
{/* Media URL (Optional) */}
<div>
<label className="block text-sm font-medium text-fg mb-1.5">
Media URL (Optional)
</label>
<input
type="url"
value={formData.mediaUrl}
onChange={(e) => setFormData({ ...formData, mediaUrl: e.target.value })}
className="input w-full"
placeholder="https://example.com/image.jpg"
/>
</div>
{/* Media Type */}
{formData.mediaUrl && (
<div className="animate-fade-in">
<label className="block text-sm font-medium text-fg mb-1.5">
Media Type
</label>
<select
value={formData.mediaType}
onChange={(e) => setFormData({ ...formData, mediaType: e.target.value })}
className="input w-full"
>
<option value="">Select type</option>
<option value="photo">Photo</option>
<option value="video">Video</option>
</select>
</div>
)}
{/* Display Type */}
<div className="bg-muted/50 p-4 rounded-lg border border-border">
<label className="block text-sm font-medium text-fg mb-3">
Display Type
</label>
<div className="space-y-3">
<label className={`flex items-start gap-3 cursor-pointer p-3 rounded-lg border transition-all ${formData.isPopup ? 'bg-purple/10 border-purple text-fg' : 'border-border text-fg-muted hover:bg-muted'}`}>
<div className="relative flex items-center mt-0.5">
<input
type="radio"
checked={formData.isPopup}
onChange={() => setFormData({ ...formData, isPopup: true })}
className="sr-only"
/>
{formData.isPopup ? <CheckCircle2 className="w-5 h-5 text-purple" /> : <div className="w-5 h-5 rounded-full border border-fg-muted" />}
</div>
<div className="flex-1">
<span className="flex items-center gap-2 font-medium mb-0.5">
<AlertCircle className="w-4 h-4" /> Popup
</span>
<span className="text-sm opacity-80 block">
Show as alert/popup (user must dismiss)
</span>
</div>
</label>
<label className={`flex items-start gap-3 cursor-pointer p-3 rounded-lg border transition-all ${!formData.isPopup ? 'bg-purple/10 border-purple text-fg' : 'border-border text-fg-muted hover:bg-muted'}`}>
<div className="relative flex items-center mt-0.5">
<input
type="radio"
checked={!formData.isPopup}
onChange={() => setFormData({ ...formData, isPopup: false })}
className="sr-only"
/>
{!formData.isPopup ? <CheckCircle2 className="w-5 h-5 text-purple" /> : <div className="w-5 h-5 rounded-full border border-fg-muted" />}
</div>
<div className="flex-1">
<span className="flex items-center gap-2 font-medium mb-0.5">
<FileText className="w-4 h-4" /> Message
</span>
<span className="text-sm opacity-80 block">
Save as message from "Telegram" (user 777000)
</span>
</div>
</label>
</div>
</div>
{/* Active Status */}
<div>
<label className="flex items-center gap-3 p-3 bg-muted/30 rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
<div className="relative flex items-center">
<input
type="checkbox"
className="peer sr-only"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
/>
<div className="w-11 h-6 bg-muted rounded-full peer peer-focus:ring-2 peer-focus:ring-purple/20 dark:peer-focus:ring-purple/40 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple"></div>
</div>
<span className="text-sm font-medium text-fg">
Active (can be sent to users)
</span>
</label>
</div>
{/* Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<button
type="button"
onClick={() => navigate('/service-notifications')}
className="btn btn-secondary"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="btn btn-primary min-w-[140px]"
>
{loading ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<span>Saving...</span>
</div>
) : (
<div className="flex items-center gap-2">
<Save className="w-4 h-4" />
<span>{isEdit ? 'Update Template' : 'Create Template'}</span>
</div>
)}
</button>
</div>
</form>
</div>
{/* Examples */}
<div className="bg-card border border-border rounded-xl p-6 h-fit sticky top-6">
<h3 className="text-lg font-bold font-heading text-fg mb-4 flex items-center gap-2">
<span className="text-2xl">💡</span> Examples
</h3>
<div className="space-y-4">
<div className="p-4 bg-muted/30 rounded-lg border border-border">
<strong className="text-fg block mb-2">Premium Ad</strong>
<div className="text-sm text-fg-muted space-y-1">
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide opacity-70">Type</span>
<code className="bg-muted px-1.5 py-0.5 rounded text-purple font-mono text-xs w-fit">ads</code>
</div>
<div className="flex flex-col gap-1 mt-2">
<span className="text-xs uppercase tracking-wide opacity-70">Message</span>
<p className="italic">"🎉 Get Premium with 50% discount! Limited time offer."</p>
</div>
</div>
</div>
<div className="p-4 bg-muted/30 rounded-lg border border-border">
<strong className="text-fg block mb-2">System Announcement</strong>
<div className="text-sm text-fg-muted space-y-1">
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide opacity-70">Type</span>
<code className="bg-muted px-1.5 py-0.5 rounded text-purple font-mono text-xs w-fit">system_update</code>
</div>
<div className="flex flex-col gap-1 mt-2">
<span className="text-xs uppercase tracking-wide opacity-70">Message</span>
<p className="italic">"⚠️ Server maintenance in 10 minutes. Service will be unavailable for 5 minutes."</p>
</div>
</div>
</div>
<div className="p-4 bg-muted/30 rounded-lg border border-border">
<strong className="text-fg block mb-2">New Feature</strong>
<div className="text-sm text-fg-muted space-y-1">
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide opacity-70">Type</span>
<code className="bg-muted px-1.5 py-0.5 rounded text-purple font-mono text-xs w-fit">feature_announcement</code>
</div>
<div className="flex flex-col gap-1 mt-2">
<span className="text-xs uppercase tracking-wide opacity-70">Message</span>
<p className="italic">"🆕 New stickers and emojis added! Check them out now."</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,180 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { ArrowLeft, Package, AlertCircle } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function CreateStickerPack() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({
title: '',
short_name: '',
masks: false
});
const validateShortName = (name) => {
// Only lowercase letters, numbers, and underscores
return /^[a-z0-9_]+$/.test(name);
};
const handleSubmit = async (e) => {
e.preventDefault();
// Validation
if (!form.title.trim()) {
toast.error('Title is required');
return;
}
if (!form.short_name.trim()) {
toast.error('Short name is required');
return;
}
if (!validateShortName(form.short_name)) {
toast.error('Short name must contain only lowercase letters, numbers, and underscores');
return;
}
setLoading(true);
try {
const response = await fetch('/api/stickerpacks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(form)
});
if (response.ok) {
const data = await response.json();
toast.success('Sticker pack created successfully!');
navigate(`/stickerpacks/${data.pack.StickerSetId}`);
} else {
const errorData = await response.json();
toast.error(errorData.error || 'Failed to create pack');
}
} catch (err) {
console.error('Error creating pack:', err);
toast.error('Failed to create pack. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto space-y-6 animate-fade-in">
{/* Header */}
<div>
<Link
to="/stickerpacks"
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
>
<ArrowLeft className="w-5 h-5 mr-2" />
Back to Sticker Packs
</Link>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<Package className="w-8 h-8 text-purple" />
Create Sticker Pack
</h1>
<p className="text-fg-muted mt-1">Create a new sticker set for regular stickers (512x512 px)</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="card p-6 space-y-6">
{/* Title */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
Pack Title <span className="text-red">*</span>
</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="input w-full focus:ring-2 focus:ring-purple/50"
placeholder="My Awesome Stickers"
required
/>
<p className="text-sm text-fg-muted mt-1">Display name of your sticker pack</p>
</div>
{/* Short Name */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
Short Name <span className="text-red">*</span> (URL identifier)
</label>
<div className="flex items-center gap-2">
<span className="text-fg-muted">t.me/addstickers/</span>
<input
type="text"
value={form.short_name}
onChange={(e) => setForm({ ...form, short_name: e.target.value.toLowerCase() })}
className="input flex-1 focus:ring-2 focus:ring-purple/50"
placeholder="my_stickers_123"
pattern="[a-z0-9_]+"
required
/>
</div>
<p className="text-sm text-fg-muted mt-1">
Only lowercase letters (a-z), numbers (0-9), and underscores (_). Must be unique.
</p>
</div>
{/* Masks */}
<div>
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={form.masks}
onChange={(e) => setForm({ ...form, masks: e.target.checked })}
className="mt-1 w-4 h-4 text-purple rounded border-input focus:ring-purple/50 bg-muted"
/>
<span className="text-sm font-medium text-fg group-hover:text-purple transition-colors">Mask Stickers</span>
</label>
<p className="text-sm text-fg-muted mt-1 ml-6">
Check if this pack contains mask stickers (stickers for face masks)
</p>
</div>
{/* Info Box */}
<div className="bg-blue/5 border border-blue/20 rounded-lg p-4">
<h3 className="font-medium text-blue mb-2">📋 Sticker Requirements</h3>
<ul className="text-sm text-blue/80 space-y-1">
<li> <strong>Static:</strong> WebP or PNG (512x512 px)</li>
<li> <strong>Animated:</strong> TGS (Lottie, 512x512, max 3 sec, 60 FPS)</li>
<li> <strong>Video:</strong> WebM (VP9, 512x512, max 3 sec, no audio)</li>
<li> Maximum file size: 512 KB</li>
</ul>
</div>
{/* Submit */}
<div className="flex gap-4 pt-4">
<button
type="submit"
disabled={loading}
className="flex-1 btn btn-primary font-semibold text-lg"
>
{loading ? 'Creating...' : 'Create Pack'}
</button>
<Link
to="/stickerpacks"
className="btn btn-secondary font-semibold"
>
Cancel
</Link>
</div>
</form>
</div>
);
}
@@ -0,0 +1,198 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Gift, TrendingUp, Star, ShoppingBag, Plus, AlertCircle } from 'lucide-react'
import { statsApi } from '../lib/api'
import useGiftsStore from '../store/useGiftsStore'
export default function Dashboard() {
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
const { gifts, fetchGifts } = useGiftsStore()
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [statsRes] = await Promise.all([
statsApi.getOverview(),
fetchGifts()
])
setStats(statsRes.data)
} catch (error) {
console.error('Failed to load dashboard data:', error)
} finally {
setLoading(false)
}
}
const statCards = [
{
title: 'Total Gifts',
value: stats?.totalGifts || 0,
icon: Gift,
color: 'bg-blue-500',
trend: null,
},
{
title: 'Available Gifts',
value: stats?.availableGifts || 0,
icon: ShoppingBag,
color: 'bg-green-500/200',
trend: '+' + stats?.availableGifts || 0,
},
{
title: 'Sold Out',
value: stats?.soldOutGifts || 0,
icon: AlertCircle,
color: 'bg-red-500/200',
trend: null,
},
{
title: 'Total Sent',
value: stats?.totalSentGifts || 0,
icon: TrendingUp,
color: 'bg-purple-500',
trend: null,
},
{
title: 'Stars Earned',
value: stats?.totalStarsEarned?.toLocaleString() || '0',
icon: Star,
color: 'bg-yellow-500/200',
trend: null,
},
{
title: 'Limited Edition',
value: stats?.limitedGifts || 0,
icon: Gift,
color: 'bg-pink-500',
trend: null,
},
]
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#5288c1]"></div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-black font-heading text-fg">Dashboard</h1>
<p className="text-fg-muted font-medium mt-1">Overview of your Star Gifts catalog</p>
</div>
<Link to="/gifts/create" className="btn btn-primary gap-2">
<Plus className="w-5 h-5" />
Create New Gift
</Link>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{statCards.map((stat, index) => (
<div key={index} className="card group">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-bold text-fg-muted uppercase tracking-wider">{stat.title}</p>
<p className="text-3xl font-black font-heading text-fg mt-2 group-hover:text-accent transition-colors">{stat.value}</p>
{stat.trend && (
<p className="text-sm text-success mt-1 font-medium">{stat.trend}</p>
)}
</div>
<div className={`p-3 rounded-lg bg-bg-app border border-border group-hover:border-accent group-hover:shadow-[0_0_10px_var(--accent-glow)] transition-all`}>
<stat.icon className="w-6 h-6 text-fg group-hover:text-accent transition-colors" />
</div>
</div>
</div>
))}
</div>
{/* Recent Gifts */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold font-heading text-fg">Recent Gifts</h2>
<Link to="/gifts" className="text-accent hover:text-white text-sm font-bold tracking-wide uppercase transition-colors">
View All
</Link>
</div>
{gifts.length === 0 ? (
<div className="text-center py-12">
<Gift className="w-12 h-12 text-fg-muted mx-auto mb-3" />
<p className="text-fg-muted font-medium">No gifts created yet</p>
<Link to="/gifts/create" className="mt-4 inline-flex items-center gap-2 btn btn-primary">
<Plus className="w-4 h-4" />
Create Your First Gift
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{gifts.slice(0, 8).map((gift) => (
<Link
key={gift.GiftId}
to={`/gifts/edit/${gift.GiftId}`}
className="bg-bg-app border border-border rounded-lg p-4 hover:border-accent hover:shadow-[0_0_15px_var(--accent-glow)] transition-all group"
>
<div className="flex items-center justify-between mb-2">
<span className="text-lg font-bold text-fg group-hover:text-accent transition-colors">#{gift.GiftId}</span>
{gift.Limited && (
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 text-xs rounded-full font-bold uppercase tracking-wider">
Limited
</span>
)}
</div>
<p className="text-sm text-fg-muted mb-2 truncate font-medium">{gift.Title || 'Untitled'}</p>
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-1 text-yellow-400 font-bold">
<Star className="w-3.5 h-3.5 fill-current" />
{gift.Stars}
</span>
{gift.SoldOut && (
<span className="text-red-500 font-bold uppercase tracking-wider text-xs">Sold Out</span>
)}
</div>
</Link>
))}
</div>
)}
</div>
{/* Most Popular */}
{stats?.mostPopular && stats.mostPopular.length > 0 && (
<div className="card">
<h2 className="text-xl font-bold font-heading text-fg mb-4">Most Popular Gifts</h2>
<div className="space-y-3">
{stats.mostPopular.map((item, index) => (
<div key={item._id} className="flex items-center justify-between p-3 bg-bg-app border border-border rounded-lg hover:border-accent/50 transition-colors">
<div className="flex items-center gap-3">
<div className="bg-accent/10 text-accent border border-accent/20 w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm shadow-[0_0_10px_rgba(0,242,255,0.1)]">
{index + 1}
</div>
<span className="font-medium text-fg">Gift #{item._id}</span>
</div>
<span className="text-fg-muted font-mono">{item.count} sent</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,216 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { Upload, ArrowLeft, Sparkles } from 'lucide-react'
import { giftsApi } from '../lib/api'
import useGiftsStore from '../store/useGiftsStore'
import toast from 'react-hot-toast'
export default function EditGift() {
const { giftId } = useParams()
const navigate = useNavigate()
const { updateGift } = useGiftsStore()
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm()
const [stickerFile, setStickerFile] = useState(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const limited = watch('Limited')
useEffect(() => {
loadGift()
}, [giftId])
const loadGift = async () => {
try {
const response = await giftsApi.getById(giftId)
const gift = response.data
setValue('title', gift.Title)
setValue('stars', gift.Stars)
setValue('convertStars', gift.ConvertStars)
setValue('upgradeStars', gift.UpgradeStars)
setValue('Limited', gift.Limited)
setValue('birthday', gift.Birthday)
setValue('SoldOut', gift.SoldOut)
setValue('availabilityTotal', gift.AvailabilityTotal)
setValue('AvailabilityRemains', gift.AvailabilityRemains)
// Force infinite availability if not limited
if (!gift.Limited) {
setValue('availabilityTotal', 2000000000) // 2 Billion (effectively infinite)
setValue('AvailabilityRemains', 2000000000)
setValue('convertStars', 0)
setValue('upgradeStars', 0)
}
setLoading(false)
} catch (error) {
toast.error('Failed to load gift')
navigate('/gifts')
}
}
const onSubmit = async (rawData) => {
setSubmitting(true)
try {
// Process data to ensure correct types
const stars = Number(rawData.stars);
const data = {
...rawData,
stars: stars,
convertStars: rawData.Limited
? Number(rawData.convertStars)
: Math.floor(stars * 0.7) || 1,
upgradeStars: rawData.Limited
? Number(rawData.upgradeStars)
: (stars * 10) || 100,
}
if (rawData.Limited) {
data.availabilityTotal = Number(rawData.availabilityTotal)
data.AvailabilityRemains = rawData.AvailabilityRemains
? Number(rawData.AvailabilityRemains)
: Number(rawData.availabilityTotal)
} else {
delete data.availabilityTotal
delete data.AvailabilityRemains
}
const formData = new FormData()
formData.append('data', JSON.stringify(data))
if (stickerFile) {
formData.append('sticker', stickerFile)
}
await updateGift(parseInt(giftId), formData)
navigate('/gifts')
} catch (error) {
console.error(error)
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
)
}
return (
<div className="max-w-3xl mx-auto">
<button onClick={() => navigate('/gifts')} className="btn btn-secondary mb-6 flex items-center gap-2">
<ArrowLeft className="w-4 h-4" />
Back to Gifts
</button>
<div className="card">
<div className="flex items-center gap-3 mb-6">
<div className="bg-gradient-to-br from-primary-500 to-primary-600 p-3 rounded-lg">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold">Edit Gift #{giftId}</h1>
<p className="text-[#8b98a5]">Update gift details</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label className="label">Title</label>
<input type="text" className="input" {...register('title')} />
</div>
<div>
<label className="label">Update Sticker (JSON/TGS)</label>
<div className="border-2 border-dashed border-[#2b5278] rounded-lg p-6 text-center">
<input
type="file"
accept=".json,.tgs"
onChange={(e) => setStickerFile(e.target.files[0])}
className="hidden"
id="sticker-upload"
/>
<label htmlFor="sticker-upload" className="cursor-pointer">
<Upload className="w-12 h-12 text-[#8b98a5] mx-auto mb-2" />
<p className="text-[#8b98a5]">{stickerFile ? stickerFile.name : 'Click to upload new sticker'}</p>
</label>
</div>
</div>
<div className={`grid grid-cols-1 ${limited ? 'md:grid-cols-3' : 'md:grid-cols-1'} gap-6`}>
<div>
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Price (Stars)</label>
<input type="number" className="input text-yellow-500 font-bold" {...register('stars', { min: 1 })} />
</div>
{limited && (
<>
<div className="animate-fade-in">
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Convert Stars</label>
<input type="number" className="input" {...register('convertStars', { required: 'Required for limited gifts', min: 1 })} />
</div>
<div className="animate-fade-in">
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Upgrade Stars</label>
<input type="number" className="input text-purple-400" {...register('upgradeStars')} />
</div>
</>
)}
</div>
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<input type="checkbox" id="Limited" className="w-4 h-4 text-primary-600 rounded" {...register('Limited')} />
<label htmlFor="Limited" className="text-sm font-medium">Limited</label>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="birthday" className="w-4 h-4 text-primary-600 rounded" {...register('birthday')} />
<label htmlFor="birthday" className="text-sm font-medium">Birthday</label>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="SoldOut" className="w-4 h-4 text-primary-600 rounded" {...register('SoldOut')} />
<label htmlFor="SoldOut" className="text-sm font-medium">Sold Out</label>
</div>
</div>
{limited && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h3 className="font-medium text-purple-900 mb-3">Limited Edition Settings</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Total Supply</label>
<input type="number" className="input" {...register('availabilityTotal')} />
</div>
<div>
<label className="label">Remaining</label>
<input type="number" className="input" {...register('AvailabilityRemains')} />
</div>
</div>
</div>
)}
<div className="flex gap-4">
<button type="submit" disabled={submitting} className="btn btn-primary flex-1">
{submitting ? 'Saving...' : 'Save Changes'}
</button>
<button type="button" onClick={() => navigate('/gifts')} className="btn btn-secondary">
Cancel
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,234 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Package, Plus, Search, ExternalLink, Trash2, Edit, FileArchive, Database } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function EmojiPacks() {
const [packs, setPacks] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
useEffect(() => {
fetchPacks();
}, [currentPage, searchTerm]);
const fetchPacks = async () => {
try {
setLoading(true);
const response = await fetch(
`/api/emojipacks?page=${currentPage}&limit=20&search=${searchTerm}&emojis=true`
);
const data = await response.json();
setPacks(data.packs || []);
setTotalPages(data.pagination?.totalPages || 1);
} catch (error) {
console.error('Error fetching emoji packs:', error);
toast.error('Failed to load emoji packs');
setPacks([]);
} finally {
setLoading(false);
}
};
const deletePack = async (packId, packName) => {
if (!confirm(`Delete emoji pack "${packName}"? This will delete all emojis in the pack.`)) {
return;
}
try {
const response = await fetch(`/api/emojipacks/${packId}`, {
method: 'DELETE'
});
if (response.ok) {
toast.success('Pack deleted successfully');
fetchPacks();
} else {
toast.error('Failed to delete pack');
}
} catch (error) {
console.error('Error deleting pack:', error);
toast.error('Error deleting pack');
}
};
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<Package className="w-8 h-8 text-purple" />
Custom Emoji Packs
</h1>
<p className="text-fg-muted mt-1">Manage custom emoji sticker sets</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
to="/emojipacks/backup"
className="btn btn-secondary flex items-center gap-2"
>
<Database className="w-5 h-5" />
Backup/Restore
</Link>
<Link
to="/emojipacks/bulk-upload"
className="btn bg-success/10 text-success hover:bg-success/20 border-success/20 flex items-center gap-2"
>
<FileArchive className="w-5 h-5" />
Bulk Upload ZIP
</Link>
<Link
to="/emojipacks/create"
className="btn btn-primary flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Create Pack
</Link>
</div>
</div>
{/* Search */}
<div className="card p-4">
<div className="relative max-w-md">
<Search className="absolute left-3 top-3 w-5 h-5 text-fg-muted" />
<input
type="text"
placeholder="Search by title or short name..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="input pl-10 w-full"
/>
</div>
</div>
{/* Packs Grid */}
{loading ? (
<div className="flex justify-center items-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple"></div>
</div>
) : packs.length === 0 ? (
<div className="text-center py-20 card">
<Package className="w-16 h-16 text-fg-muted mx-auto mb-4" />
<p className="text-fg-muted text-lg">No emoji packs found</p>
<Link
to="/emojipacks/create"
className="mt-6 btn btn-primary inline-flex"
>
<Plus className="w-5 h-5 mr-2" />
Create First Pack
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{packs.map((pack) => (
<div
key={pack.StickerSetId || pack.stickerset_id}
className="card p-6 hover:border-purple/50 transition-colors group"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold text-fg group-hover:text-purple transition-colors">
{pack.Title || pack.title}
</h3>
<p className="text-sm text-fg-muted">@{pack.ShortName || pack.short_name}</p>
</div>
<div className="flex gap-2">
<Link
to={`/emojipacks/${pack.StickerSetId || pack.stickerset_id}/edit`}
className="p-2 text-fg-muted hover:text-blue hover:bg-blue/10 rounded-lg transition-colors"
>
<Edit className="w-5 h-5" />
</Link>
<button
onClick={() => deletePack(pack.StickerSetId || pack.stickerset_id, pack.Title || pack.title)}
className="p-2 text-fg-muted hover:text-red hover:bg-red/10 rounded-lg transition-colors"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
<div className="space-y-3 mb-6 bg-muted/30 p-4 rounded-lg">
<div className="flex items-center justify-between text-sm">
<span className="text-fg-muted">Emojis</span>
<span className="font-semibold text-fg">{pack.emoji_count || pack.Count || 0}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-fg-muted">Text Color</span>
<span className={(pack.TextColor ?? pack.text_color) ? 'text-success' : 'text-fg-muted'}>
{(pack.TextColor ?? pack.text_color) ? 'Yes' : 'No'}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-fg-muted">Status</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${(pack.Archived ?? pack.archived)
? 'bg-red/10 text-red'
: 'bg-success/10 text-success'
}`}>
{(pack.Archived ?? pack.archived) ? 'Archived' : 'Active'}
</span>
</div>
</div>
<div className="flex gap-3">
<Link
to={`/emojipacks/${pack.StickerSetId || pack.stickerset_id}`}
className="flex-1 btn btn-secondary text-center justify-center"
>
Manage
</Link>
<a
href={`https://t.me/addemoji/${pack.ShortName || pack.short_name}`}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost px-3 text-fg-muted hover:text-blue"
title="Open in Telegram"
>
<ExternalLink className="w-5 h-5" />
</a>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2 pt-6">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="btn btn-secondary disabled:opacity-50"
>
Previous
</button>
<span className="btn btn-ghost cursor-default">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="btn btn-secondary disabled:opacity-50"
>
Next
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,309 @@
import { useState } from 'react'
import { Download, Upload, AlertCircle, CheckCircle, Loader } from 'lucide-react'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
export default function EmojiPacksBackup() {
const [importing, setImporting] = useState(false)
const [exporting, setExporting] = useState(false)
const [result, setResult] = useState(null)
const [error, setError] = useState(null)
const handleExport = async () => {
try {
setExporting(true)
setError(null)
setResult(null)
const response = await fetch(`${API_URL}/api/emojipacks/export`)
if (!response.ok) {
throw new Error('Export failed')
}
// Get filename from Content-Disposition header
const contentDisposition = response.headers.get('Content-Disposition')
const filenameMatch = contentDisposition?.match(/filename="(.+)"/)
const filename = filenameMatch ? filenameMatch[1] : `emoji-packs-export-${Date.now()}.json`
// Download file
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
setResult({
type: 'export',
message: 'Export successful! File downloaded.'
})
} catch (err) {
setError(err.message)
} finally {
setExporting(false)
}
}
const handleImportMetadata = async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
setImporting(true)
setError(null)
setResult(null)
const formData = new FormData()
formData.append('file', file)
const response = await fetch(`${API_URL}/api/emojipacks/import/metadata`, {
method: 'POST',
body: formData
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Import failed')
}
setResult({
type: 'metadata',
data
})
} catch (err) {
setError(err.message)
} finally {
setImporting(false)
e.target.value = ''
}
}
const handleImportFiles = async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
setImporting(true)
setError(null)
const formData = new FormData()
formData.append('file', file)
const response = await fetch(`${API_URL}/api/emojipacks/import/files`, {
method: 'POST',
body: formData
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Import failed')
}
setResult({
type: 'files',
data
})
} catch (err) {
setError(err.message)
} finally {
setImporting(false)
e.target.value = ''
}
}
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-2">Emoji Packs Backup</h1>
<p className="text-[#8b98a5]">
Export all emoji packs to JSON file or import them back after docker-compose down
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Export Card */}
<div className="card rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<Download className="w-6 h-6 text-blue-400" />
<h2 className="text-xl font-semibold">Export Packs</h2>
</div>
<p className="text-[#8b98a5] mb-4">
Download all emoji packs with metadata and TGS files as a ZIP archive.
Use this before running docker-compose down.
</p>
<button
onClick={handleExport}
disabled={exporting}
className="w-full bg-[#5288c1] text-white px-4 py-2 rounded hover:bg-[#3d5a7a] disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{exporting ? (
<>
<Loader className="w-4 h-4 animate-spin" />
Exporting...
</>
) : (
<>
<Download className="w-4 h-4" />
Export All Packs
</>
)}
</button>
</div>
{/* Import Card */}
<div className="card rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<Upload className="w-6 h-6 text-green-600" />
<h2 className="text-xl font-semibold">Import Packs (2 Steps)</h2>
</div>
<p className="text-[#8b98a5] mb-4">
Step 1: Upload metadata.json<br/>
Step 2: Upload ZIP with files folder
</p>
<div className="space-y-3">
<label className="w-full bg-[#5288c1] text-white px-4 py-2 rounded hover:bg-[#3d5a7a] disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center gap-2 cursor-pointer">
{importing ? (
<>
<Loader className="w-4 h-4 animate-spin" />
Importing...
</>
) : (
<>
<Upload className="w-4 h-4" />
1. Import Metadata (JSON)
</>
)}
<input
type="file"
accept=".json"
onChange={handleImportMetadata}
disabled={importing}
className="hidden"
/>
</label>
<label className="w-full bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center gap-2 cursor-pointer">
{importing ? (
<>
<Loader className="w-4 h-4 animate-spin" />
Importing...
</>
) : (
<>
<Upload className="w-4 h-4" />
2. Import Files (ZIP)
</>
)}
<input
type="file"
accept=".zip"
onChange={handleImportFiles}
disabled={importing}
className="hidden"
/>
</label>
</div>
</div>
</div>
{/* Results */}
{result && (
<div className="mt-6 bg-green-500/20 border border-green-500/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
<div className="flex-1">
<h3 className="font-semibold text-green-900 mb-2">
{result.type === 'export' && 'Export Successful'}
{result.type === 'metadata' && 'Metadata Imported'}
{result.type === 'files' && 'Files Imported'}
</h3>
{result.type === 'export' && (
<p className="text-green-400">{result.message}</p>
)}
{result.type === 'metadata' && (
<div className="text-green-400 space-y-1">
<p> Imported {result.data.imported.packs} packs</p>
<p> Imported {result.data.imported.documents} documents</p>
{result.data.skipped.packs > 0 && (
<p> Skipped {result.data.skipped.packs} existing packs</p>
)}
<p className="mt-2 font-semibold"> Now upload TGS files (Step 2)</p>
</div>
)}
{result.type === 'files' && (
<div className="text-green-400 space-y-1">
<p> Uploaded {result.data.uploaded} TGS files</p>
{result.data.skipped > 0 && (
<p> Skipped {result.data.skipped} existing files</p>
)}
<p className="mt-2 font-semibold">🎉 Import complete!</p>
</div>
)}
</div>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="mt-6 bg-red-500/20 border border-red-500/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-400 mt-0.5" />
<div>
<h3 className="font-semibold text-red-900 mb-1">Error</h3>
<p className="text-red-700">{error}</p>
</div>
</div>
</div>
)}
{/* Instructions */}
<div className="mt-8 bg-blue-50 border border-blue-500/30 rounded-lg p-6">
<h3 className="font-semibold text-blue-900 mb-3">📝 How to use:</h3>
<ol className="list-decimal list-inside space-y-2 text-blue-300">
<li>
<strong>Before docker-compose down:</strong> Click "Export All Packs" to download a ZIP backup
</li>
<li>
<strong>After restarting:</strong> Click "Import from ZIP" and select your backup file
</li>
<li>
All packs, documents, and TGS files will be restored automatically
</li>
<li>
Existing packs and files will be skipped (no duplicates)
</li>
</ol>
<div className="mt-4 p-3 bg-yellow-500/20 border border-yellow-500/30 rounded">
<p className="text-yellow-400 text-sm">
<strong> Complete Backup:</strong> The ZIP file contains both MongoDB data (metadata)
and MinIO files (TGS animations). You can safely delete Docker volumes after export.
</p>
</div>
</div>
</div>
)
}
@@ -0,0 +1,78 @@
/* Featured Sticker Packs Styles */
.featured-pack-item {
transition: all 0.2s ease;
}
.featured-pack-item:hover {
transform: translateX(4px);
}
.featured-pack-item .grip-handle {
cursor: grab;
}
.featured-pack-item .grip-handle:active {
cursor: grabbing;
}
/* Drag and drop styles */
.featured-pack-item.dragging {
opacity: 0.5;
transform: scale(0.98);
}
.featured-pack-item.drag-over {
border-color: #3b82f6 !important;
background-color: rgba(59, 130, 246, 0.1);
}
/* Modal animations */
.modal-backdrop {
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-content {
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Button hover effects */
.reorder-button {
transition: all 0.2s ease;
}
.reorder-button:hover:not(:disabled) {
transform: scale(1.1);
}
.reorder-button:active:not(:disabled) {
transform: scale(0.95);
}
/* Info box */
.info-box code {
background-color: rgba(59, 130, 246, 0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
@@ -0,0 +1,269 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Star, ArrowLeft, Plus, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
export default function FeaturedStickerPacks() {
const [featuredPacks, setFeaturedPacks] = useState([]);
const [allPacks, setAllPacks] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
useEffect(() => {
fetchFeaturedPacks();
fetchAllPacks();
}, []);
const fetchFeaturedPacks = async () => {
try {
setLoading(true);
const response = await fetch('/api/stickerpacks/featured');
const data = await response.json();
setFeaturedPacks(data.packs || []);
} catch (error) {
console.error('Error fetching featured packs:', error);
} finally {
setLoading(false);
}
};
const fetchAllPacks = async () => {
try {
const response = await fetch('/api/stickerpacks?limit=100');
const data = await response.json();
setAllPacks(data.packs || []);
} catch (error) {
console.error('Error fetching all packs:', error);
}
};
const toggleFeatured = async (stickerSetId, isFeatured) => {
try {
const response = await fetch(`/api/stickerpacks/${stickerSetId}/featured`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
isFeatured: !isFeatured,
featuredOrder: isFeatured ? 0 : (featuredPacks.length + 1)
})
});
if (response.ok) {
fetchFeaturedPacks();
fetchAllPacks();
} else {
alert('Failed to update featured status');
}
} catch (error) {
console.error('Error toggling featured:', error);
alert('Error updating featured status');
}
};
const moveUp = async (pack, index) => {
if (index === 0) return;
const newOrder = [...featuredPacks];
[newOrder[index], newOrder[index - 1]] = [newOrder[index - 1], newOrder[index]];
await reorderPacks(newOrder);
};
const moveDown = async (pack, index) => {
if (index === featuredPacks.length - 1) return;
const newOrder = [...featuredPacks];
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
await reorderPacks(newOrder);
};
const reorderPacks = async (newOrder) => {
try {
const updates = newOrder.map((pack, idx) => ({
stickerSetId: pack.StickerSetId,
featuredOrder: idx + 1
}));
const response = await fetch('/api/stickerpacks/featured/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ packs: updates })
});
if (response.ok) {
fetchFeaturedPacks();
} else {
alert('Failed to reorder packs');
}
} catch (error) {
console.error('Error reordering:', error);
alert('Error reordering packs');
}
};
const availablePacks = allPacks.filter(
pack => !featuredPacks.some(fp => fp.StickerSetId === pack.StickerSetId)
);
return (
<div className="p-6 max-w-6xl mx-auto">
{/* Header */}
<div className="mb-6">
<Link
to="/stickerpacks"
className="inline-flex items-center text-blue-400 hover:text-blue-300 mb-4"
>
<ArrowLeft className="w-5 h-5 mr-2" />
Back to Sticker Packs
</Link>
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-white flex items-center gap-2">
<Star className="w-8 h-8 text-yellow-500" />
Featured Sticker Packs
</h1>
<p className="text-[#8b98a5] mt-1">
Manage featured regular sticker packs shown to users
</p>
</div>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Add Featured Pack
</button>
</div>
</div>
{/* Info Box */}
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-blue-400 mb-2"> About Featured Sticker Packs</h3>
<ul className="text-sm text-[#8b98a5] space-y-1">
<li> Featured packs are shown in the sticker panel for all users</li>
<li> Only <strong>regular stickers</strong> (not custom emoji) can be featured</li>
<li> Order matters - use buttons to reorder</li>
<li> Client calls <code>messages.getFeaturedStickers</code> to get this list</li>
</ul>
</div>
{/* Featured Packs List */}
<div className="card rounded-lg p-6">
<h2 className="text-xl font-bold text-white mb-4">
Featured Packs ({featuredPacks.length})
</h2>
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-[#8b98a5]">Loading...</p>
</div>
) : featuredPacks.length === 0 ? (
<div className="text-center py-8 text-[#8b98a5]">
<Star className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No featured packs yet</p>
<button
onClick={() => setShowAddModal(true)}
className="mt-4 text-blue-400 hover:text-blue-300"
>
Add your first featured pack
</button>
</div>
) : (
<div className="space-y-2">
{featuredPacks.map((pack, index) => (
<div
key={pack.StickerSetId}
className="flex items-center gap-3 p-4 bg-[#0e1621] rounded-lg border border-[#2b5278] hover:border-blue-500/50 transition-colors"
>
<GripVertical className="w-5 h-5 text-[#8b98a5]" />
<div className="flex-1">
<h3 className="font-semibold text-white">{pack.Title}</h3>
<p className="text-sm text-[#8b98a5]">
@{pack.ShortName} {pack.Count} stickers Order: {pack.FeaturedOrder}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => moveUp(pack, index)}
disabled={index === 0}
className="p-2 text-[#8b98a5] hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
title="Move up"
>
<ChevronUp className="w-5 h-5" />
</button>
<button
onClick={() => moveDown(pack, index)}
disabled={index === featuredPacks.length - 1}
className="p-2 text-[#8b98a5] hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
title="Move down"
>
<ChevronDown className="w-5 h-5" />
</button>
<button
onClick={() => toggleFeatured(pack.StickerSetId, true)}
className="p-2 text-red-400 hover:text-red-300"
title="Remove from featured"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Add Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[#17212b] rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<h2 className="text-2xl font-bold text-white mb-4">Add Featured Pack</h2>
{availablePacks.length === 0 ? (
<div className="text-center py-8 text-[#8b98a5]">
<p>All packs are already featured</p>
</div>
) : (
<div className="space-y-2">
{availablePacks.map(pack => (
<div
key={pack.StickerSetId}
className="flex items-center justify-between p-4 bg-[#0e1621] rounded-lg border border-[#2b5278] hover:border-blue-500/50"
>
<div>
<h3 className="font-semibold text-white">{pack.Title}</h3>
<p className="text-sm text-[#8b98a5]">
@{pack.ShortName} {pack.Count} stickers
</p>
</div>
<button
onClick={() => {
toggleFeatured(pack.StickerSetId, false);
setShowAddModal(false);
}}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Add
</button>
</div>
))}
</div>
)}
<div className="mt-6 flex justify-end">
<button
onClick={() => setShowAddModal(false)}
className="px-4 py-2 bg-[#2b5278] text-white rounded-lg hover:bg-[#3d5a7a]"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,448 @@
import { useState, useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { Search, Info, Shield, Filter, User, Calendar, CheckCircle2, XCircle, AlertTriangle, Snowflake } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
const FrozenAccounts = () => {
const [frozenAccounts, setFrozenAccounts] = useState([]);
const [appeals, setAppeals] = useState([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('accounts'); // 'accounts' or 'appeals'
const [showFreezeModal, setShowFreezeModal] = useState(false);
const [freezeForm, setFreezeForm] = useState({
userId: '',
reason: 4,
durationDays: 7,
note: ''
});
const reasons = {
1: 'Spam',
2: 'Malicious Links',
3: 'Mass Reports',
4: 'ToS Violation',
99: 'Other'
};
const statuses = {
1: { label: 'Active', color: 'red' },
2: { label: 'Appeal Pending', color: 'yellow' },
3: { label: 'Approved', color: 'green' },
4: { label: 'Rejected', color: 'gray' },
5: { label: 'Expired', color: 'gray' }
};
useEffect(() => {
fetchData();
}, [activeTab]);
const fetchData = async () => {
setLoading(true);
try {
if (activeTab === 'accounts') {
// In a real scenario, this endpoint might need to be adjusted or proxied
const response = await fetch(`${API_URL}/frozen-accounts`);
const data = await response.json();
if (data.success) {
setFrozenAccounts(data.data);
}
} else {
const response = await fetch(`${API_URL}/frozen-accounts/appeals`);
const data = await response.json();
if (data.success) {
setAppeals(data.data);
}
}
} catch (error) {
toast.error('Error loading data');
console.error(error);
} finally {
setLoading(false);
}
};
const handleFreezeAccount = async (e) => {
e.preventDefault();
try {
const response = await fetch(`${API_URL}/frozen-accounts/freeze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(freezeForm)
});
const data = await response.json();
if (data.success) {
toast.success('Account frozen successfully');
setShowFreezeModal(false);
setFreezeForm({ userId: '', reason: 4, durationDays: 7, note: '' });
fetchData();
} else {
toast.error(data.error || 'Failed to freeze account');
}
} catch (error) {
toast.error('Error freezing account');
console.error(error);
}
};
const handleUnfreeze = async (userId) => {
if (!confirm(`Unfreeze account for user ${userId}?`)) return;
try {
const response = await fetch(`${API_URL}/frozen-accounts/unfreeze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
const data = await response.json();
if (data.success) {
toast.success('Account unfrozen successfully');
fetchData();
} else {
toast.error(data.error || 'Failed to unfreeze account');
}
} catch (error) {
toast.error('Error unfreezing account');
console.error(error);
}
};
const handleReviewAppeal = async (appealId, status) => {
const statusText = status === 2 ? 'approve' : 'reject';
if (!confirm(`${statusText.charAt(0).toUpperCase() + statusText.slice(1)} this appeal?`)) return;
try {
const response = await fetch(`${API_URL}/frozen-accounts/appeals/${appealId}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, moderatorUserId: 777000 })
});
const data = await response.json();
if (data.success) {
toast.success(data.message);
fetchData();
} else {
toast.error(data.error || 'Failed to review appeal');
}
} catch (error) {
toast.error('Error reviewing appeal');
console.error(error);
}
};
const formatDate = (timestamp) => {
if (!timestamp) return 'N/A';
const date = new Date(timestamp * 1000);
return date.toLocaleString();
};
return (
<div className="space-y-6 animate-fade-in">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<div className="p-2.5 bg-blue/10 rounded-xl rounded-tr-none">
<Snowflake className="w-8 h-8 text-blue" />
</div>
Frozen Accounts Management
</h1>
<p className="text-fg-muted mt-2">Manage frozen accounts and review appeals</p>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-border space-x-6">
<button
onClick={() => setActiveTab('accounts')}
className={`pb-3 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${activeTab === 'accounts'
? 'border-blue text-blue'
: 'border-transparent text-fg-muted hover:text-fg'
}`}
>
<Shield className="w-4 h-4" />
Frozen Accounts ({frozenAccounts.length})
</button>
<button
onClick={() => setActiveTab('appeals')}
className={`pb-3 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${activeTab === 'appeals'
? 'border-blue text-blue'
: 'border-transparent text-fg-muted hover:text-fg'
}`}
>
<Info className="w-4 h-4" />
Appeals ({appeals.length})
</button>
</div>
{/* Freeze Account Button */}
{activeTab === 'accounts' && (
<div className="flex justify-end">
<button
onClick={() => setShowFreezeModal(true)}
className="btn bg-red text-white hover:bg-red/90 flex items-center gap-2"
>
<Snowflake className="w-4 h-4" />
Freeze Account
</button>
</div>
)}
{/* Content */}
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
) : (
<>
{activeTab === 'accounts' ? (
<div className="card border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">User ID</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">Reason</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">Status</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">Frozen Since</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">Until</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{frozenAccounts.length === 0 ? (
<tr>
<td colSpan="6" className="px-6 py-12 text-center text-fg-muted">
<div className="flex flex-col items-center gap-3">
<div className="w-12 h-12 bg-muted rounded-full flex items-center justify-center">
<Snowflake className="w-6 h-6 text-fg-muted" />
</div>
<p>No frozen accounts</p>
</div>
</td>
</tr>
) : (
frozenAccounts.map((account) => (
<tr key={account._id} className="hover:bg-muted/30 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-fg">
{account.UserId}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-fg-muted">
{reasons[account.Reason] || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2.5 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border ${account.Status === 1 ? 'bg-red/10 text-red border-red/20' :
account.Status === 2 ? 'bg-yellow/10 text-yellow border-yellow/20' :
account.Status === 3 ? 'bg-success/10 text-success border-success/20' :
'bg-muted text-fg-muted border-border'
}`}>
{statuses[account.Status]?.label || 'Unknown'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-fg-muted font-mono">
{formatDate(account.FreezeSinceDate)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-fg-muted font-mono">
{formatDate(account.FreezeUntilDate)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{account.Status === 1 ? (
<button
onClick={() => handleUnfreeze(account.UserId)}
className="text-success hover:text-success/80 flex items-center gap-1 transition-colors"
>
<CheckCircle2 className="w-4 h-4" />
Unfreeze
</button>
) : (
<span className="text-fg-muted opacity-50">
{account.Status === 3 ? 'Already unfrozen' : '-'}
</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
) : (
<div className="grid gap-6">
{appeals.length === 0 ? (
<div className="card p-12 text-center text-fg-muted border-dashed">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
<Info className="w-8 h-8 text-fg-muted" />
</div>
No appeals yet
</div>
) : (
appeals.map((appeal) => (
<div key={appeal._id} className="card p-6 border-border hover:border-purple/50 transition-colors">
<div className="flex flex-col md:flex-row md:justify-between md:items-start mb-4 gap-4">
<div>
<h3 className="text-lg font-bold text-fg flex items-center gap-2">
Appeal #{appeal.AppealId}
</h3>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-sm text-fg-muted">
<span className="flex items-center gap-1">
<User className="w-3.5 h-3.5" />
ID: {appeal.UserId}
</span>
<span className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-fg-muted"></span>
Name: {appeal.UserName}
</span>
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
Submitted: {new Date(appeal.SubmittedDate).toLocaleString()}
</span>
</div>
</div>
<span className={`px-3 py-1 text-xs font-semibold rounded-full border w-fit ${appeal.Status === 1 ? 'bg-yellow/10 text-yellow border-yellow/20' :
appeal.Status === 2 ? 'bg-success/10 text-success border-success/20' :
'bg-red/10 text-red border-red/20'
}`}>
{appeal.Status === 1 ? '⏳ Pending' : appeal.Status === 2 ? '✅ Approved' : '❌ Rejected'}
</span>
</div>
<div className="mb-4 bg-muted/30 p-4 rounded-lg border border-border">
<p className="text-xs font-bold text-fg-muted uppercase tracking-wider mb-2">Appeal Text</p>
<p className="text-sm text-fg">{appeal.AppealText}</p>
</div>
{appeal.Status === 1 && (
<div className="flex gap-3">
<button
onClick={() => handleReviewAppeal(appeal.AppealId, 2)}
className="btn bg-success/10 text-success border border-success/20 hover:bg-success/20 flex items-center gap-2"
>
<CheckCircle2 className="w-4 h-4" />
Approve
</button>
<button
onClick={() => handleReviewAppeal(appeal.AppealId, 3)}
className="btn bg-red/10 text-red border border-red/20 hover:bg-red/20 flex items-center gap-2"
>
<XCircle className="w-4 h-4" />
Reject
</button>
</div>
)}
{appeal.ReviewNote && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-xs font-bold text-fg-muted uppercase tracking-wider mb-1">Review Note</p>
<p className="text-sm text-fg-muted italic">{appeal.ReviewNote}</p>
</div>
)}
</div>
))
)}
</div>
)}
</>
)}
{/* Freeze Modal */}
{showFreezeModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in">
<div className="bg-card w-full max-w-md rounded-2xl border border-border shadow-2xl p-6 m-4 animate-scale-in">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-heading font-bold text-fg flex items-center gap-2">
<Snowflake className="w-5 h-5 text-red" />
Freeze Account
</h2>
<button onClick={() => setShowFreezeModal(false)} className="text-fg-muted hover:text-fg">
<XCircle className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleFreezeAccount} className="space-y-4">
<div>
<label className="block text-sm font-medium text-fg mb-1.5">
User ID <span className="text-red">*</span>
</label>
<input
type="number"
value={freezeForm.userId}
onChange={(e) => setFreezeForm({ ...freezeForm, userId: e.target.value })}
className="input w-full"
placeholder="Enter User ID"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-fg mb-1.5">
Reason
</label>
<select
value={freezeForm.reason}
onChange={(e) => setFreezeForm({ ...freezeForm, reason: parseInt(e.target.value) })}
className="input w-full"
>
{Object.entries(reasons).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-fg mb-1.5">
Duration (days)
</label>
<input
type="number"
value={freezeForm.durationDays}
onChange={(e) => setFreezeForm({ ...freezeForm, durationDays: parseInt(e.target.value) })}
className="input w-full"
min="1"
max="365"
/>
</div>
<div>
<label className="block text-sm font-medium text-fg mb-1.5">
Note (optional)
</label>
<textarea
value={freezeForm.note}
onChange={(e) => setFreezeForm({ ...freezeForm, note: e.target.value })}
className="input w-full resize-none"
rows="3"
placeholder="Add internal note..."
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setShowFreezeModal(false)}
className="btn btn-secondary flex-1"
>
Cancel
</button>
<button
type="submit"
className="btn bg-red text-white hover:bg-red/90 flex-1"
>
Confirm Freeze
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default FrozenAccounts;
@@ -0,0 +1,334 @@
import { useState, useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { Snowflake, Upload, FileJson, Save, Info, CheckCircle2, AlertTriangle, File } from 'lucide-react';
export default function FrozenIconSettings() {
const [settings, setSettings] = useState({
type: 'emoji',
emoji: '❄️',
documentId: null
});
const [loading, setLoading] = useState(false);
const [file, setFile] = useState(null);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const response = await fetch('http://localhost:3001/api/users/settings/frozen-icon');
const data = await response.json();
setSettings(data);
} catch (error) {
console.error('Failed to load settings:', error);
toast.error('Failed to load settings');
}
};
const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
if (selectedFile && selectedFile.name.endsWith('.json')) {
setFile(selectedFile);
} else {
toast.error('Please select a .json file');
setFile(null);
e.target.value = null; // Reset input
}
};
const handleUpload = async () => {
if (!file) {
toast.error('Please select a file first');
return;
}
setLoading(true);
const toastId = toast.loading('Processing animation...');
try {
// Read JSON file content
const reader = new FileReader();
reader.onload = async (e) => {
try {
const jsonContent = JSON.parse(e.target.result);
// Validate Lottie JSON structure
if (!jsonContent.v || !jsonContent.layers) {
toast.error('Invalid Lottie JSON: missing required fields', { id: toastId });
setLoading(false);
return;
}
console.log('✅ Valid Lottie JSON:', {
version: jsonContent.v,
width: jsonContent.w,
height: jsonContent.h,
layers: jsonContent.layers?.length
});
// Step 1: Upload JSON as document to Telegram
const formData = new FormData();
formData.append('file', file);
formData.append('type', 'animation');
formData.append('title', 'Frozen Account Animation');
const uploadResponse = await fetch('http://localhost:3001/api/upload/document', {
method: 'POST',
body: formData
});
const uploadData = await uploadResponse.json();
if (!uploadData.success) {
toast.error('Failed to upload to Telegram: ' + (uploadData.error || 'Unknown error'), { id: toastId });
setLoading(false);
return;
}
// Step 2: Save document ID as global frozen animation setting
const response = await fetch('http://localhost:3001/api/users/settings/frozen-icon', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'animation',
emoji: settings.emoji,
documentId: uploadData.documentId
})
});
const data = await response.json();
if (data.success) {
toast.success(`Animation uploaded successfully! ID: ${uploadData.documentId}`, { id: toastId });
setSettings({ ...settings, type: 'animation', documentId: uploadData.documentId });
setFile(null);
} else {
toast.error('Failed to save settings: ' + (data.error || 'Unknown error'), { id: toastId });
}
} catch (error) {
console.error('JSON parse error:', error);
toast.error('Invalid JSON file: ' + error.message, { id: toastId });
}
setLoading(false);
};
reader.readAsText(file);
} catch (error) {
toast.error('Upload failed: ' + error.message, { id: toastId });
setLoading(false);
}
};
const handleUseEmoji = async () => {
setLoading(true);
const toastId = toast.loading('Saving emoji settings...');
try {
const response = await fetch('http://localhost:3001/api/users/settings/frozen-icon', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'emoji',
emoji: settings.emoji,
documentId: null
})
});
const data = await response.json();
if (data.success) {
toast.success('Switched to emoji mode', { id: toastId });
setSettings({ ...settings, type: 'emoji', documentId: null });
} else {
toast.error('Failed to update settings', { id: toastId });
}
} catch (error) {
toast.error('Update failed: ' + error.message, { id: toastId });
}
setLoading(false);
};
return (
<div className="space-y-6 max-w-5xl mx-auto animate-fade-in">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<div className="p-2.5 bg-blue/10 rounded-xl rounded-tr-none">
<Snowflake className="w-8 h-8 text-blue" />
</div>
Frozen Account Icon
</h1>
<p className="text-fg-muted mt-2">Customize the icon shown on frozen accounts</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Current Settings Card */}
<div className="card lg:col-span-2">
<h2 className="text-xl font-heading font-bold text-fg mb-6 flex items-center gap-2">
<Info className="w-5 h-5 text-accent" />
Current Configuration
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-muted/30 rounded-xl border border-border">
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Type</strong>
<div className="flex items-center gap-2">
{settings.type === 'emoji' ? (
<span className="p-1 bg-yellow/10 rounded text-yellow">
<Snowflake className="w-4 h-4" />
</span>
) : (
<span className="p-1 bg-blue/10 rounded text-blue">
<FileJson className="w-4 h-4" />
</span>
)}
<span className="text-fg font-medium capitalize">{settings.type}</span>
</div>
</div>
<div className="p-4 bg-muted/30 rounded-xl border border-border">
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Emoji</strong>
<span className="text-3xl filter drop-shadow-sm">{settings.emoji}</span>
</div>
{settings.documentId && (
<div className="p-4 bg-muted/30 rounded-xl border border-border md:col-span-1">
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Animation ID</strong>
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20 font-mono">
{settings.documentId}
</span>
</div>
)}
</div>
</div>
{/* Upload Animation Card */}
<div className="card h-full">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-purple/10 rounded-lg">
<FileJson className="w-5 h-5 text-purple" />
</div>
<h2 className="text-xl font-heading font-bold text-fg">Upload Animation</h2>
</div>
<p className="text-fg-muted mb-6 text-sm">
Upload a Lottie JSON animation file to use instead of a static emoji.
</p>
<div className="space-y-4">
<div className="relative group">
<input
type="file"
accept=".json"
onChange={handleFileChange}
disabled={loading}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed z-10"
/>
<div className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-colors ${file ? 'border-success bg-success/5' : 'border-border group-hover:border-accent group-hover:bg-accent/5'
}`}>
{file ? (
<>
<FileJson className="w-10 h-10 text-success mb-3" />
<p className="text-fg font-medium">{file.name}</p>
<p className="text-fg-muted text-xs mt-1">{(file.size / 1024).toFixed(2)} KB</p>
</>
) : (
<>
<Upload className="w-10 h-10 text-fg-muted mb-3 group-hover:text-accent transition-colors" />
<p className="text-fg font-medium">Click to upload JSON</p>
<p className="text-fg-muted text-xs mt-1">or drag and drop</p>
</>
)}
</div>
</div>
<button
onClick={handleUpload}
disabled={!file || loading}
className="btn btn-primary w-full flex items-center justify-center gap-2"
>
{loading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Upload className="w-5 h-5" />
)}
{loading ? 'Uploading...' : 'Upload Animation'}
</button>
</div>
</div>
{/* Use Emoji Card */}
<div className="card h-full">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-yellow/10 rounded-lg">
<Snowflake className="w-5 h-5 text-yellow" />
</div>
<h2 className="text-xl font-heading font-bold text-fg">Use Emoji</h2>
</div>
<p className="text-fg-muted mb-6 text-sm">
Use a simple emoji character instead of an animation.
</p>
<div className="flex flex-col gap-4">
<div className="flex gap-4 items-center">
<input
type="text"
value={settings.emoji}
onChange={(e) => setSettings({ ...settings, emoji: e.target.value })}
maxLength={2}
className="input text-center text-4xl w-24 h-24 p-0 flex items-center justify-center"
disabled={loading}
/>
<div className="text-sm text-fg-muted flex-1">
Enter a single emoji character to be displayed on frozen accounts.
</div>
</div>
<button
onClick={handleUseEmoji}
disabled={loading}
className="btn btn-secondary w-full flex items-center justify-center gap-2 mt-auto"
>
{loading ? (
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<Save className="w-5 h-5" />
)}
{loading ? 'Saving...' : 'Save Emoji'}
</button>
</div>
</div>
</div>
<div className="bg-blue/5 border border-blue/20 rounded-xl p-6 flex gap-4 items-start">
<div className="p-2 bg-blue/10 rounded-lg shrink-0">
<Info className="w-5 h-5 text-blue" />
</div>
<div className="space-y-2">
<h3 className="text-fg font-bold">How it works</h3>
<ul className="space-y-1.5 text-sm text-fg-muted">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-blue rounded-full"></span>
<span><strong>Emoji mode:</strong> Simple emoji () will be added to frozen account names</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-blue rounded-full"></span>
<span><strong>Animation mode:</strong> Lottie JSON animation will be used (requires client support)</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-blue rounded-full"></span>
<span><strong>Recommended size:</strong> Under 100KB for best performance</span>
</li>
</ul>
</div>
</div>
</div>
);
}
@@ -0,0 +1,322 @@
import { useState, useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { Snowflake, Upload, Save, Info, CheckCircle2, AlertTriangle, Cloud, FileCode, Server } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function FrozenSettings() {
const [settings, setSettings] = useState(null);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [file, setFile] = useState(null);
const [documentId, setDocumentId] = useState('');
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
setLoading(true);
const response = await fetch(`${API_URL}/api/frozen-settings`);
const data = await response.json();
setSettings(data.settings);
if (data.settings.snowflakeEmojiId) {
setDocumentId(data.settings.snowflakeEmojiId.toString());
}
} catch (error) {
console.error('Error fetching settings:', error);
toast.error('Failed to load settings');
} finally {
setLoading(false);
}
};
const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
if (selectedFile) {
// Check if it's a TGS file
if (!selectedFile.name.endsWith('.tgs')) {
toast.error('Please select a .tgs file');
return;
}
setFile(selectedFile);
}
};
const handleUpload = async () => {
if (!file) {
toast.error('Please select a TGS file first');
return;
}
try {
setUploading(true);
const formData = new FormData();
formData.append('snowflake', file);
const response = await fetch(`${API_URL}/api/frozen-settings/upload-snowflake`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok) {
toast.success('Snowflake TGS uploaded successfully! Now upload it to Telegram and set the Document ID.');
fetchSettings();
setFile(null);
} else {
throw new Error(data.error || 'Failed to upload file');
}
} catch (error) {
console.error('Error uploading:', error);
toast.error(error.message);
} finally {
setUploading(false);
}
};
const handleSetEmojiId = async () => {
if (!documentId || isNaN(documentId)) {
toast.error('Please enter a valid Document ID');
return;
}
try {
const response = await fetch(`${API_URL}/api/frozen-settings/set-emoji-id`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
documentId: parseInt(documentId)
})
});
const data = await response.json();
if (response.ok) {
toast.success('Emoji ID updated! Restart QueryServer to apply changes.');
fetchSettings();
} else {
throw new Error(data.error || 'Failed to set emoji ID');
}
} catch (error) {
console.error('Error setting emoji ID:', error);
toast.error(error.message);
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="space-y-6 max-w-5xl mx-auto animate-fade-in">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<div className="p-2.5 bg-blue/10 rounded-xl rounded-tr-none">
<Server className="w-8 h-8 text-blue" />
</div>
Server-Side Frozen Settings
</h1>
<p className="text-fg-muted mt-2">Configure the snowflake animation for the server</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Current Configuration */}
<div className="card lg:col-span-2">
<h2 className="text-xl font-heading font-bold text-fg mb-6 flex items-center gap-2">
<Info className="w-5 h-5 text-accent" />
Current Configuration
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 bg-muted/30 rounded-xl border border-border">
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Snowflake File</strong>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-purple" />
<span className="text-fg font-medium font-mono text-sm truncate">
{settings?.snowflakeFileName || 'Not uploaded'}
</span>
</div>
</div>
<div className="p-4 bg-muted/30 rounded-xl border border-border">
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Uploaded At</strong>
<div className="flex items-center gap-2">
<span className="text-fg font-medium text-sm">
{settings?.snowflakeUploadedAt
? new Date(settings.snowflakeUploadedAt).toLocaleDateString()
: 'N/A'}
</span>
</div>
</div>
<div className="p-4 bg-muted/30 rounded-xl border border-border">
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Document ID</strong>
<div className="flex items-center gap-2">
<span className="text-fg font-medium font-mono text-sm underline decoration-dotted">
{settings?.snowflakeEmojiId || 'Not set'}
</span>
</div>
</div>
<div className="p-4 bg-muted/30 rounded-xl border border-border">
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Status</strong>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium border ${settings?.snowflakeEmojiId
? 'bg-success/10 text-success border-success/20'
: 'bg-yellow/10 text-yellow border-yellow/20'
}`}>
{settings?.snowflakeEmojiId ? (
<>
<CheckCircle2 className="w-3 h-3 mr-1" /> Active
</>
) : (
<>
<AlertTriangle className="w-3 h-3 mr-1" /> Not Configured
</>
)}
</span>
</div>
</div>
</div>
</div>
{/* Step 1: Upload TGS */}
<div className="card h-full">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-blue text-white flex items-center justify-center font-bold text-sm">1</div>
<h2 className="text-xl font-heading font-bold text-fg">Upload Snowflake TGS</h2>
</div>
<p className="text-fg-muted mb-6 text-sm">
Upload an animated snowflake sticker file (.tgs format, max 64KB).
</p>
<div className="space-y-4">
<div className="relative group">
<input
type="file"
accept=".tgs"
onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
<div className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-colors ${file ? 'border-success bg-success/5' : 'border-border group-hover:border-accent group-hover:bg-accent/5'
}`}>
{file ? (
<>
<FileCode className="w-10 h-10 text-success mb-3" />
<p className="text-fg font-medium">{file.name}</p>
<p className="text-fg-muted text-xs mt-1">{(file.size / 1024).toFixed(2)} KB</p>
</>
) : (
<>
<Upload className="w-10 h-10 text-fg-muted mb-3 group-hover:text-accent transition-colors" />
<p className="text-fg font-medium">Click to upload TGS</p>
<p className="text-fg-muted text-xs mt-1">or drag and drop</p>
</>
)}
</div>
</div>
<button
onClick={handleUpload}
disabled={!file || uploading}
className="btn btn-primary w-full flex items-center justify-center gap-2"
>
{uploading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Upload className="w-5 h-5" />
)}
{uploading ? 'Uploading...' : 'Upload TGS'}
</button>
{settings?.snowflakeFilePath && (
<div className="p-3 bg-muted/50 rounded-lg border border-border">
<div className="text-xs text-fg-muted uppercase font-bold mb-1">Filesystem Path</div>
<code className="text-xs font-mono text-success break-all block">
{settings.snowflakeFilePath}
</code>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-6">
{/* Step 2: Upload to Telegram */}
<div className="card">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-blue text-white flex items-center justify-center font-bold text-sm">2</div>
<h2 className="text-xl font-heading font-bold text-fg">Upload to Telegram</h2>
</div>
<p className="text-fg-muted mb-4 text-sm">
After uploading the TGS above, upload it to Telegram manually:
</p>
<div className="bg-muted/80 p-4 rounded-xl font-mono text-xs space-y-2 border border-border shadow-inner">
<div className="text-fg-muted"># Use Bot API to upload as custom emoji</div>
<div className="text-success">POST /bot{'<token>'}/uploadStickerFile</div>
<div className="text-fg-muted"># Or use @Stickers bot / admin panel</div>
<div className="text-fg-muted"># Get the Document ID from response</div>
</div>
</div>
{/* Step 3: Set Document ID */}
<div className="card flex-1">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-blue text-white flex items-center justify-center font-bold text-sm">3</div>
<h2 className="text-xl font-heading font-bold text-fg">Set Document ID</h2>
</div>
<p className="text-fg-muted mb-4 text-sm">
Enter the Document ID from Telegram to link it.
</p>
<div className="space-y-4">
<input
type="text"
value={documentId}
onChange={(e) => setDocumentId(e.target.value)}
placeholder="e.g., 1234567890"
className="input w-full font-mono"
/>
<button
onClick={handleSetEmojiId}
disabled={!documentId}
className="btn btn-secondary w-full flex items-center justify-center gap-2"
>
<Save className="w-5 h-5" />
Save Document ID
</button>
</div>
<div className="mt-4 p-3 bg-yellow/10 border border-yellow/20 text-yellow text-xs rounded-lg flex items-start gap-2">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>Restart QueryServer after saving to apply changes.</span>
</div>
</div>
</div>
</div>
{/* Steps Guide */}
<div className="bg-blue/5 border border-blue/20 rounded-xl p-6">
<h3 className="text-lg font-bold font-heading text-fg mb-3 flex items-center gap-2">
<Cloud className="w-5 h-5 text-blue" />
Workflow Guide
</h3>
<ol className="list-decimal list-inside space-y-2 text-sm text-fg-muted">
<li>Upload your animated snowflake <code className="bg-muted px-1.5 py-0.5 rounded text-fg">.tgs</code> file here.</li>
<li>Upload the same TGS to your Telegram server as a custom emoji (using tools or bot).</li>
<li>Copy the <strong>Document ID</strong> from the Telegram upload response.</li>
<li>Enter that ID in step 3 and save.</li>
<li>The system updates <code className="bg-muted px-1.5 py-0.5 rounded text-fg">queryserver.appsettings.json</code> automatically.</li>
<li><strong>Restart QueryServer</strong> - frozen accounts will now show the snowflake emoji!</li>
</ol>
</div>
</div>
);
}
@@ -0,0 +1,150 @@
import { useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Star, Edit, Trash2, Filter, Plus } from 'lucide-react'
import useGiftsStore from '../store/useGiftsStore'
import StickerPreview from '../components/StickerPreview'
export default function GiftsList() {
const { gifts, loading, filters, setFilters, fetchGifts, deleteGift, toggleSoldOut } = useGiftsStore()
useEffect(() => {
fetchGifts()
}, [filters])
const handleDelete = async (giftId) => {
if (window.confirm('Are you sure you want to delete this gift?')) {
await deleteGift(giftId)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-black font-heading text-fg">All Gifts</h1>
<p className="text-fg-muted font-medium mt-1">{gifts.length} gifts in catalog</p>
</div>
<Link to="/gifts/create" className="btn btn-primary gap-2 shadow-[0_0_15px_var(--accent-glow)]">
<Plus className="w-5 h-5" />
Create Gift
</Link>
</div>
{/* Filters */}
<div className="card">
<div className="flex items-center gap-4">
<Filter className="w-5 h-5 text-fg-muted" />
<select
className="input w-auto bg-bg-app border-border focus:border-accent"
value={filters.soldOut ?? ''}
onChange={(e) => setFilters({ soldOut: e.target.value === '' ? undefined : e.target.value === 'true' })}
>
<option value="">All Status</option>
<option value="false">Available</option>
<option value="true">Sold Out</option>
</select>
<select
className="input w-auto bg-bg-app border-border focus:border-accent"
value={filters.limited ?? ''}
onChange={(e) => setFilters({ limited: e.target.value === '' ? undefined : e.target.value === 'true' })}
>
<option value="">All Types</option>
<option value="false">Regular</option>
<option value="true">Limited</option>
</select>
</div>
</div>
{/* Gifts Grid */}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-accent"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{gifts.map((gift) => (
<div key={gift.GiftId} className="card group hover:border-accent hover:shadow-[0_0_20px_var(--accent-glow)] transition-all duration-300">
{/* Sticker Preview */}
<div className="mb-6 flex justify-center p-4 bg-bg-app rounded-xl border border-border group-hover:border-accent/30 transition-colors">
<StickerPreview
documentId={typeof gift.Sticker === 'number' ? gift.Sticker : null}
stickerBase64={typeof gift.Sticker !== 'number' ? gift.Sticker : null}
size={120}
/>
</div>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold font-heading text-fg flex items-center gap-2">
#{gift.GiftId}
</h3>
<p className="text-fg-muted font-medium text-sm mt-1">{gift.Title || 'Untitled'}</p>
</div>
<div className="flex flex-col items-end gap-2">
{gift.Limited && (
<span className="px-2 py-1 bg-purple-500/10 text-purple-400 border border-purple-500/20 text-[10px] font-bold uppercase tracking-wider rounded">
Limited
</span>
)}
{gift.Birthday && (
<span className="px-2 py-1 bg-pink-500/10 text-pink-400 border border-pink-500/20 text-[10px] font-bold uppercase tracking-wider rounded">
Birthday
</span>
)}
</div>
</div>
<div className="space-y-3 mb-6 p-4 bg-bg-app rounded-lg border border-border">
<div className="flex justify-between items-center">
<span className="text-fg-muted text-sm font-medium uppercase tracking-wide">Price</span>
<span className="flex items-center gap-1 font-bold text-yellow-500">
<Star className="w-4 h-4 fill-current" />{gift.Stars}
</span>
</div>
<div className="w-full h-[1px] bg-border" />
<div className="flex justify-between items-center">
<span className="text-fg-muted text-sm font-medium uppercase tracking-wide">Convert</span>
<span className="font-bold text-fg">{gift.ConvertStars}</span>
</div>
{gift.Limited && (
<>
<div className="w-full h-[1px] bg-border" />
<div className="flex justify-between items-center">
<span className="text-fg-muted text-sm font-medium uppercase tracking-wide">Supply</span>
<span className="font-bold text-fg">
<span className="text-accent">{gift.AvailabilityRemains}</span>
<span className="text-fg-muted mx-1">/</span>
{gift.AvailabilityTotal}
</span>
</div>
</>
)}
</div>
<div className="flex gap-3">
<Link to={`/gifts/edit/${gift.GiftId}`} className="btn btn-secondary flex-1 group-hover:bg-bg-app group-hover:text-fg">
<Edit className="w-4 h-4" />
Edit
</Link>
<button
onClick={() => handleDelete(gift.GiftId)}
className="p-2.5 rounded-lg border border-border text-fg-muted hover:text-red-500 hover:border-red-500/50 hover:bg-red-500/10 transition-colors"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,282 @@
import { useState, useEffect } from 'react'
import { Search, Coins, Sparkles, TrendingUp, User, Calendar, Hash } from 'lucide-react'
import toast from 'react-hot-toast'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
export default function IssueStars() {
const [userId, setUserId] = useState('')
const [amount, setAmount] = useState('')
const [reason, setReason] = useState('')
const [loading, setLoading] = useState(false)
const [userInfo, setUserInfo] = useState(null)
const [recentTransactions, setRecentTransactions] = useState([])
useEffect(() => {
loadRecentTransactions()
}, [])
const loadRecentTransactions = async () => {
try {
const response = await fetch(`${API_URL}/stars/recent?limit=10`)
const data = await response.json()
if (data.success) {
setRecentTransactions(data.data)
}
} catch (error) {
console.error('Failed to load recent transactions:', error)
}
}
const searchUser = async () => {
if (!userId) return
try {
const response = await fetch(`${API_URL}/stars/user/${userId}`)
const data = await response.json()
if (data.success) {
setUserInfo(data.data)
toast.success(`User found: ${data.data.user.firstName || 'User ' + userId}`)
} else {
toast.error(data.error || 'User not found')
setUserInfo(null)
}
} catch (error) {
toast.error('User not found')
setUserInfo(null)
}
}
const issueStars = async (e) => {
e.preventDefault()
if (!userId || !amount || amount <= 0) {
toast.error('Please enter valid User ID and amount')
return
}
setLoading(true)
try {
const response = await fetch(`${API_URL}/stars/issue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: parseInt(userId),
amount: parseInt(amount),
reason: reason || 'Admin issued stars'
})
})
const data = await response.json()
if (data.success) {
toast.success(`✨ Successfully issued ${amount} stars!`)
setAmount('')
setReason('')
loadRecentTransactions()
searchUser() // Refresh user info
} else {
toast.error(data.error || 'Failed to issue stars')
}
} catch (error) {
toast.error('Failed to issue stars')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-6 animate-fade-in">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<div className="p-2.5 bg-gradient-to-br from-yellow to-orange-500 rounded-xl shadow-lg shadow-orange-500/20">
<Coins className="w-8 h-8 text-white" />
</div>
Issue Telegram Stars
</h1>
<p className="text-fg-muted mt-2">Grant stars to users directly from admin panel</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Form */}
<div className="lg:col-span-2 space-y-6">
{/* User Search Card */}
<div className="card p-6 border-border">
<h2 className="text-xl font-heading font-semibold text-fg mb-4 flex items-center gap-2">
<Search className="w-5 h-5 text-purple" />
Find User
</h2>
<div className="flex gap-3">
<div className="flex-1 relative">
<Hash className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-fg-muted" />
<input
type="number"
value={userId}
onChange={(e) => setUserId(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && searchUser()}
placeholder="Enter User ID"
className="input w-full pl-12"
/>
</div>
<button
onClick={searchUser}
className="btn btn-primary flex items-center gap-2 px-6"
>
<Search className="w-5 h-5" />
Search
</button>
</div>
{/* User Info Display */}
{userInfo && (
<div className="mt-4 p-4 bg-muted/40 border border-border rounded-xl animate-scale-in">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-purple to-blue rounded-full flex items-center justify-center shadow-lg shadow-purple/20">
<User className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<h3 className="text-fg font-semibold text-lg">
{userInfo.user.firstName} {userInfo.user.lastName || ''}
</h3>
<p className="text-fg-muted text-sm font-mono">
@{userInfo.user.userName || `user${userInfo.user.userId}`} · ID: {userInfo.user.userId}
</p>
</div>
<div className="text-right">
<p className="text-fg-muted text-xs uppercase tracking-wider mb-1">Current Balance</p>
<p className="text-2xl font-bold text-yellow flex items-center justify-end gap-1.5">
<Coins className="w-6 h-6 drop-shadow-sm" />
{userInfo.balance}
</p>
</div>
</div>
</div>
)}
</div>
{/* Issue Stars Form */}
<form onSubmit={issueStars} className="card p-6 border-border relative overflow-hidden">
{/* Background decoration */}
<div className="absolute top-0 right-0 p-32 bg-yellow/5 rounded-full blur-3xl -mr-16 -mt-16 pointer-events-none"></div>
<h2 className="text-xl font-heading font-semibold text-fg mb-6 flex items-center gap-2 relative z-10">
<Sparkles className="w-5 h-5 text-yellow" />
Issue Stars
</h2>
<div className="space-y-6 relative z-10">
<div>
<label className="block text-fg font-medium text-sm mb-2">
Amount of Stars
</label>
<div className="relative">
<Coins className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-yellow" />
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="Enter amount"
min="1"
required
className="input w-full pl-12 text-lg font-medium"
/>
</div>
{/* Quick Amount Buttons */}
<div className="flex flex-wrap gap-2 mt-3">
{[50, 100, 500, 1000, 5000].map((amt) => (
<button
key={amt}
type="button"
onClick={() => setAmount(amt.toString())}
className="px-3 py-1.5 bg-muted border border-border hover:border-yellow hover:text-yellow rounded-lg text-sm text-fg-muted transition-all hover:-translate-y-0.5"
>
+{amt}
</button>
))}
</div>
</div>
<div>
<label className="block text-fg font-medium text-sm mb-2">
Reason (Optional)
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Promotional bonus, Bug bounty reward, etc."
rows="3"
className="input w-full resize-none"
/>
</div>
<button
type="submit"
disabled={loading || !userId || !amount}
className="btn w-full bg-gradient-to-r from-yellow to-orange-500 hover:from-yellow/90 hover:to-orange-500/90 text-black font-bold text-lg py-4 shadow-xl shadow-orange-500/20 hover:shadow-orange-500/30 transition-all transform hover:scale-[1.02] disabled:opacity-50 disabled:scale-100 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? (
<>
<div className="w-5 h-5 border-2 border-black/30 border-t-black rounded-full animate-spin" />
Processing...
</>
) : (
<>
<Sparkles className="w-5 h-5" />
Issue {amount || '0'} Stars
</>
)}
</button>
</div>
</form>
</div>
{/* Recent Transactions Sidebar */}
<div className="lg:col-span-1">
<div className="card p-6 border-border sticky top-6 max-h-[calc(100vh-2rem)] flex flex-col">
<h2 className="text-xl font-heading font-semibold text-fg mb-4 flex items-center gap-2 shrink-0">
<TrendingUp className="w-5 h-5 text-success" />
Recent Transactions
</h2>
<div className="space-y-3 overflow-y-auto pr-2 custom-scrollbar flex-1">
{recentTransactions.length === 0 ? (
<div className="text-center py-10 bg-muted/20 rounded-xl border border-dashed border-border">
<div className="w-10 h-10 bg-muted rounded-full flex items-center justify-center mx-auto mb-3">
<TrendingUp className="w-5 h-5 text-fg-muted" />
</div>
<p className="text-fg-muted font-medium">No transactions yet</p>
</div>
) : (
recentTransactions.map((tx, idx) => (
<div
key={idx}
className="p-3 bg-muted/20 border border-border rounded-xl hover:border-purple/50 hover:bg-muted/40 transition-colors group"
>
<div className="flex items-center justify-between mb-2">
<span className="text-fg font-medium flex items-center gap-1.5 text-sm">
<User className="w-3.5 h-3.5 text-fg-muted" />
User {tx.userId}
</span>
<span className={`font-bold text-sm ${tx.Amount > 0 ? 'text-success' : 'text-red'}`}>
{tx.Amount > 0 ? '+' : ''}{tx.Amount}
</span>
</div>
<p className="text-fg-muted text-xs line-clamp-1 mb-1.5 group-hover:text-fg transition-colors">{tx.Reason}</p>
<div className="flex items-center gap-1.5 text-fg-muted opacity-70 text-[10px]">
<Calendar className="w-3 h-3" />
{new Date(tx.Date).toLocaleString()}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,360 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { ArrowLeft, Upload, Trash2, Download, ExternalLink, Copy, BarChart3 } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function ManageEmojiPack() {
const { id } = useParams();
const navigate = useNavigate();
const [pack, setPack] = useState(null);
const [emojis, setEmojis] = useState([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [stats, setStats] = useState(null);
// Redirect if no ID
useEffect(() => {
if (!id || id === 'undefined' || id === 'null') {
navigate('/emojipacks', { replace: true });
return;
}
}, [id, navigate]);
const [uploadForm, setUploadForm] = useState({
file: null,
alt: '',
is_free: true,
has_text_color: false
});
useEffect(() => {
if (id && id !== 'undefined' && id !== 'null') {
fetchPackData();
fetchStats();
}
}, [id]);
const fetchPackData = async () => {
if (!id) return;
try {
setLoading(true);
const response = await fetch(`/api/emojipacks/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch pack');
}
const data = await response.json();
setPack(data.pack);
setEmojis(data.emojis || []);
} catch (error) {
console.error('Error fetching pack:', error);
toast.error('Failed to load emoji pack');
} finally {
setLoading(false);
}
};
const fetchStats = async () => {
if (!id) return;
try {
const response = await fetch(`/api/emojipacks/${id}/stats`);
if (response.ok) {
const data = await response.json();
setStats(data);
}
} catch (error) {
console.error('Error fetching stats:', error);
}
};
const handleFileChange = (e) => {
setUploadForm({ ...uploadForm, file: e.target.files[0] });
};
const handleUpload = async (e) => {
e.preventDefault();
if (!uploadForm.file || !uploadForm.alt) {
toast.error('Please select a file and enter a fallback emoji');
return;
}
setUploading(true);
const formData = new FormData();
formData.append('file', uploadForm.file);
formData.append('alt', uploadForm.alt);
formData.append('is_free', uploadForm.is_free);
formData.append('has_text_color', uploadForm.has_text_color);
try {
const response = await fetch(`/api/emojipacks/${id}/emojis`, {
method: 'POST',
body: formData
});
if (response.ok) {
toast.success('Emoji uploaded successfully!');
setUploadForm({ file: null, alt: '', is_free: true, has_text_color: false });
document.getElementById('file-input').value = '';
fetchPackData();
fetchStats();
} else {
const error = await response.json();
toast.error(`Upload failed: ${error.error}`);
}
} catch (error) {
console.error('Error uploading:', error);
toast.error('Upload failed');
} finally {
setUploading(false);
}
};
const deleteEmoji = async (emojiId) => {
if (!confirm('Delete this emoji?')) return;
try {
const response = await fetch(`/api/emojipacks/${id}/emojis/${emojiId}`, {
method: 'DELETE'
});
if (response.ok) {
toast.success('Emoji deleted');
fetchPackData();
fetchStats();
} else {
toast.error('Failed to delete emoji');
}
} catch (error) {
console.error('Error deleting emoji:', error);
toast.error('Failed to delete emoji');
}
};
const copyPackLink = () => {
const shortName = pack.ShortName || pack.short_name;
const link = `https://t.me/addemoji/${shortName}`;
navigator.clipboard.writeText(link);
toast.success('Pack link copied to clipboard!');
};
if (loading) {
return (
<div className="flex justify-center items-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple"></div>
</div>
);
}
if (!pack) {
return (
<div className="p-6 text-center">
<p className="text-red-400">Pack not found</p>
<Link to="/emojipacks" className="text-purple hover:underline mt-4 inline-block">
Back to Packs
</Link>
</div>
);
}
const packTitle = pack.Title || pack.title;
const packShortName = pack.ShortName || pack.short_name;
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div>
<Link
to="/emojipacks"
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
>
<ArrowLeft className="w-5 h-5 mr-2" />
Back to Packs
</Link>
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-heading font-bold text-fg">{packTitle}</h1>
<p className="text-fg-muted">@{packShortName}</p>
</div>
<div className="flex gap-2">
<button
onClick={copyPackLink}
className="btn btn-secondary flex items-center gap-2"
>
<Copy className="w-4 h-4" />
Copy Link
</button>
<a
href={`https://t.me/addemoji/${packShortName}`}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
Open in Telegram
</a>
</div>
</div>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card p-4">
<div className="text-sm text-fg-muted">Total Emojis</div>
<div className="text-2xl font-bold text-purple">{stats.total_emojis}</div>
</div>
<div className="card p-4">
<div className="text-sm text-fg-muted">Free Emojis</div>
<div className="text-2xl font-bold text-success">{stats.free_emojis}</div>
</div>
<div className="card p-4">
<div className="text-sm text-fg-muted">Premium Emojis</div>
<div className="text-2xl font-bold text-yellow">{stats.premium_emojis}</div>
</div>
<div className="card p-4">
<div className="text-sm text-fg-muted">Total Usage</div>
<div className="text-2xl font-bold text-blue">{stats.total_usage}</div>
</div>
</div>
)}
{/* Upload Form */}
<div className="card p-6">
<h2 className="text-xl font-bold text-fg mb-6 flex items-center gap-2">
<Upload className="w-6 h-6 text-purple" />
Upload New Emoji
</h2>
<form onSubmit={handleUpload} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* File Upload */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
TGS/WEBM File <span className="text-red">*</span>
</label>
<input
id="file-input"
type="file"
accept=".tgs,.json,.webm"
onChange={handleFileChange}
className="input w-full p-2"
/>
<p className="mt-1 text-xs text-fg-muted">Max 64 KB, 512×512px, 60 FPS, 3 sec</p>
</div>
{/* Alt Emoji */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
Fallback Emoji <span className="text-red">*</span>
</label>
<input
type="text"
value={uploadForm.alt}
onChange={(e) => setUploadForm({ ...uploadForm, alt: e.target.value })}
placeholder="🔥"
maxLength="10"
className="input w-full"
/>
<p className="mt-1 text-xs text-fg-muted">UTF-8 emoji for fallback</p>
</div>
</div>
{/* Options */}
<div className="flex flex-wrap gap-6 pt-2">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={uploadForm.is_free}
onChange={(e) => setUploadForm({ ...uploadForm, is_free: e.target.checked })}
className="w-4 h-4 text-purple rounded border-input focus:ring-purple/50 bg-muted"
/>
<span className="text-sm text-fg group-hover:text-purple transition-colors">Free (non-Premium users)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={uploadForm.has_text_color}
onChange={(e) => setUploadForm({ ...uploadForm, has_text_color: e.target.checked })}
className="w-4 h-4 text-purple rounded border-input focus:ring-purple/50 bg-muted"
/>
<span className="text-sm text-fg group-hover:text-purple transition-colors">Text Color Support</span>
</label>
</div>
<button
type="submit"
disabled={uploading}
className="btn btn-primary flex items-center justify-center gap-2 w-full md:w-auto px-8"
>
<Upload className="w-5 h-5" />
{uploading ? 'Uploading...' : 'Upload Emoji'}
</button>
</form>
</div>
{/* Emojis List */}
<div className="card p-6">
<h2 className="text-xl font-bold text-fg mb-6">
Emojis ({emojis.length})
</h2>
{emojis.length === 0 ? (
<p className="text-fg-muted text-center py-8">
No emojis yet. Upload your first emoji above!
</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{emojis.map((emoji) => (
<div
key={emoji.document_id}
className="card border-border hover:border-purple transition-colors relative group p-4"
>
<div className="text-center">
<div className="text-5xl mb-2">{emoji.attributes.alt}</div>
<div className="text-xs text-fg-muted truncate w-full">ID: {emoji.document_id}</div>
<div className="text-xs text-fg-muted">{(emoji.size / 1024).toFixed(1)} KB</div>
{/* Badges */}
<div className="mt-3 flex flex-wrap gap-1 justify-center">
{emoji.attributes.free && (
<span className="px-2 py-0.5 bg-success/10 text-success text-[10px] rounded border border-success/20">Free</span>
)}
{!emoji.attributes.free && (
<span className="px-2 py-0.5 bg-yellow/10 text-yellow text-[10px] rounded border border-yellow/20">Premium</span>
)}
{emoji.attributes.text_color && (
<span className="px-2 py-0.5 bg-purple/10 text-purple text-[10px] rounded border border-purple/20">Color</span>
)}
</div>
{/* Actions */}
<button
onClick={() => deleteEmoji(emoji.document_id)}
className="absolute top-2 right-2 p-1.5 text-red-400 hover:text-red hover:bg-red/10 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,410 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { ArrowLeft, Upload, Trash2, Copy, ExternalLink, BarChart3, FileImage, FileVideo, Film } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function ManageStickerPack() {
const { id } = useParams();
const navigate = useNavigate();
const [pack, setPack] = useState(null);
const [stickers, setStickers] = useState([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [stats, setStats] = useState(null);
// Redirect if no ID
useEffect(() => {
if (!id || id === 'undefined' || id === 'null') {
navigate('/stickerpacks', { replace: true });
return;
}
}, [id, navigate]);
const [uploadForm, setUploadForm] = useState({
file: null,
emoji: '😀',
creator_id: '2010001' // Default user ID
});
useEffect(() => {
if (id && id !== 'undefined' && id !== 'null') {
fetchPackData();
fetchStats();
}
}, [id]);
const fetchPackData = async () => {
if (!id) return;
try {
setLoading(true);
const response = await fetch(`/api/stickerpacks/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch pack');
}
const data = await response.json();
setPack(data.pack);
setStickers(data.stickers || []);
} catch (error) {
console.error('Error fetching pack:', error);
toast.error('Failed to load sticker pack');
} finally {
setLoading(false);
}
};
const fetchStats = async () => {
if (!id) return;
try {
const response = await fetch(`/api/stickerpacks/${id}/stats`);
if (response.ok) {
const data = await response.json();
setStats(data);
}
} catch (error) {
console.error('Error fetching stats:', error);
}
};
const handleFileChange = (e) => {
setUploadForm({ ...uploadForm, file: e.target.files[0] });
};
const handleUpload = async (e) => {
e.preventDefault();
if (!uploadForm.file || !uploadForm.emoji) {
toast.error('Please select a file and enter an emoji');
return;
}
// Validate file type
const ext = uploadForm.file.name.split('.').pop().toLowerCase();
const allowedExtensions = ['tgs', 'webm', 'webp', 'png'];
if (!allowedExtensions.includes(ext)) {
toast.error('Invalid file type. Allowed: .tgs, .webm, .webp, .png');
return;
}
setUploading(true);
const formData = new FormData();
formData.append('file', uploadForm.file);
formData.append('emoji', uploadForm.emoji);
formData.append('creator_id', uploadForm.creator_id);
try {
const response = await fetch(`/api/stickerpacks/${id}/stickers`, {
method: 'POST',
body: formData
});
if (response.ok) {
toast.success('Sticker uploaded successfully!');
setUploadForm({ file: null, emoji: '😀', creator_id: uploadForm.creator_id });
document.getElementById('file-input').value = '';
fetchPackData();
fetchStats();
} else {
const error = await response.json();
toast.error(`Upload failed: ${error.error}`);
}
} catch (error) {
console.error('Error uploading:', error);
toast.error('Upload failed');
} finally {
setUploading(false);
}
};
const deleteSticker = async (stickerId) => {
if (!confirm('Delete this sticker?')) return;
try {
const response = await fetch(`/api/stickerpacks/${id}/stickers/${stickerId}`, {
method: 'DELETE'
});
if (response.ok) {
toast.success('Sticker deleted');
fetchPackData();
fetchStats();
} else {
toast.error('Failed to delete sticker');
}
} catch (error) {
console.error('Error deleting sticker:', error);
toast.error('Failed to delete sticker');
}
};
const copyPackLink = () => {
const shortName = pack.ShortName;
const link = `https://t.me/addstickers/${shortName}`;
navigator.clipboard.writeText(link);
toast.success('Pack link copied to clipboard!');
};
const getStickerIcon = (mimeType) => {
if (mimeType === 'application/x-tgsticker') {
return <Film className="w-5 h-5 text-purple" />;
} else if (mimeType === 'video/webm') {
return <FileVideo className="w-5 h-5 text-blue" />;
} else {
return <FileImage className="w-5 h-5 text-success" />;
}
};
const getStickerTypeLabel = (mimeType) => {
switch (mimeType) {
case 'application/x-tgsticker':
return 'Animated (TGS)';
case 'video/webm':
return 'Video (WebM)';
case 'image/webp':
return 'Static (WebP)';
case 'image/png':
return 'Static (PNG)';
default:
return 'Unknown';
}
};
if (loading) {
return (
<div className="flex justify-center items-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple"></div>
</div>
);
}
if (!pack) {
return (
<div className="p-6 text-center">
<p className="text-red-400">Pack not found</p>
<Link to="/stickerpacks" className="text-purple hover:underline mt-4 inline-block">
Back to Sticker Packs
</Link>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div>
<Link
to="/stickerpacks"
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
>
<ArrowLeft className="w-5 h-5 mr-2" />
Back to Sticker Packs
</Link>
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-heading font-bold text-fg">{pack.Title}</h1>
<p className="text-fg-muted mt-1">@{pack.ShortName}</p>
</div>
<button
onClick={copyPackLink}
className="btn btn-secondary flex items-center gap-2"
>
<Copy className="w-5 h-5" />
Copy Link
</button>
</div>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card p-4">
<div className="text-sm text-fg-muted">Total Stickers</div>
<div className="text-2xl font-bold text-purple">{stats.total_stickers}</div>
</div>
<div className="card p-4">
<div className="text-sm text-fg-muted">Animated (TGS)</div>
<div className="text-2xl font-bold text-blue">{stats.sticker_types?.tgs || 0}</div>
</div>
<div className="card p-4">
<div className="text-sm text-fg-muted">Video (WebM)</div>
<div className="text-2xl font-bold text-yellow">{stats.sticker_types?.webm || 0}</div>
</div>
<div className="card p-4">
<div className="text-sm text-fg-muted">Static (WebP/PNG)</div>
<div className="text-2xl font-bold text-success">
{(stats.sticker_types?.webp || 0) + (stats.sticker_types?.png || 0)}
</div>
</div>
</div>
)}
{/* Upload Form */}
<div className="card p-6">
<h2 className="text-xl font-bold text-fg mb-6 flex items-center gap-2">
<Upload className="w-6 h-6 text-purple" />
Upload Sticker
</h2>
<form onSubmit={handleUpload} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-medium text-fg mb-2">
Sticker File <span className="text-red">*</span>
</label>
<input
id="file-input"
type="file"
accept=".tgs,.webm,.webp,.png"
onChange={handleFileChange}
className="input w-full p-2"
required
/>
<p className="text-xs text-fg-muted mt-1">
Formats: TGS (animated), WebM (video), WebP/PNG (static)
</p>
</div>
<div>
<label className="block text-sm font-medium text-fg mb-2">
Associated Emoji <span className="text-red">*</span>
</label>
<input
type="text"
value={uploadForm.emoji}
onChange={(e) => setUploadForm({ ...uploadForm, emoji: e.target.value })}
className="input w-full focus:ring-2 focus:ring-purple/50"
placeholder="😀"
maxLength="2"
required
/>
<p className="text-xs text-fg-muted mt-1">
Emoji that represents this sticker
</p>
</div>
<div>
<label className="block text-sm font-medium text-fg mb-2">
Creator User ID <span className="text-red">*</span>
</label>
<input
type="number"
value={uploadForm.creator_id}
onChange={(e) => setUploadForm({ ...uploadForm, creator_id: e.target.value })}
className="input w-full focus:ring-2 focus:ring-purple/50"
placeholder="2010001"
required
/>
<p className="text-xs text-fg-muted mt-1">
User ID who created the sticker
</p>
</div>
</div>
<div className="bg-blue/5 border border-blue/20 rounded-lg p-4 text-sm">
<strong className="text-blue">Requirements:</strong>
<ul className="mt-2 space-y-1 text-blue/80">
<li> Static (WebP/PNG): 512x512 pixels</li>
<li> Animated (TGS): 512x512 px, max 3 sec, 60 FPS</li>
<li> Video (WebM): VP9 codec, 512x512 px, max 3 sec, no audio</li>
<li> Max file size: 512 KB</li>
</ul>
</div>
<button
type="submit"
disabled={uploading}
className="btn btn-primary flex items-center justify-center gap-2 w-full md:w-auto px-8"
>
<Upload className="w-5 h-5" />
{uploading ? 'Uploading...' : 'Upload Sticker'}
</button>
</form>
</div>
{/* Stickers List */}
<div className="card p-6">
<h2 className="text-xl font-bold text-fg mb-6">Stickers ({stickers.length})</h2>
{stickers.length === 0 ? (
<div className="text-center py-12 text-fg-muted">
<Upload className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>No stickers yet. Upload your first sticker above!</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{stickers.map((sticker) => (
<div
key={sticker.DocumentId}
className="card border-border hover:border-purple transition-colors relative group p-3"
>
<div className="aspect-square bg-muted/30 rounded-lg mb-2 flex items-center justify-center">
{getStickerIcon(sticker.MimeType)}
</div>
<div className="text-xs text-fg-muted mb-1 text-center">
{getStickerTypeLabel(sticker.MimeType)}
</div>
<div className="text-xs text-fg-muted mb-2 text-center">
{Math.round(sticker.Size / 1024)} KB
</div>
<div className="text-xs text-fg-muted mb-2 font-mono truncate text-center">
ID: {sticker.DocumentId}
</div>
<button
onClick={() => deleteSticker(sticker.DocumentId)}
className="w-full px-2 py-1 bg-red/10 text-red rounded hover:bg-red/20 text-xs flex items-center justify-center gap-1 transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 className="w-3 h-3" />
Delete
</button>
</div>
))}
</div>
)}
</div>
{/* Pack Link */}
<div className="card p-6">
<h2 className="text-xl font-bold text-fg mb-4">Share Pack</h2>
<div className="flex items-center gap-4">
<input
type="text"
value={`https://t.me/addstickers/${pack.ShortName}`}
readOnly
className="input flex-1 bg-muted font-mono text-sm"
/>
<button
onClick={copyPackLink}
className="btn btn-secondary flex items-center gap-2"
>
<Copy className="w-5 h-5" />
Copy
</button>
<a
href={`https://t.me/addstickers/${pack.ShortName}`}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
>
<ExternalLink className="w-5 h-5" />
</a>
</div>
</div>
</div>
);
}
@@ -0,0 +1,198 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Smile, Plus, Trash2, Edit, Loader, Upload } from 'lucide-react';
import Lottie from 'lottie-react';
export default function Reactions() {
const [reactions, setReactions] = useState([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(null);
useEffect(() => {
fetchReactions();
}, []);
const fetchReactions = async () => {
try {
setLoading(true);
const response = await fetch('/api/reactions');
const data = await response.json();
setReactions(data.reactions || []);
} catch (error) {
console.error('Error fetching reactions:', error);
} finally {
setLoading(false);
}
};
const handleDelete = async (reactionId) => {
if (!confirm('Are you sure you want to delete this reaction?')) return;
try {
setDeleting(reactionId);
const response = await fetch(`/api/reactions/${reactionId}`, {
method: 'DELETE'
});
if (response.ok) {
setReactions(reactions.filter(r => r.id !== reactionId));
} else {
alert('Failed to delete reaction');
}
} catch (error) {
console.error('Error deleting reaction:', error);
alert('Error deleting reaction');
} finally {
setDeleting(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader className="w-8 h-8 animate-spin text-purple-600" />
</div>
);
}
return (
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="bg-gradient-to-br from-purple-500 to-pink-600 p-3 rounded-lg">
<Smile className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold">Message Reactions</h1>
<p className="text-[#8b98a5]">Manage available reactions for messages</p>
</div>
</div>
<div className="flex gap-3">
<Link to="/reactions/bulk-upload" className="btn btn-secondary flex items-center gap-2">
<Upload className="w-5 h-5" />
Bulk Upload ZIP
</Link>
<Link to="/reactions/create" className="btn btn-primary flex items-center gap-2">
<Plus className="w-5 h-5" />
Create Reaction
</Link>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="card">
<div className="text-sm text-[#8b98a5]">Total Reactions</div>
<div className="text-2xl font-bold">{reactions.length}</div>
</div>
<div className="card">
<div className="text-sm text-[#8b98a5]">Free Reactions</div>
<div className="text-2xl font-bold text-green-600">
{reactions.filter(r => !r.premium).length}
</div>
</div>
<div className="card">
<div className="text-sm text-[#8b98a5]">Premium Reactions</div>
<div className="text-2xl font-bold text-purple-600">
{reactions.filter(r => r.premium).length}
</div>
</div>
<div className="card">
<div className="text-sm text-[#8b98a5]">Inactive</div>
<div className="text-2xl font-bold text-[#8b98a5]">
{reactions.filter(r => r.inactive).length}
</div>
</div>
</div>
{/* Reactions Grid */}
{reactions.length === 0 ? (
<div className="card text-center py-12">
<Smile className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<h3 className="text-xl font-semibold text-white mb-2">No reactions yet</h3>
<p className="text-[#8b98a5] mb-6">Create your first reaction or bulk upload from ZIP</p>
<div className="flex gap-3 justify-center">
<Link to="/reactions/bulk-upload" className="btn btn-secondary">
Bulk Upload
</Link>
<Link to="/reactions/create" className="btn btn-primary">
Create Reaction
</Link>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{reactions.map((reaction) => (
<div key={reaction.id} className="card hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="text-lg font-semibold">{reaction.emoji}</div>
<div className="text-sm text-[#8b98a5]">{reaction.title}</div>
</div>
<div className="flex gap-2">
<Link
to={`/reactions/edit/${reaction.id}`}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<Edit className="w-4 h-4 text-[#8b98a5]" />
</Link>
<button
onClick={() => handleDelete(reaction.id)}
disabled={deleting === reaction.id}
className="p-2 hover:bg-red-500/300/20 rounded-lg transition-colors disabled:opacity-50"
>
{deleting === reaction.id ? (
<Loader className="w-4 h-4 text-red-400 animate-spin" />
) : (
<Trash2 className="w-4 h-4 text-red-400" />
)}
</button>
</div>
</div>
{/* Preview */}
{reaction.selectAnimation && (
<div className="bg-gradient-to-br from-purple-50 to-pink-50 rounded-lg p-4 mb-3 flex items-center justify-center">
<Lottie
animationData={reaction.selectAnimation}
loop={true}
style={{ width: 80, height: 80 }}
/>
</div>
)}
{/* Badges */}
<div className="flex flex-wrap gap-2">
{reaction.premium && (
<span className="px-2 py-1 bg-purple-100 text-purple-400 text-xs font-medium rounded">
Premium
</span>
)}
{reaction.inactive && (
<span className="px-2 py-1 bg-gray-100 text-white text-xs font-medium rounded">
Inactive
</span>
)}
{reaction.hasAllAnimations && (
<span className="px-2 py-1 bg-green-100 text-green-400 text-xs font-medium rounded">
Complete
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,469 @@
import { useState, useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Send, Gift, User, MessageSquare, EyeOff, CheckCircle2, AlertCircle, Search } from 'lucide-react'
import toast from 'react-hot-toast'
export default function SendGift() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [gifts, setGifts] = useState([])
const [loading, setLoading] = useState(true)
const [sending, setSending] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searching, setSearching] = useState(false)
const [formData, setFormData] = useState({
giftId: '',
userId: searchParams.get('userId') || '',
fromUserId: '',
message: '',
nameHidden: false,
count: 1
})
useEffect(() => {
fetchGifts()
// If userId is in URL params, show a toast
const userIdFromUrl = searchParams.get('userId')
if (userIdFromUrl) {
toast.success(`User ID ${userIdFromUrl} pre-filled from URL`)
}
}, [])
const searchUsers = async (query) => {
if (!query || query.length < 2) {
setSearchResults([])
return
}
setSearching(true)
try {
// Try to search by phone or username
const isPhone = /^\d+$/.test(query)
const params = isPhone
? `phone=${encodeURIComponent(query)}`
: `username=${encodeURIComponent(query)}`
const response = await fetch(`/api/users/search?${params}`)
const data = await response.json()
setSearchResults(data)
} catch (error) {
console.error('Search error:', error)
setSearchResults([])
} finally {
setSearching(false)
}
}
const handleSearchChange = (e) => {
const value = e.target.value
setSearchQuery(value)
searchUsers(value)
}
const selectUser = (user) => {
setFormData({ ...formData, userId: user.UserId.toString() })
setSearchQuery('')
setSearchResults([])
toast.success(`Selected: ${user.FirstName} (ID: ${user.UserId})`)
}
const fetchGifts = async () => {
try {
const response = await fetch('/api/gifts')
const data = await response.json()
setGifts(data.filter(g => !g.SoldOut)) // Only available gifts
setLoading(false)
} catch (error) {
toast.error('Failed to load gifts')
setLoading(false)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!formData.giftId || !formData.userId) {
toast.error('Gift and User ID are required')
return
}
setSending(true)
try {
const response = await fetch(`/api/gifts/${formData.giftId}/send-to-user`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: parseInt(formData.userId),
fromUserId: formData.fromUserId ? parseInt(formData.fromUserId) : 0,
message: formData.message || null,
nameHidden: formData.nameHidden,
count: parseInt(formData.count) || 1
})
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to send gift')
}
// Show success with restart instructions
toast.success(`🎁 Gift added to database!`)
if (data.instructions) {
setTimeout(() => {
toast((t) => (
<div className="space-y-2">
<p className="font-semibold"> Important: Restart Query Server</p>
<p className="text-sm">To see the gift in user profile, run:</p>
<code className="block bg-gray-800 text-white px-2 py-1 rounded text-xs mt-1">
{data.instructions.command}
</code>
<button
onClick={() => {
navigator.clipboard.writeText(data.instructions.command)
toast.success('Command copied!')
}}
className="text-xs text-primary-600 hover:underline"
>
Copy command
</button>
</div>
), {
duration: 8000,
icon: '🔄',
})
}, 500)
}
// Reset form
setFormData({
giftId: '',
userId: '',
fromUserId: '',
message: '',
nameHidden: false
})
} catch (error) {
toast.error(error.message)
} finally {
setSending(false)
}
}
const selectedGift = gifts.find(g => g.GiftId === parseInt(formData.giftId))
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">Send Gift to User</h1>
<p className="mt-1 text-sm text-[#8b98a5]">
Manually send a gift to any user (Admin function)
</p>
</div>
<Send className="w-8 h-8 text-primary-500" />
</div>
{/* Info Alert */}
<div className="bg-blue-500/20 border border-blue-500/30 rounded-lg p-4 flex gap-3">
<AlertCircle className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-300">
<p className="font-medium text-blue-400">Admin Gift Delivery</p>
<p className="mt-1">
This will directly insert a gift into the user's received gifts collection.
The gift will be automatically saved to their profile.
</p>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="card p-6 space-y-6">
{/* Gift Selection */}
<div>
<label className="block text-sm font-medium text-white mb-2">
<Gift className="w-4 h-4 inline mr-2" />
Select Gift
</label>
<select
value={formData.giftId}
onChange={(e) => setFormData({ ...formData, giftId: e.target.value })}
className="input focus:ring-2 focus:ring-primary-500 focus:border-transparent"
required
disabled={loading}
>
<option value="">Choose a gift...</option>
{gifts.map((gift) => (
<option key={gift.GiftId} value={gift.GiftId}>
#{gift.GiftId} - {gift.Title || 'Unnamed'} ({gift.Stars} ⭐)
{gift.Limited && ` - Limited (${gift.AvailabilityRemains}/${gift.AvailabilityTotal})`}
</option>
))}
</select>
</div>
{/* Selected Gift Preview */}
{selectedGift && (
<div className="bg-gradient-to-br from-[#2b5278] to-[#3d5a7a] rounded-lg p-4 border border-[#5288c1]/30">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-white">
{selectedGift.Title || `Gift #${selectedGift.GiftId}`}
</h3>
<div className="mt-2 space-y-1 text-sm">
<p className="text-white">💫 Price: <span className="font-medium">{selectedGift.Stars} Stars</span></p>
<p className="text-white">💰 Convert Value: <span className="font-medium">{selectedGift.ConvertStars} Stars</span></p>
{selectedGift.UpgradeStars && (
<p className="text-white">⬆️ Upgrade: <span className="font-medium">{selectedGift.UpgradeStars} Stars</span></p>
)}
{selectedGift.Limited && (
<p className="text-amber-700">🎯 Limited Edition: {selectedGift.AvailabilityRemains} remaining</p>
)}
</div>
</div>
</div>
</div>
)}
{/* User Search */}
<div>
<label className="block text-sm font-medium text-white mb-2">
<Search className="w-4 h-4 inline mr-2" />
Search User (Optional)
</label>
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={handleSearchChange}
placeholder="Search by phone (79123456789) or username"
className="input focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
{searching && (
<div className="absolute right-3 top-3">
<div className="w-5 h-5 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
{/* Search Results */}
{searchResults.length > 0 && (
<div className="mt-2 card border border-[#2b5278] rounded-lg shadow-lg max-h-60 overflow-y-auto">
{searchResults.map((user) => (
<button
key={user._id}
type="button"
onClick={() => selectUser(user)}
className="w-full px-4 py-3 text-left hover:bg-[#0e1621] border-b last:border-b-0 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-white">
{user.FirstName} {user.LastName}
</p>
<p className="text-xs text-[#8b98a5]">
{user.UserName && `@${user.UserName} • `}
{user.PhoneNumber}
</p>
</div>
<span className="text-sm font-mono text-primary-600">
ID: {user.UserId}
</span>
</div>
</button>
))}
</div>
)}
{searchQuery && !searching && searchResults.length === 0 && (
<p className="mt-2 text-sm text-amber-600">
No users found. Try phone number or username.
</p>
)}
</div>
{/* User ID */}
<div>
<label className="block text-sm font-medium text-white mb-2">
<User className="w-4 h-4 inline mr-2" />
Recipient User ID *
</label>
<input
type="number"
value={formData.userId}
onChange={(e) => setFormData({ ...formData, userId: e.target.value })}
placeholder="e.g. 2010001 (or search above)"
className="input focus:ring-2 focus:ring-primary-500 focus:border-transparent"
required
/>
<p className="mt-1 text-xs text-[#8b98a5]">
The Telegram user ID who will receive this gift
</p>
</div>
{/* From User ID */}
<div>
<label className="block text-sm font-medium text-white mb-2">
<User className="w-4 h-4 inline mr-2" />
From User ID (Optional)
</label>
<input
type="number"
value={formData.fromUserId}
onChange={(e) => setFormData({ ...formData, fromUserId: e.target.value })}
placeholder="Leave empty for system gift (0)"
className="input focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<p className="mt-1 text-xs text-[#8b98a5]">
Who sent this gift (0 = System/Admin)
</p>
</div>
{/* Count */}
<div>
<label className="block text-sm font-medium text-white mb-2">
<Send className="w-4 h-4 inline mr-2" />
Quantity
</label>
<input
type="number"
min="1"
value={formData.count}
onChange={(e) => setFormData({ ...formData, count: e.target.value })}
placeholder="1"
className="input focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
<p className="mt-1 text-xs text-[#8b98a5]">
Number of gifts to send (will charge stars per gift)
</p>
</div>
{/* Message */}
<div>
<label className="block text-sm font-medium text-white mb-2">
<MessageSquare className="w-4 h-4 inline mr-2" />
Gift Message (Optional)
</label>
<textarea
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
placeholder="Add a personal message with the gift..."
rows={3}
className="input focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
maxLength={255}
/>
<p className="mt-1 text-xs text-[#8b98a5]">
{formData.message.length}/255 characters
</p>
</div>
{/* Name Hidden */}
<div className="flex items-center gap-3 p-4 bg-[#0e1621] rounded-lg">
<input
type="checkbox"
id="nameHidden"
checked={formData.nameHidden}
onChange={(e) => setFormData({ ...formData, nameHidden: e.target.checked })}
className="w-4 h-4 text-primary-600 border-[#2b5278] rounded focus:ring-primary-500"
/>
<label htmlFor="nameHidden" className="flex items-center gap-2 text-sm text-white cursor-pointer">
<EyeOff className="w-4 h-4" />
<span>Send Anonymously (hide sender name)</span>
</label>
</div>
{/* Submit */}
<div className="flex gap-3 pt-4 border-t">
<button
type="button"
onClick={() => navigate('/gifts')}
className="flex-1 px-6 py-3 border border-[#2b5278] text-white rounded-lg hover:bg-[#0e1621] font-medium transition-colors"
disabled={sending}
>
Cancel
</button>
<button
type="submit"
disabled={sending || !formData.giftId || !formData.userId}
className="flex-1 px-6 py-3 bg-gradient-to-r from-[#2b5278]0 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{sending ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Sending...
</>
) : (
<>
<Send className="w-4 h-4" />
Send Gift
</>
)}
</button>
</div>
</form>
{/* Important Notice */}
<div className="bg-amber-50 border-2 border-amber-300 rounded-xl p-6">
<div className="flex gap-3">
<AlertCircle className="w-6 h-6 text-amber-600 flex-shrink-0" />
<div>
<h3 className="font-semibold text-amber-900 mb-2"> Important: Restart Required</h3>
<p className="text-sm text-amber-800 mb-3">
Gifts are inserted directly into the database. To make them visible in user profiles,
you must <strong>restart the Query Server</strong>:
</p>
<code className="block bg-amber-900 text-amber-100 px-3 py-2 rounded text-sm font-mono">
docker restart messenger-query-server-1
</code>
<p className="text-xs text-amber-700 mt-2">
This is because MyTelegram uses Event Sourcing and caches ReadModel in memory.
</p>
</div>
</div>
</div>
{/* Tips */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-white mb-4">💡 Tips & Tricks</h2>
<ul className="space-y-2 text-sm text-[#8b98a5]">
<li className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span><strong>Search users</strong> - Type phone number or @username to quickly find user ID</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span><strong>Auto-saved</strong> - Gifts sent via admin are automatically saved to user profile</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span><strong>Limited gifts</strong> - Availability counter will automatically decrease</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span><strong>System gifts</strong> - Leave "From User ID" empty to send as System (ID: 0)</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span><strong>Find your ID</strong> - Search for yourself by phone to get your User ID</span>
</li>
</ul>
</div>
</div>
)
}
@@ -0,0 +1,270 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import toast from 'react-hot-toast';
import { Plus, Send, Edit, Trash2, Bell, AlertCircle, Info, FileText, CheckCircle2 } from 'lucide-react';
import SendNotificationModal from '../components/SendNotificationModal';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function ServiceNotifications() {
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
const [sendModalOpen, setSendModalOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState(null);
useEffect(() => {
loadTemplates();
}, []);
const loadTemplates = async () => {
try {
setLoading(true);
const response = await fetch(`${API_URL}/api/service-notifications`);
const data = await response.json();
if (data.success) {
setTemplates(data.templates);
} else {
toast.error('Failed to load templates');
}
} catch (error) {
console.error('Error loading templates:', error);
toast.error('Error loading templates');
} finally {
setLoading(false);
}
};
const handleDelete = async (id) => {
if (!confirm('Are you sure you want to delete this template?')) {
return;
}
try {
const response = await fetch(`${API_URL}/api/service-notifications/${id}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
toast.success('Template deleted successfully');
loadTemplates();
} else {
toast.error(data.error || 'Failed to delete template');
}
} catch (error) {
console.error('Error deleting template:', error);
toast.error('Error deleting template');
}
};
const handleToggleActive = async (template) => {
try {
const response = await fetch(`${API_URL}/api/service-notifications/${template.Id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !template.IsActive })
});
const data = await response.json();
if (data.success) {
toast.success(`Template ${!template.IsActive ? 'activated' : 'deactivated'}`);
loadTemplates();
} else {
toast.error(data.error || 'Failed to update template');
}
} catch (error) {
console.error('Error updating template:', error);
toast.error('Error updating template');
}
};
const handleSendClick = (template) => {
setSelectedTemplate(template);
setSendModalOpen(true);
};
const handleSendComplete = () => {
setSendModalOpen(false);
setSelectedTemplate(null);
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple"></div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<Bell className="w-8 h-8 text-purple" />
Service Notifications
</h1>
<p className="mt-1 text-fg-muted">
Manage system notifications and popups for users
</p>
</div>
<Link
to="/service-notifications/create"
className="btn btn-primary flex items-center justify-center gap-2"
>
<Plus className="w-5 h-5" />
Create Template
</Link>
</div>
{/* Templates List */}
<div className="bg-card rounded-xl shadow-lg overflow-hidden border border-border">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">
Type
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">
Title
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">
Message
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">
Display
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-fg-muted uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{templates.length === 0 ? (
<tr>
<td colSpan="6" className="px-6 py-12 text-center text-fg-muted">
<div className="flex flex-col items-center gap-3">
<div className="w-12 h-12 bg-muted rounded-full flex items-center justify-center">
<Bell className="w-6 h-6 text-fg-muted" />
</div>
<p>No templates found. Create your first notification template!</p>
</div>
</td>
</tr>
) : (
templates.map((template) => (
<tr key={template.Id} className="hover:bg-muted/30 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2.5 py-1 text-xs font-medium bg-blue/10 text-blue border border-blue/20 rounded-lg">
{template.Type}
</span>
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-fg">
{template.Title}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-fg-muted max-w-md truncate" title={template.Message}>
{template.Message}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg border ${template.IsPopup
? 'bg-purple/10 text-purple border-purple/20'
: 'bg-muted text-fg-muted border-border'
}`}>
{template.IsPopup ? <AlertCircle className="w-3 h-3" /> : <FileText className="w-3 h-3" />}
{template.IsPopup ? 'Popup' : 'Message'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleToggleActive(template)}
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg border transition-colors ${template.IsActive
? 'bg-success/10 text-success border-success/20 hover:bg-success/20'
: 'bg-red/10 text-red border-red/20 hover:bg-red/20'
}`}
>
<CheckCircle2 className="w-3 h-3" />
{template.IsActive ? 'Active' : 'Inactive'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end gap-2">
<button
onClick={() => handleSendClick(template)}
disabled={!template.IsActive}
className="p-2 text-blue hover:bg-blue/10 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Send Notification"
>
<Send className="w-4 h-4" />
</button>
<Link
to={`/service-notifications/edit/${template.Id}`}
className="p-2 text-fg-muted hover:text-purple hover:bg-purple/10 rounded-lg transition-colors"
title="Edit Template"
>
<Edit className="w-4 h-4" />
</Link>
<button
onClick={() => handleDelete(template.Id)}
className="p-2 text-red hover:bg-red/10 rounded-lg transition-colors"
title="Delete Template"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Info Box */}
<div className="bg-blue/5 border border-blue/20 rounded-xl p-5 flex items-start gap-4">
<div className="p-2 bg-blue/10 rounded-lg shrink-0">
<Info className="w-5 h-5 text-blue" />
</div>
<div>
<h3 className="text-base font-semibold text-blue mb-2">About Service Notifications</h3>
<ul className="text-sm text-fg-muted space-y-1.5">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue/50"></span>
<span><strong className="text-fg">Popup:</strong> Shows as an alert/popup in the client (user must dismiss)</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue/50"></span>
<span><strong className="text-fg">Message:</strong> Saved as a message from "Telegram" (user 777000)</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue/50"></span>
<span><strong className="text-fg">Type:</strong> Used for deduplication (same type won't show twice within 15 minutes)</span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-blue/50"></span>
<span><strong className="text-fg">Examples:</strong> Premium ads, system announcements, new features</span>
</li>
</ul>
</div>
</div>
{/* Send Modal */}
{sendModalOpen && selectedTemplate && (
<SendNotificationModal
template={selectedTemplate}
onClose={handleSendComplete}
/>
)}
</div>
);
}
@@ -0,0 +1,542 @@
import { useState, useEffect } from 'react';
import { Plus, Edit, Trash2, Power, BarChart3, Filter, X } from 'lucide-react';
import { toast } from 'react-hot-toast';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function SponsoredMessages() {
const [messages, setMessages] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingMessage, setEditingMessage] = useState(null);
const [filterChannelId, setFilterChannelId] = useState('');
const [filterActive, setFilterActive] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const [formData, setFormData] = useState({
channelId: '',
title: '',
message: '',
url: '',
buttonText: 'Learn More',
photoUrl: '',
sponsorInfo: '',
additionalInfo: '',
isActive: true,
recommended: false,
canReport: true,
postsBetween: 10,
expiresDate: null
});
useEffect(() => {
fetchMessages();
}, [filterChannelId, filterActive]);
const fetchMessages = async () => {
try {
setLoading(true);
const params = new URLSearchParams();
if (filterChannelId) params.append('channelId', filterChannelId);
if (filterActive !== '') params.append('isActive', filterActive);
const response = await fetch(`${API_URL}/api/sponsored-messages?${params}`);
const data = await response.json();
setMessages(data.messages || []);
} catch (error) {
console.error('Error fetching sponsored messages:', error);
toast.error('Failed to load sponsored messages');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setActionLoading(true);
try {
const url = editingMessage
? `${API_URL}/api/sponsored-messages/${editingMessage.Id}`
: `${API_URL}/api/sponsored-messages`;
const method = editingMessage ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
channelId: parseInt(formData.channelId),
postsBetween: parseInt(formData.postsBetween),
expiresDate: formData.expiresDate ? Math.floor(new Date(formData.expiresDate).getTime() / 1000) : null
})
});
const data = await response.json();
if (data.success) {
toast.success(editingMessage ? 'Sponsored message updated!' : 'Sponsored message created!');
setShowModal(false);
resetForm();
fetchMessages();
} else {
toast.error('Error: ' + data.error);
}
} catch (error) {
console.error('Error saving sponsored message:', error);
toast.error('Failed to save sponsored message');
} finally {
setActionLoading(false);
}
};
const handleEdit = (message) => {
setEditingMessage(message);
setFormData({
channelId: message.ChannelId?.toString() || '',
title: message.Title || '',
message: message.Message || '',
url: message.Url || '',
buttonText: message.ButtonText || '',
photoUrl: message.PhotoUrl || '',
sponsorInfo: message.SponsorInfo || '',
additionalInfo: message.AdditionalInfo || '',
isActive: message.IsActive,
recommended: message.Recommended,
canReport: message.CanReport,
postsBetween: message.PostsBetween || 10,
expiresDate: message.ExpiresDate ? new Date(message.ExpiresDate * 1000).toISOString().split('T')[0] : ''
});
setShowModal(true);
};
const handleDelete = async (id) => {
if (!window.confirm('Are you sure you want to delete this sponsored message?')) return;
try {
const response = await fetch(`${API_URL}/api/sponsored-messages/${id}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
toast.success('Sponsored message deleted!');
fetchMessages();
} else {
toast.error('Error: ' + data.error);
}
} catch (error) {
console.error('Error deleting sponsored message:', error);
toast.error('Failed to delete sponsored message');
}
};
const handleToggleActive = async (id) => {
try {
const response = await fetch(`${API_URL}/api/sponsored-messages/${id}/toggle`, {
method: 'PATCH'
});
const data = await response.json();
if (data.success) {
fetchMessages();
toast.success('Status updated successfully');
} else {
toast.error('Error: ' + data.error);
}
} catch (error) {
console.error('Error toggling status:', error);
toast.error('Failed to toggle status');
}
};
const resetForm = () => {
setEditingMessage(null);
setFormData({
channelId: '',
title: '',
message: '',
url: '',
buttonText: 'Learn More',
photoUrl: '',
sponsorInfo: '',
additionalInfo: '',
isActive: true,
recommended: false,
canReport: true,
postsBetween: 10,
expiresDate: null
});
};
const formatDate = (timestamp) => {
if (!timestamp) return 'Never';
return new Date(timestamp * 1000).toLocaleDateString();
};
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-heading font-bold text-fg">Sponsored Messages</h1>
<p className="text-fg-muted mt-1">{messages.length} campaigns</p>
</div>
<button
onClick={() => { resetForm(); setShowModal(true); }}
className="btn btn-primary flex items-center justify-center gap-2"
>
<Plus className="w-5 h-5" />
Create Campaign
</button>
</div>
{/* Filters */}
<div className="card p-4">
<div className="flex flex-col md:flex-row items-center gap-4">
<div className="flex items-center gap-2 w-full md:w-auto">
<Filter className="w-5 h-5 text-fg-muted" />
<span className="text-sm font-medium text-fg">Filters:</span>
</div>
<input
type="number"
placeholder="Filter by Channel ID"
className="input md:w-64 w-full"
value={filterChannelId}
onChange={(e) => setFilterChannelId(e.target.value)}
/>
<select
className="input md:w-auto w-full"
value={filterActive}
onChange={(e) => setFilterActive(e.target.value)}
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
{/* Messages List */}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple"></div>
</div>
) : (
<div className="space-y-4">
{messages.map((msg) => (
<div key={msg.Id} className="card p-6 hover:border-purple transition-all duration-300">
<div className="flex flex-col md:flex-row items-start justify-between gap-4">
<div className="flex-1 w-full">
<div className="flex flex-wrap items-center gap-3 mb-2">
<h3 className="text-xl font-bold text-fg">{msg.Title}</h3>
<span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wider ${msg.IsActive
? 'bg-success/10 text-success border border-success/20'
: 'bg-muted text-fg-muted border border-border'
}`}>
{msg.IsActive ? 'Active' : 'Inactive'}
</span>
{msg.Recommended && (
<span className="px-2 py-0.5 bg-blue/10 text-blue border border-blue/20 rounded text-xs font-semibold uppercase tracking-wider">
Recommended
</span>
)}
</div>
<p className="text-fg-muted mb-4 line-clamp-2">{msg.Message}</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm bg-muted/30 p-4 rounded-lg border border-border">
<div>
<span className="text-fg-muted block text-xs uppercase tracking-wide mb-1">Channel ID</span>
<p className="font-medium text-fg font-mono">{msg.ChannelId}</p>
</div>
<div>
<span className="text-fg-muted block text-xs uppercase tracking-wide mb-1">URL</span>
<p className="font-medium truncate">
<a href={msg.Url} target="_blank" rel="noopener noreferrer" className="text-purple hover:underline hover:text-purple/80">
{msg.Url}
</a>
</p>
</div>
<div>
<span className="text-fg-muted block text-xs uppercase tracking-wide mb-1">Frequency</span>
<p className="font-medium text-fg">Every {msg.PostsBetween || 10} posts</p>
</div>
<div>
<span className="text-fg-muted block text-xs uppercase tracking-wide mb-1">Expires</span>
<p className="font-medium text-fg">{formatDate(msg.ExpiresDate)}</p>
</div>
</div>
<div className="mt-4 flex items-center gap-6 text-sm text-fg-muted">
<span className="flex items-center gap-1.5" title="Displays">
<BarChart3 className="w-4 h-4" /> {msg.DisplayCount || 0}
</span>
<span className="flex items-center gap-1.5" title="Clicks">
<span className="text-lg leading-none">🖱</span> {msg.ClickCount || 0}
</span>
<span className="ml-auto text-xs">Created: {formatDate(msg.CreatedDate)}</span>
</div>
</div>
<div className="flex gap-2 self-start">
<button
onClick={() => handleToggleActive(msg.Id)}
className={`p-2 rounded-lg border transition-colors ${msg.IsActive
? 'border-yellow text-yellow hover:bg-yellow/10'
: 'border-success text-success hover:bg-success/10'
}`}
title={msg.IsActive ? 'Deactivate' : 'Activate'}
>
<Power className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(msg)}
className="p-2 rounded-lg border border-border text-fg-muted hover:text-purple hover:border-purple hover:bg-purple/5 transition-colors"
title="Edit"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(msg.Id)}
className="p-2 rounded-lg border border-red text-red hover:bg-red/10 transition-colors"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
{messages.length === 0 && (
<div className="text-center py-20 bg-card rounded-xl border border-border border-dashed">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
<Plus className="w-8 h-8 text-fg-muted" />
</div>
<h3 className="text-lg font-medium text-fg mb-1">No campaigns yet</h3>
<p className="text-fg-muted mb-4">Create your first sponsored message campaign</p>
<button
onClick={() => { resetForm(); setShowModal(true); }}
className="btn btn-primary"
>
Create Campaign
</button>
</div>
)}
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in">
<div className="bg-card border border-border text-fg rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-scale-in">
<div className="p-6 border-b border-border flex justify-between items-center sticky top-0 bg-card z-10">
<h2 className="text-2xl font-heading font-bold">
{editingMessage ? 'Edit Campaign' : 'Create Campaign'}
</h2>
<button onClick={() => setShowModal(false)} className="text-fg-muted hover:text-fg transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6">
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label className="block text-sm font-medium text-fg mb-1.5">Channel ID <span className="text-red">*</span></label>
<input
type="number"
required
className="input w-full"
value={formData.channelId}
onChange={(e) => setFormData({ ...formData, channelId: e.target.value })}
placeholder="-100..."
/>
</div>
<div>
<label className="block text-sm font-medium text-fg mb-1.5">Posts Between Ads</label>
<input
type="number"
min="1"
max="100"
className="input w-full"
value={formData.postsBetween}
onChange={(e) => setFormData({ ...formData, postsBetween: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-fg mb-1.5">Title <span className="text-red">*</span></label>
<input
type="text"
required
maxLength="100"
className="input w-full"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="Campaign Title"
/>
</div>
<div>
<label className="block text-sm font-medium text-fg mb-1.5">Message <span className="text-red">*</span></label>
<textarea
required
maxLength="500"
rows="3"
className="input w-full resize-none"
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
placeholder="Ad content..."
/>
</div>
<div>
<label className="block text-sm font-medium text-fg mb-1.5">URL <span className="text-red">*</span></label>
<input
type="url"
required
className="input w-full"
value={formData.url}
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
placeholder="https://..."
/>
</div>
<div>
<label className="block text-sm font-medium text-fg mb-1.5">Button Text <span className="text-red">*</span></label>
<input
type="text"
required
maxLength="50"
className="input w-full"
value={formData.buttonText}
onChange={(e) => setFormData({ ...formData, buttonText: e.target.value })}
placeholder="e.g. Learn More"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label className="block text-sm font-medium text-fg mb-1.5">Photo URL (optional)</label>
<input
type="url"
className="input w-full"
value={formData.photoUrl}
onChange={(e) => setFormData({ ...formData, photoUrl: e.target.value })}
placeholder="https://..."
/>
</div>
<div>
<label className="block text-sm font-medium text-fg mb-1.5">Expires Date (optional)</label>
<input
type="date"
className="input w-full"
value={formData.expiresDate || ''}
onChange={(e) => setFormData({ ...formData, expiresDate: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label className="block text-sm font-medium text-fg mb-1.5">Sponsor Info (optional)</label>
<input
type="text"
maxLength="200"
className="input w-full"
value={formData.sponsorInfo}
onChange={(e) => setFormData({ ...formData, sponsorInfo: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-fg mb-1.5">Additional Info (optional)</label>
<input
type="text"
maxLength="200"
className="input w-full"
value={formData.additionalInfo}
onChange={(e) => setFormData({ ...formData, additionalInfo: e.target.value })}
/>
</div>
</div>
<div className="flex flex-wrap gap-6 pt-2">
<label className="flex items-center gap-2 cursor-pointer group">
<div className="relative flex items-center">
<input
type="checkbox"
className="peer sr-only"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
/>
<div className="w-11 h-6 bg-muted rounded-full peer peer-focus:ring-2 peer-focus:ring-purple/20 dark:peer-focus:ring-purple/40 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple"></div>
</div>
<span className="text-sm font-medium text-fg group-hover:text-purple transition-colors">Active</span>
</label>
<label className="flex items-center gap-2 cursor-pointer group">
<div className="relative flex items-center">
<input
type="checkbox"
className="peer sr-only"
checked={formData.recommended}
onChange={(e) => setFormData({ ...formData, recommended: e.target.checked })}
/>
<div className="w-11 h-6 bg-muted rounded-full peer peer-focus:ring-2 peer-focus:ring-purple/20 dark:peer-focus:ring-purple/40 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple"></div>
</div>
<span className="text-sm font-medium text-fg group-hover:text-purple transition-colors">Recommended</span>
</label>
<label className="flex items-center gap-2 cursor-pointer group">
<div className="relative flex items-center">
<input
type="checkbox"
className="peer sr-only"
checked={formData.canReport}
onChange={(e) => setFormData({ ...formData, canReport: e.target.checked })}
/>
<div className="w-11 h-6 bg-muted rounded-full peer peer-focus:ring-2 peer-focus:ring-purple/20 dark:peer-focus:ring-purple/40 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple"></div>
</div>
<span className="text-sm font-medium text-fg group-hover:text-purple transition-colors">Can Report</span>
</label>
</div>
<div className="flex gap-3 pt-6 border-t border-border">
<button
type="submit"
disabled={actionLoading}
className="btn btn-primary flex-1 py-2.5 font-semibold text-base"
>
{actionLoading ? 'Saving...' : (editingMessage ? 'Update Campaign' : 'Create Campaign')}
</button>
<button
type="button"
disabled={actionLoading}
onClick={() => { setShowModal(false); resetForm(); }}
className="btn btn-secondary px-6"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,184 @@
import { useEffect, useState } from 'react'
import { TrendingUp, Gift, Star, Users } from 'lucide-react'
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
import { statsApi } from '../lib/api'
const COLORS = ['#f97316', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899']
export default function Statistics() {
const [stats, setStats] = useState(null)
const [sentStats, setSentStats] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadStats()
}, [])
const loadStats = async () => {
try {
const [overview, sent] = await Promise.all([
statsApi.getOverview(),
statsApi.getSentStats()
])
setStats(overview.data)
setSentStats(sent.data)
} catch (error) {
console.error('Failed to load stats:', error)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
)
}
const giftDistribution = [
{ name: 'Available', value: stats.availableGifts, color: COLORS[2] },
{ name: 'Sold Out', value: stats.soldOutGifts, color: COLORS[0] },
]
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-black font-heading text-fg">Statistics</h1>
<p className="text-fg-muted font-medium mt-1">Analytics and insights</p>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{[
{ label: 'Total Gifts', value: stats.totalGifts, icon: Gift, color: 'text-blue-500' },
{ label: 'Sent', value: stats.totalSentGifts, icon: TrendingUp, color: 'text-success' },
{ label: 'Stars Earned', value: stats.totalStarsEarned?.toLocaleString(), icon: Star, color: 'text-yellow-500' },
{ label: 'Limited', value: stats.limitedGifts, icon: Gift, color: 'text-purple-500' },
].map((stat, i) => (
<div key={i} className="card group">
<div className="flex justify-between items-start">
<div>
<p className="text-sm font-bold text-fg-muted uppercase tracking-wider">{stat.label}</p>
<p className="text-2xl font-black font-heading text-fg mt-1 group-hover:text-accent transition-colors">{stat.value}</p>
</div>
<div className="p-2 bg-bg-app rounded-lg border border-border group-hover:border-accent group-hover:shadow-[0_0_10px_var(--accent-glow)] transition-all">
<stat.icon className={`w-6 h-6 ${stat.color} group-hover:text-accent transition-colors`} />
</div>
</div>
</div>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Gift Distribution */}
<div className="card">
<h3 className="text-lg font-bold font-heading text-fg mb-4">Gift Status Distribution</h3>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={giftDistribution}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{giftDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} stroke="var(--bg-panel)" strokeWidth={2} />
))}
</Pie>
<Tooltip
contentStyle={{ backgroundColor: 'var(--bg-panel)', borderColor: 'var(--border)', color: 'var(--fg)' }}
itemStyle={{ color: 'var(--fg)' }}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Conversion Rate */}
{sentStats?.conversion && (
<div className="card">
<h3 className="text-lg font-bold font-heading text-fg mb-4">Gift Actions</h3>
<div className="space-y-6">
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-fg-muted font-medium">Saved</span>
<span className="font-bold text-fg">{sentStats.conversion[0]?.saved || 0}</span>
</div>
<div className="w-full bg-bg-app rounded-full h-2 border border-border overflow-hidden">
<div
className="bg-success h-full rounded-full shadow-[0_0_10px_var(--success)]"
style={{ width: `${(sentStats.conversion[0]?.saved / sentStats.conversion[0]?.total * 100) || 0}%` }}
></div>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-fg-muted font-medium">Converted</span>
<span className="font-bold text-fg">{sentStats.conversion[0]?.converted || 0}</span>
</div>
<div className="w-full bg-bg-app rounded-full h-2 border border-border overflow-hidden">
<div
className="bg-accent h-full rounded-full shadow-[0_0_10px_var(--accent)]"
style={{ width: `${(sentStats.conversion[0]?.converted / sentStats.conversion[0]?.total * 100) || 0}%` }}
></div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Daily Stats */}
{sentStats?.byDay && sentStats.byDay.length > 0 && (
<div className="card">
<h3 className="text-lg font-bold font-heading text-fg mb-4">Gifts Sent Over Time</h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={sentStats.byDay.reverse()}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="_id" stroke="var(--fg-muted)" />
<YAxis stroke="var(--fg-muted)" />
<Tooltip
contentStyle={{ backgroundColor: 'var(--bg-panel)', borderColor: 'var(--border)', color: 'var(--fg)' }}
labelStyle={{ color: 'var(--accent)' }}
/>
<Line type="monotone" dataKey="count" stroke="var(--accent)" strokeWidth={3} dot={{ fill: 'var(--bg-app)', strokeWidth: 2 }} activeDot={{ r: 6, fill: 'var(--accent)' }} name="Gifts Sent" />
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Top Gifts */}
{sentStats?.byGift && sentStats.byGift.length > 0 && (
<div className="card">
<h3 className="text-lg font-bold font-heading text-fg mb-4">Most Sent Gifts</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={sentStats.byGift.slice(0, 10)}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="_id" stroke="var(--fg-muted)" />
<YAxis stroke="var(--fg-muted)" />
<Tooltip
contentStyle={{ backgroundColor: 'var(--bg-panel)', borderColor: 'var(--border)', color: 'var(--fg)' }}
cursor={{ fill: 'var(--bg-app)' }}
/>
<Bar dataKey="count" fill="var(--accent)" radius={[4, 4, 0, 0]} name="Times Sent" />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
)
}
@@ -0,0 +1,242 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Sticker, Plus, Search, ExternalLink, Trash2, Edit, FileArchive, Star } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function StickerPacks() {
const [packs, setPacks] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
useEffect(() => {
fetchPacks();
}, [currentPage, searchTerm]);
const fetchPacks = async () => {
try {
setLoading(true);
const response = await fetch(
`/api/stickerpacks?page=${currentPage}&limit=20&search=${searchTerm}`
);
const data = await response.json();
console.log('Sticker packs response:', data);
setPacks(data.packs || []);
setTotalPages(data.pagination?.totalPages || 1);
} catch (error) {
console.error('Error fetching sticker packs:', error);
toast.error('Failed to load sticker packs');
setPacks([]);
} finally {
setLoading(false);
}
};
const deletePack = async (packId, packName) => {
if (!confirm(`Delete sticker pack "${packName}"? This will delete all stickers in the pack.`)) {
return;
}
try {
const response = await fetch(`/api/stickerpacks/${packId}`, {
method: 'DELETE'
});
if (response.ok) {
toast.success('Pack deleted successfully');
fetchPacks();
} else {
toast.error('Failed to delete pack');
}
} catch (error) {
console.error('Error deleting pack:', error);
toast.error('Error deleting pack');
}
};
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
<Sticker className="w-8 h-8 text-blue" />
Sticker Packs
</h1>
<p className="text-fg-muted mt-1">Manage regular sticker sets (not custom emoji)</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
to="/stickerpacks/featured"
className="btn btn-secondary text-yellow hover:text-yellow/80 hover:bg-yellow/10 border-yellow/20 flex items-center gap-2"
>
<Star className="w-5 h-5" />
Featured
</Link>
<Link
to="/stickerpacks/bulk-upload"
className="btn bg-success/10 text-success hover:bg-success/20 border-success/20 flex items-center gap-2"
>
<FileArchive className="w-5 h-5" />
Bulk ZIP
</Link>
<Link
to="/stickerpacks/bulk-upload-stickers"
className="btn bg-purple/10 text-purple hover:bg-purple/20 border-purple/20 flex items-center gap-2"
>
<FileArchive className="w-5 h-5" />
Bulk TGS
</Link>
<Link
to="/stickerpacks/create"
className="btn btn-primary flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Create Pack
</Link>
</div>
</div>
{/* Search */}
<div className="card p-4">
<div className="relative max-w-md">
<Search className="absolute left-3 top-3 w-5 h-5 text-fg-muted" />
<input
type="text"
placeholder="Search by title or short name..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="input pl-10 w-full"
/>
</div>
</div>
{/* Packs Grid */}
{loading ? (
<div className="flex justify-center items-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue"></div>
</div>
) : packs.length === 0 ? (
<div className="text-center py-20 card">
<Sticker className="w-16 h-16 text-fg-muted mx-auto mb-4" />
<p className="text-fg-muted text-lg">No sticker packs found</p>
<Link
to="/stickerpacks/create"
className="mt-6 btn btn-primary inline-flex"
>
<Plus className="w-5 h-5 mr-2" />
Create First Pack
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{packs.map((pack) => (
<div
key={pack.StickerSetId}
className="card p-6 hover:border-blue/50 transition-colors group"
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold text-fg group-hover:text-blue transition-colors">
{pack.Title}
</h3>
<p className="text-sm text-fg-muted">@{pack.ShortName}</p>
</div>
<div className="flex gap-2">
<Link
to={`/stickerpacks/${pack.StickerSetId}/edit`}
className="p-2 text-fg-muted hover:text-blue hover:bg-blue/10 rounded-lg transition-colors"
>
<Edit className="w-5 h-5" />
</Link>
<button
onClick={() => deletePack(pack.StickerSetId, pack.Title)}
className="p-2 text-fg-muted hover:text-red hover:bg-red/10 rounded-lg transition-colors"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
<div className="space-y-3 mb-6 bg-muted/30 p-4 rounded-lg">
<div className="flex items-center justify-between text-sm">
<span className="text-fg-muted">Stickers</span>
<span className="font-semibold text-fg">{pack.Count || 0}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-fg-muted">Type</span>
<span className="text-fg">
{pack.Masks ? 'Mask Stickers' : 'Regular Stickers'}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-fg-muted">Status</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${pack.Archived
? 'bg-red/10 text-red'
: 'bg-success/10 text-success'
}`}>
{pack.Archived ? 'Archived' : 'Active'}
</span>
</div>
</div>
<div className="flex gap-3">
<Link
to={`/stickerpacks/${pack.StickerSetId}`}
className="flex-1 btn btn-secondary text-center justify-center"
>
Manage
</Link>
<a
href={`https://t.me/addstickers/${pack.ShortName}`}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost px-3 text-fg-muted hover:text-blue"
title="Open in Telegram"
>
<ExternalLink className="w-5 h-5" />
</a>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2 pt-6">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="btn btn-secondary disabled:opacity-50"
>
Previous
</button>
<span className="btn btn-ghost cursor-default">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="btn btn-secondary disabled:opacity-50"
>
Next
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,276 @@
.user-management {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.header h1 {
margin: 0;
font-size: 28px;
}
.back-btn {
padding: 10px 20px;
background: #6c757d;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.back-btn:hover {
background: #5a6268;
}
.search-bar {
margin-bottom: 20px;
}
.search-input {
width: 100%;
padding: 12px;
font-size: 16px;
border: 1px solid #2b5278;
border-radius: 5px;
}
.loading {
text-align: center;
padding: 40px;
font-size: 18px;
color: #8b98a5;
}
.users-table {
background: #17212b;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.users-table table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
background: #f8f9fa;
padding: 12px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #dee2e6;
}
.users-table td {
padding: 12px;
border-bottom: 1px solid #dee2e6;
}
.users-table tr:hover {
background: #f8f9fa;
}
.users-table tr.frozen {
background: #fff3cd;
}
.users-table tr.frozen:hover {
background: #ffe69c;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
margin-left: 5px;
}
.badge.bot {
background: #17a2b8;
color: white;
}
.badge.verified {
background: #28a745;
color: white;
}
.badge.premium {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.status {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.status.active {
background: #d4edda;
color: #155724;
}
.status.frozen {
background: #fff3cd;
color: #856404;
}
.status.deleted {
background: #f8d7da;
color: #721c24;
}
.actions {
display: flex;
gap: 8px;
}
.btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn.freeze {
background: #ffc107;
color: #000;
}
.btn.freeze:hover:not(:disabled) {
background: #e0a800;
}
.btn.unfreeze {
background: #28a745;
color: white;
}
.btn.unfreeze:hover {
background: #218838;
}
.btn.delete {
background: #dc3545;
color: white;
}
.btn.delete:hover:not(:disabled) {
background: #c82333;
}
.btn.cancel {
background: #6c757d;
color: white;
}
.btn.cancel:hover {
background: #5a6268;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
padding: 20px;
}
.pagination button {
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.pagination button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.pagination button:hover:not(:disabled) {
background: #0056b3;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: #17212b;
padding: 30px;
border-radius: 8px;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.modal h2 {
margin-top: 0;
margin-bottom: 20px;
}
.modal p {
margin-bottom: 20px;
color: #8b98a5;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
}
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #2b5278;
border-radius: 4px;
font-family: inherit;
font-size: 14px;
resize: vertical;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
@@ -0,0 +1,389 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, UserX, Trash2, Shield, ChevronLeft, ChevronRight, X, Star } from 'lucide-react';
function UserManagement() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [selectedUser, setSelectedUser] = useState(null);
const [showFreezeModal, setShowFreezeModal] = useState(false);
const [freezeReason, setFreezeReason] = useState('Account restricted for violating Terms of Service');
const navigate = useNavigate();
useEffect(() => {
loadUsers();
}, [page, search]);
const loadUsers = async () => {
setLoading(true);
try {
const response = await fetch(`/api/users?page=${page}&limit=20&search=${search}`);
const data = await response.json();
if (data.success) {
setUsers(data.users);
setTotalPages(data.pagination.pages);
}
} catch (error) {
console.error('Failed to load users:', error);
alert('Failed to load users');
} finally {
setLoading(false);
}
};
const handleFreeze = (user) => {
setSelectedUser(user);
setShowFreezeModal(true);
};
const confirmFreeze = async () => {
if (!selectedUser) return;
try {
const response = await fetch(`/api/users/${selectedUser.UserId}/freeze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: freezeReason })
});
const data = await response.json();
if (data.success) {
alert(`User ${selectedUser.UserId} has been frozen`);
setShowFreezeModal(false);
loadUsers();
} else {
alert('Failed to freeze user: ' + data.error);
}
} catch (error) {
console.error('Freeze error:', error);
alert('Failed to freeze user');
}
};
const handleUnfreeze = async (user) => {
if (!confirm(`Unfreeze user ${user.UserId} (${user.FirstName})?`)) return;
try {
const response = await fetch(`/api/users/${user.UserId}/unfreeze`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
alert(`User ${user.UserId} has been unfrozen`);
loadUsers();
} else {
alert('Failed to unfreeze user: ' + data.error);
}
} catch (error) {
console.error('Unfreeze error:', error);
alert('Failed to unfreeze user');
}
};
const handleDelete = async (user) => {
if (!confirm(`Delete user ${user.UserId} (${user.FirstName})? This will mark account as deleted.`)) return;
try {
const response = await fetch(`/api/users/${user.UserId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
alert(`User ${user.UserId} has been deleted`);
loadUsers();
} else {
alert('Failed to delete user: ' + data.error);
}
} catch (error) {
console.error('Delete error:', error);
alert('Failed to delete user');
}
};
const handleRemovePremium = async (user) => {
if (!confirm(`Remove Premium from ${user.FirstName} (${user.UserId})?`)) return;
try {
const response = await fetch(`/api/users/${user.UserId}/premium`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
alert(`Premium removed from user ${user.UserId}`);
loadUsers();
} else {
alert('Failed to remove premium: ' + data.error);
}
} catch (error) {
console.error('Remove premium error:', error);
alert('Failed to remove premium');
}
};
const handleGivePremium = async (user) => {
if (!confirm(`Give Premium to ${user.FirstName} (${user.UserId})?`)) return;
try {
const response = await fetch(`/api/users/${user.UserId}/premium`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
alert(`Premium given to user ${user.UserId}`);
loadUsers();
} else {
alert('Failed to give premium: ' + data.error);
}
} catch (error) {
console.error('Give premium error:', error);
alert('Failed to give premium');
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-black font-heading text-fg">User Management</h1>
<p className="text-fg-muted font-medium mt-1">Manage user accounts and permissions</p>
</div>
</div>
{/* Search Bar */}
<div className="card p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-fg-muted" />
<input
type="text"
placeholder="Search by name, username, or phone..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="input pl-10 bg-bg-app border-border focus:border-accent w-full"
/>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-accent"></div>
</div>
) : (
<>
{/* Users Table */}
<div className="card overflow-hidden p-0 border border-border">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-bg-panel border-b border-border">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">User ID</th>
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">Name</th>
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">Username</th>
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">Phone</th>
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">Status</th>
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">Premium</th>
<th className="px-6 py-4 text-right text-xs font-bold text-fg-muted uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-bg-app">
{users.map(user => (
<tr key={user.UserId} className="hover:bg-bg-panel/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-fg font-mono text-sm font-medium">{user.UserId}</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<span className="text-fg font-bold">{user.FirstName} {user.LastName}</span>
{user.Bot && <span className="px-2 py-0.5 bg-blue-500/10 text-blue-400 border border-blue-500/20 text-[10px] rounded font-bold uppercase tracking-wider">BOT</span>}
{user.Verified && <span className="text-accent" title="Verified"></span>}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-accent font-medium text-sm">@{user.UserName || <span className="text-fg-muted font-normal">N/A</span>}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-fg-muted font-mono text-sm">{user.PhoneNumber}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{user.Restricted ? (
<span className="px-2 py-0.5 bg-red-500/10 text-red-400 border border-red-500/20 text-[10px] rounded-full font-bold uppercase tracking-wider" title={user.RestrictionReason}>
🔒 FROZEN
</span>
) : user.IsDeleted ? (
<span className="px-2 py-0.5 bg-bg-panel text-fg-muted border border-border text-[10px] rounded-full font-bold uppercase tracking-wider">
🗑 DELETED
</span>
) : (
<span className="px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 text-[10px] rounded-full font-bold uppercase tracking-wider">
ACTIVE
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{user.Premium ? (
<span className="px-2 py-0.5 bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 text-[10px] rounded-full font-bold uppercase tracking-wider">
Premium
</span>
) : (
<span className="px-2 py-0.5 bg-bg-panel text-fg-muted border border-border text-[10px] rounded-full font-bold uppercase tracking-wider">
Free
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
{user.Premium ? (
<button
onClick={() => handleRemovePremium(user)}
className="p-1.5 bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-400 rounded-lg transition-colors"
title="Remove Premium"
>
<div className="w-4 h-4 flex items-center justify-center font-bold text-xs"></div>
</button>
) : (
<button
onClick={() => handleGivePremium(user)}
className="p-1.5 bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-400 rounded-lg transition-colors"
title="Give Premium"
>
<div className="w-4 h-4 flex items-center justify-center font-bold text-xs">+</div>
</button>
)}
{user.Restricted ? (
<button
onClick={() => handleUnfreeze(user)}
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 rounded-lg text-xs font-bold uppercase tracking-wider transition-colors flex items-center gap-1 border border-emerald-500/10"
>
<Shield className="w-3.5 h-3.5" />
Unfreeze
</button>
) : (
<button
onClick={() => handleFreeze(user)}
className="px-3 py-1.5 bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 rounded-lg text-xs font-bold uppercase tracking-wider transition-colors flex items-center gap-1 border border-orange-500/10 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={user.IsDeleted}
>
<UserX className="w-3.5 h-3.5" />
Freeze
</button>
)}
<button
onClick={() => handleDelete(user)}
className="p-1.5 text-fg-muted hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={user.IsDeleted}
title="Delete User"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
<div className="flex items-center justify-between bg-bg-panel border border-border rounded-lg px-6 py-4">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 border border-border bg-bg-app hover:bg-bg-panel text-fg rounded-lg flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
>
<ChevronLeft className="w-4 h-4" />
Previous
</button>
<span className="text-fg font-medium">
Page <span className="text-accent font-bold">{page}</span> of <span className="text-fg-muted">{totalPages}</span>
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 border border-border bg-bg-app hover:bg-bg-panel text-fg rounded-lg flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
>
Next
<ChevronRight className="w-4 h-4" />
</button>
</div>
</>
)}
{/* Freeze Modal */}
{showFreezeModal && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in" onClick={() => setShowFreezeModal(false)}>
<div className="card w-full max-w-md mx-4 shadow-2xl shadow-black/50 border-border scale-100 animate-scale-in" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-6 pb-4 border-b border-border">
<h2 className="text-xl font-black font-heading text-fg flex items-center gap-2">
<UserX className="w-6 h-6 text-orange-400" />
Freeze Account
</h2>
<button onClick={() => setShowFreezeModal(false)} className="text-fg-muted hover:text-fg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="bg-bg-app border border-border rounded-xl p-4 mb-6">
<p className="text-fg-muted text-xs font-bold uppercase tracking-wider mb-1">User</p>
<p className="text-fg font-bold text-lg">{selectedUser?.FirstName} {selectedUser?.LastName}</p>
<p className="text-fg-muted text-sm mt-1">ID: <span className="text-accent font-mono">{selectedUser?.UserId}</span></p>
</div>
<div className="mb-6">
<label className="block text-fg-muted text-xs font-bold uppercase tracking-wider mb-2">Restriction Reason</label>
<textarea
value={freezeReason}
onChange={(e) => setFreezeReason(e.target.value)}
rows={4}
placeholder="Enter reason for account restriction..."
className="input w-full resize-none h-32"
/>
</div>
<div className="flex gap-3">
<button
onClick={confirmFreeze}
className="flex-1 btn bg-orange-500 hover:bg-orange-600 text-white shadow-[0_0_15px_rgba(249,115,22,0.3)] border-none"
>
Freeze Account
</button>
<button
onClick={() => setShowFreezeModal(false)}
className="flex-1 btn btn-secondary"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default UserManagement;
+364
View File
@@ -0,0 +1,364 @@
import { useState, useEffect } from 'react'
import { Users as UsersIcon, Search, Copy, Check, Bot, UserX, ChevronLeft, ChevronRight, Hash } from 'lucide-react'
import toast from 'react-hot-toast'
export default function Users() {
const [users, setUsers] = useState([])
const [channels, setChannels] = useState([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [copiedId, setCopiedId] = useState(null)
const [showChannels, setShowChannels] = useState(false)
const [pagination, setPagination] = useState({
page: 1,
limit: 50,
total: 0,
totalPages: 0
})
useEffect(() => {
if (showChannels) {
fetchChannels()
} else {
fetchUsers()
}
}, [pagination.page, searchQuery, showChannels])
const fetchUsers = async () => {
setLoading(true)
try {
const params = new URLSearchParams({
page: pagination.page,
limit: pagination.limit,
search: searchQuery
})
const response = await fetch(`/api/users?${params}`)
const data = await response.json()
setUsers(data.users)
setPagination(prev => ({
...prev,
total: data.pagination.total,
totalPages: data.pagination.totalPages
}))
} catch (error) {
toast.error('Failed to load users')
} finally {
setLoading(false)
}
}
const fetchChannels = async () => {
setLoading(true)
try {
const response = await fetch('/api/channels')
const data = await response.json()
setChannels(data.channels || [])
setPagination(prev => ({
...prev,
total: data.channels?.length || 0,
totalPages: 1
}))
} catch (error) {
toast.error('Failed to load channels')
} finally {
setLoading(false)
}
}
const copyToClipboard = (userId) => {
navigator.clipboard.writeText(userId.toString())
setCopiedId(userId)
toast.success('User ID copied!')
setTimeout(() => setCopiedId(null), 2000)
}
const handleSearchChange = (e) => {
setSearchQuery(e.target.value)
setPagination(prev => ({ ...prev, page: 1 }))
}
const nextPage = () => {
if (pagination.page < pagination.totalPages) {
setPagination(prev => ({ ...prev, page: prev.page + 1 }))
}
}
const prevPage = () => {
if (pagination.page > 1) {
setPagination(prev => ({ ...prev, page: prev.page - 1 }))
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-black font-heading text-fg">
{showChannels ? 'All Channels' : 'All Users'}
</h1>
<p className="mt-1 text-fg-muted font-medium">
{pagination.total} {showChannels ? 'channels' : 'users'} in MyTelegram
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => {
setShowChannels(!showChannels)
setPagination(prev => ({ ...prev, page: 1 }))
setSearchQuery('')
}}
className="btn btn-secondary flex items-center gap-2"
>
{showChannels ? <UsersIcon className="w-5 h-5" /> : <Hash className="w-5 h-5" />}
{showChannels ? 'Show Users' : 'Show Channels'}
</button>
<div className="p-2 bg-bg-app rounded-lg border border-border">
{showChannels ? <Hash className="w-8 h-8 text-accent" /> : <UsersIcon className="w-8 h-8 text-accent" />}
</div>
</div>
</div>
{/* Search */}
<div className="card p-4">
<div className="relative">
<Search className="absolute left-3 top-3 w-5 h-5 text-fg-muted" />
<input
type="text"
value={searchQuery}
onChange={handleSearchChange}
placeholder="Search by name, username, or phone..."
className="input pl-10 bg-bg-app border-border focus:border-accent"
/>
</div>
</div>
{/* Users Table */}
<div className="card overflow-hidden p-0 border border-border">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : users.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-fg-muted">
<UserX className="w-12 h-12 mb-2" />
<p>No users found</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-bg-panel border-b border-border">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">
{showChannels ? 'Channel ID' : 'User ID'}
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">
{showChannels ? 'Title' : 'Name'}
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">
Username
</th>
{!showChannels && (
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">
Phone
</th>
)}
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">
{showChannels ? 'Members' : 'Type'}
</th>
<th className="px-6 py-4 text-right text-xs font-bold text-fg-muted uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-bg-app">
{showChannels ? channels.map((channel) => (
<tr key={channel._id} className="hover:bg-bg-panel/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-medium text-fg">
{channel.ChannelId}
</span>
<button
onClick={() => copyToClipboard(channel.ChannelId)}
className="p-1 hover:bg-bg-panel rounded transition-colors group"
title="Copy Channel ID"
>
{copiedId === channel.ChannelId ? (
<Check className="w-3.5 h-3.5 text-success" />
) : (
<Copy className="w-3.5 h-3.5 text-fg-muted group-hover:text-fg" />
)}
</button>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-bold text-fg">
{channel.Title || <span className="text-fg-muted">Untitled</span>}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-accent">
{channel.UserName ? `@${channel.UserName}` : <span className="text-fg-muted font-normal"></span>}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-fg-muted">
<span className="text-fg font-bold">{channel.MembersCount || 0}</span> members
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<a
href={`/sponsored-messages?channelId=${channel.ChannelId}`}
className="text-accent hover:text-accent-hover text-sm font-bold transition-colors"
>
Create Ad
</a>
</td>
</tr>
)) : users.map((user) => (
<tr key={user._id} className="hover:bg-bg-panel/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-medium text-fg">
{user.UserId}
</span>
<button
onClick={() => copyToClipboard(user.UserId)}
className="p-1 hover:bg-bg-panel rounded transition-colors group"
title="Copy User ID"
>
{copiedId === user.UserId ? (
<Check className="w-3.5 h-3.5 text-success" />
) : (
<Copy className="w-3.5 h-3.5 text-fg-muted group-hover:text-fg" />
)}
</button>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-bold text-fg">
{user.FirstName} {user.LastName}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-accent">
{user.UserName ? `@${user.UserName}` : <span className="text-fg-muted font-normal"></span>}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-mono text-fg-muted">
{user.PhoneNumber || <span className="text-fg-muted"></span>}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{user.Bot && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-500/10 text-blue-400 border border-blue-500/20 rounded text-[10px] font-bold uppercase tracking-wider">
<Bot className="w-3 h-3" />
Bot
</span>
)}
{user.IsDeleted && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-red-500/10 text-red-500 border border-red-500/20 rounded text-[10px] font-bold uppercase tracking-wider">
<UserX className="w-3 h-3" />
Deleted
</span>
)}
{!user.Bot && !user.IsDeleted && (
<span className="text-xs font-medium text-fg-muted px-2 py-0.5 rounded bg-bg-panel border border-border">User</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<a
href={`/gifts/send?userId=${user.UserId}`}
className="text-accent hover:text-accent-hover text-sm font-bold transition-colors"
>
Send Gift
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="bg-bg-panel px-6 py-4 border-t border-border flex items-center justify-between">
<div className="text-sm text-fg-muted">
Showing <span className="font-bold text-fg">{(pagination.page - 1) * pagination.limit + 1}</span> to{' '}
<span className="font-bold text-fg">
{Math.min(pagination.page * pagination.limit, pagination.total)}
</span>{' '}
of <span className="font-bold text-fg">{pagination.total}</span> users
</div>
<div className="flex items-center gap-2">
<button
onClick={prevPage}
disabled={pagination.page === 1}
className="px-4 py-2 border border-border rounded-lg text-sm font-medium text-fg hover:bg-bg-app hover:border-accent/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2"
>
<ChevronLeft className="w-4 h-4" />
Previous
</button>
<span className="px-4 py-2 text-sm text-fg">
Page <span className="font-bold text-accent">{pagination.page}</span> of{' '}
<span className="font-medium">{pagination.totalPages}</span>
</span>
<button
onClick={nextPage}
disabled={pagination.page === pagination.totalPages}
className="px-4 py-2 border border-border rounded-lg text-sm font-medium text-fg hover:bg-bg-app hover:border-accent/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2"
>
Next
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</>
)}
</div>
{/* Info */}
<div className="bg-accent/10 border border-accent/20 rounded-lg p-5 flex gap-4 animate-fade-in shadow-[0_0_15px_var(--accent-glow-subtle)]">
<div className="p-2 bg-accent/20 rounded-lg h-fit">
<span className="text-lg">💡</span>
</div>
<div className="text-sm text-fg">
<p className="font-bold text-accent mb-2">Quick Actions</p>
<ul className="space-y-2 text-fg-muted font-medium">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-accent"></span>
Click <Copy className="w-3.5 h-3.5 inline text-fg" /> to copy User ID
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-accent"></span>
Click "Send Gift →" to send a gift directly to this user
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-accent"></span>
Use search to quickly find users by name, username, or phone
</li>
</ul>
</div>
</div>
</div>
)
}
@@ -0,0 +1,659 @@
import { useState, useEffect } from 'react';
import { Search, Shield, Upload, CheckCircle, XCircle, Plus, Edit2, Trash2 } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function Verification() {
const [activeTab, setActiveTab] = useState('assign'); // assign or manage
const [verifiers, setVerifiers] = useState([]);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState(null);
// Assign verification state
const [userId, setUserId] = useState('');
const [selectedVerifier, setSelectedVerifier] = useState('');
const [customDescription, setCustomDescription] = useState('');
const [currentVerification, setCurrentVerification] = useState(null);
// Create verifier state
const [showCreateForm, setShowCreateForm] = useState(false);
const [botUserId, setBotUserId] = useState('');
const [iconFile, setIconFile] = useState(null);
const [iconDocumentId, setIconDocumentId] = useState('');
const [companyName, setCompanyName] = useState('');
const [canModifyDesc, setCanModifyDesc] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
useEffect(() => {
loadVerifiers();
loadStats();
}, []);
const loadVerifiers = async () => {
try {
const response = await fetch(`${API_URL}/api/verification/bots`);
const data = await response.json();
if (data.success) {
setVerifiers(data.data);
}
} catch (error) {
console.error('Error loading verifiers:', error);
}
};
const loadStats = async () => {
try {
const response = await fetch(`${API_URL}/api/verification/stats`);
const data = await response.json();
if (data.success) {
setStats(data.data);
}
} catch (error) {
console.error('Error loading stats:', error);
}
};
const checkUserVerification = async (uid) => {
if (!uid) return;
try {
const response = await fetch(`${API_URL}/api/verification/users/${uid}`);
const data = await response.json();
if (data.success) {
setCurrentVerification(data.data);
}
} catch (error) {
console.error('Error checking verification:', error);
}
};
const handleFileUpload = async (file) => {
if (!file) return;
const formData = new FormData();
formData.append('icon', file);
try {
setUploadProgress(10);
const response = await fetch(`${API_URL}/api/verification/upload-icon`, {
method: 'POST',
body: formData
});
setUploadProgress(90);
const data = await response.json();
if (data.success) {
setIconDocumentId(data.data.documentId.toString());
setUploadProgress(100);
alert(`✅ Файл загружен! Document ID: ${data.data.documentId}`);
setTimeout(() => setUploadProgress(0), 2000);
} else {
alert('❌ Ошибка загрузки: ' + data.error);
setUploadProgress(0);
}
} catch (error) {
console.error('Upload error:', error);
alert('❌ Ошибка загрузки файла');
setUploadProgress(0);
}
};
const createVerifier = async (e) => {
e.preventDefault();
if (!botUserId || !iconDocumentId || !companyName) {
alert('Заполните все обязательные поля');
return;
}
setLoading(true);
try {
const response = await fetch(`${API_URL}/api/verification/bots`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
botUserId: parseInt(botUserId),
iconEmojiId: parseInt(iconDocumentId),
companyName,
canModifyCustomDescription: canModifyDesc
})
});
const data = await response.json();
if (data.success) {
alert('✅ Верификатор создан!');
setShowCreateForm(false);
setBotUserId('');
setIconDocumentId('');
setCompanyName('');
setCanModifyDesc(false);
setIconFile(null);
loadVerifiers();
loadStats();
} else {
alert('❌ Ошибка: ' + data.error);
}
} catch (error) {
console.error('Error creating verifier:', error);
alert('❌ Ошибка создания верификатора');
} finally {
setLoading(false);
}
};
const assignVerification = async (e) => {
e.preventDefault();
if (!userId || !selectedVerifier) {
alert('Заполните User ID и выберите верификатора');
return;
}
setLoading(true);
try {
const response = await fetch(`${API_URL}/api/verification/users/${userId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
botUserId: parseInt(selectedVerifier),
customDescription: customDescription || null
})
});
const data = await response.json();
if (data.success) {
alert('✅ Верификация выдана!');
checkUserVerification(userId);
setCustomDescription('');
loadStats();
} else {
alert('❌ Ошибка: ' + data.error);
}
} catch (error) {
console.error('Error assigning verification:', error);
alert('❌ Ошибка выдачи верификации');
} finally {
setLoading(false);
}
};
const removeVerification = async () => {
if (!userId || !confirm('Удалить верификацию?')) return;
setLoading(true);
try {
const response = await fetch(`${API_URL}/api/verification/users/${userId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
alert('✅ Верификация удалена!');
setCurrentVerification(null);
loadStats();
} else {
alert('❌ Ошибка: ' + data.error);
}
} catch (error) {
console.error('Error removing verification:', error);
alert('❌ Ошибка удаления верификации');
} finally {
setLoading(false);
}
};
const deleteVerifier = async (botUserId) => {
if (!confirm(`Деактивировать верификатора ${botUserId}?`)) return;
try {
const response = await fetch(`${API_URL}/api/verification/bots/${botUserId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
alert('✅ Верификатор деактивирован!');
loadVerifiers();
loadStats();
} else {
alert('❌ Ошибка: ' + data.error);
}
} catch (error) {
console.error('Error deleting verifier:', error);
alert('❌ Ошибка деактивации');
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
<Shield className="w-8 h-8 text-blue-400" />
Third-Party Verification
</h1>
<p className="text-[#8b98a5] mt-1">Управление сторонней верификацией пользователей</p>
</div>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-[#8b98a5] text-sm">Всего верификаторов</p>
<p className="text-2xl font-bold text-white">{stats.totalVerifiers}</p>
</div>
<Shield className="w-8 h-8 text-blue-500" />
</div>
</div>
<div className="card rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-[#8b98a5] text-sm">Активных</p>
<p className="text-2xl font-bold text-green-600">{stats.activeVerifiers}</p>
</div>
<CheckCircle className="w-8 h-8 text-green-500" />
</div>
</div>
<div className="card rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-[#8b98a5] text-sm">Верифицировано пользователей</p>
<p className="text-2xl font-bold text-purple-600">{stats.userVerifications}</p>
</div>
<CheckCircle className="w-8 h-8 text-purple-500" />
</div>
</div>
<div className="card rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-[#8b98a5] text-sm">Верифицировано каналов</p>
<p className="text-2xl font-bold text-indigo-600">{stats.channelVerifications}</p>
</div>
<CheckCircle className="w-8 h-8 text-indigo-500" />
</div>
</div>
</div>
)}
{/* Tabs */}
<div className="card rounded-lg shadow">
<div className="border-b border-[#2b5278]">
<nav className="flex -mb-px">
<button
onClick={() => setActiveTab('assign')}
className={`px-6 py-4 text-sm font-medium ${
activeTab === 'assign'
? 'border-b-2 border-blue-500 text-blue-400'
: 'text-[#8b98a5] hover:text-white'
}`}
>
Выдать верификацию
</button>
<button
onClick={() => setActiveTab('manage')}
className={`px-6 py-4 text-sm font-medium ${
activeTab === 'manage'
? 'border-b-2 border-blue-500 text-blue-400'
: 'text-[#8b98a5] hover:text-white'
}`}
>
Управление верификаторами
</button>
</nav>
</div>
<div className="p-6">
{activeTab === 'assign' ? (
<div className="space-y-6">
{/* Check user form */}
<div>
<label className="block text-sm font-medium text-white mb-2">
User ID <span className="text-red-500">*</span>
</label>
<div className="flex gap-2">
<input
type="number"
value={userId}
onChange={(e) => setUserId(e.target.value)}
onBlur={(e) => checkUserVerification(e.target.value)}
className="input flex-1"
placeholder="Введите User ID"
/>
<button
onClick={() => checkUserVerification(userId)}
className="px-4 py-2 bg-[#5288c1] text-white rounded-lg hover:bg-[#3d5a7a] flex items-center gap-2"
>
<Search className="w-4 h-4" />
Проверить
</button>
</div>
</div>
{/* Current verification status */}
{userId && (
<div className={`p-4 rounded-lg ${currentVerification ? 'bg-green-500/20 border border-green-500/30' : 'bg-[#0e1621] border border-[#2b5278]'}`}>
{currentVerification ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-green-400 font-medium">
<CheckCircle className="w-5 h-5" />
Пользователь уже верифицирован
</div>
<div className="text-sm text-[#8b98a5] space-y-1">
<p><strong>Bot ID:</strong> {currentVerification.BotVerifierId}</p>
<p><strong>Icon ID:</strong> {currentVerification.IconEmojiId}</p>
<p><strong>Компания (дефолт):</strong> {currentVerification.Description}</p>
{currentVerification.CustomDescription && currentVerification.CustomDescription.trim() !== '' ? (
<p><strong>Кастомное описание:</strong> <span className="text-green-400 font-medium">{currentVerification.CustomDescription}</span></p>
) : (
<p className="text-[#8b98a5] text-xs">Кастомное описание не задано</p>
)}
<p className="pt-2 border-t border-[#2b5278]">
<strong>Клиент видит:</strong> <span className="text-purple-400 font-semibold">
{currentVerification.CustomDescription && currentVerification.CustomDescription.trim() !== ''
? currentVerification.CustomDescription
: currentVerification.Description}
</span>
</p>
<p><strong>Верифицирован:</strong> {new Date(currentVerification.VerifiedAt).toLocaleString('ru-RU')}</p>
</div>
<button
onClick={removeVerification}
disabled={loading}
className="mt-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Удалить верификацию
</button>
</div>
) : (
<div className="flex items-center gap-2 text-[#8b98a5]">
<XCircle className="w-5 h-5" />
Пользователь не верифицирован
</div>
)}
</div>
)}
{/* Assign verification form */}
{userId && !currentVerification && (
<form onSubmit={assignVerification} className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
Выберите верификатора <span className="text-red-500">*</span>
</label>
<select
value={selectedVerifier}
onChange={(e) => setSelectedVerifier(e.target.value)}
className="input focus:ring-2 focus:ring-blue-500"
required
>
<option value="">-- Выберите верификатора --</option>
{verifiers.map((v) => (
<option key={v.BotUserId} value={v.BotUserId}>
{v.CompanyName} (Bot ID: {v.BotUserId}, Icon: {v.IconEmojiId})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Кастомное описание (опционально)
</label>
<textarea
value={customDescription}
onChange={(e) => setCustomDescription(e.target.value)}
className="input focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="Оставьте пустым для использования названия компании"
disabled={!selectedVerifier || !verifiers.find(v => v.BotUserId === parseInt(selectedVerifier))?.CanModifyCustomDescription}
/>
{selectedVerifier && !verifiers.find(v => v.BotUserId === parseInt(selectedVerifier))?.CanModifyCustomDescription && (
<p className="text-sm text-red-500 mt-1"> Этот верификатор не разрешает кастомные описания</p>
)}
{selectedVerifier && verifiers.find(v => v.BotUserId === parseInt(selectedVerifier))?.CanModifyCustomDescription && (
<div className="mt-2 p-3 bg-blue-50 border border-blue-500/30 rounded-lg">
<p className="text-sm text-blue-300">
<strong>Клиент увидит:</strong> {customDescription && customDescription.trim() !== ''
? <span className="text-purple-400 font-semibold">{customDescription}</span>
: <span className="text-white">{verifiers.find(v => v.BotUserId === parseInt(selectedVerifier))?.CompanyName}</span>
}
</p>
</div>
)}
</div>
<button
type="submit"
disabled={loading}
className="w-full px-6 py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:bg-[#2b5278]/50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<CheckCircle className="w-5 h-5" />
{loading ? 'Выдаю...' : 'Выдать верификацию'}
</button>
</form>
)}
</div>
) : (
<div className="space-y-6">
{/* Create verifier button */}
{!showCreateForm && (
<button
onClick={() => setShowCreateForm(true)}
className="px-6 py-3 bg-[#5288c1] text-white rounded-lg hover:bg-[#3d5a7a] flex items-center gap-2"
>
<Plus className="w-5 h-5" />
Создать верификатора
</button>
)}
{/* Create verifier form */}
{showCreateForm && (
<div className="bg-blue-500/20 border border-blue-500/30 rounded-lg p-6">
<h3 className="text-lg font-semibold text-white mb-4">Создание нового верификатора</h3>
<form onSubmit={createVerifier} className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
Bot User ID <span className="text-red-500">*</span>
</label>
<input
type="number"
value={botUserId}
onChange={(e) => setBotUserId(e.target.value)}
className="input focus:ring-2 focus:ring-blue-500"
placeholder="ID бота-верификатора"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
TGS иконка <span className="text-red-500">*</span>
</label>
<div className="flex gap-2 items-center">
<input
type="file"
accept=".tgs,.json"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setIconFile(file);
handleFileUpload(file);
}
}}
className="input flex-1"
/>
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="text-sm text-blue-400">Загрузка: {uploadProgress}%</div>
)}
</div>
{iconDocumentId && (
<div className="mt-2 space-y-1">
<p className="text-sm text-green-600"> Document ID: {iconDocumentId}</p>
<button
type="button"
onClick={async () => {
try {
const response = await fetch(`${API_URL}/api/verification/icons/${iconDocumentId}/check`);
const data = await response.json();
if (data.success) {
const status = data.data;
alert(
`📊 Проверка документа ${iconDocumentId}:\n\n` +
`MongoDB: ${status.existsInMongoDB ? '✅ Найден' : '❌ Не найден'}\n` +
`MinIO: ${status.existsInMinIO ? '✅ Найден' : '❌ Не найден'}\n` +
(status.document ? `\nРазмер: ${status.document.Size} байт\nMimeType: ${status.document.MimeType}` : '')
);
} else {
alert('❌ Ошибка проверки: ' + data.error);
}
} catch (error) {
alert('❌ Ошибка: ' + error.message);
}
}}
className="text-xs text-blue-400 hover:text-blue-300 underline"
>
🔍 Проверить документ
</button>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Icon Document ID <span className="text-red-500">*</span>
</label>
<input
type="number"
value={iconDocumentId}
onChange={(e) => setIconDocumentId(e.target.value)}
className="input focus:ring-2 focus:ring-blue-500"
placeholder="Заполнится автоматически после загрузки"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Название компании <span className="text-red-500">*</span>
</label>
<input
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className="input focus:ring-2 focus:ring-blue-500"
placeholder="Например: Verified Company"
required
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={canModifyDesc}
onChange={(e) => setCanModifyDesc(e.target.checked)}
className="w-4 h-4 text-blue-400 border-[#2b5278] rounded focus:ring-blue-500"
/>
<label className="text-sm text-white">
Разрешить изменение кастомного описания
</label>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={loading}
className="flex-1 px-6 py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:bg-[#2b5278]/50 disabled:cursor-not-allowed"
>
{loading ? 'Создаю...' : 'Создать'}
</button>
<button
type="button"
onClick={() => {
setShowCreateForm(false);
setBotUserId('');
setIconDocumentId('');
setCompanyName('');
setCanModifyDesc(false);
setIconFile(null);
}}
className="px-6 py-3 bg-[#2b5278] text-white rounded-lg hover:bg-[#3d5a7a]"
>
Отмена
</button>
</div>
</form>
</div>
)}
{/* Verifiers list */}
<div>
<h3 className="text-lg font-semibold text-white mb-4">Список верификаторов</h3>
{verifiers.length === 0 ? (
<p className="text-[#8b98a5] text-center py-8">Нет верификаторов</p>
) : (
<div className="space-y-3">
{verifiers.map((verifier) => (
<div
key={verifier.BotUserId}
className="card border border-[#2b5278] rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<Shield className="w-6 h-6 text-blue-400" />
<div>
<h4 className="font-semibold text-white">{verifier.CompanyName}</h4>
<p className="text-sm text-[#8b98a5]">Bot User ID: {verifier.BotUserId}</p>
</div>
</div>
<div className="mt-2 text-sm text-[#8b98a5] space-y-1">
<p><strong>Icon Emoji ID:</strong> {verifier.IconEmojiId}</p>
<p><strong>Кастомное описание:</strong> {verifier.CanModifyCustomDescription ? '✅ Разрешено' : '❌ Запрещено'}</p>
<p><strong>Создан:</strong> {new Date(verifier.CreatedAt).toLocaleString('ru-RU')}</p>
{verifier.UpdatedAt && (
<p><strong>Обновлен:</strong> {new Date(verifier.UpdatedAt).toLocaleString('ru-RU')}</p>
)}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => deleteVerifier(verifier.BotUserId)}
className="p-2 text-red-400 hover:bg-red-500/300/20 rounded-lg"
title="Деактивировать"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { create } from 'zustand'
import { giftsApi } from '../lib/api'
import toast from 'react-hot-toast'
const useGiftsStore = create((set, get) => ({
gifts: [],
loading: false,
error: null,
filters: {
soldOut: undefined,
limited: undefined,
sort: 'stars',
},
setFilters: (filters) => set((state) => ({
filters: { ...state.filters, ...filters }
})),
fetchGifts: async () => {
set({ loading: true, error: null })
try {
const response = await giftsApi.getAll(get().filters)
set({ gifts: response.data, loading: false })
} catch (error) {
set({ error: error.message, loading: false })
toast.error('Failed to load gifts')
}
},
createGift: async (formData) => {
try {
const response = await giftsApi.create(formData)
set((state) => ({ gifts: [...state.gifts, response.data] }))
toast.success('Gift created successfully!')
return response.data
} catch (error) {
toast.error(error.response?.data?.error || 'Failed to create gift')
throw error
}
},
updateGift: async (giftId, formData) => {
try {
const response = await giftsApi.update(giftId, formData)
set((state) => ({
gifts: state.gifts.map((g) =>
g.GiftId === giftId ? response.data : g
),
}))
toast.success('Gift updated successfully!')
return response.data
} catch (error) {
toast.error(error.response?.data?.error || 'Failed to update gift')
throw error
}
},
deleteGift: async (giftId) => {
try {
await giftsApi.delete(giftId)
set((state) => ({
gifts: state.gifts.filter((g) => g.GiftId !== giftId),
}))
toast.success('Gift deleted successfully!')
} catch (error) {
toast.error('Failed to delete gift')
throw error
}
},
toggleSoldOut: async (giftId, soldOut) => {
try {
const response = await giftsApi.markSoldOut(giftId, soldOut)
set((state) => ({
gifts: state.gifts.map((g) =>
g.GiftId === giftId ? response.data : g
),
}))
toast.success(soldOut ? 'Marked as sold out' : 'Marked as available')
} catch (error) {
toast.error('Failed to update gift status')
throw error
}
},
}))
export default useGiftsStore
@@ -0,0 +1,48 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
heading: ['Outfit', 'sans-serif'],
sans: ['Inter', 'sans-serif'],
},
colors: {
bg: {
app: 'var(--bg-app)',
side: 'var(--bg-side)',
panel: 'var(--bg-panel)',
},
fg: {
DEFAULT: 'var(--fg)',
muted: 'var(--fg-muted)',
},
accent: {
DEFAULT: 'var(--accent)',
glow: 'var(--accent-glow)',
},
border: 'var(--border)',
glass: 'var(--glass)',
success: 'var(--success)',
// Keep primary as an alias to accent for compatibility or gradual migration
primary: {
DEFAULT: 'var(--accent)',
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#22d3ee',
500: '#06b6d4',
600: '#0891b2',
700: '#0e7490',
800: '#155e75',
900: '#164e63',
}
}
},
},
plugins: [],
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
})