feat: static applications dashboard
This commit is contained in:
+4
-1
@@ -23,9 +23,12 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"@reduxjs/toolkit": "^2.0.1",
|
"@reduxjs/toolkit": "^2.0.1",
|
||||||
|
"@tanstack/match-sorter-utils": "^8.11.8",
|
||||||
|
"@tanstack/react-table": "^8.12.0",
|
||||||
"appwrite": "^13.0.1",
|
"appwrite": "^13.0.1",
|
||||||
|
"date-fns": "^3.3.1",
|
||||||
"entgamers-database": "0.0.16",
|
"entgamers-database": "0.0.16",
|
||||||
"entgamers-panda-preset": "0.1.2",
|
"entgamers-panda-preset": "0.1.5",
|
||||||
"formik": "^2.4.5",
|
"formik": "^2.4.5",
|
||||||
"framer-motion": "^10.17.6",
|
"framer-motion": "^10.17.6",
|
||||||
"isomorphic-fetch": "^3.0.0",
|
"isomorphic-fetch": "^3.0.0",
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch'
|
import { useAppDispatch } from '@/hooks/useAppDispatch'
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector'
|
import { useAppSelector } from '@/hooks/useAppSelector'
|
||||||
import { setCurrentUser, setSession, setStatus } from '@/state/sessionSlice'
|
import { setClanes, setCurrentUser, setSession, setStatus } from '@/state/sessionSlice'
|
||||||
import { AppwriteException } from 'appwrite'
|
import { AppwriteException } from 'appwrite'
|
||||||
|
import { getClanes } from 'entgamers-database/frontend/clanes'
|
||||||
import { getCurrentUser, getSession } from 'entgamers-database/frontend/session'
|
import { getCurrentUser, getSession } from 'entgamers-database/frontend/session'
|
||||||
import { useCallback, useEffect, type FC } from 'react'
|
import { useCallback, useEffect, type FC } from 'react'
|
||||||
|
|
||||||
const SessionConsumer: FC = () => {
|
const SessionConsumer: FC = () => {
|
||||||
const session = useAppSelector((state) => state.session)
|
const { status, session, user, clanes } = useAppSelector((state) => state.session)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const ensureSession = useCallback(async () => {
|
const ensureSession = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (session.status !== 'initializing' || session.session !== undefined) return
|
if (status !== 'initializing' || session !== undefined) return
|
||||||
dispatch(setStatus('loading'))
|
dispatch(setStatus('loading'))
|
||||||
const currentSession = await getSession('current')
|
const currentSession = await getSession('current')
|
||||||
const currentUser = await getCurrentUser()
|
const currentUser = await getCurrentUser()
|
||||||
@@ -36,6 +37,22 @@ const SessionConsumer: FC = () => {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user !== undefined && clanes === undefined) {
|
||||||
|
getClanes()
|
||||||
|
.then((clanes) => {
|
||||||
|
dispatch(setClanes(clanes))
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (error instanceof AppwriteException) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (user === undefined && clanes !== undefined) {
|
||||||
|
dispatch(setClanes())
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import IconButton from '@/components/ui/IconButton'
|
||||||
|
import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeadCell, TableRow } from '@/components/ui/Table'
|
||||||
|
import DebouncedInput from '@/components/ui/form/DebouncedInput'
|
||||||
|
import useManageError from '@/hooks/useManageError'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { formatDate } from '@/utilities/date'
|
||||||
|
import { type TeamApplication, type TeamApplicationList } from '@/utilities/teamApplication'
|
||||||
|
import { faChevronLeft, faChevronRight, faSort, faSortAsc, faSortDesc } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { rankItem } from '@tanstack/match-sorter-utils'
|
||||||
|
import { createColumnHelper, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, type Column, type FilterFn, type Table as TableType } from '@tanstack/react-table'
|
||||||
|
import { useCallback, useEffect, useMemo, useState, type FC, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
|
||||||
|
// Rank the item
|
||||||
|
const itemRank = rankItem(row.getValue(columnId), value as string)
|
||||||
|
|
||||||
|
// Store the itemRank info
|
||||||
|
addMeta({
|
||||||
|
itemRank
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return if the item should be filtered in/out
|
||||||
|
return itemRank.passed
|
||||||
|
}
|
||||||
|
|
||||||
|
function Filter ({
|
||||||
|
column,
|
||||||
|
table
|
||||||
|
}: {
|
||||||
|
column: Column<any, unknown>
|
||||||
|
table: TableType<any>
|
||||||
|
}): ReactNode {
|
||||||
|
const firstValue = table
|
||||||
|
.getPreFilteredRowModel()
|
||||||
|
.flatRows[0]?.getValue(column.id)
|
||||||
|
|
||||||
|
const columnFilterValue = column.getFilterValue()
|
||||||
|
|
||||||
|
const sortedUniqueValues = useMemo(() => typeof firstValue === 'number'
|
||||||
|
? []
|
||||||
|
// eslint-disable-next-line @typescript-eslint/require-array-sort-compare
|
||||||
|
: Array.from(column.getFacetedUniqueValues().keys()).sort()
|
||||||
|
, [column.getFacetedUniqueValues()])
|
||||||
|
|
||||||
|
return typeof firstValue === 'number'
|
||||||
|
? (
|
||||||
|
<div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<DebouncedInput
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
min={Number(column.getFacetedMinMaxValues()?.[0] ?? '')}
|
||||||
|
max={Number(column.getFacetedMinMaxValues()?.[1] ?? '')}
|
||||||
|
value={(columnFilterValue as [number, number])?.[0] ?? ''}
|
||||||
|
onChange={value => { column.setFilterValue((old: [number, number]) => [value, old?.[1]]) }
|
||||||
|
}
|
||||||
|
placeholder={`Min ${
|
||||||
|
((column.getFacetedMinMaxValues()?.[0]) != null)
|
||||||
|
? `(${column.getFacetedMinMaxValues()?.[0]})`
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
className="w-24 border shadow rounded"
|
||||||
|
/>
|
||||||
|
<DebouncedInput
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
min={Number(column.getFacetedMinMaxValues()?.[0] ?? '')}
|
||||||
|
max={Number(column.getFacetedMinMaxValues()?.[1] ?? '')}
|
||||||
|
value={(columnFilterValue as [number, number])?.[1] ?? ''}
|
||||||
|
onChange={value => { column.setFilterValue((old: [number, number]) => [old?.[0], value]) }
|
||||||
|
}
|
||||||
|
placeholder={`Max ${
|
||||||
|
((column.getFacetedMinMaxValues()?.[1]) != null)
|
||||||
|
? `(${column.getFacetedMinMaxValues()?.[1]})`
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
className="w-24 border shadow rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="h-1" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<datalist id={column.id + 'list'}>
|
||||||
|
{sortedUniqueValues.slice(0, 5000).map((value: any) => (
|
||||||
|
<option value={value} key={value} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<DebouncedInput
|
||||||
|
fullWidth
|
||||||
|
type="text"
|
||||||
|
value={(columnFilterValue ?? '') as string}
|
||||||
|
onChange={value => { column.setFilterValue(value) }}
|
||||||
|
placeholder={`Search... (${column.getFacetedUniqueValues().size})`}
|
||||||
|
className="w-36 border shadow rounded"
|
||||||
|
list={column.id + 'list'}
|
||||||
|
/>
|
||||||
|
<div className="h-1" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<TeamApplication>()
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor('id', {
|
||||||
|
header: 'ID'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('status', {
|
||||||
|
header: 'Estado',
|
||||||
|
enableSorting: false
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('role', {
|
||||||
|
header: 'Rol',
|
||||||
|
enableSorting: false
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('name', {
|
||||||
|
header: 'Nombre'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('message', {
|
||||||
|
header: 'Mensaje',
|
||||||
|
minSize: 450
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('email', {
|
||||||
|
header: 'Correo'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('discord', {
|
||||||
|
header: 'Discord'
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('createdAt', {
|
||||||
|
header: 'Creado',
|
||||||
|
cell: (info) => {
|
||||||
|
return formatDate(new Date(info.getValue()))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('updatedAt', {
|
||||||
|
header: 'Actualizado',
|
||||||
|
cell: (info) => {
|
||||||
|
return formatDate(new Date(info.getValue()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const ApplicationsList: FC = () => {
|
||||||
|
const { manageError } = useManageError()
|
||||||
|
const [applications, setApplications] = useState<TeamApplication[]>([])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: applications,
|
||||||
|
columns,
|
||||||
|
filterFns: {
|
||||||
|
fuzzy: fuzzyFilter
|
||||||
|
},
|
||||||
|
initialState: {
|
||||||
|
columnVisibility: {
|
||||||
|
id: false
|
||||||
|
},
|
||||||
|
sorting: [{ id: 'createdAt', desc: true }]
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel()
|
||||||
|
})
|
||||||
|
|
||||||
|
const getTeamApplications = useCallback(async (controller?: AbortController) => {
|
||||||
|
const callController = controller ?? new AbortController()
|
||||||
|
const response = await fetch('/api/team-applications', { signal: callController.signal })
|
||||||
|
if (response.ok) {
|
||||||
|
const teamApplicationList: TeamApplicationList = await response.json()
|
||||||
|
setApplications(teamApplicationList.teamApplications)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
getTeamApplications(controller)
|
||||||
|
.catch((error) => {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') return
|
||||||
|
manageError(error, 'Error al obtener las aplicaciones', 'Error desconocido al obtener las aplicaciones', 'error')
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// TODO: Better UI Controls for: column visibility. Quantity selector.
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'small',
|
||||||
|
marginBottom: 'small'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{table.getAllLeafColumns().map((column) => (
|
||||||
|
<div key={column.id}>
|
||||||
|
<label htmlFor={`${column.id}-view`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`${column.id}-view`}
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onChange={column.getToggleVisibilityHandler()}
|
||||||
|
/> {column.columnDef.header?.toString() ?? column.id}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHeadCell
|
||||||
|
key={header.id}
|
||||||
|
className={css({
|
||||||
|
position: 'relative',
|
||||||
|
'&:hover > [data-is-resizing]': {
|
||||||
|
backgroundColor: 'border'
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
style={{ minWidth: header.getSize() }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'small'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) }
|
||||||
|
{header.column.getCanSort() && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={header.column.getIsSorted() === 'asc' ? faSortAsc : header.column.getIsSorted() === 'desc' ? faSortDesc : faSort} size="sm" fixedWidth />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{header.column.getCanFilter()
|
||||||
|
? (
|
||||||
|
<div>
|
||||||
|
<Filter column={header.column} table={table} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: '5px',
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'border'
|
||||||
|
},
|
||||||
|
'&[data-is-resizing=true]': {
|
||||||
|
backgroundColor: 'primary'
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
transform: `translateX(${1 * (table.getState().columnSizingInfo
|
||||||
|
.deltaOffset ?? 0)}px)`
|
||||||
|
}}
|
||||||
|
data-is-resizing={header.column.getIsResizing()}
|
||||||
|
onDoubleClick={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
/>
|
||||||
|
</TableHeadCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'small',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingBlock: 'medium'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => { table.previousPage() }}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faChevronLeft} fixedWidth />
|
||||||
|
</IconButton>
|
||||||
|
Pagina {table.getState().pagination.pageIndex + 1} de {table.getPageCount()}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => { table.nextPage() }}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} fixedWidth />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApplicationsList
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
'use client'
|
||||||
|
import TeamApplications from '@/app/dashboard/_components/TeamApplications'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import ButtonGroup from '@/components/ui/ButtonGroup'
|
||||||
|
import Typography from '@/components/ui/Typography'
|
||||||
|
import useSession from '@/hooks/useSession'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { ADMIN_CLAN_ID } from 'entgamers-database/frontend/clanes/administrative'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
import { useState, type FC } from 'react'
|
||||||
|
|
||||||
|
type Tab = undefined | 'teamApplications'
|
||||||
|
|
||||||
|
const DashboardTabs: FC = () => {
|
||||||
|
const { clanes, belongToClan } = useSession('/login')
|
||||||
|
|
||||||
|
const [currentTab, setCurrentTab] = useState<Tab>(undefined)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{clanes !== undefined && (
|
||||||
|
<ButtonGroup
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<Button fullWidth onClick={() => { setCurrentTab(undefined) }} disabled={currentTab === undefined}>
|
||||||
|
Panel
|
||||||
|
</Button>
|
||||||
|
{belongToClan(ADMIN_CLAN_ID) && (
|
||||||
|
<Button fullWidth onClick={() => { setCurrentTab('teamApplications') }} disabled={currentTab === 'teamApplications'}>
|
||||||
|
Aplicaciones
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ButtonGroup>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: 'medium'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<AnimatePresence
|
||||||
|
initial={false}
|
||||||
|
mode='wait'
|
||||||
|
>
|
||||||
|
{currentTab === undefined && (
|
||||||
|
<motion.div
|
||||||
|
transition={{ duration: 0.15, ease: 'easeInOut' }}
|
||||||
|
initial={{ opacity: 0, x: '-100%' }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: '100%' }}
|
||||||
|
key="dashboard"
|
||||||
|
>
|
||||||
|
<Typography variant="body1">Selecciona una de las opciones de arriba para comenzar.</Typography>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{currentTab === 'teamApplications' && (
|
||||||
|
<motion.div
|
||||||
|
transition={{ duration: 0.15, ease: 'easeInOut' }}
|
||||||
|
initial={{ opacity: 0, x: '-100%' }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: '100%' }}
|
||||||
|
key="teamApplications"
|
||||||
|
>
|
||||||
|
<TeamApplications />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardTabs
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import ApplicationsList from '@/app/dashboard/_components/ApplicationsList'
|
||||||
|
import Typography from '@/components/ui/Typography'
|
||||||
|
import { type FC } from 'react'
|
||||||
|
|
||||||
|
const TeamApplications: FC = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography variant="h2">Aplicaciones</Typography>
|
||||||
|
<ApplicationsList />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TeamApplications
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Typography from '@/components/ui/Typography'
|
||||||
|
import { Container } from '@/styled-system/jsx'
|
||||||
|
import { type FC } from 'react'
|
||||||
|
import DashboardTabs from './_components/DashboardTabs'
|
||||||
|
|
||||||
|
const page: FC = () => {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Typography variant="h1" align="center">Panel de control</Typography>
|
||||||
|
<DashboardTabs />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default page
|
||||||
@@ -1,32 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import EntGamers from '@/assets/logos/EntGamers'
|
import EntGamers from '@/assets/logos/EntGamers'
|
||||||
import Menu from '@/components/layout/Menu'
|
import Menu from '@/components/layout/Menu'
|
||||||
import IconButton from '@/components/ui/IconButton'
|
|
||||||
import Tooltip from '@/components/ui/Tooltip'
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch'
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector'
|
|
||||||
import { addAlert } from '@/state/feedbackSlice'
|
|
||||||
import { setSession } from '@/state/sessionSlice'
|
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Container } from '@/styled-system/jsx'
|
import { Container } from '@/styled-system/jsx'
|
||||||
import { iconButton } from '@/styled-system/recipes'
|
|
||||||
import { faRightFromBracket, faUser } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
||||||
import { nanoid } from '@reduxjs/toolkit'
|
|
||||||
import { AppwriteException } from 'appwrite'
|
|
||||||
import { logout } from 'entgamers-database/frontend/session'
|
|
||||||
import NextLink from 'next/link'
|
import NextLink from 'next/link'
|
||||||
import { useCallback, useEffect, useState, type FC } from 'react'
|
import { useCallback, useEffect, useState, type FC } from 'react'
|
||||||
|
import SessionButtons from './Header/SessionButtons'
|
||||||
|
|
||||||
const Header: FC = () => {
|
const Header: FC = () => {
|
||||||
const session = useAppSelector(state => state.session)
|
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
|
|
||||||
const [isScrolled, setIsScrolled] = useState(typeof window !== 'undefined' ? window.scrollY > 0 : false)
|
const [isScrolled, setIsScrolled] = useState(typeof window !== 'undefined' ? window.scrollY > 0 : false)
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
setIsScrolled(window.scrollY > 0)
|
setIsScrolled(window.scrollY > 0)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
window.addEventListener('scroll', handleScroll)
|
window.addEventListener('scroll', handleScroll)
|
||||||
@@ -79,65 +67,7 @@ const Header: FC = () => {
|
|||||||
</NextLink>
|
</NextLink>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{session.status === 'idle' && typeof session.session !== 'undefined'
|
<SessionButtons />
|
||||||
? (
|
|
||||||
<>
|
|
||||||
<Tooltip
|
|
||||||
title="Cuenta"
|
|
||||||
position="bottom"
|
|
||||||
>
|
|
||||||
<NextLink
|
|
||||||
href="/cuenta"
|
|
||||||
className={
|
|
||||||
iconButton()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faUser} fixedWidth />
|
|
||||||
</NextLink>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
title="Cerrar sesión"
|
|
||||||
position="bottom"
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => {
|
|
||||||
logout('current')
|
|
||||||
.then(() => {
|
|
||||||
dispatch(setSession(undefined))
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (error instanceof AppwriteException) {
|
|
||||||
dispatch(addAlert({
|
|
||||||
id: nanoid(),
|
|
||||||
message: error.message,
|
|
||||||
title: 'Error mientras se cerraba sesión',
|
|
||||||
severity: 'error'
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faRightFromBracket} fixedWidth />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<Tooltip
|
|
||||||
title="Iniciar sesión"
|
|
||||||
position="bottom"
|
|
||||||
>
|
|
||||||
<NextLink
|
|
||||||
href="/login"
|
|
||||||
className={
|
|
||||||
iconButton()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faUser} fixedWidth />
|
|
||||||
</NextLink>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
<Menu />
|
<Menu />
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import IconButton from '@/components/ui/IconButton'
|
||||||
|
import Tooltip from '@/components/ui/Tooltip'
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch'
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector'
|
||||||
|
import useManageError from '@/hooks/useManageError'
|
||||||
|
import { setCurrentUser, setSession, setStatus } from '@/state/sessionSlice'
|
||||||
|
import { iconButton } from '@/styled-system/recipes'
|
||||||
|
import { faCogs, faRightFromBracket } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faUser } from '@fortawesome/free-solid-svg-icons/faUser'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { ADMIN_CLAN_ID, MODERATOR_CLAN_ID } from 'entgamers-database/frontend/clanes/administrative'
|
||||||
|
import { logout } from 'entgamers-database/frontend/session'
|
||||||
|
import NextLink from 'next/link'
|
||||||
|
import { type FC } from 'react'
|
||||||
|
|
||||||
|
const SessionButtons: FC = () => {
|
||||||
|
const { session, status, clanes } = useAppSelector(state => state.session)
|
||||||
|
const { manageError } = useManageError()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
if (status !== 'idle') return null
|
||||||
|
|
||||||
|
if (status === 'idle' && session === undefined) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip
|
||||||
|
title={'Iniciar sesión'}
|
||||||
|
position='bottom'
|
||||||
|
>
|
||||||
|
<NextLink
|
||||||
|
href="/login"
|
||||||
|
className={
|
||||||
|
iconButton()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUser} fixedWidth />
|
||||||
|
</NextLink>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'idle' && session !== undefined) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip
|
||||||
|
title={'Mi cuenta'}
|
||||||
|
position='bottom'
|
||||||
|
>
|
||||||
|
<NextLink
|
||||||
|
href="/cuenta"
|
||||||
|
className={
|
||||||
|
iconButton()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUser} fixedWidth />
|
||||||
|
</NextLink>
|
||||||
|
</Tooltip>
|
||||||
|
{clanes !== undefined && clanes?.teams.some(team => team.$id === ADMIN_CLAN_ID || team.$id === MODERATOR_CLAN_ID) && (
|
||||||
|
<Tooltip
|
||||||
|
title={'Panel de administración'}
|
||||||
|
position='bottom'
|
||||||
|
>
|
||||||
|
<NextLink
|
||||||
|
href="/dashboard"
|
||||||
|
className={
|
||||||
|
iconButton()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCogs} fixedWidth />
|
||||||
|
</NextLink>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
title={'Cerrar sesión'}
|
||||||
|
position='bottom'
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(setStatus('loading'))
|
||||||
|
logout('current')
|
||||||
|
.then(() => {
|
||||||
|
dispatch(setSession(undefined))
|
||||||
|
dispatch(setCurrentUser(undefined))
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
manageError(error, 'Error al cerrar sesión', 'Error desconocido al cerrar sesión')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
dispatch(setStatus('idle'))
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faRightFromBracket} fixedWidth />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SessionButtons
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { cx } from '@/styled-system/css'
|
||||||
|
import { skeleton, type SkeletonVariantProps } from '@/styled-system/recipes/skeleton'
|
||||||
|
import { type MergeOmitting } from '@/types/utilities'
|
||||||
|
import { type DetailedHTMLProps, type FC, type HTMLAttributes } from 'react'
|
||||||
|
|
||||||
|
export type SkeletonProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, SkeletonVariantProps & { children?: never }>
|
||||||
|
|
||||||
|
const Skeleton: FC<SkeletonProps> = ({ children: _, className, ...props }) => {
|
||||||
|
const [skeletonRecipeArgs, allOtherSkeletonProps] = skeleton.splitVariantProps(props)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...allOtherSkeletonProps}
|
||||||
|
className={cx(skeleton(skeletonRecipeArgs), className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Skeleton
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { cx } from '@/styled-system/css'
|
||||||
|
import { table, type TableVariantProps } from '@/styled-system/recipes/table'
|
||||||
|
import { type MergeOmitting } from '@/types/utilities'
|
||||||
|
import { type DetailedHTMLProps, type FC, type HTMLAttributes } from 'react'
|
||||||
|
|
||||||
|
type TableContainerProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, TableVariantProps>
|
||||||
|
|
||||||
|
export const TableContainer: FC<TableContainerProps> = ({ children, className, ...props }) => {
|
||||||
|
const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...allOtherTableProps}
|
||||||
|
className={cx(table(tableRecipeArgs).container, className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLTableElement>, HTMLTableElement>, TableVariantProps>
|
||||||
|
|
||||||
|
export const Table: FC<TableProps> = ({ children, className, ...props }) => {
|
||||||
|
const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props)
|
||||||
|
return (
|
||||||
|
<table
|
||||||
|
{...allOtherTableProps}
|
||||||
|
className={cx(table(tableRecipeArgs).table, className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableHeadProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLTableSectionElement>, HTMLTableSectionElement>, TableVariantProps>
|
||||||
|
|
||||||
|
export const TableHead: FC<TableHeadProps> = ({ children, className, ...props }) => {
|
||||||
|
const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props)
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
{...allOtherTableProps}
|
||||||
|
className={cx(table(tableRecipeArgs).thead, className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</thead>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableBodyProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLTableSectionElement>, HTMLTableSectionElement>, TableVariantProps>
|
||||||
|
|
||||||
|
export const TableBody: FC<TableBodyProps> = ({ children, className, ...props }) => {
|
||||||
|
const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props)
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
{...allOtherTableProps}
|
||||||
|
className={cx(table(tableRecipeArgs).tbody, className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</tbody>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableFootProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLTableSectionElement>, HTMLTableSectionElement>, TableVariantProps>
|
||||||
|
|
||||||
|
export const TableFoot: FC<TableFootProps> = ({ children, className, ...props }) => {
|
||||||
|
const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props)
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
{...allOtherTableProps}
|
||||||
|
className={cx(table(tableRecipeArgs).tfoot, className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</tfoot>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableRowProps = MergeOmitting<DetailedHTMLProps<HTMLAttributes<HTMLTableRowElement>, HTMLTableRowElement>, TableVariantProps>
|
||||||
|
|
||||||
|
export const TableRow: FC<TableRowProps> = ({ children, className, ...props }) => {
|
||||||
|
const [tableRecipeArgs, allOtherTableProps] = table.splitVariantProps(props)
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
{...allOtherTableProps}
|
||||||
|
className={cx(table(tableRecipeArgs).tr, className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableCellProps = DetailedHTMLProps<HTMLAttributes<HTMLTableCellElement>, HTMLTableCellElement>
|
||||||
|
|
||||||
|
export const TableCell: FC<TableCellProps> = ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableHeadCellProps = DetailedHTMLProps<HTMLAttributes<HTMLTableCellElement>, HTMLTableCellElement>
|
||||||
|
|
||||||
|
export const TableHeadCell: FC<TableHeadCellProps> = ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import Input, { type InputProps } from '@/components/ui/form/Input'
|
||||||
|
import { useEffect, useState, type FC } from 'react'
|
||||||
|
|
||||||
|
interface DebouncedInputProps extends Omit<InputProps, 'value' | 'onChange'> {
|
||||||
|
value: string | number
|
||||||
|
onChange: (value: string | number) => void
|
||||||
|
debounce?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DebouncedInput: FC<DebouncedInputProps> = ({ value: initialValue, onChange, debounce = 500, ...props }) => {
|
||||||
|
const [value, setValue] = useState(initialValue)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(initialValue)
|
||||||
|
}, [initialValue])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
onChange(value)
|
||||||
|
}, debounce)
|
||||||
|
|
||||||
|
return () => { clearTimeout(timeout) }
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
value={value}
|
||||||
|
onChange={e => { setValue(e.target.value) }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DebouncedInput
|
||||||
@@ -1,21 +1,26 @@
|
|||||||
import { type SessionState } from '@/state/sessionSlice'
|
import { type SessionState } from '@/state/sessionSlice'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import { useAppSelector } from './useAppSelector'
|
import { useAppSelector } from './useAppSelector'
|
||||||
|
|
||||||
type UseSession = (redirect?: string) => SessionState
|
type UseSession = (redirect?: string) => SessionState & { belongToClan: (clanId: string) => boolean }
|
||||||
|
|
||||||
const useSession: UseSession = (redirect?: string) => {
|
const useSession: UseSession = (redirect?: string) => {
|
||||||
const { status, session, user } = useAppSelector((state) => state.session)
|
const { status, session, user, clanes } = useAppSelector((state) => state.session)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const belongToClan = useCallback((clanId: string): boolean => {
|
||||||
|
if (session === undefined || clanes === undefined || clanes.total === 0) return false
|
||||||
|
return clanes.teams.some((team) => team.$id === clanId) ?? false
|
||||||
|
}, [clanes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'idle' && session === undefined) {
|
if (status === 'idle' && session === undefined) {
|
||||||
router.push(redirect ?? '/')
|
router.push(redirect ?? '/')
|
||||||
}
|
}
|
||||||
}, [status, session])
|
}, [status, session])
|
||||||
|
|
||||||
return { status, session, user }
|
return { status, session, user, clanes, belongToClan }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useSession
|
export default useSession
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||||
import { type Models } from 'appwrite'
|
import { type Models } from 'appwrite'
|
||||||
|
import { type ClanList } from 'entgamers-database/frontend/clanes'
|
||||||
import { type UserWithPreferences } from 'entgamers-database/frontend/session'
|
import { type UserWithPreferences } from 'entgamers-database/frontend/session'
|
||||||
|
|
||||||
export type SessionState =
|
export type SessionState =
|
||||||
@@ -7,6 +8,7 @@ export type SessionState =
|
|||||||
status: 'idle' | 'loading' | 'initializing'
|
status: 'idle' | 'loading' | 'initializing'
|
||||||
session?: Models.Session
|
session?: Models.Session
|
||||||
user?: UserWithPreferences
|
user?: UserWithPreferences
|
||||||
|
clanes?: ClanList
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: SessionState = {
|
const initialState: SessionState = {
|
||||||
@@ -34,10 +36,16 @@ const sessionSlice = createSlice({
|
|||||||
...state,
|
...state,
|
||||||
user: action.payload
|
user: action.payload
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
setClanes: (state, action: PayloadAction<SessionState['clanes']>) => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
clanes: action.payload
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setStatus, setSession, setCurrentUser } = sessionSlice.actions
|
export const { setStatus, setSession, setCurrentUser, setClanes } = sessionSlice.actions
|
||||||
|
|
||||||
export default sessionSlice
|
export default sessionSlice
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { type Models } from 'appwrite'
|
|
||||||
|
|
||||||
export interface TeamApplyData {
|
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
discordName: string
|
|
||||||
message: string
|
|
||||||
role: 'moderator' | 'administrator' | 'collaborator'
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TeamApplyDocument = Models.Document & TeamApplyData
|
|
||||||
|
|
||||||
export type TeamApplyList = Models.DocumentList<TeamApplyDocument>
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { format, formatDistance, setDefaultOptions } from 'date-fns'
|
||||||
|
import { es } from 'date-fns/locale'
|
||||||
|
|
||||||
|
setDefaultOptions({ locale: es })
|
||||||
|
|
||||||
|
const DATE_FORMAT = 'yyyy-MM-dd HH:mm'
|
||||||
|
|
||||||
|
export const formatDate = (date: Date): string => format(date, DATE_FORMAT)
|
||||||
|
|
||||||
|
export const formatDistanceDate = (date: Date): string => formatDistance(date, new Date(), { locale: es })
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type PaginationOptions } from '@/types/api'
|
import { type PaginationOptions } from '@/types/api'
|
||||||
import { type TeamApplication } from 'entgamers-database/backend/teamApplication'
|
import { type TeamApplication, type TeamApplicationList } from 'entgamers-database/backend/teamApplication'
|
||||||
import { number, object, string, type ObjectSchema } from 'yup'
|
import { number, object, string, type ObjectSchema } from 'yup'
|
||||||
|
|
||||||
export interface TeamApplicationDynamicParams {
|
export interface TeamApplicationDynamicParams {
|
||||||
@@ -19,6 +19,8 @@ export interface TeamApplicationSearchParams extends PaginationOptions {
|
|||||||
|
|
||||||
export type TeamApplicationData = Omit<TeamApplication, 'id' | 'createdAt' | 'updatedAt' >
|
export type TeamApplicationData = Omit<TeamApplication, 'id' | 'createdAt' | 'updatedAt' >
|
||||||
|
|
||||||
|
export { type TeamApplication, type TeamApplicationList }
|
||||||
|
|
||||||
export const teamApplicationDataSchema: ObjectSchema<TeamApplicationData> = object({
|
export const teamApplicationDataSchema: ObjectSchema<TeamApplicationData> = object({
|
||||||
name: string().required('El nombre es obligatorio'),
|
name: string().required('El nombre es obligatorio'),
|
||||||
email: string().email('Invalid email').required('El email es obligatorio'),
|
email: string().email('Invalid email').required('El email es obligatorio'),
|
||||||
|
|||||||
Reference in New Issue
Block a user