From a6f80bbd02741e35066c0c40fa337fc4fb0f3aaa Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 17 Sep 2025 21:20:49 +0700 Subject: [PATCH] Games Page --- .../apps/marketing/games/list/page.tsx | 7 + .../layout/vertical/VerticalMenu.tsx | 3 + src/data/dictionaries/en.json | 3 +- src/data/dictionaries/id.json | 3 +- src/types/services/game.ts | 17 + .../games/list/AddEditGamesDrawer.tsx | 407 +++++++++++ .../marketing/games/list/GameListTable.tsx | 640 ++++++++++++++++++ src/views/apps/marketing/games/list/index.tsx | 17 + 8 files changed, 1095 insertions(+), 2 deletions(-) create mode 100644 src/app/[lang]/(dashboard)/(private)/apps/marketing/games/list/page.tsx create mode 100644 src/types/services/game.ts create mode 100644 src/views/apps/marketing/games/list/AddEditGamesDrawer.tsx create mode 100644 src/views/apps/marketing/games/list/GameListTable.tsx create mode 100644 src/views/apps/marketing/games/list/index.tsx diff --git a/src/app/[lang]/(dashboard)/(private)/apps/marketing/games/list/page.tsx b/src/app/[lang]/(dashboard)/(private)/apps/marketing/games/list/page.tsx new file mode 100644 index 0000000..b94c7cf --- /dev/null +++ b/src/app/[lang]/(dashboard)/(private)/apps/marketing/games/list/page.tsx @@ -0,0 +1,7 @@ +import GamesList from '@/views/apps/marketing/games/list' + +const GamePage = () => { + return +} + +export default GamePage diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 86d4623..3434cb1 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -161,6 +161,9 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { {dictionary['navigation'].wheel_spin} + + {dictionary['navigation'].list} + {dictionary['navigation'].campaign} {dictionary['navigation'].customer_analytics} diff --git a/src/data/dictionaries/en.json b/src/data/dictionaries/en.json index 3a3c7b1..1545ad5 100644 --- a/src/data/dictionaries/en.json +++ b/src/data/dictionaries/en.json @@ -135,6 +135,7 @@ "campaign": "Campaign", "customer_analytics": "Customer Analytics", "voucher": "Voucher", - "tiers_text": "Tiers" + "tiers_text": "Tiers", + "games": "Games" } } diff --git a/src/data/dictionaries/id.json b/src/data/dictionaries/id.json index dd3b696..460e162 100644 --- a/src/data/dictionaries/id.json +++ b/src/data/dictionaries/id.json @@ -135,6 +135,7 @@ "campaign": "Kampanye", "customer_analytics": "Analisis Pelanggan", "voucher": "Vocher", - "tiers_text": "Tiers" + "tiers_text": "Tiers", + "games": "Permaninan" } } diff --git a/src/types/services/game.ts b/src/types/services/game.ts new file mode 100644 index 0000000..c95bf42 --- /dev/null +++ b/src/types/services/game.ts @@ -0,0 +1,17 @@ +export interface Game { + id: string // uuid + name: string + type: string + is_active: boolean + metadata: Record + created_at: string // ISO datetime + updated_at: string // ISO datetime +} + +export interface Games { + data: Game[] + total_count: number + page: number + limit: number + total_pages: number +} diff --git a/src/views/apps/marketing/games/list/AddEditGamesDrawer.tsx b/src/views/apps/marketing/games/list/AddEditGamesDrawer.tsx new file mode 100644 index 0000000..04a2356 --- /dev/null +++ b/src/views/apps/marketing/games/list/AddEditGamesDrawer.tsx @@ -0,0 +1,407 @@ +// React Imports +import { useState, useEffect } from 'react' + +// MUI Imports +import Button from '@mui/material/Button' +import Drawer from '@mui/material/Drawer' +import IconButton from '@mui/material/IconButton' +import MenuItem from '@mui/material/MenuItem' +import Typography from '@mui/material/Typography' +import Divider from '@mui/material/Divider' +import Grid from '@mui/material/Grid2' +import Box from '@mui/material/Box' +import Switch from '@mui/material/Switch' +import FormControlLabel from '@mui/material/FormControlLabel' +import Chip from '@mui/material/Chip' +import InputAdornment from '@mui/material/InputAdornment' +import Avatar from '@mui/material/Avatar' +import Card from '@mui/material/Card' +import CardContent from '@mui/material/CardContent' +import FormHelperText from '@mui/material/FormHelperText' + +// Third-party Imports +import { useForm, Controller } from 'react-hook-form' + +// Component Imports +import CustomTextField from '@core/components/mui/TextField' + +// Types +export interface Game { + id: string // uuid + name: string + type: string + is_active: boolean + metadata: Record + created_at: string // ISO datetime + updated_at: string // ISO datetime +} + +export interface GameRequest { + name: string + type: string + is_active: boolean + metadata: { + imageUrl?: string + } +} + +type Props = { + open: boolean + handleClose: () => void + data?: Game // Game data for edit (if exists) +} + +type FormValidateType = { + name: string + type: string + is_active: boolean + imageUrl: string +} + +// Initial form data +const initialData: FormValidateType = { + name: '', + type: 'quiz', + is_active: true, + imageUrl: '' +} + +// Mock mutation hooks (replace with actual hooks) +const useGameMutation = () => { + const createGame = { + mutate: (data: GameRequest, options?: { onSuccess?: () => void }) => { + console.log('Creating game:', data) + setTimeout(() => options?.onSuccess?.(), 1000) + } + } + + const updateGame = { + mutate: (data: { id: string; payload: GameRequest }, options?: { onSuccess?: () => void }) => { + console.log('Updating game:', data) + setTimeout(() => options?.onSuccess?.(), 1000) + } + } + + return { createGame, updateGame } +} + +// Game types +const GAME_TYPES = [ + { value: 'quiz', label: 'Quiz' }, + { value: 'puzzle', label: 'Puzzle' }, + { value: 'memory', label: 'Memory Game' }, + { value: 'trivia', label: 'Trivia' }, + { value: 'word', label: 'Word Game' }, + { value: 'math', label: 'Math Game' }, + { value: 'arcade', label: 'Arcade' }, + { value: 'strategy', label: 'Strategy' } +] + +// Game categories +const GAME_CATEGORIES = [ + { value: 'trivia', label: 'Trivia' }, + { value: 'educational', label: 'Educational' }, + { value: 'entertainment', label: 'Entertainment' }, + { value: 'brain_training', label: 'Brain Training' }, + { value: 'casual', label: 'Casual' }, + { value: 'competitive', label: 'Competitive' } +] + +// Difficulty levels +const DIFFICULTY_LEVELS = [ + { value: 'easy', label: 'Easy' }, + { value: 'medium', label: 'Medium' }, + { value: 'hard', label: 'Hard' }, + { value: 'expert', label: 'Expert' } +] + +const AddEditGamesDrawer = (props: Props) => { + // Props + const { open, handleClose, data } = props + + // States + const [showMore, setShowMore] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [imagePreview, setImagePreview] = useState(null) + + const { createGame, updateGame } = useGameMutation() + + // Determine if this is edit mode + const isEditMode = Boolean(data?.id) + + // Hooks + const { + control, + reset: resetForm, + handleSubmit, + watch, + setValue, + formState: { errors } + } = useForm({ + defaultValues: initialData + }) + + const watchedImageUrl = watch('imageUrl') + + // Effect to populate form when editing + useEffect(() => { + if (isEditMode && data) { + // Extract imageUrl from metadata + const imageUrl = data.metadata?.imageUrl || '' + + // Populate form with existing data + const formData: FormValidateType = { + name: data.name || '', + type: data.type || 'quiz', + is_active: data.is_active ?? true, + imageUrl: imageUrl + } + + resetForm(formData) + setImagePreview(imageUrl || null) + } else { + // Reset to initial data for add mode + resetForm(initialData) + setImagePreview(null) + } + }, [data, isEditMode, resetForm]) + + // Handle image URL change + useEffect(() => { + if (watchedImageUrl) { + setImagePreview(watchedImageUrl) + } else { + setImagePreview(null) + } + }, [watchedImageUrl]) + + // Handle unlimited stock toggle + useEffect(() => { + if (watchedImageUrl) { + setImagePreview(watchedImageUrl) + } else { + setImagePreview(null) + } + }, [watchedImageUrl]) + + const handleFormSubmit = async (formData: FormValidateType) => { + try { + setIsSubmitting(true) + + // Create GameRequest object + const gameRequest: GameRequest = { + name: formData.name, + type: formData.type, + is_active: formData.is_active, + metadata: { + imageUrl: formData.imageUrl || undefined + } + } + + if (isEditMode && data?.id) { + // Update existing game + updateGame.mutate( + { id: data.id, payload: gameRequest }, + { + onSuccess: () => { + handleReset() + handleClose() + } + } + ) + } else { + // Create new game + createGame.mutate(gameRequest, { + onSuccess: () => { + handleReset() + handleClose() + } + }) + } + } catch (error) { + console.error('Error submitting game:', error) + // Handle error (show toast, etc.) + } finally { + setIsSubmitting(false) + } + } + + const handleReset = () => { + handleClose() + resetForm(initialData) + setImagePreview(null) + } + + return ( + + {/* Sticky Header */} + +
+ {isEditMode ? 'Edit Game' : 'Tambah Game Baru'} + + + +
+
+ + {/* Scrollable Content */} + +
+
+ {/* Image Preview */} + {imagePreview && ( + + + + Preview Gambar + + + + + + + )} + + {/* Nama Game */} +
+ + Nama Game * + + ( + + )} + /> +
+ + {/* Tipe Game */} +
+ + Tipe Game * + + ( + + {GAME_TYPES.map(type => ( + + {type.label} + + ))} + + )} + /> +
+ + {/* Image URL */} +
+ + URL Gambar + + ( + + + + ) + }} + /> + )} + /> +
+ + {/* Status Aktif */} +
+ ( + } + label='Game Aktif' + /> + )} + /> +
+
+ +
+ + {/* Sticky Footer */} + +
+ + +
+
+
+ ) +} + +export default AddEditGamesDrawer diff --git a/src/views/apps/marketing/games/list/GameListTable.tsx b/src/views/apps/marketing/games/list/GameListTable.tsx new file mode 100644 index 0000000..de740e8 --- /dev/null +++ b/src/views/apps/marketing/games/list/GameListTable.tsx @@ -0,0 +1,640 @@ +'use client' + +// React Imports +import { useEffect, useState, useMemo, useCallback } from 'react' + +// Next Imports +import Link from 'next/link' +import { useParams } from 'next/navigation' + +// MUI Imports +import Card from '@mui/material/Card' +import CardHeader from '@mui/material/CardHeader' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import Chip from '@mui/material/Chip' +import Checkbox from '@mui/material/Checkbox' +import IconButton from '@mui/material/IconButton' +import { styled } from '@mui/material/styles' +import TablePagination from '@mui/material/TablePagination' +import type { TextFieldProps } from '@mui/material/TextField' +import MenuItem from '@mui/material/MenuItem' + +// Third-party Imports +import classnames from 'classnames' +import { rankItem } from '@tanstack/match-sorter-utils' +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + getFilteredRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, + getPaginationRowModel, + getSortedRowModel +} from '@tanstack/react-table' +import type { ColumnDef, FilterFn } from '@tanstack/react-table' +import type { RankingInfo } from '@tanstack/match-sorter-utils' + +// Type Imports +import type { ThemeColor } from '@core/types' +import type { Locale } from '@configs/i18n' + +// Component Imports +import OptionMenu from '@core/components/option-menu' +import TablePaginationComponent from '@components/TablePaginationComponent' +import CustomTextField from '@core/components/mui/TextField' +import CustomAvatar from '@core/components/mui/Avatar' + +// Util Imports +import { getInitials } from '@/utils/getInitials' +import { getLocalizedUrl } from '@/utils/i18n' +import { formatCurrency } from '@/utils/transform' + +// Style Imports +import tableStyles from '@core/styles/table.module.css' +import Loading from '@/components/layout/shared/Loading' +import AddEditGamesDrawer from './AddEditGamesDrawer' + +// Game Interface +export interface Game { + id: string // uuid + name: string + type: string + is_active: boolean + metadata: Record + created_at: string // ISO datetime + updated_at: string // ISO datetime +} + +declare module '@tanstack/table-core' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank: RankingInfo + } +} + +type GameWithAction = Game & { + action?: string +} + +// Styled Components +const Icon = styled('i')({}) + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the itemRank info + addMeta({ + itemRank + }) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const DebouncedInput = ({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit) => { + // States + const [value, setValue] = useState(initialValue) + + useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value) + }, debounce) + + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + return setValue(e.target.value)} /> +} + +// Dummy data for games +const DUMMY_GAME_DATA: Game[] = [ + { + id: '1', + name: 'Quiz Master Challenge', + type: 'quiz', + is_active: true, + metadata: { + imageUrl: 'https://example.com/quiz-master.jpg' + }, + created_at: '2024-01-15T08:30:00Z', + updated_at: '2024-02-10T10:15:00Z' + }, + { + id: '2', + name: 'Memory Palace', + type: 'memory', + is_active: true, + metadata: { + imageUrl: 'https://example.com/memory-palace.jpg' + }, + created_at: '2024-01-20T09:00:00Z', + updated_at: '2024-02-15T11:30:00Z' + }, + { + id: '3', + name: 'Word Wizard', + type: 'word', + is_active: true, + metadata: {}, + created_at: '2024-01-25T14:20:00Z', + updated_at: '2024-02-20T16:45:00Z' + }, + { + id: '4', + name: 'Math Sprint', + type: 'math', + is_active: true, + metadata: {}, + created_at: '2024-02-01T07:15:00Z', + updated_at: '2024-02-25T13:20:00Z' + }, + { + id: '5', + name: 'Puzzle Paradise', + type: 'puzzle', + is_active: true, + metadata: { + imageUrl: 'https://example.com/puzzle-paradise.jpg' + }, + created_at: '2024-02-05T11:40:00Z', + updated_at: '2024-03-01T09:25:00Z' + }, + { + id: '6', + name: 'Trivia Tournament', + type: 'trivia', + is_active: true, + metadata: {}, + created_at: '2024-02-10T16:00:00Z', + updated_at: '2024-03-05T12:10:00Z' + }, + { + id: '7', + name: 'Speed Arcade', + type: 'arcade', + is_active: true, + metadata: { + imageUrl: 'https://example.com/speed-arcade.jpg' + }, + created_at: '2024-02-15T13:30:00Z', + updated_at: '2024-03-10T15:45:00Z' + }, + { + id: '8', + name: 'Strategy Kingdom', + type: 'strategy', + is_active: true, + metadata: {}, + created_at: '2024-03-01T10:20:00Z', + updated_at: '2024-03-15T08:55:00Z' + }, + { + id: '9', + name: 'Quick Quiz', + type: 'quiz', + is_active: false, + metadata: {}, + created_at: '2024-03-05T12:45:00Z', + updated_at: '2024-03-20T14:30:00Z' + }, + { + id: '10', + name: 'Brain Teaser Deluxe', + type: 'puzzle', + is_active: true, + metadata: { + imageUrl: 'https://example.com/brain-teaser.jpg' + }, + created_at: '2024-03-10T09:15:00Z', + updated_at: '2024-03-25T11:40:00Z' + } +] + +// Mock data hook with dummy data +const useGameCatalog = ({ page, limit, search }: { page: number; limit: number; search: string }) => { + const [isLoading, setIsLoading] = useState(false) + + // Simulate loading + useEffect(() => { + setIsLoading(true) + const timer = setTimeout(() => setIsLoading(false), 500) + return () => clearTimeout(timer) + }, [page, limit, search]) + + // Filter data based on search + const filteredData = useMemo(() => { + if (!search) return DUMMY_GAME_DATA + + return DUMMY_GAME_DATA.filter( + game => + game.name.toLowerCase().includes(search.toLowerCase()) || + game.type.toLowerCase().includes(search.toLowerCase()) || + game.metadata?.description?.toLowerCase().includes(search.toLowerCase()) + ) + }, [search]) + + // Paginate data + const paginatedData = useMemo(() => { + const startIndex = (page - 1) * limit + const endIndex = startIndex + limit + return filteredData.slice(startIndex, endIndex) + }, [filteredData, page, limit]) + + return { + data: { + games: paginatedData, + total_count: filteredData.length + }, + isLoading, + error: null, + isFetching: isLoading + } +} + +// Helper functions +const getGameTypeLabel = (type: string) => { + const typeMap: Record = { + quiz: 'Quiz', + puzzle: 'Puzzle', + memory: 'Memory', + trivia: 'Trivia', + word: 'Word Game', + math: 'Math Game', + arcade: 'Arcade', + strategy: 'Strategy' + } + return typeMap[type] || type +} + +const getDifficultyColor = (difficulty: string): ThemeColor => { + switch (difficulty) { + case 'easy': + return 'success' + case 'medium': + return 'warning' + case 'hard': + return 'error' + case 'expert': + return 'primary' + default: + return 'secondary' + } +} + +const getDifficultyLabel = (difficulty: string) => { + const difficultyMap: Record = { + easy: 'Mudah', + medium: 'Sedang', + hard: 'Sulit', + expert: 'Ahli' + } + return difficultyMap[difficulty] || difficulty +} + +const formatDuration = (seconds: number) => { + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m` +} + +// Column Definitions +const columnHelper = createColumnHelper() + +const GameListTable = () => { + // States + const [addGameOpen, setAddGameOpen] = useState(false) + const [editGameData, setEditGameData] = useState(undefined) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState('') + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [search, setSearch] = useState('') + + const { data, isLoading, error, isFetching } = useGameCatalog({ + page: currentPage, + limit: pageSize, + search + }) + + const games = data?.games ?? [] + const totalCount = data?.total_count ?? 0 + + // Hooks + const { lang: locale } = useParams() + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setCurrentPage(newPage) + }, []) + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10) + setPageSize(newPageSize) + setCurrentPage(1) // Reset to first page + }, []) + + const handleEditGame = (game: Game) => { + setEditGameData(game) + setAddGameOpen(true) + } + + const handleDeleteGame = (gameId: string) => { + if (confirm('Apakah Anda yakin ingin menghapus game ini?')) { + console.log('Deleting game:', gameId) + // Add your delete logic here + // deleteGame.mutate(gameId) + } + } + + const handleToggleActive = (gameId: string, currentStatus: boolean) => { + console.log('Toggling active status for game:', gameId, !currentStatus) + // Add your toggle logic here + // toggleGameStatus.mutate({ id: gameId, is_active: !currentStatus }) + } + + const handleCloseGameDrawer = () => { + setAddGameOpen(false) + setEditGameData(undefined) + } + + const columns = useMemo[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }, + columnHelper.accessor('name', { + header: 'Nama Game', + cell: ({ row }) => ( +
+ + {getInitials(row.original.name)} + +
+ + + {row.original.name} + + + + {getGameTypeLabel(row.original.type)} + +
+
+ ) + }), + columnHelper.accessor('type', { + header: 'Tipe Game', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('is_active', { + header: 'Status', + cell: ({ row }) => ( + + ) + }), + columnHelper.accessor('created_at', { + header: 'Tanggal Dibuat', + cell: ({ row }) => ( + + {new Date(row.original.created_at).toLocaleDateString('id-ID', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + + ) + }), + { + id: 'actions', + header: 'Aksi', + cell: ({ row }) => ( +
+ handleToggleActive(row.original.id, row.original.is_active) + } + }, + { + text: 'Edit', + icon: 'tabler-edit text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleEditGame(row.original) + } + }, + { + text: 'Hapus', + icon: 'tabler-trash text-[22px]', + menuItemProps: { + className: 'flex items-center gap-2 text-textSecondary', + onClick: () => handleDeleteGame(row.original.id) + } + } + ]} + /> +
+ ), + enableSorting: false + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [locale, handleEditGame, handleDeleteGame, handleToggleActive] + ) + + const table = useReactTable({ + data: games as Game[], + columns, + filterFns: { + fuzzy: fuzzyFilter + }, + state: { + rowSelection, + globalFilter, + pagination: { + pageIndex: currentPage, + pageSize + } + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: Math.ceil(totalCount / pageSize) + }) + + return ( + <> + +
+ table.setPageSize(Number(e.target.value))} + className='max-sm:is-full sm:is-[70px]' + > + 10 + 25 + 50 + +
+ setSearch(value as string)} + placeholder='Cari Game' + className='max-sm:is-full' + /> + + +
+
+
+ {isLoading ? ( + + ) : ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + ))} + + ))} + + {table.getFilteredRowModel().rows.length === 0 ? ( + + + + + + ) : ( + + {table + .getRowModel() + .rows.slice(0, table.getState().pagination.pageSize) + .map(row => { + return ( + + {row.getVisibleCells().map(cell => ( + + ))} + + ) + })} + + )} +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {{ + asc: , + desc: + }[header.column.getIsSorted() as 'asc' | 'desc'] ?? null} +
+ + )} +
+ Tidak ada data tersedia +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ )} +
+ ( + + )} + count={totalCount} + rowsPerPage={pageSize} + page={currentPage} + onPageChange={handlePageChange} + onRowsPerPageChange={handlePageSizeChange} + rowsPerPageOptions={[10, 25, 50]} + disabled={isLoading} + /> +
+ + + ) +} + +export default GameListTable diff --git a/src/views/apps/marketing/games/list/index.tsx b/src/views/apps/marketing/games/list/index.tsx new file mode 100644 index 0000000..5d88053 --- /dev/null +++ b/src/views/apps/marketing/games/list/index.tsx @@ -0,0 +1,17 @@ +// MUI Imports +import Grid from '@mui/material/Grid2' +import GameListTable from './GameListTable' + +// Type Imports + +const GamesList = () => { + return ( + + + + + + ) +} + +export default GamesList