diff --git a/admin/src/api/media.ts b/admin/src/api/media.ts deleted file mode 100644 index 807aa7e..0000000 --- a/admin/src/api/media.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { adminApiClient } from './client' - -export interface UploadResponse { - objectId: string - url: string // Presigned URL for preview -} - -export interface PresignedUrlResponse { - url: string - expiresAt: string -} - -export const mediaApi = { - /** - * Upload card image to MinIO - * @param file Image file to upload - * @returns Object ID and presigned URL - */ - async uploadCardImage(file: File): Promise { - const formData = new FormData() - formData.append('file', file) - - const response = await adminApiClient.post( - '/api/v2/media/upload/card-image', - formData, - { - headers: { - 'Content-Type': 'multipart/form-data', - }, - } - ) - - return response.data - }, - - /** - * Upload test image to MinIO - * @param file Image file to upload - * @returns Object ID and presigned URL - */ - async uploadTestImage(file: File): Promise { - const formData = new FormData() - formData.append('file', file) - - const response = await adminApiClient.post( - '/api/v2/media/upload/test-image', - formData, - { - headers: { - 'Content-Type': 'multipart/form-data', - }, - } - ) - - return response.data - }, - - /** - * Upload voice audio file to MinIO - * @param file Audio file to upload - * @returns Object ID and presigned URL - */ - async uploadVoice(file: File): Promise { - const formData = new FormData() - formData.append('file', file) - - const response = await adminApiClient.post( - '/api/v2/media/upload/voice', - formData, - { - headers: { - 'Content-Type': 'multipart/form-data', - }, - } - ) - - return response.data - }, - - /** - * Get presigned URL for an object - * @param bucket Bucket name (card-images, test-images, voice-audio) - * @param objectId Object ID (UUID) - * @param expirySeconds Optional expiry time in seconds - * @returns Presigned URL and expiration time - */ - async getPresignedUrl( - bucket: string, - objectId: string, - expirySeconds?: number - ): Promise { - const params = expirySeconds ? { expirySeconds: expirySeconds.toString() } : {} - const response = await adminApiClient.get( - `/api/v2/media/${bucket}/${objectId}/url`, - { params } - ) - - return response.data - }, - - /** - * Delete a file from MinIO - * @param bucket Bucket name - * @param objectId Object ID to delete - */ - async deleteFile(bucket: string, objectId: string): Promise { - await adminApiClient.delete(`/api/v2/media/${bucket}/${objectId}`) - }, -} diff --git a/admin/src/api/packs.ts b/admin/src/api/packs.ts index c7faae7..556229b 100644 --- a/admin/src/api/packs.ts +++ b/admin/src/api/packs.ts @@ -48,10 +48,23 @@ export const packsApi = { page: params?.page || 1, limit: params?.limit || 20, search: params?.search, - showDisabled: params?.showDisabled, + isPublic: params?.showDisabled === false ? true : undefined, }, }) - return response.data + // Transform backend response to match frontend PaginatedResponse + const backendData = response.data + return { + items: backendData.packs.map((pack: any) => ({ + id: pack.id, + title: pack.name, + cards: pack.questionCount, + enabled: pack.isPublic, + })), + total: backendData.total, + page: backendData.page, + limit: backendData.limit, + totalPages: backendData.totalPages, + } } catch (error) { const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }> @@ -83,7 +96,16 @@ export const packsApi = { getPack: async (packId: string): Promise => { try { const response = await adminApiClient.get(`/api/admin/packs/${packId}`) - return response.data + const pack = response.data + // Transform backend response to match EditCardPackDto + return { + id: pack.id, + name: pack.name, + description: pack.description, + category: pack.category, + isPublic: pack.isPublic, + questions: pack.questions, + } } catch (error) { const axiosError = error as AxiosError<{ error?: string; message?: string }> @@ -110,8 +132,22 @@ export const packsApi = { // Create or update pack upsertPack: async (pack: EditCardPackDto): Promise<{ success: boolean; pack: EditCardPackDto }> => { try { - const response = await adminApiClient.post('/api/admin/packs', pack) - return response.data + const isUpdate = pack.id && pack.id.length > 0 + const url = isUpdate ? `/api/admin/packs/${pack.id}` : '/api/admin/packs' + const method = isUpdate ? 'patch' : 'post' + + const response = await adminApiClient[method](url, { + name: pack.name, + description: pack.description, + category: pack.category, + isPublic: pack.isPublic, + questions: pack.questions, + }) + + return { + success: true, + pack: response.data, + } } catch (error) { const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }> const isUpdate = pack.id && pack.id.length > 0 diff --git a/admin/src/components/BulkCardUpload.tsx b/admin/src/components/BulkCardUpload.tsx deleted file mode 100644 index 52d39bc..0000000 --- a/admin/src/components/BulkCardUpload.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import { useState, useRef } from 'react' -import { useQuery } from '@tanstack/react-query' -import { Button } from './ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card' -import { Label } from './ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select' -import { Upload, X, Check, Loader2 } from 'lucide-react' -import { packsApi } from '@/api/packs' -import { mediaApi } from '@/api/media' - -interface UploadedImage { - id: string - file: File - preview: string - objectId: string // Object ID in MinIO (UUID) - presignedUrl?: string // Presigned URL for preview - isUploading?: boolean - uploadError?: string -} - -interface BulkCardUploadProps { - onImagesUploaded: (images: UploadedImage[], packId?: string) => void - onClose: () => void -} - -export function BulkCardUpload({ onImagesUploaded, onClose }: BulkCardUploadProps) { - const fileInputRef = useRef(null) - const [uploadedImages, setUploadedImages] = useState([]) - const [isDragging, setIsDragging] = useState(false) - const [selectedPackId, setSelectedPackId] = useState('') - const [isUploading, setIsUploading] = useState(false) - - // Fetch packs for pack selection - const { data: packsData } = useQuery({ - queryKey: ['packs', 1, 100, ''], - queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '' }), - }) - - const handleFileSelect = async (files: FileList) => { - const filesArray = Array.from(files) - - // Filter valid files - const validFiles = filesArray.filter((file) => { - if (!file.type.startsWith('image/')) { - return false - } - const fileSizeMB = file.size / (1024 * 1024) - if (fileSizeMB > 10) { // Updated to match backend limit - return false - } - return true - }) - - if (validFiles.length === 0) { - alert('No valid image files selected. Please select PNG, JPG, WEBP, or GIF files up to 10MB each.') - return - } - - setIsUploading(true) - - // Create placeholder entries with loading state - const placeholders: UploadedImage[] = validFiles.map((file, i) => ({ - id: `${Date.now()}-${i}`, - file, - preview: URL.createObjectURL(file), // Temporary preview - objectId: '', // Will be set after upload - isUploading: true, - })) - - setUploadedImages((prev) => [...prev, ...placeholders]) - - // Upload files in parallel (limit to 5 concurrent uploads) - const uploadPromises = validFiles.map(async (file, index) => { - const placeholderId = placeholders[index].id - - try { - const response = await mediaApi.uploadCardImage(file) - - // Update the placeholder with objectId and presigned URL - setUploadedImages((prev) => - prev.map((img) => - img.id === placeholderId - ? { - ...img, - objectId: response.objectId, - presignedUrl: response.url, - isUploading: false, - } - : img - ) - ) - } catch (error) { - console.error('Error uploading file:', file.name, error) - - // Mark as error - setUploadedImages((prev) => - prev.map((img) => - img.id === placeholderId - ? { - ...img, - isUploading: false, - uploadError: 'Upload failed', - } - : img - ) - ) - } - }) - - // Process in batches of 5 - const batchSize = 5 - for (let i = 0; i < uploadPromises.length; i += batchSize) { - const batch = uploadPromises.slice(i, i + batchSize) - await Promise.all(batch) - } - - setIsUploading(false) - } - - const handleInputChange = (e: React.ChangeEvent) => { - const files = e.target.files - if (files && files.length > 0) { - handleFileSelect(files) - } - // Reset input value to allow selecting the same files again - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - } - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(true) - } - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) - } - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) - - const files = e.dataTransfer.files - if (files && files.length > 0) { - handleFileSelect(files) - } - } - - const handleRemove = (id: string) => { - setUploadedImages((prev) => { - const image = prev.find((img) => img.id === id) - if (image) { - URL.revokeObjectURL(image.preview) - } - return prev.filter((img) => img.id !== id) - }) - } - - const handleClearAll = () => { - uploadedImages.forEach((img) => URL.revokeObjectURL(img.preview)) - setUploadedImages([]) - } - - const handleContinue = () => { - // Filter out images that failed to upload or are still uploading - const validImages = uploadedImages.filter( - (img) => img.objectId && !img.isUploading && !img.uploadError - ) - - if (validImages.length === 0) { - alert('Please wait for all images to finish uploading, or remove failed uploads.') - return - } - - if (validImages.length < uploadedImages.length) { - const failedCount = uploadedImages.length - validImages.length - if ( - !confirm( - `${failedCount} image(s) failed to upload or are still uploading. Continue with ${validImages.length} successfully uploaded images?` - ) - ) { - return - } - } - - onImagesUploaded(validImages, selectedPackId || undefined) - } - - return ( -
-
-
-

Bulk Card Upload

-

- Upload multiple images, then fill in card details one by one -

-
- -
- - - - Upload Images - - Select multiple image files or drag and drop them here - - - -
-
- - -

- Selected pack will be applied to all uploaded cards -

-
-
-
fileInputRef.current?.click()} - > - -
- {isUploading ? ( - - ) : ( - - )} -
- {isUploading ? ( - Uploading images... - ) : ( - <> - Click to upload or drag and drop - - )} -
-

- PNG, JPG, WEBP, GIF up to 10MB each. Multiple files supported. -

-
-
-
-
- - {uploadedImages.length > 0 && ( - - -
-
- Uploaded Images ({uploadedImages.length}) - - Review uploaded images and continue to fill in card details - -
- -
-
- -
- {uploadedImages.map((image) => ( -
-
- {image.isUploading ? ( -
- -
- ) : image.uploadError ? ( -
- -

Upload failed

-
- ) : ( - <> - {image.file.name} { - // Fallback to object URL if presigned URL fails - }} - /> -
- -
- - )} -
-

- {image.file.name} - {image.isUploading && ' (uploading...)'} - {image.uploadError && ' (failed)'} -

-
- ))} -
-
-
- )} - - {uploadedImages.length > 0 && ( -
- - -
- )} -
- ) -} - diff --git a/admin/src/components/ui/audio-upload.tsx b/admin/src/components/ui/audio-upload.tsx deleted file mode 100644 index 37c39df..0000000 --- a/admin/src/components/ui/audio-upload.tsx +++ /dev/null @@ -1,335 +0,0 @@ -import { useRef, useState, useEffect } from 'react' -import { Button } from './button' -import { Label } from './label' -import { Input } from './input' -import { X, Music, Play, Pause, Loader2 } from 'lucide-react' -import { mediaApi } from '@/api/media' - -interface AudioUploadProps { - label?: string - value?: string // Object ID (UUID) or legacy base64 - onChange: (value: string | undefined) => void - language?: string - onLanguageChange?: (language: string) => void - accept?: string - maxSizeMB?: number - disabled?: boolean -} - -export function AudioUpload({ - label, - value, - onChange, - language = 'en', - onLanguageChange, - accept = 'audio/*', - maxSizeMB = 20, // Updated to match backend limit - disabled = false, -}: AudioUploadProps) { - const fileInputRef = useRef(null) - const audioRef = useRef(null) - const [isPlaying, setIsPlaying] = useState(false) - const [isDragging, setIsDragging] = useState(false) - const [isUploading, setIsUploading] = useState(false) - const [presignedUrl, setPresignedUrl] = useState(null) - - // Load presigned URL for existing objectId - useEffect(() => { - if (value && !presignedUrl) { - // Check if value is a UUID (objectId) or legacy format - const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) - - if (isUuid) { - // It's an objectId, fetch presigned URL - mediaApi - .getPresignedUrl('voice-audio', value) - .then((response) => { - setPresignedUrl(response.url) - }) - .catch((error) => { - console.error('Failed to load presigned URL:', error) - // Keep presignedUrl as null - }) - } else { - // Legacy base64 format - setPresignedUrl(`data:audio/mpeg;base64,${value}`) - } - } else if (!value) { - setPresignedUrl(null) - } - }, [value, presignedUrl]) - - const handleFileSelect = async (file: File) => { - // Validate file size - const fileSizeMB = file.size / (1024 * 1024) - if (fileSizeMB > maxSizeMB) { - alert(`File size must be less than ${maxSizeMB}MB`) - return - } - - // Validate file type - if (!file.type.startsWith('audio/')) { - alert('Please select an audio file') - return - } - - try { - setIsUploading(true) - - // Upload to MinIO via Media API - const response = await mediaApi.uploadVoice(file) - - // Save objectId (not presigned URL!) - onChange(response.objectId) - - // Use presigned URL for playback - setPresignedUrl(response.url) - } catch (error) { - console.error('Error uploading audio:', error) - alert('Failed to upload audio. Please try again.') - } finally { - setIsUploading(false) - } - } - - const handleInputChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - handleFileSelect(file) - } - // Reset input value to allow selecting the same file again - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - } - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - if (!disabled) { - setIsDragging(true) - } - } - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) - } - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) - - if (disabled) return - - const file = e.dataTransfer.files?.[0] - if (file) { - handleFileSelect(file) - } - } - - const handleRemove = () => { - onChange(undefined) - setPresignedUrl(null) - if (audioRef.current) { - audioRef.current.pause() - audioRef.current = null - } - setIsPlaying(false) - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - } - - const handleClick = () => { - if (!disabled && !isUploading) { - fileInputRef.current?.click() - } - } - - // Cleanup audio on unmount or value change - useEffect(() => { - return () => { - if (audioRef.current) { - audioRef.current.pause() - audioRef.current = null - } - setIsPlaying(false) - } - }, [value, presignedUrl]) - - const handlePlayPause = () => { - if (!presignedUrl) return - - if (!audioRef.current) { - try { - const audio = new Audio(presignedUrl) - audioRef.current = audio - - audio.onended = () => { - setIsPlaying(false) - audioRef.current = null - } - - audio.onerror = (e) => { - console.error('Audio playback error:', e) - alert('Failed to play audio. The file may be corrupted or in an unsupported format.') - setIsPlaying(false) - audioRef.current = null - } - - audio.onloadstart = () => { - setIsPlaying(true) - } - - audio.play().catch((error) => { - console.error('Audio play error:', error) - alert('Failed to play audio. Please check your browser audio settings.') - setIsPlaying(false) - audioRef.current = null - }) - } catch (error) { - console.error('Error creating audio element:', error) - alert('Failed to initialize audio player') - setIsPlaying(false) - } - } else { - if (isPlaying) { - audioRef.current.pause() - setIsPlaying(false) - } else { - audioRef.current.play().catch((error) => { - console.error('Audio play error:', error) - alert('Failed to resume audio playback') - }) - setIsPlaying(true) - } - } - } - - return ( -
- {label && } - - {value || isUploading ? ( -
-
- {isUploading ? ( -
- - Uploading audio... -
- ) : ( - <> -
- - -
- Audio file loaded - {isPlaying && ( - Playing... - )} - {!presignedUrl && ( - (Loading preview...) - )} -
-
- - - )} -
- {onLanguageChange && ( -
- - onLanguageChange(e.target.value)} - placeholder="en" - className="mt-1" - disabled={disabled} - /> -
- )} -

- {isUploading ? 'Uploading...' : 'Click to change audio file'} -

-
- ) : ( -
- -
- {isUploading ? ( - - ) : ( - - )} -
- {isUploading ? ( - Uploading... - ) : ( - <> - Click to upload or drag and drop - - )} -
-

- MP3, WAV, OGG, FLAC up to {maxSizeMB}MB -

-
-
- )} -
- ) -} \ No newline at end of file diff --git a/admin/src/components/ui/image-upload.tsx b/admin/src/components/ui/image-upload.tsx deleted file mode 100644 index b2458ca..0000000 --- a/admin/src/components/ui/image-upload.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { useRef, useState, useEffect } from 'react' -import { Button } from './button' -import { Label } from './label' -import { X, Image as ImageIcon, Loader2 } from 'lucide-react' -import { mediaApi } from '@/api/media' - -interface ImageUploadProps { - label?: string - value?: string // Object ID (UUID) or legacy base64/URL - onChange: (value: string | undefined) => void - accept?: string - maxSizeMB?: number - disabled?: boolean - uploadType?: 'card-image' | 'test-image' // Type of upload endpoint -} - -export function ImageUpload({ - label, - value, - onChange, - accept = 'image/*', - maxSizeMB = 10, // Updated to match backend limit - disabled = false, - uploadType = 'card-image', -}: ImageUploadProps) { - const fileInputRef = useRef(null) - const [isDragging, setIsDragging] = useState(false) - const [isUploading, setIsUploading] = useState(false) - const [previewUrl, setPreviewUrl] = useState(null) - - // Load presigned URL for existing objectId - useEffect(() => { - if (value && !previewUrl) { - // Check if value is a UUID (objectId) or legacy format - const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) - - if (isUuid) { - // It's an objectId, fetch presigned URL - const bucket = uploadType === 'card-image' ? 'card-images' : 'test-images' - mediaApi - .getPresignedUrl(bucket, value) - .then((response) => { - setPreviewUrl(response.url) - }) - .catch((error) => { - console.error('Failed to load preview URL:', error) - // Keep previewUrl as null, will show placeholder - }) - } else if ( - value.startsWith('http://') || - value.startsWith('https://') || - value.startsWith('/api/') - ) { - // Legacy URL format - setPreviewUrl(value) - } else { - // Legacy base64 format - setPreviewUrl(`data:image/png;base64,${value}`) - } - } else if (!value) { - setPreviewUrl(null) - } - }, [value, uploadType, previewUrl]) - - const handleFileSelect = async (file: File) => { - // Validate file size - const fileSizeMB = file.size / (1024 * 1024) - if (fileSizeMB > maxSizeMB) { - alert(`File size must be less than ${maxSizeMB}MB`) - return - } - - // Validate file type - if (!file.type.startsWith('image/')) { - alert('Please select an image file') - return - } - - try { - setIsUploading(true) - - // Upload to MinIO via Media API - const uploadEndpoint = uploadType === 'card-image' - ? mediaApi.uploadCardImage(file) - : mediaApi.uploadTestImage(file) - - const response = await uploadEndpoint - - // Save objectId (not presigned URL!) - onChange(response.objectId) - - // Use presigned URL for preview - setPreviewUrl(response.url) - } catch (error) { - console.error('Error uploading image:', error) - alert('Failed to upload image. Please try again.') - } finally { - setIsUploading(false) - } - } - - const handleInputChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - handleFileSelect(file) - } - // Reset input value to allow selecting the same file again - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - } - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - if (!disabled) { - setIsDragging(true) - } - } - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) - } - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) - - if (disabled) return - - const file = e.dataTransfer.files?.[0] - if (file) { - handleFileSelect(file) - } - } - - const handleRemove = () => { - onChange(undefined) - setPreviewUrl(null) - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - } - - const handleClick = () => { - if (!disabled && !isUploading) { - fileInputRef.current?.click() - } - } - - return ( -
- {label && } - - {value || previewUrl ? ( -
-
- {isUploading ? ( -
- -
- ) : previewUrl ? ( - Preview { - // If presigned URL fails, clear preview - setPreviewUrl(null) - }} - /> - ) : ( -
- Image loaded (preview unavailable) -
- )} - -
-

- {isUploading ? 'Uploading...' : 'Click image to change'} -

-
- ) : ( -
- -
- {isUploading ? ( - - ) : ( - - )} -
- {isUploading ? ( - Uploading... - ) : ( - <> - Click to upload or drag and drop - - )} -
-

- PNG, JPG, WEBP, GIF up to {maxSizeMB}MB -

-
-
- )} -
- ) -} \ No newline at end of file diff --git a/admin/src/pages/UsersPage.tsx b/admin/src/pages/UsersPage.tsx index cc49d6b..bda82e4 100644 --- a/admin/src/pages/UsersPage.tsx +++ b/admin/src/pages/UsersPage.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { usersApi } from '@/api/users' -import type { UserDto, PaginatedResponse, PaymentDto } from '@/types/models' +import type { UserDto, PaginatedResponse } from '@/types/models' import type { AxiosError } from 'axios' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -34,10 +34,9 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Label } from '@/components/ui/label' -import { Checkbox } from '@/components/ui/checkbox' import { Badge } from '@/components/ui/badge' -import { packsApi } from '@/api/packs' -import { Search, Edit, Trash2, ChevronLeft, ChevronRight, CreditCard } from 'lucide-react' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react' export default function UsersPage() { const queryClient = useQueryClient() @@ -46,18 +45,13 @@ export default function UsersPage() { const [selectedUser, setSelectedUser] = useState(null) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) - const [isPurchasesDialogOpen, setIsPurchasesDialogOpen] = useState(false) const [userToDelete, setUserToDelete] = useState(null) - const [userPurchases, setUserPurchases] = useState([]) - // Form state + // Form state matching new UserDto const [formData, setFormData] = useState({ name: '', email: '', - admin: false, - subscription: false, - packs: [] as string[], - subscriptionFeatures: [] as string[], + role: 'USER' as 'USER' | 'ADMIN', }) const limit = 20 @@ -68,15 +62,10 @@ export default function UsersPage() { queryFn: () => usersApi.getUsers({ page, limit }), }) - // Fetch packs for selection - const { data: packsData } = useQuery({ - queryKey: ['packs', 1, 100, ''], - queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '', showDisabled: true }), - }) - // Mutations const updateMutation = useMutation({ - mutationFn: (user: UserDto) => usersApi.upsertUser(user), + mutationFn: ({ userId, userData }: { userId: string; userData: Partial }) => + usersApi.updateUser(userId, userData), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }) toast.success('User updated successfully') @@ -107,10 +96,7 @@ export default function UsersPage() { setFormData({ name: user.name || '', email: user.email || '', - admin: user.admin, - subscription: user.subscription || false, - packs: [...user.packs], - subscriptionFeatures: [...user.subscriptionFeatures], + role: user.role, }) setIsEditDialogOpen(true) } @@ -120,25 +106,6 @@ export default function UsersPage() { setSelectedUser(null) } - const openPurchasesDialog = async (user: UserDto) => { - if (!user.id) return - - try { - const purchases = await usersApi.getUserPurchases(user.id.toString()) - setUserPurchases(purchases) - setSelectedUser(user) - setIsPurchasesDialogOpen(true) - } catch { - toast.error('Failed to load user purchases') - } - } - - const closePurchasesDialog = () => { - setIsPurchasesDialogOpen(false) - setSelectedUser(null) - setUserPurchases([]) - } - const handleDelete = (user: UserDto) => { setUserToDelete(user) setIsDeleteDialogOpen(true) @@ -146,7 +113,7 @@ export default function UsersPage() { const confirmDelete = () => { if (userToDelete?.id) { - deleteMutation.mutate(userToDelete.id.toString()) + deleteMutation.mutate(userToDelete.id) } } @@ -158,18 +125,13 @@ export default function UsersPage() { return } - const userData: UserDto = { - id: selectedUser.id, + const userData: Partial = { name: formData.name.trim() || undefined, email: formData.email.trim() || undefined, - admin: formData.admin, - subscription: formData.subscription, - packs: formData.packs, - purchases: selectedUser.purchases, - subscriptionFeatures: formData.subscriptionFeatures, + role: formData.role, } - updateMutation.mutate(userData) + updateMutation.mutate({ userId: selectedUser.id, userData }) } if (error) { @@ -203,7 +165,7 @@ export default function UsersPage() {
setSearch(e.target.value)} className="max-w-sm" @@ -228,11 +190,12 @@ export default function UsersPage() { - ID Name Email - Admin - Packs + Telegram ID + Role + Stats + Created Actions @@ -241,21 +204,27 @@ export default function UsersPage() { .filter(user => search === '' || user.name?.toLowerCase().includes(search.toLowerCase()) || - user.id?.toString().includes(search) + user.telegramId?.includes(search) || + user.email?.toLowerCase().includes(search.toLowerCase()) ) .map((user) => ( - {user.id} {user.name || 'No name'} - {user.email || 'No email'} + {user.email || '-'} + {user.telegramId || '-'} - {user.admin ? ( + {user.role === 'ADMIN' ? ( Admin ) : ( User )} - {user.packs.length} packs + + {user.gamesPlayed} games, {user.gamesWon} wins, {user.totalPoints} pts + + + {new Date(user.createdAt).toLocaleDateString()} +
-
+
+ + +
+
@@ -365,93 +337,38 @@ export default function UsersPage() {
-
-
- setFormData(prev => ({ ...prev, admin: checked as boolean }))} - /> - -
-
- setFormData(prev => ({ ...prev, subscription: checked as boolean }))} - /> - -
+
+ +
- -
- {!packsData?.items || packsData.items.length === 0 ? ( -
No packs available
- ) : ( - packsData.items.map((pack) => ( -
- { - if (checked) { - setFormData(prev => ({ - ...prev, - packs: [...prev.packs, pack.id], - })) - } else { - setFormData(prev => ({ - ...prev, - packs: prev.packs.filter(id => id !== pack.id), - })) - } - }} - /> - -
- )) - )} + +
+
+
{selectedUser?.gamesPlayed || 0}
+
Games
+
+
+
{selectedUser?.gamesWon || 0}
+
Wins
+
+
+
{selectedUser?.totalPoints || 0}
+
Points
+
-

- Selected: {formData.packs.length} pack(s) -

-
- -
- -
- {['premium', 'unlimited', 'ad_free', 'early_access', 'priority_support'].map((feature) => ( -
- { - if (checked) { - setFormData(prev => ({ - ...prev, - subscriptionFeatures: [...prev.subscriptionFeatures, feature], - })) - } else { - setFormData(prev => ({ - ...prev, - subscriptionFeatures: prev.subscriptionFeatures.filter(f => f !== feature), - })) - } - }} - /> - -
- ))} -
-

- Selected: {formData.subscriptionFeatures.length} feature(s) -

@@ -466,62 +383,6 @@ export default function UsersPage() { - {/* User Purchases Dialog */} - - - - Purchase History - - Payment history for user: {selectedUser?.name || selectedUser?.id} - - -
- {userPurchases.length === 0 ? ( -
- No purchases found for this user -
- ) : ( -
- - - ID - Amount - Currency - Status - Date - - - - {userPurchases.map((payment) => ( - - {payment.id} - {payment.amount} - {payment.currency} - - - {payment.status} - - - - {new Date(payment.createdAt).toLocaleDateString()} - - - ))} - -
- )} -
- - - - - - {/* Delete Confirmation Dialog */} diff --git a/admin/src/types/models.ts b/admin/src/types/models.ts index 0428517..3e837ec 100644 --- a/admin/src/types/models.ts +++ b/admin/src/types/models.ts @@ -1,32 +1,60 @@ // Types based on backend DTOs -export interface EditCardPackDto { - id?: string - title?: string - subtitle?: string - color?: string - cover?: string - size?: number - googlePlayId?: string - rustoreId?: string - appStoreId?: string - price?: string +// Question and Answer structure +export interface Answer { + text: string + points: number +} + +export interface Question { + question: string + answers: Answer[] +} + +// Question Pack DTOs +export interface CreatePackDto { + name: string + description: string + category: string + isPublic?: boolean + questions: Question[] +} + +export interface UpdatePackDto { + name?: string description?: string - enabled?: boolean - version?: string - order?: number - cardsOrder?: string[] + category?: string + isPublic?: boolean + questions?: Question[] +} + +export interface QuestionPackDto { + id: string + name: string + description: string + category: string + isPublic: boolean + questionCount: number + timesUsed: number + rating: number + createdAt: string + updatedAt: string + creator: { + id: string + name?: string + } +} + +// For compatibility with existing pack pages +export interface EditCardPackDto extends CreatePackDto { + id?: string } export interface CardPackPreviewDto { id: string title: string - subtitle?: string - color?: string - cover?: string cards: number enabled: boolean - order?: number } // User model matching backend Prisma schema @@ -42,44 +70,8 @@ export interface UserDto { totalPoints: number } -// Note: PaymentDto removed - no payment system exists in the backend - -export interface SubscriptionPlanAdminDto { - id: string - name: string - description?: string - price: number - currency: string - features: string[] - enabled: boolean - order: number -} - -export interface PromoCodesCampaignDto { - id: string - title: string - description?: string - codes: string[] - discountPercent: number - validUntil: string - maxUses?: number - usedCount: number - enabled: boolean -} - -export interface DiscountCampaignDto { - id: string - title: string - description?: string - discountPercent: number - validFrom: string - validUntil: string - enabled: boolean - packIds?: string[] - userIds?: string[] - maxUses?: number - usedCount: number -} +// Note: Payment, Subscription, PromoCodes, and Discount DTOs removed +// These features are not implemented in the backend // API Response types export interface ApiResponse {