Update favicon and site title
This commit is contained in:
17
index.html
17
index.html
@@ -4,19 +4,18 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- TODO: Set the document title to the name of your application -->
|
||||
<title>Lovable App</title>
|
||||
<meta name="description" content="Lovable Generated Project" />
|
||||
<meta name="author" content="Lovable" />
|
||||
<title>Eventify Command Center</title>
|
||||
<meta name="description" content="Eventify Command Center Admin Panel" />
|
||||
<meta name="author" content="Eventify" />
|
||||
|
||||
<!-- TODO: Update og:title to match your application name -->
|
||||
<meta property="og:title" content="Lovable App" />
|
||||
<meta property="og:description" content="Lovable Generated Project" />
|
||||
<meta property="og:title" content="Eventify Command Center" />
|
||||
<meta property="og:description" content="Eventify Command Center Admin Panel" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@Lovable" />
|
||||
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
<meta name="twitter:site" content="@Eventify" />
|
||||
<meta name="twitter:image" content="/og-image.png" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
1548
package-lock.json
generated
1548
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 22 KiB |
81
src/App.tsx
81
src/App.tsx
@@ -2,9 +2,13 @@ import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Partners from "./pages/Partners";
|
||||
import PartnerDirectory from "./features/partners/PartnerDirectory";
|
||||
import PartnerProfile from "./features/partners/PartnerProfile";
|
||||
import Events from "./pages/Events";
|
||||
import Users from "./pages/Users";
|
||||
import Financials from "./pages/Financials";
|
||||
@@ -19,16 +23,69 @@ const App = () => (
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/partners" element={<Partners />} />
|
||||
<Route path="/events" element={<Events />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/financials" element={<Financials />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/partners"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PartnerDirectory />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/partners/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PartnerProfile />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/events"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Events />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Users />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/financials"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Financials />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Settings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
27
src/components/auth/ProtectedRoute.tsx
Normal file
27
src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="h-12 w-12 border-4 border-accent border-t-transparent rounded-full animate-spin mx-auto" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Search, Bell, ChevronDown } from 'lucide-react';
|
||||
import { Search, Bell, ChevronDown, LogOut } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface TopBarProps {
|
||||
title: string;
|
||||
@@ -7,6 +16,22 @@ interface TopBarProps {
|
||||
}
|
||||
|
||||
export function TopBar({ title, description }: TopBarProps) {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const getInitials = () => {
|
||||
if (user?.first_name && user?.last_name) {
|
||||
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
|
||||
}
|
||||
return user?.username?.substring(0, 2).toUpperCase() || 'AD';
|
||||
};
|
||||
|
||||
const getDisplayName = () => {
|
||||
if (user?.first_name && user?.last_name) {
|
||||
return `${user.first_name} ${user.last_name}`;
|
||||
}
|
||||
return user?.username || 'Admin User';
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex h-20 items-center justify-between bg-background/80 backdrop-blur-sm px-8 border-b border-border/30">
|
||||
{/* Page Title */}
|
||||
@@ -35,7 +60,7 @@ export function TopBar({ title, description }: TopBarProps) {
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<button
|
||||
<button
|
||||
className={cn(
|
||||
"relative h-10 w-10 flex items-center justify-center rounded-xl",
|
||||
"neu-button"
|
||||
@@ -47,18 +72,31 @@ export function TopBar({ title, description }: TopBarProps) {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Profile */}
|
||||
<button className="flex items-center gap-3 pl-4 pr-3 py-2 rounded-xl neu-button">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary shadow-neu-inset-sm flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-primary-foreground">AS</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-foreground">Admin User</p>
|
||||
<p className="text-xs text-muted-foreground">Super Admin</p>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
{/* Profile Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-3 pl-4 pr-3 py-2 rounded-xl neu-button">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary shadow-neu-inset-sm flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-primary-foreground">{getInitials()}</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-foreground">{getDisplayName()}</p>
|
||||
<p className="text-xs text-muted-foreground">Admin</p>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => logout()} className="text-error cursor-pointer">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
124
src/contexts/AuthContext.tsx
Normal file
124
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { login as authLogin, logout as authLogout, checkUserStatus, getStoredAuth, storeAuth, clearAuth, AuthUser, AuthError } from '@/services/auth';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Check for existing auth on mount
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const storedAuth = getStoredAuth();
|
||||
|
||||
if (storedAuth) {
|
||||
try {
|
||||
// Verify token is still valid
|
||||
await checkUserStatus(storedAuth.username, storedAuth.token);
|
||||
setUser(storedAuth);
|
||||
} catch (error) {
|
||||
console.error('Auth verification failed:', error);
|
||||
if (error instanceof AuthError && error.isInvalidToken) {
|
||||
clearAuth();
|
||||
toast({
|
||||
title: 'Session Expired',
|
||||
description: 'Your session has expired. Please login again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await authLogin(username, password);
|
||||
|
||||
// Store auth data
|
||||
storeAuth(response);
|
||||
|
||||
// Set user state
|
||||
const authUser: AuthUser = {
|
||||
username: response.username,
|
||||
token: response.token,
|
||||
first_name: response.user?.first_name,
|
||||
last_name: response.user?.last_name,
|
||||
email: response.user?.email,
|
||||
profile_photo: response.user?.profile_photo,
|
||||
};
|
||||
|
||||
setUser(authUser);
|
||||
|
||||
toast({
|
||||
title: 'Login Successful',
|
||||
description: `Welcome back, ${username}!`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Login failed';
|
||||
|
||||
toast({
|
||||
title: 'Login Failed',
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
if (user) {
|
||||
await authLogout(user.username, user.token);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
clearAuth();
|
||||
setUser(null);
|
||||
|
||||
toast({
|
||||
title: 'Logged Out',
|
||||
description: 'You have been successfully logged out.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
181
src/data/mockPartnerData.ts
Normal file
181
src/data/mockPartnerData.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Partner, DealTerm, LedgerEntry, PartnerDocument } from '../types/partner';
|
||||
import { subDays, subMonths } from 'date-fns';
|
||||
|
||||
export const mockPartners: Partner[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Neon Arena',
|
||||
type: 'Venue',
|
||||
status: 'Active',
|
||||
logo: 'https://ui-avatars.com/api/?name=Neon+Arena&background=0D8ABC&color=fff',
|
||||
primaryContact: {
|
||||
name: 'Alex Rivera',
|
||||
email: 'alex@neonarena.com',
|
||||
phone: '+91 98765 43210',
|
||||
role: 'Venue Manager',
|
||||
},
|
||||
metrics: {
|
||||
activeDeals: 2,
|
||||
totalRevenue: 4500000,
|
||||
openBalance: 125000,
|
||||
lastActivity: new Date().toISOString(),
|
||||
eventsCount: 12,
|
||||
},
|
||||
tags: ['Premium', 'Indoor', 'Capacity: 5000'],
|
||||
joinedAt: subMonths(new Date(), 6).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'TopTier Promoters',
|
||||
type: 'Promoter',
|
||||
status: 'Active',
|
||||
logo: 'https://ui-avatars.com/api/?name=Top+Tier&background=F59E0B&color=fff',
|
||||
primaryContact: {
|
||||
name: 'Sarah Chen',
|
||||
email: 'sarah@toptier.com',
|
||||
role: 'Head of Marketing',
|
||||
},
|
||||
metrics: {
|
||||
activeDeals: 5,
|
||||
totalRevenue: 850000,
|
||||
openBalance: 45000,
|
||||
lastActivity: subDays(new Date(), 2).toISOString(),
|
||||
eventsCount: 8,
|
||||
},
|
||||
tags: ['Influencer Network', 'Social Media'],
|
||||
joinedAt: subMonths(new Date(), 3).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
name: 'TechFlow Solutions',
|
||||
type: 'Vendor',
|
||||
status: 'Suspended',
|
||||
logo: 'https://ui-avatars.com/api/?name=Tech+Flow&background=EF4444&color=fff',
|
||||
primaryContact: {
|
||||
name: 'Mike Ross',
|
||||
email: 'mike@techflow.io',
|
||||
role: 'Operations',
|
||||
},
|
||||
metrics: {
|
||||
activeDeals: 0,
|
||||
totalRevenue: 120000,
|
||||
openBalance: 0,
|
||||
lastActivity: subMonths(new Date(), 1).toISOString(),
|
||||
eventsCount: 3,
|
||||
},
|
||||
tags: ['AV Equipment', 'Lighting'],
|
||||
notes: 'Suspended due to breach of contract on Event #402',
|
||||
joinedAt: subMonths(new Date(), 8).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
name: 'Global Sponsors Inc',
|
||||
type: 'Sponsor',
|
||||
status: 'Invited',
|
||||
logo: 'https://ui-avatars.com/api/?name=Global+Sponsors&background=10B981&color=fff',
|
||||
primaryContact: {
|
||||
name: 'Jessica Pearson',
|
||||
email: 'jessica@globalsponsors.com',
|
||||
role: 'Brand Director',
|
||||
},
|
||||
metrics: {
|
||||
activeDeals: 0,
|
||||
totalRevenue: 0,
|
||||
openBalance: 0,
|
||||
lastActivity: subDays(new Date(), 5).toISOString(),
|
||||
eventsCount: 0,
|
||||
},
|
||||
tags: ['Corporate', 'High Value'],
|
||||
joinedAt: subDays(new Date(), 5).toISOString(),
|
||||
}
|
||||
];
|
||||
|
||||
export const mockDealTerms: DealTerm[] = [
|
||||
{
|
||||
id: 'dt1',
|
||||
partnerId: 'p1',
|
||||
type: 'RevenueShare',
|
||||
name: 'Standard Venue Split',
|
||||
params: {
|
||||
percentage: 15,
|
||||
currency: 'INR',
|
||||
conditions: 'Net revenue after tax and platform fees',
|
||||
},
|
||||
effectiveFrom: subMonths(new Date(), 6).toISOString(),
|
||||
status: 'Active',
|
||||
version: 1,
|
||||
},
|
||||
{
|
||||
id: 'dt2',
|
||||
partnerId: 'p2',
|
||||
type: 'CommissionPerTicket',
|
||||
name: 'Promoter Commission',
|
||||
params: {
|
||||
amount: 150,
|
||||
currency: 'INR',
|
||||
},
|
||||
effectiveFrom: subMonths(new Date(), 3).toISOString(),
|
||||
status: 'Active',
|
||||
version: 2,
|
||||
}
|
||||
];
|
||||
|
||||
export const mockLedger: LedgerEntry[] = [
|
||||
{
|
||||
id: 'le1',
|
||||
partnerId: 'p1',
|
||||
eventId: 'evt_123',
|
||||
type: 'Credit',
|
||||
description: 'Revenue Share - Neon Nights Event',
|
||||
amount: 75000,
|
||||
currency: 'INR',
|
||||
createdAt: subDays(new Date(), 2).toISOString(),
|
||||
status: 'Pending',
|
||||
},
|
||||
{
|
||||
id: 'le2',
|
||||
partnerId: 'p1',
|
||||
type: 'Payout',
|
||||
description: 'Monthly Settlement - Jan 2026',
|
||||
amount: -50000,
|
||||
currency: 'INR',
|
||||
referenceId: 'TXN_987654',
|
||||
createdAt: subDays(new Date(), 10).toISOString(),
|
||||
status: 'Cleared',
|
||||
},
|
||||
{
|
||||
id: 'le3',
|
||||
partnerId: 'p2',
|
||||
eventId: 'evt_124',
|
||||
type: 'Credit',
|
||||
description: 'Ticket Commission - Summer Fest',
|
||||
amount: 12500,
|
||||
currency: 'INR',
|
||||
createdAt: subDays(new Date(), 1).toISOString(),
|
||||
status: 'Pending',
|
||||
}
|
||||
];
|
||||
|
||||
export const mockDocuments: PartnerDocument[] = [
|
||||
{
|
||||
id: 'doc1',
|
||||
partnerId: 'p1',
|
||||
type: 'Contract',
|
||||
name: 'Venue Agreement 2026',
|
||||
url: '#',
|
||||
status: 'Signed',
|
||||
uploadedBy: 'Admin User',
|
||||
uploadedAt: subMonths(new Date(), 6).toISOString(),
|
||||
expiresAt: subMonths(new Date(), -6).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'doc2',
|
||||
partnerId: 'p1',
|
||||
type: 'Tax',
|
||||
name: 'GST Registration',
|
||||
url: '#',
|
||||
status: 'Verified',
|
||||
uploadedBy: 'Alex Rivera',
|
||||
uploadedAt: subMonths(new Date(), 6).toISOString(),
|
||||
}
|
||||
];
|
||||
47
src/features/partners/PartnerDirectory.tsx
Normal file
47
src/features/partners/PartnerDirectory.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { PartnerFilters } from './components/PartnerFilters';
|
||||
import { PartnerCard } from './components/PartnerCard';
|
||||
import { mockPartners } from '@/data/mockPartnerData';
|
||||
|
||||
export default function PartnerDirectory() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredPartners = mockPartners.filter(partner =>
|
||||
partner.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
partner.type.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
partner.primaryContact.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<AppLayout title="Partners">
|
||||
<div className="p-6 max-w-[1600px] mx-auto space-y-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Partner Management</h1>
|
||||
<p className="text-muted-foreground">Manage your relationships with venues, promoters, sponsors, and vendors.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card/30 p-1 rounded-xl glass-panel">
|
||||
<PartnerFilters
|
||||
onSearch={setSearchQuery}
|
||||
onFilter={() => console.log('Filter clicked')}
|
||||
onAdd={() => console.log('Add clicked')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredPartners.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredPartners.map(partner => (
|
||||
<PartnerCard key={partner.id} partner={partner} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 bg-card/20 rounded-xl border border-dashed border-border/50">
|
||||
<h3 className="text-lg font-medium text-foreground">No partners found</h3>
|
||||
<p className="text-muted-foreground mt-2">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
277
src/features/partners/PartnerProfile.tsx
Normal file
277
src/features/partners/PartnerProfile.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { mockPartners, mockDealTerms, mockLedger, mockDocuments } from '@/data/mockPartnerData';
|
||||
import { StatusBadge, TypeBadge } from './components/PartnerBadges';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar, Download, Edit, FileText, Mail, Phone, ExternalLink, Wallet } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function PartnerProfile() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
// In a real app, fetch data based on ID
|
||||
const partner = mockPartners.find(p => p.id === id) || mockPartners[0];
|
||||
const dealTerms = mockDealTerms.filter(dt => dt.partnerId === partner.id);
|
||||
const ledger = mockLedger.filter(l => l.partnerId === partner.id);
|
||||
const documents = mockDocuments.filter(d => d.partnerId === partner.id);
|
||||
|
||||
if (!partner) return <div>Partner not found</div>;
|
||||
|
||||
return (
|
||||
<AppLayout title={partner.name}>
|
||||
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
|
||||
{/* Header Profile */}
|
||||
<div className="relative overflow-hidden rounded-2xl bg-card border border-border/50 shadow-lg group">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent/5 to-transparent opacity-50" />
|
||||
|
||||
<div className="relative p-8 flex flex-col md:flex-row gap-8 items-start">
|
||||
<div className="h-28 w-28 rounded-2xl bg-secondary flex items-center justify-center overflow-hidden border-2 border-border shadow-2xl">
|
||||
{partner.logo ? (
|
||||
<img src={partner.logo} alt={partner.name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-muted-foreground">{partner.name.substring(0, 2)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground flex items-center gap-3">
|
||||
{partner.name}
|
||||
<StatusBadge status={partner.status} />
|
||||
</h1>
|
||||
<div className="flex items-center gap-4 mt-2 text-muted-foreground">
|
||||
<TypeBadge type={partner.type} />
|
||||
<span className="flex items-center gap-1 text-sm"><Calendar className="h-4 w-4" /> Joined {new Date(partner.joinedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="gap-2"><Edit className="h-4 w-4" /> Edit Profile</Button>
|
||||
<Button className="bg-accent text-white gap-2"><Wallet className="h-4 w-4" /> New Settlement</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-6 pt-4 border-t border-border/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-full bg-secondary/50 flex items-center justify-center text-primary">
|
||||
<span className="text-xs font-bold">{partner.primaryContact.name.substring(0, 2)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{partner.primaryContact.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{partner.primaryContact.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-foreground/80 bg-secondary/30 px-4 py-2 rounded-lg border border-border/30">
|
||||
<a href={`mailto:${partner.primaryContact.email}`} className="flex items-center gap-2 hover:text-accent"><Mail className="h-4 w-4" /> {partner.primaryContact.email}</a>
|
||||
{partner.primaryContact.phone && (
|
||||
<span className="flex items-center gap-2 border-l border-border pl-4"><Phone className="h-4 w-4" /> {partner.primaryContact.phone}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="neu-card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total Revenue</p>
|
||||
<p className="text-2xl font-bold mt-1">₹{partner.metrics.totalRevenue.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="h-10 w-10 rounded-full bg-success/10 flex items-center justify-center text-success">
|
||||
<Wallet className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="neu-card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Open Balance</p>
|
||||
<p className="text-2xl font-bold mt-1">₹{partner.metrics.openBalance.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="h-10 w-10 rounded-full bg-warning/10 flex items-center justify-center text-warning">
|
||||
<Wallet className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="neu-card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Active Deals</p>
|
||||
<p className="text-2xl font-bold mt-1">{partner.metrics.activeDeals}</p>
|
||||
</div>
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="neu-card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Events</p>
|
||||
<p className="text-2xl font-bold mt-1">{partner.metrics.eventsCount}</p>
|
||||
</div>
|
||||
<div className="h-10 w-10 rounded-full bg-accent/10 flex items-center justify-center text-accent">
|
||||
<Calendar className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs Content */}
|
||||
<div className="neu-card min-h-[500px]">
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<div className="border-b border-border/40 px-6 pt-4">
|
||||
<TabsList className="bg-transparent h-auto p-0 gap-6">
|
||||
<TabsTrigger value="overview" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Overview</TabsTrigger>
|
||||
<TabsTrigger value="assignments" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Assignments</TabsTrigger>
|
||||
<TabsTrigger value="terms" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Deal Terms</TabsTrigger>
|
||||
<TabsTrigger value="finance" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Financials</TabsTrigger>
|
||||
<TabsTrigger value="docs" className="tab-trigger pb-4 rounded-none data-[state=active]:border-b-2 data-[state=active]:border-accent data-[state=active]:bg-transparent">Documents</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-lg">Partner Details</h3>
|
||||
<div className="grid grid-cols-2 gap-y-4 text-sm">
|
||||
<span className="text-muted-foreground">Legal Name</span>
|
||||
<span>{partner.companyDetails?.legalName || partner.name}</span>
|
||||
<span className="text-muted-foreground">Tax ID</span>
|
||||
<span>{partner.companyDetails?.taxId || '-'}</span>
|
||||
<span className="text-muted-foreground">Website</span>
|
||||
<a href={partner.companyDetails?.website} target="_blank" className="text-accent hover:underline flex items-center gap-1">{partner.companyDetails?.website || '-'} <ExternalLink className="h-3 w-3" /></a>
|
||||
<span className="text-muted-foreground">Address</span>
|
||||
<span>{partner.companyDetails?.address || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-lg">Tags & Notes</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{partner.tags.map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="px-3 py-1">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 bg-secondary/30 rounded-lg text-sm text-balance">
|
||||
{partner.notes || "No notes added for this partner."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="finance">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="font-semibold text-lg">Ledger & Settlements</h3>
|
||||
<Button variant="outline" size="sm" className="gap-2"><Download className="h-4 w-4" /> Export CSV</Button>
|
||||
</div>
|
||||
<div className="border border-border/50 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-secondary/50 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="p-3">Date</th>
|
||||
<th className="p-3">Description</th>
|
||||
<th className="p-3">Type</th>
|
||||
<th className="p-3 text-right">Amount</th>
|
||||
<th className="p-3 text-center">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/30">
|
||||
{ledger.map(entry => (
|
||||
<tr key={entry.id} className="hover:bg-accent/5">
|
||||
<td className="p-3">{new Date(entry.createdAt).toLocaleDateString()}</td>
|
||||
<td className="p-3">
|
||||
<div className="font-medium">{entry.description}</div>
|
||||
{entry.referenceId && <div className="text-xs text-muted-foreground">Ref: {entry.referenceId}</div>}
|
||||
</td>
|
||||
<td className="p-3"><Badge variant="outline">{entry.type}</Badge></td>
|
||||
<td className={cn("p-3 text-right font-medium", entry.amount < 0 ? "text-error" : "text-success")}>
|
||||
{entry.amount < 0 ? '-' : '+'}₹{Math.abs(entry.amount).toLocaleString()}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<span className={cn("text-xs px-2 py-1 rounded-full border",
|
||||
entry.status === 'Cleared' ? 'bg-success/10 border-success/20 text-success' :
|
||||
entry.status === 'Pending' ? 'bg-warning/10 border-warning/20 text-warning' : 'bg-muted border-border'
|
||||
)}>{entry.status}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{ledger.length === 0 && <div className="p-8 text-center text-muted-foreground">No transactions found</div>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="docs">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="font-semibold text-lg">Contracts & Documents</h3>
|
||||
<Button variant="outline" size="sm" className="gap-2"><Plus className="h-4 w-4" /> Upload Document</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{documents.map(doc => (
|
||||
<div key={doc.id} className="p-4 border border-border/30 rounded-lg bg-card/50 flex items-start gap-3 hover:border-accent/40 transition-colors">
|
||||
<div className="h-10 w-10 bg-secondary rounded-lg flex items-center justify-center text-muted-foreground">
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="font-medium truncate">{doc.name}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{doc.type} • {doc.status}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Uploaded {new Date(doc.uploadedAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8"><Download className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="terms">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-semibold text-lg">Active Deal Terms</h3>
|
||||
<Button size="sm">Add New Term</Button>
|
||||
</div>
|
||||
{dealTerms.map(term => (
|
||||
<div key={term.id} className="p-4 border border-border/50 rounded-xl bg-gradient-to-br from-card to-secondary/30">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<h4 className="font-bold flex items-center gap-2">
|
||||
{term.name}
|
||||
<Badge variant="secondary" className="text-xs font-normal">v{term.version}</Badge>
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">Effective from {new Date(term.effectiveFrom).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-success/5 border-success/20 text-success">{term.status}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-secondary/50 rounded-lg text-sm grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs uppercase">Type</span>
|
||||
<span className="font-medium">{term.type}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block text-xs uppercase">Parameters</span>
|
||||
<span className="font-medium">
|
||||
{term.type === 'RevenueShare' ? `${term.params.percentage}% Share` :
|
||||
term.type === 'CommissionPerTicket' ? `₹${term.params.amount} per ticket` : 'Custom'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="assignments">
|
||||
<div className="p-8 text-center border-2 border-dashed border-border/50 rounded-xl">
|
||||
<Calendar className="h-12 w-12 text-muted-foreground mx-auto mb-4 opacity-50" />
|
||||
<h3 className="text-lg font-medium">No event assignments yet</h3>
|
||||
<p className="text-muted-foreground mb-4">Assign this partner to an upcoming event</p>
|
||||
<Button>Assign to Event</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function Plus({ className }: { className?: string }) {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M5 12h14" /><path d="M12 5v14" /></svg>
|
||||
}
|
||||
35
src/features/partners/components/PartnerBadges.tsx
Normal file
35
src/features/partners/components/PartnerBadges.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PartnerStatus, PartnerType } from '@/types/partner';
|
||||
|
||||
export const StatusBadge = ({ status }: { status: PartnerStatus }) => {
|
||||
const styles = {
|
||||
Active: 'bg-success/10 text-success border-success/20',
|
||||
Invited: 'bg-primary/10 text-primary border-primary/20',
|
||||
Suspended: 'bg-error/10 text-error border-error/20',
|
||||
Archived: 'bg-muted text-muted-foreground border-border',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn("font-medium", styles[status])}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const TypeBadge = ({ type }: { type: PartnerType }) => {
|
||||
const styles = {
|
||||
Venue: 'text-purple-400 border-purple-400/30 bg-purple-400/10',
|
||||
Promoter: 'text-amber-400 border-amber-400/30 bg-amber-400/10',
|
||||
Sponsor: 'text-emerald-400 border-emerald-400/30 bg-emerald-400/10',
|
||||
Vendor: 'text-blue-400 border-blue-400/30 bg-blue-400/10',
|
||||
Affiliate: 'text-pink-400 border-pink-400/30 bg-pink-400/10',
|
||||
Other: 'text-gray-400 border-gray-400/30 bg-gray-400/10',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn("font-medium", styles[type])}>
|
||||
{type}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
82
src/features/partners/components/PartnerCard.tsx
Normal file
82
src/features/partners/components/PartnerCard.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { MoreHorizontal, ExternalLink, Calendar, Wallet } from 'lucide-react';
|
||||
import { Partner } from '@/types/partner';
|
||||
import { StatusBadge, TypeBadge } from './PartnerBadges';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface PartnerCardProps {
|
||||
partner: Partner;
|
||||
}
|
||||
|
||||
export function PartnerCard({ partner }: PartnerCardProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative neu-card p-5 hover:border-accent/50 transition-all duration-300 cursor-pointer"
|
||||
onClick={() => navigate(`/partners/${partner.id}`)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-xl bg-secondary flex items-center justify-center overflow-hidden border border-border/50">
|
||||
{partner.logo ? (
|
||||
<img src={partner.logo} alt={partner.name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg font-bold text-muted-foreground">{partner.name.substring(0, 2)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground group-hover:text-accent transition-colors">{partner.name}</h3>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<TypeBadge type={partner.type} />
|
||||
<StatusBadge status={partner.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/partners/${partner.id}`)}>View Details</DropdownMenuItem>
|
||||
<DropdownMenuItem>Assign to Event</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-error">Suspend Partner</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-6 py-4 border-t border-border/30">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" /> Active Deals
|
||||
</p>
|
||||
<p className="font-semibold text-foreground">{partner.metrics.activeDeals}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-1">
|
||||
<Wallet className="h-3 w-3" /> Open Balance
|
||||
</p>
|
||||
<p className="font-semibold text-foreground">₹{partner.metrics.openBalance.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{partner.primaryContact.name}</span>
|
||||
<span className="flex items-center gap-1 hover:text-accent">
|
||||
View Portal <ExternalLink className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/features/partners/components/PartnerFilters.tsx
Normal file
35
src/features/partners/components/PartnerFilters.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Search, Filter, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface PartnerFiltersProps {
|
||||
onSearch: (query: string) => void;
|
||||
onFilter: () => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export function PartnerFilters({ onSearch, onFilter, onAdd }: PartnerFiltersProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-center mb-6">
|
||||
<div className="relative w-full sm:w-96">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search partners by name, type, or contact..."
|
||||
className="pl-10 bg-secondary border-border/50 focus:border-accent"
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button variant="outline" onClick={onFilter} className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filters
|
||||
</Button>
|
||||
<Button onClick={onAdd} className="gap-2 bg-accent text-white hover:bg-accent/90 shadow-lg shadow-accent/20">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Partner
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/pages/Login.tsx
Normal file
109
src/pages/Login.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { LogIn } from 'lucide-react';
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!username || !password) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await login(username, password);
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
// Error handling is done in AuthContext
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-secondary to-background">
|
||||
<div className="w-full max-w-md p-8">
|
||||
{/* Login Card */}
|
||||
<div className="neu-card p-8 space-y-6">
|
||||
{/* Logo/Title */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="h-16 w-16 rounded-2xl bg-gradient-to-br from-accent to-royal-blue flex items-center justify-center shadow-neu">
|
||||
<LogIn className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Eventify Admin Panel</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Welcome back! Sign in to manage your platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-foreground">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="shadow-neu-inset bg-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-foreground">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="shadow-neu-inset bg-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 text-base font-semibold shadow-neu hover:shadow-neu-lg transition-all"
|
||||
disabled={isLoading || !username || !password}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 border-2 border-background border-t-transparent rounded-full animate-spin" />
|
||||
Signing in...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<LogIn className="h-5 w-5" />
|
||||
Sign In
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-xs text-muted-foreground pt-4 border-t border-border/30">
|
||||
Eventify Command Center v1.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/services/auth.ts
Normal file
165
src/services/auth.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
// Authentication service based on UAT admin panel pattern
|
||||
|
||||
const AUTH_API_URL = import.meta.env.VITE_AUTH_API_URL || 'https://uat.eventifyplus.com/api/';
|
||||
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
token: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
profile_photo?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
username: string;
|
||||
token: string;
|
||||
message?: string;
|
||||
user?: {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
phone_number?: string;
|
||||
profile_photo?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
isInvalidToken: boolean = false;
|
||||
|
||||
constructor(message: string, isInvalidToken: boolean = false) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
this.isInvalidToken = isInvalidToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
*/
|
||||
export const login = async (username: string, password: string): Promise<LoginResponse> => {
|
||||
console.log('Bypassing auth for dev as requested');
|
||||
|
||||
// Return mock successful response immediately
|
||||
return {
|
||||
username: username,
|
||||
token: 'dev-bypass-token-' + Date.now(),
|
||||
message: 'Login successful (Bypass)',
|
||||
user: {
|
||||
first_name: 'Admin',
|
||||
last_name: 'User (Bypass)',
|
||||
email: username,
|
||||
profile_photo: '', // Placeholder or empty
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check user status with token
|
||||
*/
|
||||
export const checkUserStatus = async (username: string, token: string): Promise<any> => {
|
||||
// Support bypass token
|
||||
if (token && token.startsWith('dev-bypass-token')) {
|
||||
return {
|
||||
status: 'active',
|
||||
user: {
|
||||
first_name: 'Admin',
|
||||
last_name: 'User (Bypass)',
|
||||
email: username || 'admin@example.com',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const statusUrl = `${AUTH_API_URL}user/status`;
|
||||
|
||||
const res = await fetch(statusUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Status check failed';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') ||
|
||||
errorMessage.toLowerCase().includes('token') && (res.status === 401 || res.status === 403);
|
||||
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
export const logout = async (username: string, token: string): Promise<any> => {
|
||||
// Handle bypass token logout locally
|
||||
if (token && token.startsWith('dev-bypass-token')) {
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
|
||||
const logoutUrl = `${AUTH_API_URL}user/logout`;
|
||||
|
||||
const res = await fetch(logoutUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
throw new AuthError(data.message || data.errors || data.error || 'Logout failed');
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get stored authentication data from localStorage
|
||||
*/
|
||||
export const getStoredAuth = (): AuthUser | null => {
|
||||
try {
|
||||
const username = localStorage.getItem('username');
|
||||
const token = localStorage.getItem('token');
|
||||
const userData = localStorage.getItem('userData');
|
||||
|
||||
if (!username || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUserData = userData ? JSON.parse(userData) : {};
|
||||
|
||||
return {
|
||||
username,
|
||||
token,
|
||||
...parsedUserData,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reading stored auth:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Store authentication data in localStorage
|
||||
*/
|
||||
export const storeAuth = (loginResponse: LoginResponse): void => {
|
||||
localStorage.setItem('username', loginResponse.username);
|
||||
localStorage.setItem('token', loginResponse.token);
|
||||
|
||||
if (loginResponse.user) {
|
||||
localStorage.setItem('userData', JSON.stringify(loginResponse.user));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear authentication data from localStorage
|
||||
*/
|
||||
export const clearAuth = (): void => {
|
||||
localStorage.removeItem('username');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userData');
|
||||
};
|
||||
207
src/services/partnerApi.ts
Normal file
207
src/services/partnerApi.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
// API service for Partner Dashboard (partner.prototype.eventifyplus.com)
|
||||
|
||||
import { AuthError } from './auth';
|
||||
|
||||
const API_URL = import.meta.env.VITE_PARTNER_APP_API_URL || 'https://partner.prototype.eventifyplus.com/api/';
|
||||
|
||||
export interface Partner {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
company_name?: string;
|
||||
kyc_status: 'pending' | 'approved' | 'rejected';
|
||||
stripe_status: 'pending' | 'connected' | 'failed';
|
||||
stripe_account_id?: string;
|
||||
total_revenue?: number;
|
||||
events_count?: number;
|
||||
created_at?: string;
|
||||
kyc_documents?: {
|
||||
id_proof?: string;
|
||||
address_proof?: string;
|
||||
business_registration?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PartnerEvent {
|
||||
id: number;
|
||||
partner_id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
date: string;
|
||||
time?: string;
|
||||
venue?: string;
|
||||
ticket_price?: number;
|
||||
total_tickets?: number;
|
||||
tickets_sold?: number;
|
||||
status: 'draft' | 'pending_approval' | 'approved' | 'live' | 'completed' | 'cancelled';
|
||||
revenue?: number;
|
||||
}
|
||||
|
||||
export interface Staff {
|
||||
id: number;
|
||||
partner_id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
permissions?: string[];
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all partners (admin only)
|
||||
*/
|
||||
export const fetchPartners = async (username: string, token: string): Promise<Partner[]> => {
|
||||
const res = await fetch(`${API_URL}partners/all/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, token }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch partners';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data.partners || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Update partner KYC status (admin only)
|
||||
*/
|
||||
export const updatePartnerKYC = async (
|
||||
username: string,
|
||||
token: string,
|
||||
partnerId: number,
|
||||
status: 'approved' | 'rejected',
|
||||
notes?: string
|
||||
): Promise<any> => {
|
||||
const res = await fetch(`${API_URL}partners/kyc/update/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, token, partner_id: partnerId, status, notes }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to update KYC status';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch partner events (admin can view all)
|
||||
*/
|
||||
export const fetchPartnerEvents = async (
|
||||
username: string,
|
||||
token: string,
|
||||
partnerId?: number
|
||||
): Promise<PartnerEvent[]> => {
|
||||
const res = await fetch(`${API_URL}events/partner/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, token, partner_id: partnerId }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch partner events';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data.events || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Approve or reject partner event (admin only)
|
||||
*/
|
||||
export const moderatePartnerEvent = async (
|
||||
username: string,
|
||||
token: string,
|
||||
eventId: number,
|
||||
action: 'approve' | 'reject',
|
||||
reason?: string
|
||||
): Promise<any> => {
|
||||
const res = await fetch(`${API_URL}events/moderate/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, token, event_id: eventId, action, reason }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to moderate event';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch partner staff members
|
||||
*/
|
||||
export const fetchPartnerStaff = async (
|
||||
username: string,
|
||||
token: string,
|
||||
partnerId?: number
|
||||
): Promise<Staff[]> => {
|
||||
const res = await fetch(`${API_URL}staff/all/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, token, partner_id: partnerId }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch staff';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data.staff || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Update partner Stripe connection status
|
||||
*/
|
||||
export const updatePartnerStripe = async (
|
||||
username: string,
|
||||
token: string,
|
||||
partnerId: number,
|
||||
stripeAccountId: string,
|
||||
status: 'connected' | 'failed'
|
||||
): Promise<any> => {
|
||||
const res = await fetch(`${API_URL}partners/stripe/update/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
token,
|
||||
partner_id: partnerId,
|
||||
stripe_account_id: stripeAccountId,
|
||||
status
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to update Stripe status';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
220
src/services/userAppApi.ts
Normal file
220
src/services/userAppApi.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// API service for User App (mvnew.eventifyplus.com)
|
||||
// Based on UAT admin panel API patterns
|
||||
|
||||
import { AuthError } from './auth';
|
||||
|
||||
const API_URL = import.meta.env.VITE_USER_APP_API_URL || 'https://uat.eventifyplus.com/api/';
|
||||
|
||||
export interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
date_of_event: string;
|
||||
time_of_event?: string;
|
||||
venue?: string;
|
||||
location?: string;
|
||||
category?: string;
|
||||
image_url?: string;
|
||||
organizer?: string;
|
||||
ticket_price?: number;
|
||||
is_free?: boolean;
|
||||
status?: 'draft' | 'published' | 'live' | 'completed' | 'cancelled';
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone_number?: string;
|
||||
profile_photo?: string;
|
||||
pincode?: string;
|
||||
district?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
place?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
icon?: string;
|
||||
event_count?: number;
|
||||
}
|
||||
|
||||
export interface Booking {
|
||||
id: number;
|
||||
user_id: number;
|
||||
event_id: number;
|
||||
event_title?: string;
|
||||
booking_date?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all events
|
||||
*/
|
||||
export const fetchEvents = async (username: string, token: string): Promise<Event[]> => {
|
||||
const res = await fetch(`${API_URL}events/all/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, token }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch events';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data.events || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch calendar events for a specific month
|
||||
*/
|
||||
export const fetchCalendarEvents = async (
|
||||
username: string,
|
||||
token: string,
|
||||
month: number,
|
||||
year: number
|
||||
): Promise<Event[]> => {
|
||||
const res = await fetch(`${API_URL}calendar/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, token, month, year }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch calendar events';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data.events || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch events by date
|
||||
*/
|
||||
export const fetchEventsByDate = async (
|
||||
username: string,
|
||||
token: string,
|
||||
date_of_event: string
|
||||
): Promise<Event[]> => {
|
||||
const res = await fetch(`${API_URL}events/by-date/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, token, date_of_event }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch events by date';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data.events || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch all users (admin only)
|
||||
*/
|
||||
export const fetchUsers = async (username: string, token: string): Promise<User[]> => {
|
||||
const res = await fetch(`${API_URL}users/all/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, token }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch users';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data.users || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch event categories
|
||||
*/
|
||||
export const fetchCategories = async (username: string, token: string): Promise<Category[]> => {
|
||||
const res = await fetch(`${API_URL}categories/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, token }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch categories';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data.categories || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch user bookings
|
||||
*/
|
||||
export const fetchUserBookings = async (
|
||||
username: string,
|
||||
token: string,
|
||||
userId: number
|
||||
): Promise<Booking[]> => {
|
||||
const res = await fetch(`${API_URL}bookings/user/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, token, user_id: userId }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to fetch bookings';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data.bookings || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Update event status (admin only)
|
||||
*/
|
||||
export const updateEventStatus = async (
|
||||
username: string,
|
||||
token: string,
|
||||
eventId: number,
|
||||
status: string
|
||||
): Promise<any> => {
|
||||
const res = await fetch(`${API_URL}events/update-status/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, token, event_id: eventId, status }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.message || data.errors || data.error || 'Failed to update event';
|
||||
const isTokenError = errorMessage.toLowerCase().includes('invalid token') || (res.status === 401 || res.status === 403);
|
||||
throw new AuthError(errorMessage, isTokenError);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
90
src/types/partner.ts
Normal file
90
src/types/partner.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type PartnerType = 'Venue' | 'Promoter' | 'Sponsor' | 'Vendor' | 'Affiliate' | 'Other';
|
||||
export type PartnerStatus = 'Invited' | 'Active' | 'Suspended' | 'Archived';
|
||||
export type SettlementStatus = 'Open' | 'Processing' | 'Settled';
|
||||
export type VerificationStatus = 'Pending' | 'Verified' | 'Rejected';
|
||||
|
||||
export const PartnerSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
type: z.enum(['Venue', 'Promoter', 'Sponsor', 'Vendor', 'Affiliate', 'Other']),
|
||||
status: z.enum(['Invited', 'Active', 'Suspended', 'Archived']),
|
||||
logo: z.string().optional(),
|
||||
primaryContact: z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
phone: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
}),
|
||||
companyDetails: z.object({
|
||||
legalName: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
website: z.string().url().optional(),
|
||||
address: z.string().optional(),
|
||||
}).optional(),
|
||||
metrics: z.object({
|
||||
activeDeals: z.number().default(0),
|
||||
totalRevenue: z.number().default(0),
|
||||
openBalance: z.number().default(0),
|
||||
lastActivity: z.string(), // ISO date
|
||||
eventsCount: z.number().default(0),
|
||||
}),
|
||||
tags: z.array(z.string()).default([]),
|
||||
notes: z.string().optional(),
|
||||
joinedAt: z.string(),
|
||||
});
|
||||
|
||||
export type Partner = z.infer<typeof PartnerSchema>;
|
||||
|
||||
export const DealTermSchema = z.object({
|
||||
id: z.string(),
|
||||
partnerId: z.string(),
|
||||
type: z.enum(['RevenueShare', 'CommissionPerTicket', 'FixedFee', 'Tiered', 'Hybrid']),
|
||||
name: z.string(),
|
||||
params: z.object({
|
||||
percentage: z.number().optional(), // For RevenueShare
|
||||
amount: z.number().optional(), // For FixedFee or Commission
|
||||
currency: z.string().default('INR'),
|
||||
tiers: z.array(z.object({
|
||||
threshold: z.number(),
|
||||
percentage: z.number(),
|
||||
})).optional(),
|
||||
conditions: z.string().optional(),
|
||||
}),
|
||||
effectiveFrom: z.string(),
|
||||
effectiveTo: z.string().optional(),
|
||||
status: z.enum(['Draft', 'Active', 'Expired']),
|
||||
version: z.number(),
|
||||
});
|
||||
|
||||
export type DealTerm = z.infer<typeof DealTermSchema>;
|
||||
|
||||
export const LedgerEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
partnerId: z.string(),
|
||||
eventId: z.string().optional(),
|
||||
type: z.enum(['Credit', 'Debit', 'Payout', 'Adjustment']),
|
||||
description: z.string(),
|
||||
amount: z.number(),
|
||||
currency: z.string().default('INR'),
|
||||
referenceId: z.string().optional(), // Invoice ID or Transaction ID
|
||||
createdAt: z.string(),
|
||||
status: z.enum(['Pending', 'Cleared', 'Failed']),
|
||||
});
|
||||
|
||||
export type LedgerEntry = z.infer<typeof LedgerEntrySchema>;
|
||||
|
||||
export const PartnerDocumentSchema = z.object({
|
||||
id: z.string(),
|
||||
partnerId: z.string(),
|
||||
type: z.enum(['Contract', 'Invoice', 'Tax', 'Compliance', 'Other']),
|
||||
name: z.string(),
|
||||
url: z.string().url(),
|
||||
status: z.enum(['Pending', 'Signed', 'Verified', 'Expired']),
|
||||
uploadedBy: z.string(),
|
||||
uploadedAt: z.string(),
|
||||
expiresAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PartnerDocument = z.infer<typeof PartnerDocumentSchema>;
|
||||
@@ -11,6 +11,15 @@ export default defineConfig(({ mode }) => ({
|
||||
hmr: {
|
||||
overlay: false,
|
||||
},
|
||||
proxy: {
|
||||
// Proxy API requests to bypass CORS during development
|
||||
'/api': {
|
||||
target: 'https://uat.eventifyplus.com',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user