608 lines
22 KiB
TypeScript
608 lines
22 KiB
TypeScript
|
|
import { useState } from 'react'
|
||
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
import { packsApi, isPacksApiError } from '@/api/packs'
|
||
|
|
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
||
|
|
import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Input } from '@/components/ui/input'
|
||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||
|
|
import {
|
||
|
|
Table,
|
||
|
|
TableBody,
|
||
|
|
TableCell,
|
||
|
|
TableHead,
|
||
|
|
TableHeader,
|
||
|
|
TableRow,
|
||
|
|
} from '@/components/ui/table'
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from '@/components/ui/dialog'
|
||
|
|
import {
|
||
|
|
AlertDialog,
|
||
|
|
AlertDialogAction,
|
||
|
|
AlertDialogCancel,
|
||
|
|
AlertDialogContent,
|
||
|
|
AlertDialogDescription,
|
||
|
|
AlertDialogFooter,
|
||
|
|
AlertDialogHeader,
|
||
|
|
AlertDialogTitle,
|
||
|
|
} from '@/components/ui/alert-dialog'
|
||
|
|
import { Label } from '@/components/ui/label'
|
||
|
|
import { Textarea } from '@/components/ui/textarea'
|
||
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
||
|
|
import { ImageUpload } from '@/components/ui/image-upload'
|
||
|
|
import { ColorPaletteInput } from '@/components/ui/color-palette-input'
|
||
|
|
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||
|
|
|
||
|
|
export default function PacksPage() {
|
||
|
|
const queryClient = useQueryClient()
|
||
|
|
const [page, setPage] = useState(1)
|
||
|
|
const [search, setSearch] = useState('')
|
||
|
|
const [showDisabled, setShowDisabled] = useState(false)
|
||
|
|
const [selectedPack, setSelectedPack] = useState<EditCardPackDto | null>(null)
|
||
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||
|
|
const [packToDelete, setPackToDelete] = useState<CardPackPreviewDto | null>(null)
|
||
|
|
|
||
|
|
// Form state
|
||
|
|
const [formData, setFormData] = useState({
|
||
|
|
title: '',
|
||
|
|
subtitle: '',
|
||
|
|
description: '',
|
||
|
|
color: '',
|
||
|
|
enabled: true,
|
||
|
|
googlePlayId: '',
|
||
|
|
rustoreId: '',
|
||
|
|
appStoreId: '',
|
||
|
|
price: '',
|
||
|
|
order: 0,
|
||
|
|
version: '',
|
||
|
|
size: 0,
|
||
|
|
cover: undefined as string | undefined,
|
||
|
|
})
|
||
|
|
|
||
|
|
|
||
|
|
const limit = 20
|
||
|
|
|
||
|
|
// Fetch packs
|
||
|
|
const { data, isLoading, error } = useQuery<PaginatedResponse<CardPackPreviewDto>>({
|
||
|
|
queryKey: ['packs', page, search, showDisabled],
|
||
|
|
queryFn: () => packsApi.getPacks({ page, limit, search, showDisabled }),
|
||
|
|
retry: (failureCount, error) => {
|
||
|
|
// Don't retry on client errors (4xx)
|
||
|
|
if (isPacksApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return failureCount < 2
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
// Mutations
|
||
|
|
const createMutation = useMutation({
|
||
|
|
mutationFn: (pack: EditCardPackDto) => packsApi.upsertPack(pack),
|
||
|
|
onSuccess: () => {
|
||
|
|
queryClient.invalidateQueries({ queryKey: ['packs'] })
|
||
|
|
toast.success('Pack created successfully')
|
||
|
|
closeDialog()
|
||
|
|
},
|
||
|
|
onError: (error: unknown) => {
|
||
|
|
const errorMessage = isPacksApiError(error)
|
||
|
|
? error.message
|
||
|
|
: getDetailedErrorMessage(error, 'create', 'pack')
|
||
|
|
toast.error(errorMessage)
|
||
|
|
console.error('Error creating pack:', error)
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
const updateMutation = useMutation({
|
||
|
|
mutationFn: (pack: EditCardPackDto) => packsApi.upsertPack(pack),
|
||
|
|
onSuccess: () => {
|
||
|
|
queryClient.invalidateQueries({ queryKey: ['packs'] })
|
||
|
|
toast.success('Pack updated successfully')
|
||
|
|
closeDialog()
|
||
|
|
},
|
||
|
|
onError: (error: unknown) => {
|
||
|
|
const errorMessage = isPacksApiError(error)
|
||
|
|
? error.message
|
||
|
|
: getDetailedErrorMessage(error, 'update', 'pack')
|
||
|
|
toast.error(errorMessage)
|
||
|
|
console.error('Error updating pack:', error)
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
const deleteMutation = useMutation({
|
||
|
|
mutationFn: (packId: string) => packsApi.deletePack(packId),
|
||
|
|
onSuccess: () => {
|
||
|
|
queryClient.invalidateQueries({ queryKey: ['packs'] })
|
||
|
|
toast.success('Pack deleted successfully')
|
||
|
|
setIsDeleteDialogOpen(false)
|
||
|
|
setPackToDelete(null)
|
||
|
|
},
|
||
|
|
onError: (error: unknown) => {
|
||
|
|
const errorMessage = isPacksApiError(error)
|
||
|
|
? error.message
|
||
|
|
: getDetailedErrorMessage(error, 'delete', 'pack')
|
||
|
|
toast.error(errorMessage)
|
||
|
|
console.error('Error deleting pack:', error)
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
const openCreateDialog = () => {
|
||
|
|
setSelectedPack(null)
|
||
|
|
setFormData({
|
||
|
|
title: '',
|
||
|
|
subtitle: '',
|
||
|
|
description: '',
|
||
|
|
color: '',
|
||
|
|
enabled: true,
|
||
|
|
googlePlayId: '',
|
||
|
|
rustoreId: '',
|
||
|
|
appStoreId: '',
|
||
|
|
price: '',
|
||
|
|
order: 0,
|
||
|
|
version: '',
|
||
|
|
size: 0,
|
||
|
|
cover: undefined,
|
||
|
|
})
|
||
|
|
setIsDialogOpen(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
const openEditDialog = async (pack: CardPackPreviewDto) => {
|
||
|
|
try {
|
||
|
|
const fullPack = await packsApi.getPack(pack.id)
|
||
|
|
setSelectedPack(fullPack)
|
||
|
|
setFormData({
|
||
|
|
title: fullPack.title || '',
|
||
|
|
subtitle: fullPack.subtitle || '',
|
||
|
|
description: fullPack.description || '',
|
||
|
|
color: fullPack.color || '',
|
||
|
|
enabled: fullPack.enabled ?? true,
|
||
|
|
googlePlayId: fullPack.googlePlayId || '',
|
||
|
|
rustoreId: fullPack.rustoreId || '',
|
||
|
|
appStoreId: fullPack.appStoreId || '',
|
||
|
|
price: fullPack.price || '',
|
||
|
|
order: fullPack.order || 0,
|
||
|
|
version: fullPack.version || '',
|
||
|
|
size: fullPack.size || 0,
|
||
|
|
cover: fullPack.cover,
|
||
|
|
})
|
||
|
|
|
||
|
|
setIsDialogOpen(true)
|
||
|
|
} catch (error) {
|
||
|
|
const errorMessage = isPacksApiError(error)
|
||
|
|
? error.message
|
||
|
|
: getDetailedErrorMessage(error, 'load', `pack "${pack.id}"`)
|
||
|
|
toast.error(errorMessage)
|
||
|
|
console.error('Error loading pack details:', error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const closeDialog = () => {
|
||
|
|
setIsDialogOpen(false)
|
||
|
|
setSelectedPack(null)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleSubmit = (e: React.FormEvent) => {
|
||
|
|
e.preventDefault()
|
||
|
|
|
||
|
|
if (!formData.title.trim()) {
|
||
|
|
toast.error('Title is required')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const packData: EditCardPackDto = {
|
||
|
|
// Only include id for updates, not for new packs
|
||
|
|
...(selectedPack && { id: selectedPack.id }),
|
||
|
|
title: formData.title.trim(),
|
||
|
|
subtitle: formData.subtitle.trim() || undefined,
|
||
|
|
description: formData.description.trim() || undefined,
|
||
|
|
color: formData.color.trim() || undefined,
|
||
|
|
enabled: formData.enabled,
|
||
|
|
googlePlayId: formData.googlePlayId.trim() || undefined,
|
||
|
|
rustoreId: formData.rustoreId.trim() || undefined,
|
||
|
|
appStoreId: formData.appStoreId.trim() || undefined,
|
||
|
|
price: formData.price.trim() || undefined,
|
||
|
|
order: formData.order,
|
||
|
|
version: formData.version.trim() || undefined,
|
||
|
|
size: formData.size > 0 ? formData.size : undefined,
|
||
|
|
cover: formData.cover || undefined,
|
||
|
|
}
|
||
|
|
|
||
|
|
if (selectedPack) {
|
||
|
|
updateMutation.mutate(packData)
|
||
|
|
} else {
|
||
|
|
createMutation.mutate(packData)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDelete = (pack: CardPackPreviewDto) => {
|
||
|
|
setPackToDelete(pack)
|
||
|
|
setIsDeleteDialogOpen(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
const confirmDelete = () => {
|
||
|
|
if (packToDelete) {
|
||
|
|
deleteMutation.mutate(packToDelete.id)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleSearch = (value: string) => {
|
||
|
|
setSearch(value)
|
||
|
|
setPage(1) // Reset to first page when searching
|
||
|
|
}
|
||
|
|
|
||
|
|
if (error) {
|
||
|
|
const errorMessage = isPacksApiError(error)
|
||
|
|
? error.message
|
||
|
|
: formatApiError(error)
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-3xl font-bold tracking-tight">Packs Management</h1>
|
||
|
|
<p className="text-muted-foreground">Error loading packs</p>
|
||
|
|
</div>
|
||
|
|
<Card>
|
||
|
|
<CardContent className="pt-6">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<p className="text-red-500 font-medium">Failed to load packs</p>
|
||
|
|
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['packs'] })}
|
||
|
|
className="mt-2"
|
||
|
|
>
|
||
|
|
Retry
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-3xl font-bold tracking-tight">Packs Management</h1>
|
||
|
|
<p className="text-muted-foreground">
|
||
|
|
View, create, edit and delete card packs
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Button onClick={openCreateDialog}>
|
||
|
|
<Plus className="mr-2 h-4 w-4" />
|
||
|
|
Add Pack
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Search and Filters */}
|
||
|
|
<Card>
|
||
|
|
<CardContent className="pt-6">
|
||
|
|
<div className="flex items-center space-x-4">
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||
|
|
<Input
|
||
|
|
placeholder="Search packs..."
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => handleSearch(e.target.value)}
|
||
|
|
className="max-w-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<Checkbox
|
||
|
|
id="showDisabled"
|
||
|
|
checked={showDisabled}
|
||
|
|
onCheckedChange={(checked) => setShowDisabled(checked as boolean)}
|
||
|
|
/>
|
||
|
|
<Label htmlFor="showDisabled">Show disabled packs</Label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Packs Table */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>All Packs ({data?.total || 0})</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Manage card packs and their contents
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{isLoading ? (
|
||
|
|
<div className="text-center py-8">Loading packs...</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead>ID</TableHead>
|
||
|
|
<TableHead>Title</TableHead>
|
||
|
|
<TableHead>Cards</TableHead>
|
||
|
|
<TableHead>Enabled</TableHead>
|
||
|
|
<TableHead>Order</TableHead>
|
||
|
|
<TableHead>Actions</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{data?.items.map((pack) => (
|
||
|
|
<TableRow key={pack.id}>
|
||
|
|
<TableCell className="font-mono text-sm">{pack.id}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<div>
|
||
|
|
<div className="font-medium">{pack.title}</div>
|
||
|
|
{pack.subtitle && (
|
||
|
|
<div className="text-sm text-muted-foreground">{pack.subtitle}</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>{pack.cards}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||
|
|
pack.enabled
|
||
|
|
? 'bg-green-100 text-green-800'
|
||
|
|
: 'bg-red-100 text-red-800'
|
||
|
|
}`}>
|
||
|
|
{pack.enabled ? 'Enabled' : 'Disabled'}
|
||
|
|
</span>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>{pack.order ?? 0}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => openEditDialog(pack)}
|
||
|
|
>
|
||
|
|
<Edit className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => handleDelete(pack)}
|
||
|
|
>
|
||
|
|
<Trash2 className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
|
||
|
|
{/* Pagination */}
|
||
|
|
{data && data.totalPages > 1 && (
|
||
|
|
<div className="flex items-center justify-between mt-4">
|
||
|
|
<div className="text-sm text-muted-foreground">
|
||
|
|
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} packs
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => setPage(page - 1)}
|
||
|
|
disabled={page <= 1}
|
||
|
|
>
|
||
|
|
<ChevronLeft className="h-4 w-4" />
|
||
|
|
Previous
|
||
|
|
</Button>
|
||
|
|
<span className="text-sm">
|
||
|
|
Page {page} of {data.totalPages}
|
||
|
|
</span>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => setPage(page + 1)}
|
||
|
|
disabled={page >= data.totalPages}
|
||
|
|
>
|
||
|
|
Next
|
||
|
|
<ChevronRight className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Create/Edit Dialog */}
|
||
|
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||
|
|
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>
|
||
|
|
{selectedPack ? 'Edit Pack' : 'Create New Pack'}
|
||
|
|
</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
{selectedPack ? 'Update the pack information' : 'Add a new pack to the system'}
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<form onSubmit={handleSubmit}>
|
||
|
|
<div className="grid gap-4 py-4">
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="title">Title *</Label>
|
||
|
|
<Input
|
||
|
|
id="title"
|
||
|
|
value={formData.title}
|
||
|
|
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||
|
|
placeholder="Pack title"
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="subtitle">Subtitle</Label>
|
||
|
|
<Input
|
||
|
|
id="subtitle"
|
||
|
|
value={formData.subtitle}
|
||
|
|
onChange={(e) => setFormData(prev => ({ ...prev, subtitle: e.target.value }))}
|
||
|
|
placeholder="Pack subtitle"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="description">Description</Label>
|
||
|
|
<Textarea
|
||
|
|
id="description"
|
||
|
|
value={formData.description}
|
||
|
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||
|
|
placeholder="Pack description"
|
||
|
|
rows={3}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="color">Color</Label>
|
||
|
|
<ColorPaletteInput
|
||
|
|
id="color"
|
||
|
|
value={formData.color}
|
||
|
|
onChange={(value) => setFormData(prev => ({ ...prev, color: value }))}
|
||
|
|
disabled={createMutation.isPending || updateMutation.isPending}
|
||
|
|
placeholder="#FF0000"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="order">Order</Label>
|
||
|
|
<Input
|
||
|
|
id="order"
|
||
|
|
type="number"
|
||
|
|
value={formData.order}
|
||
|
|
onChange={(e) => setFormData(prev => ({ ...prev, order: parseInt(e.target.value) || 0 }))}
|
||
|
|
placeholder="0"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<Checkbox
|
||
|
|
id="enabled"
|
||
|
|
checked={formData.enabled}
|
||
|
|
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, enabled: checked as boolean }))}
|
||
|
|
/>
|
||
|
|
<Label htmlFor="enabled">Enabled</Label>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Store IDs</Label>
|
||
|
|
<div className="grid grid-cols-3 gap-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="googlePlayId" className="text-xs">Google Play</Label>
|
||
|
|
<Input
|
||
|
|
id="googlePlayId"
|
||
|
|
value={formData.googlePlayId}
|
||
|
|
onChange={(e) => setFormData(prev => ({ ...prev, googlePlayId: e.target.value }))}
|
||
|
|
placeholder="com.example.app"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="rustoreId" className="text-xs">RuStore</Label>
|
||
|
|
<Input
|
||
|
|
id="rustoreId"
|
||
|
|
value={formData.rustoreId}
|
||
|
|
onChange={(e) => setFormData(prev => ({ ...prev, rustoreId: e.target.value }))}
|
||
|
|
placeholder="123456789"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="appStoreId" className="text-xs">App Store</Label>
|
||
|
|
<Input
|
||
|
|
id="appStoreId"
|
||
|
|
value={formData.appStoreId}
|
||
|
|
onChange={(e) => setFormData(prev => ({ ...prev, appStoreId: e.target.value }))}
|
||
|
|
placeholder="1234567890"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="price">Price</Label>
|
||
|
|
<Input
|
||
|
|
id="price"
|
||
|
|
value={formData.price}
|
||
|
|
onChange={(e) => setFormData(prev => ({ ...prev, price: e.target.value }))}
|
||
|
|
placeholder="Free, $1.99, etc."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="version">Version</Label>
|
||
|
|
<Input
|
||
|
|
id="version"
|
||
|
|
value={formData.version}
|
||
|
|
onChange={(e) => setFormData(prev => ({ ...prev, version: e.target.value }))}
|
||
|
|
placeholder="1.0.0"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="size">Size</Label>
|
||
|
|
<Input
|
||
|
|
id="size"
|
||
|
|
type="number"
|
||
|
|
value={formData.size}
|
||
|
|
onChange={(e) => setFormData(prev => ({ ...prev, size: parseInt(e.target.value) || 0 }))}
|
||
|
|
placeholder="0"
|
||
|
|
/>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
Number of cards in the pack (auto-calculated if not set)
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<ImageUpload
|
||
|
|
label="Cover Image"
|
||
|
|
value={formData.cover}
|
||
|
|
onChange={(value) => setFormData(prev => ({ ...prev, cover: value }))}
|
||
|
|
disabled={createMutation.isPending || updateMutation.isPending}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
</div>
|
||
|
|
<DialogFooter>
|
||
|
|
<Button type="button" variant="outline" onClick={closeDialog}>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||
|
|
{createMutation.isPending || updateMutation.isPending ? 'Saving...' : (selectedPack ? 'Update' : 'Create')}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</form>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
{/* Delete Confirmation Dialog */}
|
||
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||
|
|
<AlertDialogContent>
|
||
|
|
<AlertDialogHeader>
|
||
|
|
<AlertDialogTitle>Delete Pack</AlertDialogTitle>
|
||
|
|
<AlertDialogDescription>
|
||
|
|
Are you sure you want to delete the pack "{packToDelete?.title}"? This action cannot be undone and will remove all associated cards.
|
||
|
|
</AlertDialogDescription>
|
||
|
|
</AlertDialogHeader>
|
||
|
|
<AlertDialogFooter>
|
||
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||
|
|
<AlertDialogAction
|
||
|
|
onClick={confirmDelete}
|
||
|
|
className="bg-red-600 hover:bg-red-700"
|
||
|
|
disabled={deleteMutation.isPending}
|
||
|
|
>
|
||
|
|
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||
|
|
</AlertDialogAction>
|
||
|
|
</AlertDialogFooter>
|
||
|
|
</AlertDialogContent>
|
||
|
|
</AlertDialog>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|