Add admin panel pages for additional models

- Add confirmation codes management page
- Add devices management page
- Add feature benefits management page
- Add password reset codes management page
- Add promotions management page
- Add share codes management page
- Add corresponding backend handlers and routes
- Update sidebar navigation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-29 01:18:25 -06:00
parent 409d9716bd
commit 15e361f983
15 changed files with 3831 additions and 0 deletions
@@ -0,0 +1,324 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { Mail, Trash2, Search, ChevronLeft, ChevronRight, CheckCircle, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { confirmationCodesApi, type ConfirmationCode, type ConfirmationCodeListParams } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
export default function ConfirmationCodesPage() {
const queryClient = useQueryClient();
const [params, setParams] = useState<ConfirmationCodeListParams>({
page: 1,
per_page: 20,
});
const [search, setSearch] = useState('');
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const { data, isLoading, error } = useQuery({
queryKey: ['confirmation-codes', params],
queryFn: () => confirmationCodesApi.list(params),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => confirmationCodesApi.delete(id),
onSuccess: () => {
toast.success('Confirmation code deleted successfully');
queryClient.invalidateQueries({ queryKey: ['confirmation-codes'] });
},
onError: () => {
toast.error('Failed to delete confirmation code');
},
});
const bulkDeleteMutation = useMutation({
mutationFn: (ids: number[]) => confirmationCodesApi.bulkDelete(ids),
onSuccess: () => {
toast.success('Confirmation codes deleted successfully');
queryClient.invalidateQueries({ queryKey: ['confirmation-codes'] });
setSelectedRows([]);
},
onError: () => {
toast.error('Failed to delete confirmation codes');
},
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setParams({ ...params, search, page: 1 });
};
const handleSelectAll = (checked: boolean) => {
if (checked && data?.data) {
setSelectedRows(data.data.map((c) => c.id));
} else {
setSelectedRows([]);
}
};
const handleSelectRow = (id: number, checked: boolean) => {
if (checked) {
setSelectedRows([...selectedRows, id]);
} else {
setSelectedRows(selectedRows.filter((rowId) => rowId !== id));
}
};
const isExpired = (expiresAt: string) => {
return new Date(expiresAt) < new Date();
};
const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0;
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-500">Failed to load confirmation codes</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Confirmation Codes</h1>
<p className="text-muted-foreground">
Manage email verification codes
</p>
</div>
</div>
{/* Search and Bulk Actions */}
<div className="flex items-center justify-between gap-4">
<form onSubmit={handleSearch} className="flex gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by username, email, or code..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">
Search
</Button>
</form>
{selectedRows.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected ({selectedRows.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Selected Codes?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete {selectedRows.length} confirmation code(s).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => bulkDeleteMutation.mutate(selectedRows)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={
data?.data &&
data.data.length > 0 &&
selectedRows.length === data.data.length
}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Code</TableHead>
<TableHead>Status</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : data?.data?.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
No confirmation codes found
</TableCell>
</TableRow>
) : (
data?.data?.map((code) => (
<TableRow key={code.id}>
<TableCell>
<Checkbox
checked={selectedRows.includes(code.id)}
onCheckedChange={(checked) =>
handleSelectRow(code.id, checked as boolean)
}
/>
</TableCell>
<TableCell>
<Link
href={`/admin/users/${code.user_id}`}
className="font-medium text-blue-600 hover:underline"
>
{code.username}
</Link>
</TableCell>
<TableCell>{code.email}</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{code.code}
</code>
</TableCell>
<TableCell>
{code.is_used ? (
<Badge variant="secondary" className="gap-1">
<CheckCircle className="h-3 w-3" />
Used
</Badge>
) : isExpired(code.expires_at) ? (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Expired
</Badge>
) : (
<Badge variant="default" className="gap-1">
Active
</Badge>
)}
</TableCell>
<TableCell>
{new Date(code.expires_at).toLocaleString()}
</TableCell>
<TableCell>
{new Date(code.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Code?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the confirmation code for {code.username}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate(code.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '}
{Math.min(
(params.page || 1) * (params.per_page || 20),
data.total
)}{' '}
of {data.total} codes
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) <= 1}
onClick={() =>
setParams({ ...params, page: (params.page || 1) - 1 })
}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) >= totalPages}
onClick={() =>
setParams({ ...params, page: (params.page || 1) + 1 })
}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
+411
View File
@@ -0,0 +1,411 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { Smartphone, Trash2, Search, ChevronLeft, ChevronRight, Apple, Tablet } from 'lucide-react';
import { toast } from 'sonner';
import { devicesApi, type APNSDevice, type GCMDevice, type DeviceListParams } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
export default function DevicesPage() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'apns' | 'gcm'>('apns');
const [params, setParams] = useState<DeviceListParams>({
page: 1,
per_page: 20,
});
const [search, setSearch] = useState('');
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const { data: stats } = useQuery({
queryKey: ['device-stats'],
queryFn: () => devicesApi.getStats(),
});
const { data: apnsData, isLoading: apnsLoading } = useQuery({
queryKey: ['devices-apns', params],
queryFn: () => devicesApi.listAPNS(params),
enabled: activeTab === 'apns',
});
const { data: gcmData, isLoading: gcmLoading } = useQuery({
queryKey: ['devices-gcm', params],
queryFn: () => devicesApi.listGCM(params),
enabled: activeTab === 'gcm',
});
const updateAPNSMutation = useMutation({
mutationFn: ({ id, active }: { id: number; active: boolean }) =>
devicesApi.updateAPNS(id, { active }),
onSuccess: () => {
toast.success('Device updated');
queryClient.invalidateQueries({ queryKey: ['devices-apns'] });
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
},
});
const updateGCMMutation = useMutation({
mutationFn: ({ id, active }: { id: number; active: boolean }) =>
devicesApi.updateGCM(id, { active }),
onSuccess: () => {
toast.success('Device updated');
queryClient.invalidateQueries({ queryKey: ['devices-gcm'] });
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
},
});
const deleteAPNSMutation = useMutation({
mutationFn: (id: number) => devicesApi.deleteAPNS(id),
onSuccess: () => {
toast.success('Device deleted');
queryClient.invalidateQueries({ queryKey: ['devices-apns'] });
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
},
});
const deleteGCMMutation = useMutation({
mutationFn: (id: number) => devicesApi.deleteGCM(id),
onSuccess: () => {
toast.success('Device deleted');
queryClient.invalidateQueries({ queryKey: ['devices-gcm'] });
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
},
});
const bulkDeleteAPNSMutation = useMutation({
mutationFn: (ids: number[]) => devicesApi.bulkDeleteAPNS(ids),
onSuccess: () => {
toast.success('Devices deleted');
queryClient.invalidateQueries({ queryKey: ['devices-apns'] });
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
setSelectedRows([]);
},
});
const bulkDeleteGCMMutation = useMutation({
mutationFn: (ids: number[]) => devicesApi.bulkDeleteGCM(ids),
onSuccess: () => {
toast.success('Devices deleted');
queryClient.invalidateQueries({ queryKey: ['devices-gcm'] });
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
setSelectedRows([]);
},
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setParams({ ...params, search, page: 1 });
};
const data = activeTab === 'apns' ? apnsData : gcmData;
const isLoading = activeTab === 'apns' ? apnsLoading : gcmLoading;
const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Push Notification Devices</h1>
<p className="text-muted-foreground">
Manage registered iOS and Android devices
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Apple className="h-4 w-4" />
iOS Devices
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.apns.total || 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.apns.active || 0} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Tablet className="h-4 w-4" />
Android Devices
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.gcm.total || 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.gcm.active || 0} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Smartphone className="h-4 w-4" />
Total Devices
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.total || 0}</div>
</CardContent>
</Card>
</div>
{/* Tabs */}
<div className="flex gap-2">
<Button
variant={activeTab === 'apns' ? 'default' : 'outline'}
onClick={() => { setActiveTab('apns'); setSelectedRows([]); setParams({ ...params, page: 1 }); }}
>
<Apple className="mr-2 h-4 w-4" />
iOS (APNS)
</Button>
<Button
variant={activeTab === 'gcm' ? 'default' : 'outline'}
onClick={() => { setActiveTab('gcm'); setSelectedRows([]); setParams({ ...params, page: 1 }); }}
>
<Tablet className="mr-2 h-4 w-4" />
Android (FCM)
</Button>
</div>
{/* Search and Bulk Actions */}
<div className="flex items-center justify-between gap-4">
<form onSubmit={handleSearch} className="flex gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by name, device ID, or user..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">
Search
</Button>
</form>
{selectedRows.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected ({selectedRows.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Selected Devices?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete {selectedRows.length} device(s).
Users will need to re-register for push notifications.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => activeTab === 'apns'
? bulkDeleteAPNSMutation.mutate(selectedRows)
: bulkDeleteGCMMutation.mutate(selectedRows)
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={data?.data && data.data.length > 0 && selectedRows.length === data.data.length}
onCheckedChange={(checked) => {
if (checked && data?.data) {
setSelectedRows(data.data.map((d) => d.id));
} else {
setSelectedRows([]);
}
}}
/>
</TableHead>
<TableHead>Name</TableHead>
<TableHead>User</TableHead>
<TableHead>Device ID</TableHead>
<TableHead>Active</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : data?.data?.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
No devices found
</TableCell>
</TableRow>
) : (
data?.data?.map((device) => (
<TableRow key={device.id}>
<TableCell>
<Checkbox
checked={selectedRows.includes(device.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedRows([...selectedRows, device.id]);
} else {
setSelectedRows(selectedRows.filter((id) => id !== device.id));
}
}}
/>
</TableCell>
<TableCell className="font-medium">{device.name || 'Unknown'}</TableCell>
<TableCell>
{device.user_id ? (
<Link
href={`/admin/users/${device.user_id}`}
className="text-blue-600 hover:underline"
>
{device.username || `User #${device.user_id}`}
</Link>
) : (
<span className="text-muted-foreground">Anonymous</span>
)}
</TableCell>
<TableCell>
<code className="text-xs bg-muted px-2 py-1 rounded">
{device.device_id.substring(0, 12)}...
</code>
</TableCell>
<TableCell>
<Switch
checked={device.active}
onCheckedChange={(checked) =>
activeTab === 'apns'
? updateAPNSMutation.mutate({ id: device.id, active: checked })
: updateGCMMutation.mutate({ id: device.id, active: checked })
}
/>
</TableCell>
<TableCell>
{new Date(device.date_created).toLocaleDateString()}
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Device?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this device registration.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
activeTab === 'apns'
? deleteAPNSMutation.mutate(device.id)
: deleteGCMMutation.mutate(device.id)
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '}
{Math.min((params.page || 1) * (params.per_page || 20), data.total)} of {data.total}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) <= 1}
onClick={() => setParams({ ...params, page: (params.page || 1) - 1 })}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) >= totalPages}
onClick={() => setParams({ ...params, page: (params.page || 1) + 1 })}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,231 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Star, Trash2, Search, Plus, Edit, ChevronLeft, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { featureBenefitsApi, type FeatureBenefit } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
export default function FeatureBenefitsPage() {
const queryClient = useQueryClient();
const [params, setParams] = useState({ page: 1, per_page: 20, search: '' });
const [search, setSearch] = useState('');
const [editingBenefit, setEditingBenefit] = useState<FeatureBenefit | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [formData, setFormData] = useState({
feature_name: '',
free_tier_text: '',
pro_tier_text: '',
display_order: 0,
is_active: true,
});
const { data, isLoading } = useQuery({
queryKey: ['feature-benefits', params],
queryFn: () => featureBenefitsApi.list(params),
});
const createMutation = useMutation({
mutationFn: featureBenefitsApi.create,
onSuccess: () => {
toast.success('Feature benefit created');
queryClient.invalidateQueries({ queryKey: ['feature-benefits'] });
setIsCreating(false);
resetForm();
},
onError: () => toast.error('Failed to create feature benefit'),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: any }) => featureBenefitsApi.update(id, data),
onSuccess: () => {
toast.success('Feature benefit updated');
queryClient.invalidateQueries({ queryKey: ['feature-benefits'] });
setEditingBenefit(null);
resetForm();
},
onError: () => toast.error('Failed to update feature benefit'),
});
const deleteMutation = useMutation({
mutationFn: featureBenefitsApi.delete,
onSuccess: () => {
toast.success('Feature benefit deleted');
queryClient.invalidateQueries({ queryKey: ['feature-benefits'] });
},
onError: () => toast.error('Failed to delete feature benefit'),
});
const resetForm = () => {
setFormData({ feature_name: '', free_tier_text: '', pro_tier_text: '', display_order: 0, is_active: true });
};
const handleEdit = (benefit: FeatureBenefit) => {
setEditingBenefit(benefit);
setFormData({
feature_name: benefit.feature_name,
free_tier_text: benefit.free_tier_text,
pro_tier_text: benefit.pro_tier_text,
display_order: benefit.display_order,
is_active: benefit.is_active,
});
};
const handleSubmit = () => {
if (editingBenefit) {
updateMutation.mutate({ id: editingBenefit.id, data: formData });
} else {
createMutation.mutate(formData);
}
};
const totalPages = data ? Math.ceil(data.total / params.per_page) : 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Feature Benefits</h1>
<p className="text-muted-foreground">Manage subscription tier comparison features</p>
</div>
<Button onClick={() => { setIsCreating(true); resetForm(); }}>
<Plus className="mr-2 h-4 w-4" />
Add Feature
</Button>
</div>
<form onSubmit={(e) => { e.preventDefault(); setParams({ ...params, search, page: 1 }); }} className="flex gap-2 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9" />
</div>
<Button type="submit" variant="secondary">Search</Button>
</form>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Order</TableHead>
<TableHead>Feature</TableHead>
<TableHead>Free Tier</TableHead>
<TableHead>Pro Tier</TableHead>
<TableHead>Active</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow><TableCell colSpan={6} className="text-center py-8">Loading...</TableCell></TableRow>
) : data?.data?.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center py-8">No feature benefits found</TableCell></TableRow>
) : (
data?.data?.map((benefit) => (
<TableRow key={benefit.id}>
<TableCell>{benefit.display_order}</TableCell>
<TableCell className="font-medium">{benefit.feature_name}</TableCell>
<TableCell>{benefit.free_tier_text}</TableCell>
<TableCell>{benefit.pro_tier_text}</TableCell>
<TableCell>
<Badge variant={benefit.is_active ? 'default' : 'secondary'}>
{benefit.is_active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" onClick={() => handleEdit(benefit)}>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-red-600 hover:text-red-700">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Feature?</AlertDialogTitle>
<AlertDialogDescription>This will permanently delete this feature benefit.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteMutation.mutate(benefit.id)} className="bg-destructive">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{data && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">Page {params.page} of {totalPages}</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={params.page <= 1} onClick={() => setParams({ ...params, page: params.page - 1 })}>
<ChevronLeft className="h-4 w-4" /> Previous
</Button>
<Button variant="outline" size="sm" disabled={params.page >= totalPages} onClick={() => setParams({ ...params, page: params.page + 1 })}>
Next <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Create/Edit Dialog */}
<Dialog open={isCreating || !!editingBenefit} onOpenChange={(open) => { if (!open) { setIsCreating(false); setEditingBenefit(null); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingBenefit ? 'Edit Feature Benefit' : 'Create Feature Benefit'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Feature Name</Label>
<Input value={formData.feature_name} onChange={(e) => setFormData({ ...formData, feature_name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Free Tier Text</Label>
<Input value={formData.free_tier_text} onChange={(e) => setFormData({ ...formData, free_tier_text: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Pro Tier Text</Label>
<Input value={formData.pro_tier_text} onChange={(e) => setFormData({ ...formData, pro_tier_text: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Display Order</Label>
<Input type="number" value={formData.display_order} onChange={(e) => setFormData({ ...formData, display_order: parseInt(e.target.value) || 0 })} />
</div>
<div className="flex items-center gap-2">
<Switch checked={formData.is_active} onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })} />
<Label>Active</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setIsCreating(false); setEditingBenefit(null); }}>Cancel</Button>
<Button onClick={handleSubmit}>{editingBenefit ? 'Update' : 'Create'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,346 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { KeyRound, Trash2, Search, ChevronLeft, ChevronRight, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
import { passwordResetCodesApi, type PasswordResetCode, type PasswordResetCodeListParams } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
export default function PasswordResetCodesPage() {
const queryClient = useQueryClient();
const [params, setParams] = useState<PasswordResetCodeListParams>({
page: 1,
per_page: 20,
});
const [search, setSearch] = useState('');
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const { data, isLoading, error } = useQuery({
queryKey: ['password-reset-codes', params],
queryFn: () => passwordResetCodesApi.list(params),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => passwordResetCodesApi.delete(id),
onSuccess: () => {
toast.success('Password reset code deleted successfully');
queryClient.invalidateQueries({ queryKey: ['password-reset-codes'] });
},
onError: () => {
toast.error('Failed to delete password reset code');
},
});
const bulkDeleteMutation = useMutation({
mutationFn: (ids: number[]) => passwordResetCodesApi.bulkDelete(ids),
onSuccess: () => {
toast.success('Password reset codes deleted successfully');
queryClient.invalidateQueries({ queryKey: ['password-reset-codes'] });
setSelectedRows([]);
},
onError: () => {
toast.error('Failed to delete password reset codes');
},
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setParams({ ...params, search, page: 1 });
};
const handleSelectAll = (checked: boolean) => {
if (checked && data?.data) {
setSelectedRows(data.data.map((c) => c.id));
} else {
setSelectedRows([]);
}
};
const handleSelectRow = (id: number, checked: boolean) => {
if (checked) {
setSelectedRows([...selectedRows, id]);
} else {
setSelectedRows(selectedRows.filter((rowId) => rowId !== id));
}
};
const isExpired = (expiresAt: string) => {
return new Date(expiresAt) < new Date();
};
const getStatusBadge = (code: PasswordResetCode) => {
if (code.used) {
return (
<Badge variant="secondary" className="gap-1">
<CheckCircle className="h-3 w-3" />
Used
</Badge>
);
}
if (code.attempts >= code.max_attempts) {
return (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
Max Attempts
</Badge>
);
}
if (isExpired(code.expires_at)) {
return (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Expired
</Badge>
);
}
return (
<Badge variant="default" className="gap-1">
Active
</Badge>
);
};
const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0;
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-500">Failed to load password reset codes</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Password Reset Codes</h1>
<p className="text-muted-foreground">
Manage password reset requests
</p>
</div>
</div>
{/* Search and Bulk Actions */}
<div className="flex items-center justify-between gap-4">
<form onSubmit={handleSearch} className="flex gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by username, email, or token..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">
Search
</Button>
</form>
{selectedRows.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected ({selectedRows.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Selected Codes?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete {selectedRows.length} password reset code(s).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => bulkDeleteMutation.mutate(selectedRows)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={
data?.data &&
data.data.length > 0 &&
selectedRows.length === data.data.length
}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Token</TableHead>
<TableHead>Status</TableHead>
<TableHead>Attempts</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : data?.data?.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8">
No password reset codes found
</TableCell>
</TableRow>
) : (
data?.data?.map((code) => (
<TableRow key={code.id}>
<TableCell>
<Checkbox
checked={selectedRows.includes(code.id)}
onCheckedChange={(checked) =>
handleSelectRow(code.id, checked as boolean)
}
/>
</TableCell>
<TableCell>
<Link
href={`/admin/users/${code.user_id}`}
className="font-medium text-blue-600 hover:underline"
>
{code.username}
</Link>
</TableCell>
<TableCell>{code.email}</TableCell>
<TableCell>
<code className="text-xs bg-muted px-2 py-1 rounded">
{code.reset_token}
</code>
</TableCell>
<TableCell>{getStatusBadge(code)}</TableCell>
<TableCell>
<span className={code.attempts >= code.max_attempts ? 'text-red-600 font-medium' : ''}>
{code.attempts} / {code.max_attempts}
</span>
</TableCell>
<TableCell>
{new Date(code.expires_at).toLocaleString()}
</TableCell>
<TableCell>
{new Date(code.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Code?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the password reset code for {code.username}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate(code.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '}
{Math.min(
(params.page || 1) * (params.per_page || 20),
data.total
)}{' '}
of {data.total} codes
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) <= 1}
onClick={() =>
setParams({ ...params, page: (params.page || 1) - 1 })
}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) >= totalPages}
onClick={() =>
setParams({ ...params, page: (params.page || 1) + 1 })
}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,282 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Megaphone, Trash2, Search, Plus, Edit, ChevronLeft, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { promotionsApi, type Promotion } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
export default function PromotionsPage() {
const queryClient = useQueryClient();
const [params, setParams] = useState({ page: 1, per_page: 20, search: '' });
const [search, setSearch] = useState('');
const [editingPromotion, setEditingPromotion] = useState<Promotion | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [formData, setFormData] = useState({
promotion_id: '',
title: '',
message: '',
link: '',
start_date: '',
end_date: '',
target_tier: 'free' as 'free' | 'pro',
is_active: true,
});
const { data, isLoading } = useQuery({
queryKey: ['promotions', params],
queryFn: () => promotionsApi.list(params),
});
const createMutation = useMutation({
mutationFn: promotionsApi.create,
onSuccess: () => {
toast.success('Promotion created');
queryClient.invalidateQueries({ queryKey: ['promotions'] });
setIsCreating(false);
resetForm();
},
onError: () => toast.error('Failed to create promotion'),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: any }) => promotionsApi.update(id, data),
onSuccess: () => {
toast.success('Promotion updated');
queryClient.invalidateQueries({ queryKey: ['promotions'] });
setEditingPromotion(null);
resetForm();
},
onError: () => toast.error('Failed to update promotion'),
});
const deleteMutation = useMutation({
mutationFn: promotionsApi.delete,
onSuccess: () => {
toast.success('Promotion deleted');
queryClient.invalidateQueries({ queryKey: ['promotions'] });
},
onError: () => toast.error('Failed to delete promotion'),
});
const resetForm = () => {
setFormData({
promotion_id: '', title: '', message: '', link: '',
start_date: '', end_date: '', target_tier: 'free', is_active: true,
});
};
const handleEdit = (promo: Promotion) => {
setEditingPromotion(promo);
setFormData({
promotion_id: promo.promotion_id,
title: promo.title,
message: promo.message,
link: promo.link || '',
start_date: promo.start_date.split('T')[0],
end_date: promo.end_date.split('T')[0],
target_tier: promo.target_tier,
is_active: promo.is_active,
});
};
const handleSubmit = () => {
const submitData = {
...formData,
link: formData.link || undefined,
};
if (editingPromotion) {
updateMutation.mutate({ id: editingPromotion.id, data: submitData });
} else {
createMutation.mutate(submitData);
}
};
const isActive = (promo: Promotion) => {
const now = new Date();
return promo.is_active && new Date(promo.start_date) <= now && new Date(promo.end_date) >= now;
};
const totalPages = data ? Math.ceil(data.total / params.per_page) : 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Promotions</h1>
<p className="text-muted-foreground">Manage promotional content and banners</p>
</div>
<Button onClick={() => { setIsCreating(true); resetForm(); }}>
<Plus className="mr-2 h-4 w-4" />
Add Promotion
</Button>
</div>
<form onSubmit={(e) => { e.preventDefault(); setParams({ ...params, search, page: 1 }); }} className="flex gap-2 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9" />
</div>
<Button type="submit" variant="secondary">Search</Button>
</form>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Title</TableHead>
<TableHead>Target</TableHead>
<TableHead>Date Range</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow><TableCell colSpan={6} className="text-center py-8">Loading...</TableCell></TableRow>
) : data?.data?.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center py-8">No promotions found</TableCell></TableRow>
) : (
data?.data?.map((promo) => (
<TableRow key={promo.id}>
<TableCell><code className="text-xs bg-muted px-2 py-1 rounded">{promo.promotion_id}</code></TableCell>
<TableCell className="font-medium">{promo.title}</TableCell>
<TableCell>
<Badge variant={promo.target_tier === 'pro' ? 'default' : 'secondary'}>
{promo.target_tier === 'pro' ? 'Pro Users' : 'Free Users'}
</Badge>
</TableCell>
<TableCell className="text-sm">
{new Date(promo.start_date).toLocaleDateString()} - {new Date(promo.end_date).toLocaleDateString()}
</TableCell>
<TableCell>
{isActive(promo) ? (
<Badge variant="default" className="bg-green-600">Active</Badge>
) : promo.is_active ? (
<Badge variant="secondary">Scheduled</Badge>
) : (
<Badge variant="destructive">Inactive</Badge>
)}
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" onClick={() => handleEdit(promo)}>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-red-600 hover:text-red-700">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Promotion?</AlertDialogTitle>
<AlertDialogDescription>This will permanently delete this promotion.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteMutation.mutate(promo.id)} className="bg-destructive">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{data && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">Page {params.page} of {totalPages}</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={params.page <= 1} onClick={() => setParams({ ...params, page: params.page - 1 })}>
<ChevronLeft className="h-4 w-4" /> Previous
</Button>
<Button variant="outline" size="sm" disabled={params.page >= totalPages} onClick={() => setParams({ ...params, page: params.page + 1 })}>
Next <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Create/Edit Dialog */}
<Dialog open={isCreating || !!editingPromotion} onOpenChange={(open) => { if (!open) { setIsCreating(false); setEditingPromotion(null); } }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingPromotion ? 'Edit Promotion' : 'Create Promotion'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Promotion ID</Label>
<Input value={formData.promotion_id} onChange={(e) => setFormData({ ...formData, promotion_id: e.target.value })} placeholder="e.g., summer_sale_2024" />
</div>
<div className="space-y-2">
<Label>Title</Label>
<Input value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Message</Label>
<Input value={formData.message} onChange={(e) => setFormData({ ...formData, message: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Link (optional)</Label>
<Input value={formData.link} onChange={(e) => setFormData({ ...formData, link: e.target.value })} placeholder="https://..." />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={formData.start_date} onChange={(e) => setFormData({ ...formData, start_date: e.target.value })} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={formData.end_date} onChange={(e) => setFormData({ ...formData, end_date: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label>Target Tier</Label>
<Select value={formData.target_tier} onValueChange={(value: 'free' | 'pro') => setFormData({ ...formData, target_tier: value })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="free">Free Users</SelectItem>
<SelectItem value="pro">Pro Users</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch checked={formData.is_active} onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })} />
<Label>Active</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setIsCreating(false); setEditingPromotion(null); }}>Cancel</Button>
<Button onClick={handleSubmit}>{editingPromotion ? 'Update' : 'Create'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,349 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { Share2, Trash2, Search, ChevronLeft, ChevronRight, CheckCircle, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { shareCodesApi, type ShareCode, type ShareCodeListParams } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
export default function ShareCodesPage() {
const queryClient = useQueryClient();
const [params, setParams] = useState<ShareCodeListParams>({
page: 1,
per_page: 20,
});
const [search, setSearch] = useState('');
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const { data, isLoading, error } = useQuery({
queryKey: ['share-codes', params],
queryFn: () => shareCodesApi.list(params),
});
const updateMutation = useMutation({
mutationFn: ({ id, is_active }: { id: number; is_active: boolean }) =>
shareCodesApi.update(id, { is_active }),
onSuccess: () => {
toast.success('Share code updated successfully');
queryClient.invalidateQueries({ queryKey: ['share-codes'] });
},
onError: () => {
toast.error('Failed to update share code');
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => shareCodesApi.delete(id),
onSuccess: () => {
toast.success('Share code deleted successfully');
queryClient.invalidateQueries({ queryKey: ['share-codes'] });
},
onError: () => {
toast.error('Failed to delete share code');
},
});
const bulkDeleteMutation = useMutation({
mutationFn: (ids: number[]) => shareCodesApi.bulkDelete(ids),
onSuccess: () => {
toast.success('Share codes deleted successfully');
queryClient.invalidateQueries({ queryKey: ['share-codes'] });
setSelectedRows([]);
},
onError: () => {
toast.error('Failed to delete share codes');
},
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setParams({ ...params, search, page: 1 });
};
const handleSelectAll = (checked: boolean) => {
if (checked && data?.data) {
setSelectedRows(data.data.map((c) => c.id));
} else {
setSelectedRows([]);
}
};
const handleSelectRow = (id: number, checked: boolean) => {
if (checked) {
setSelectedRows([...selectedRows, id]);
} else {
setSelectedRows(selectedRows.filter((rowId) => rowId !== id));
}
};
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
};
const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0;
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-500">Failed to load share codes</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Share Codes</h1>
<p className="text-muted-foreground">
Manage residence sharing codes
</p>
</div>
</div>
{/* Search and Bulk Actions */}
<div className="flex items-center justify-between gap-4">
<form onSubmit={handleSearch} className="flex gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by code, residence, or creator..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">
Search
</Button>
</form>
{selectedRows.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected ({selectedRows.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Selected Codes?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete {selectedRows.length} share code(s).
Users who have already joined will remain on the residence.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => bulkDeleteMutation.mutate(selectedRows)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={
data?.data &&
data.data.length > 0 &&
selectedRows.length === data.data.length
}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>Code</TableHead>
<TableHead>Residence</TableHead>
<TableHead>Created By</TableHead>
<TableHead>Active</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : data?.data?.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
No share codes found
</TableCell>
</TableRow>
) : (
data?.data?.map((code) => (
<TableRow key={code.id}>
<TableCell>
<Checkbox
checked={selectedRows.includes(code.id)}
onCheckedChange={(checked) =>
handleSelectRow(code.id, checked as boolean)
}
/>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded font-mono">
{code.code}
</code>
</TableCell>
<TableCell>
<Link
href={`/admin/residences/${code.residence_id}`}
className="font-medium text-blue-600 hover:underline"
>
{code.residence_name}
</Link>
</TableCell>
<TableCell>
<Link
href={`/admin/users/${code.created_by_id}`}
className="text-blue-600 hover:underline"
>
{code.created_by}
</Link>
</TableCell>
<TableCell>
<Switch
checked={code.is_active}
onCheckedChange={(checked) =>
updateMutation.mutate({ id: code.id, is_active: checked })
}
/>
</TableCell>
<TableCell>
{code.expires_at ? (
isExpired(code.expires_at) ? (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Expired
</Badge>
) : (
new Date(code.expires_at).toLocaleDateString()
)
) : (
<span className="text-muted-foreground">Never</span>
)}
</TableCell>
<TableCell>
{new Date(code.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Code?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the share code for {code.residence_name}.
Users who have already joined will remain on the residence.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate(code.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '}
{Math.min(
(params.page || 1) * (params.per_page || 20),
data.total
)}{' '}
of {data.total} codes
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) <= 1}
onClick={() =>
setParams({ ...params, page: (params.page || 1) - 1 })
}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) >= totalPages}
onClick={() =>
setParams({ ...params, page: (params.page || 1) + 1 })
}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
+6
View File
@@ -19,6 +19,9 @@ import {
Shield,
Layers,
Sparkles,
Mail,
Share2,
KeyRound,
} from 'lucide-react';
import { useRouter, usePathname } from 'next/navigation';
import { useAuthStore } from '@/store/auth';
@@ -40,7 +43,10 @@ const menuItems = [
{ title: 'Dashboard', url: '/admin/', icon: Home },
{ title: 'Users', url: '/admin/users', icon: Users },
{ title: 'Auth Tokens', url: '/admin/auth-tokens', icon: Key },
{ title: 'Confirmation Codes', url: '/admin/confirmation-codes', icon: Mail },
{ title: 'Password Resets', url: '/admin/password-reset-codes', icon: KeyRound },
{ title: 'Residences', url: '/admin/residences', icon: Building2 },
{ title: 'Share Codes', url: '/admin/share-codes', icon: Share2 },
{ title: 'Tasks', url: '/admin/tasks', icon: ClipboardList },
{ title: 'Completions', url: '/admin/completions', icon: CheckCircle },
{ title: 'Contractors', url: '/admin/contractors', icon: Wrench },
+334
View File
@@ -795,4 +795,338 @@ export const limitationsApi = {
},
};
// Confirmation Codes Types
export interface ConfirmationCode {
id: number;
user_id: number;
username: string;
email: string;
code: string;
expires_at: string;
is_used: boolean;
created_at: string;
}
export interface ConfirmationCodeListParams {
page?: number;
per_page?: number;
search?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
}
// Confirmation Codes API
export const confirmationCodesApi = {
list: async (params?: ConfirmationCodeListParams): Promise<PaginatedResponse<ConfirmationCode>> => {
const response = await api.get<PaginatedResponse<ConfirmationCode>>('/confirmation-codes', { params });
return response.data;
},
get: async (id: number): Promise<ConfirmationCode> => {
const response = await api.get<ConfirmationCode>(`/confirmation-codes/${id}`);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/confirmation-codes/${id}`);
},
bulkDelete: async (ids: number[]): Promise<void> => {
await api.delete('/confirmation-codes/bulk', { data: { ids } });
},
};
// Share Codes Types
export interface ShareCode {
id: number;
residence_id: number;
residence_name: string;
code: string;
created_by_id: number;
created_by: string;
is_active: boolean;
expires_at: string | null;
created_at: string;
}
export interface ShareCodeListParams {
page?: number;
per_page?: number;
search?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
}
export interface UpdateShareCodeRequest {
is_active: boolean;
}
// Share Codes API
export const shareCodesApi = {
list: async (params?: ShareCodeListParams): Promise<PaginatedResponse<ShareCode>> => {
const response = await api.get<PaginatedResponse<ShareCode>>('/share-codes', { params });
return response.data;
},
get: async (id: number): Promise<ShareCode> => {
const response = await api.get<ShareCode>(`/share-codes/${id}`);
return response.data;
},
update: async (id: number, data: UpdateShareCodeRequest): Promise<ShareCode> => {
const response = await api.put<ShareCode>(`/share-codes/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/share-codes/${id}`);
},
bulkDelete: async (ids: number[]): Promise<void> => {
await api.delete('/share-codes/bulk', { data: { ids } });
},
};
// Password Reset Codes Types
export interface PasswordResetCode {
id: number;
user_id: number;
username: string;
email: string;
reset_token: string;
expires_at: string;
used: boolean;
attempts: number;
max_attempts: number;
created_at: string;
}
export interface PasswordResetCodeListParams {
page?: number;
per_page?: number;
search?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
}
// Password Reset Codes API
export const passwordResetCodesApi = {
list: async (params?: PasswordResetCodeListParams): Promise<PaginatedResponse<PasswordResetCode>> => {
const response = await api.get<PaginatedResponse<PasswordResetCode>>('/password-reset-codes', { params });
return response.data;
},
get: async (id: number): Promise<PasswordResetCode> => {
const response = await api.get<PasswordResetCode>(`/password-reset-codes/${id}`);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/password-reset-codes/${id}`);
},
bulkDelete: async (ids: number[]): Promise<void> => {
await api.delete('/password-reset-codes/bulk', { data: { ids } });
},
};
// Device Types
export interface APNSDevice {
id: number;
name: string;
active: boolean;
user_id: number | null;
username: string | null;
device_id: string;
registration_id: string;
date_created: string;
}
export interface GCMDevice {
id: number;
name: string;
active: boolean;
user_id: number | null;
username: string | null;
device_id: string;
registration_id: string;
cloud_message_type: string;
date_created: string;
}
export interface DeviceListParams {
page?: number;
per_page?: number;
search?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
}
export interface DeviceStats {
apns: { total: number; active: number };
gcm: { total: number; active: number };
total: number;
}
// Devices API
export const devicesApi = {
getStats: async (): Promise<DeviceStats> => {
const response = await api.get<DeviceStats>('/devices/stats');
return response.data;
},
listAPNS: async (params?: DeviceListParams): Promise<PaginatedResponse<APNSDevice>> => {
const response = await api.get<PaginatedResponse<APNSDevice>>('/devices/apns', { params });
return response.data;
},
listGCM: async (params?: DeviceListParams): Promise<PaginatedResponse<GCMDevice>> => {
const response = await api.get<PaginatedResponse<GCMDevice>>('/devices/gcm', { params });
return response.data;
},
updateAPNS: async (id: number, data: { active: boolean }): Promise<void> => {
await api.put(`/devices/apns/${id}`, data);
},
updateGCM: async (id: number, data: { active: boolean }): Promise<void> => {
await api.put(`/devices/gcm/${id}`, data);
},
deleteAPNS: async (id: number): Promise<void> => {
await api.delete(`/devices/apns/${id}`);
},
deleteGCM: async (id: number): Promise<void> => {
await api.delete(`/devices/gcm/${id}`);
},
bulkDeleteAPNS: async (ids: number[]): Promise<void> => {
await api.delete('/devices/apns/bulk', { data: { ids } });
},
bulkDeleteGCM: async (ids: number[]): Promise<void> => {
await api.delete('/devices/gcm/bulk', { data: { ids } });
},
};
// Feature Benefit Types
export interface FeatureBenefit {
id: number;
feature_name: string;
free_tier_text: string;
pro_tier_text: string;
display_order: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateFeatureBenefitRequest {
feature_name: string;
free_tier_text: string;
pro_tier_text: string;
display_order?: number;
is_active?: boolean;
}
export interface UpdateFeatureBenefitRequest {
feature_name?: string;
free_tier_text?: string;
pro_tier_text?: string;
display_order?: number;
is_active?: boolean;
}
// Feature Benefits API
export const featureBenefitsApi = {
list: async (params?: { page?: number; per_page?: number; search?: string }): Promise<PaginatedResponse<FeatureBenefit>> => {
const response = await api.get<PaginatedResponse<FeatureBenefit>>('/feature-benefits', { params });
return response.data;
},
get: async (id: number): Promise<FeatureBenefit> => {
const response = await api.get<FeatureBenefit>(`/feature-benefits/${id}`);
return response.data;
},
create: async (data: CreateFeatureBenefitRequest): Promise<FeatureBenefit> => {
const response = await api.post<FeatureBenefit>('/feature-benefits', data);
return response.data;
},
update: async (id: number, data: UpdateFeatureBenefitRequest): Promise<FeatureBenefit> => {
const response = await api.put<FeatureBenefit>(`/feature-benefits/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/feature-benefits/${id}`);
},
};
// Promotion Types
export interface Promotion {
id: number;
promotion_id: string;
title: string;
message: string;
link: string | null;
start_date: string;
end_date: string;
target_tier: 'free' | 'pro';
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreatePromotionRequest {
promotion_id: string;
title: string;
message: string;
link?: string;
start_date: string;
end_date: string;
target_tier?: 'free' | 'pro';
is_active?: boolean;
}
export interface UpdatePromotionRequest {
promotion_id?: string;
title?: string;
message?: string;
link?: string;
start_date?: string;
end_date?: string;
target_tier?: 'free' | 'pro';
is_active?: boolean;
}
// Promotions API
export const promotionsApi = {
list: async (params?: { page?: number; per_page?: number; search?: string }): Promise<PaginatedResponse<Promotion>> => {
const response = await api.get<PaginatedResponse<Promotion>>('/promotions', { params });
return response.data;
},
get: async (id: number): Promise<Promotion> => {
const response = await api.get<Promotion>(`/promotions/${id}`);
return response.data;
},
create: async (data: CreatePromotionRequest): Promise<Promotion> => {
const response = await api.post<Promotion>('/promotions', data);
return response.data;
},
update: async (id: number, data: UpdatePromotionRequest): Promise<Promotion> => {
const response = await api.put<Promotion>(`/promotions/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/promotions/${id}`);
},
};
export default api;