Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38ec9a28f7 | ||
|
|
97a3ac90eb | ||
|
|
8f8c720632 | ||
|
|
ac22bd6cab | ||
|
|
27ed5605ce | ||
|
|
2c6389a67a | ||
|
|
a4c75770ae | ||
|
|
d923237969 | ||
|
|
b2a28c6fd3 | ||
|
|
417ff3390b | ||
|
|
0c646ceb85 | ||
|
|
6bbc87479a | ||
|
|
23b6677323 | ||
|
|
45c75b3283 | ||
|
|
764eb34e47 | ||
|
|
fe4e9291fa | ||
|
|
ec9a81d7b5 | ||
|
|
e652c6ed0e | ||
|
|
d431564aac | ||
|
|
350d08a2b4 | ||
|
|
eb5e043357 | ||
|
|
d67e3e7244 | ||
|
|
a7196dd453 | ||
|
|
c83e54fae8 | ||
|
|
35a5761a7c | ||
|
|
b472fbdbbf | ||
|
|
4fcc96ac15 | ||
|
|
2dfcd3adad | ||
|
|
d79788258b | ||
|
|
427d2b69c7 | ||
|
|
7e5e4cc29b | ||
|
|
a1e89b882a | ||
|
|
a74f0181f1 | ||
|
|
a9a1e5450b | ||
|
|
5e65942060 | ||
|
|
d044d0ef50 | ||
|
|
d40a913ccc | ||
|
|
66178ccc18 | ||
|
|
8280ba2a64 | ||
|
|
8259020450 | ||
|
|
692a7a7258 | ||
|
|
1ee04ada14 | ||
|
|
193cb61b45 | ||
|
|
4777a34f23 | ||
|
|
4d3677ebd2 | ||
|
|
f0eb11010d | ||
|
|
e1f13b3872 | ||
|
|
fa30849d03 | ||
|
|
f8d592d811 | ||
|
|
c385aa1996 | ||
|
|
9a97eb3e8e | ||
|
|
70ee5300f7 | ||
|
|
35c9539c08 | ||
|
|
bc5b072ad8 | ||
|
|
66b8b064ba | ||
|
|
63643cc286 | ||
|
|
19bd44a7ca | ||
|
|
b3bdf25b5f | ||
|
|
cdd4fd4703 | ||
|
|
7d1b86acdb | ||
|
|
6e6c361eb8 | ||
|
|
62780c8889 | ||
|
|
1839f4cab0 | ||
|
|
c9f5b9e4a5 | ||
|
|
33365affa2 | ||
|
|
63db7ff6ad | ||
|
|
7f8b53edd4 | ||
|
|
eb0628ac3e | ||
|
|
cbb880a053 | ||
|
|
083a912e91 | ||
|
|
ab744b411d | ||
|
|
8947b4d05e | ||
|
|
7e3eaa6584 | ||
|
|
98a459c7d7 | ||
|
|
9d24a11c05 | ||
|
|
ee60787100 | ||
|
|
301a349776 | ||
|
|
81488ceb6b | ||
|
|
9e99fd61e7 | ||
|
|
d36c693615 | ||
|
|
20125a6f4a | ||
|
|
4bc5006c91 | ||
|
|
71769b71aa | ||
|
|
a2cf67d210 | ||
|
|
d9c4217fd3 | ||
|
|
719e36e46c | ||
|
|
bd052f4866 |
@@ -10,7 +10,13 @@
|
||||
"Bash(ren page.tsx page-original.tsx)",
|
||||
"Bash(ren:*)",
|
||||
"Bash(npx supabase:*)",
|
||||
"Bash(libs/service/src/types/supabase.ts)"
|
||||
"Bash(libs/service/src/types/supabase.ts)",
|
||||
"Bash(npx nx build:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(ls:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,6 +25,7 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
|
||||
@@ -34,6 +39,6 @@ jobs:
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
|
||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
|
||||
docker image prune -af
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,6 +25,7 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
|
||||
@@ -34,6 +39,6 @@ jobs:
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
|
||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
|
||||
docker image prune -af
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,6 +25,7 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
|
||||
@@ -34,6 +39,6 @@ jobs:
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
|
||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
|
||||
docker image prune -af
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,6 +25,7 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
|
||||
@@ -28,12 +33,13 @@ jobs:
|
||||
else
|
||||
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-hackathon/apps/hackathon/.env << EOF
|
||||
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
|
||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build
|
||||
docker builder prune -af || true
|
||||
DOCKER_BUILDKIT=1 docker compose --env-file ~/imphnen-frontend-service-hackathon/.env.local -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
|
||||
docker image prune -af
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,6 +25,7 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service ]; then
|
||||
@@ -34,6 +39,6 @@ jobs:
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
||||
docker image prune -af
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"nx-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["nx", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!-- nx configuration start-->
|
||||
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||
|
||||
# General Guidelines for working with Nx
|
||||
|
||||
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
|
||||
- You have access to the Nx MCP server and its tools, use them to help the user
|
||||
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
|
||||
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
|
||||
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
|
||||
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
|
||||
|
||||
<!-- nx configuration end-->
|
||||
@@ -1 +1 @@
|
||||
Tue, Nov 25, 2025 11:09:55 AM
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { FC, ReactElement } from 'react';
|
||||
|
||||
export const HackathonDashboardPage: FC = (): ReactElement => {
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
||||
|
||||
<section className="grid grid-cols-5 gap-5">
|
||||
{/* Participant */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||
1261
|
||||
</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Participants</p>
|
||||
</div>
|
||||
{/* Team */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">206</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Teams</p>
|
||||
</div>
|
||||
{/* Project Submitted */}
|
||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
|
||||
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
|
||||
</div>
|
||||
</section>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonDashboardPage;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
|
||||
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const mockData: any[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
project_name: `Project ${i + 1}`,
|
||||
repository_url: `https://github.com/user/repo${i + 1}`,
|
||||
demo_url: `https://demo.example.com/project${i + 1}`,
|
||||
presentation_url: `https://slides.example.com/project${i + 1}`,
|
||||
}));
|
||||
|
||||
type UserStatus = 'active' | 'inactive';
|
||||
|
||||
interface SubmissionType {
|
||||
id: number;
|
||||
project_name: string;
|
||||
repository_url: string;
|
||||
demo_url: string;
|
||||
presentation_url: string;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<SubmissionType>[] = [
|
||||
{
|
||||
header: 'Project Name',
|
||||
accessorKey: 'project_name',
|
||||
},
|
||||
{
|
||||
header: 'Repository URL',
|
||||
accessorKey: 'repository_url',
|
||||
},
|
||||
{
|
||||
header: 'Demo URL',
|
||||
accessorKey: 'demo_url',
|
||||
},
|
||||
{
|
||||
header: 'Presentation URL',
|
||||
accessorKey: 'presentation_url',
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-72') },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 w-max"
|
||||
onClick={() => {
|
||||
// View detail logic
|
||||
}}
|
||||
>
|
||||
<EditOutlined className="text-base" /> View & Manage
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
Project Submission
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
|
||||
placeholder="Search name or email"
|
||||
/>
|
||||
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||
<option value="all">All City</option>
|
||||
<option value="jakarta">Jakarta</option>
|
||||
<option value="bandung">Bandung</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
|
||||
{/* Modals extracted into shared backoffice components */}
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonUsersPage;
|
||||
@@ -0,0 +1,210 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { useTeams } from '@imphnen-frontend-service/service';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
|
||||
export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||
const { data: teamsData } = useTeams();
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
});
|
||||
|
||||
const mockData: TeamType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
id: `team-${i + 1}`,
|
||||
name: `Team ${i + 1} - ${i % 3 === 0 ? 'Innovators' : 'Hackers'}`,
|
||||
city: i % 2 === 0 ? 'Jakarta' : 'Bandung',
|
||||
visibility: i % 4 === 0 ? 'private' : 'public',
|
||||
member_count: Math.floor(Math.random() * 4) + 1,
|
||||
has_submission: i % 3 !== 0,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
leader: {
|
||||
user: {
|
||||
fullname: `Leader User ${i}`,
|
||||
email: `leader${i}@example.com`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
interface TeamType {
|
||||
id: string;
|
||||
name: string;
|
||||
city: string;
|
||||
visibility: 'public' | 'private';
|
||||
member_count: number;
|
||||
has_submission: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
leader?: {
|
||||
user: {
|
||||
fullname: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const columns: ColumnDef<TeamType>[] = [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Team Name',
|
||||
},
|
||||
{
|
||||
accessorKey: 'city',
|
||||
header: 'City',
|
||||
},
|
||||
{
|
||||
accessorKey: 'visibility',
|
||||
header: 'Visibility',
|
||||
cell: ({ row }) => {
|
||||
const isPublic = row.original.visibility === 'public';
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'py-2 px-4 text-sm rounded-2xl text-center',
|
||||
isPublic
|
||||
? 'bg-info-200 text-info-700'
|
||||
: 'bg-gray-200 text-gray-700'
|
||||
)}
|
||||
>
|
||||
{isPublic ? 'Public' : 'Private'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'member_count',
|
||||
header: 'Members',
|
||||
},
|
||||
{
|
||||
id: 'leader',
|
||||
header: 'Leader',
|
||||
cell: ({ row }) => {
|
||||
const leader = row.original.leader?.user;
|
||||
return leader ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{leader.fullname}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{leader.email}</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">-</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'has_submission',
|
||||
header: 'Submitted',
|
||||
cell: ({ row }) => {
|
||||
const hasSubmission = row.original.has_submission;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'py-2 px-4 text-sm rounded-2xl text-center',
|
||||
hasSubmission
|
||||
? 'bg-success-200 text-success-700'
|
||||
: 'bg-danger-200 text-danger-700'
|
||||
)}
|
||||
>
|
||||
{hasSubmission ? 'Yes' : 'No'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: 'Last Updated',
|
||||
cell: ({ row }) => {
|
||||
return new Date(row.original.updated_at).toLocaleDateString();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-48') },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 w-max"
|
||||
onClick={() => {
|
||||
// View detail logic
|
||||
}}
|
||||
>
|
||||
<EditOutlined className="text-base" /> View & Manage
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||
manualPagination: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
Team Management
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
|
||||
placeholder="Search name or email"
|
||||
/>
|
||||
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||
<option value="all">All City</option>
|
||||
<option value="jakarta">Jakarta</option>
|
||||
<option value="bandung">Bandung</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Table */}
|
||||
<DataTable data={mockData} columns={columns} table={table} />
|
||||
</section>
|
||||
{/* Modals extracted into shared backoffice components */}
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonTeamsPage;
|
||||
+651
@@ -0,0 +1,651 @@
|
||||
import { FC, useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
UserOutlined,
|
||||
EnvironmentOutlined,
|
||||
CalendarOutlined,
|
||||
SaveOutlined,
|
||||
CloseOutlined,
|
||||
ExclamationOutlined,
|
||||
CameraOutlined,
|
||||
DeleteOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface UserType {
|
||||
id: string;
|
||||
avatar?: string;
|
||||
fullname: string;
|
||||
bio?: string;
|
||||
location: string;
|
||||
is_active: boolean;
|
||||
skills: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: UserType | null;
|
||||
}
|
||||
|
||||
const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||
const [formData, setFormData] = useState<UserType | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Initialize form data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (user) {
|
||||
// Edit existing user
|
||||
setFormData({ ...user });
|
||||
} else {
|
||||
// Create new user
|
||||
setFormData({
|
||||
id: '', // Will be generated by backend
|
||||
fullname: '',
|
||||
bio: '',
|
||||
location: '',
|
||||
is_active: true,
|
||||
skills: [],
|
||||
avatar: undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
// Check if form has changes
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!formData) return false;
|
||||
if (!user) return true; // New user always has changes
|
||||
return (
|
||||
formData.fullname !== user.fullname ||
|
||||
formData.location !== user.location ||
|
||||
formData.is_active !== user.is_active ||
|
||||
formData.avatar !== user.avatar ||
|
||||
JSON.stringify(formData.skills) !== JSON.stringify(user.skills) ||
|
||||
formData.bio !== user.bio
|
||||
);
|
||||
}, [formData, user]);
|
||||
|
||||
// Check if required fields are filled
|
||||
const isFormValid = useMemo(() => {
|
||||
if (!formData) return false;
|
||||
return formData.fullname.trim() !== '' && formData.location.trim() !== '';
|
||||
}, [formData]);
|
||||
|
||||
const canSave = hasChanges && isFormValid;
|
||||
|
||||
if (!isOpen || !formData) return null;
|
||||
|
||||
const handleInputChange = (
|
||||
field: keyof UserType,
|
||||
value: string | boolean | string[] | undefined
|
||||
) => {
|
||||
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
|
||||
};
|
||||
|
||||
const handleSkillsChange = (skills: string[]) => {
|
||||
setFormData((prev) => (prev ? { ...prev, skills } : null));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData) return;
|
||||
|
||||
if (user) {
|
||||
// Update existing user
|
||||
console.log('Update user data:', formData);
|
||||
} else {
|
||||
// Create new user
|
||||
console.log('Create new user:', formData);
|
||||
}
|
||||
// Here you would typically make an API call to save the data
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (user) {
|
||||
setFormData({ ...user }); // Reset to original for edit mode
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
if (!user) return; // Can't delete new user
|
||||
console.log('Delete user:', user.id);
|
||||
setShowDeleteConfirm(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('Image size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const avatarUrl = e.target?.result as string;
|
||||
handleInputChange('avatar', avatarUrl);
|
||||
setShowAvatarMenu(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = () => {
|
||||
handleInputChange('avatar', undefined);
|
||||
setShowAvatarMenu(false);
|
||||
};
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const availableSkills = [
|
||||
'Frontend Developer',
|
||||
'Backend Developer',
|
||||
'Full Stack Developer',
|
||||
'DevOps Engineer',
|
||||
'UI/UX Designer',
|
||||
'Product Manager',
|
||||
'Data Scientist',
|
||||
'Mobile Developer',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={(e) => {
|
||||
setShowAvatarMenu(false);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Hidden File Input */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleAvatarUpload}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Interactive User Avatar */}
|
||||
<div className="relative group ">
|
||||
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
|
||||
{formData.avatar ? (
|
||||
<img
|
||||
src={formData.avatar}
|
||||
alt={formData.fullname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<UserOutlined className="text-neutral-500 text-2xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Avatar Hover Overlay */}
|
||||
<button
|
||||
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
|
||||
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<CameraOutlined className="text-white text-lg" />
|
||||
</button>
|
||||
|
||||
{/* Avatar Menu Dropdown */}
|
||||
{showAvatarMenu && (
|
||||
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
|
||||
<button
|
||||
onClick={triggerFileUpload}
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<UploadOutlined className="text-sm" />
|
||||
{formData.avatar ? 'Change Photo' : 'Upload Photo'}
|
||||
</button>
|
||||
{formData.avatar && (
|
||||
<button
|
||||
onClick={handleRemoveAvatar}
|
||||
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<DeleteOutlined className="text-sm" />
|
||||
Remove Photo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h2 className="text-2xl font-bold text-neutral-900">
|
||||
{user ? 'Edit User Profile' : 'Create New User'}
|
||||
</h2>
|
||||
{user && (
|
||||
<span className="px-3 py-1 bg-info-100 text-info-700 text-xs font-medium rounded-2xl">
|
||||
Hover avatar to change
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500">
|
||||
{user
|
||||
? `Make changes to ${
|
||||
formData.fullname || 'this user'
|
||||
}'s profile information`
|
||||
: 'Fill in the information below to create a new user account'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
onClick={() => {
|
||||
setShowAvatarMenu(false);
|
||||
handleCancel();
|
||||
}}
|
||||
>
|
||||
<CloseOutlined className="text-neutral-400 text-lg cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Left Column - Basic Info */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Basic Information
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Full Name - Required */}
|
||||
<div className="flex items-center gap-3">
|
||||
<UserOutlined className="text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<label className="text-sm text-neutral-500 block mb-1">
|
||||
Full Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.fullname}
|
||||
onChange={(e) =>
|
||||
handleInputChange('fullname', e.target.value)
|
||||
}
|
||||
className={cn(
|
||||
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
|
||||
formData.fullname.trim() === ''
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-neutral-300'
|
||||
)}
|
||||
placeholder="Enter full name"
|
||||
/>
|
||||
{formData.fullname.trim() === '' && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
Full name is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location - Required */}
|
||||
<div className="flex items-center gap-3">
|
||||
<EnvironmentOutlined className="text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<label className="text-sm text-neutral-500 block mb-1">
|
||||
Location <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.location}
|
||||
onChange={(e) =>
|
||||
handleInputChange('location', e.target.value)
|
||||
}
|
||||
className={cn(
|
||||
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
|
||||
formData.location.trim() === ''
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-neutral-300'
|
||||
)}
|
||||
>
|
||||
<option value="">Select location</option>
|
||||
<option value="Jakarta">Jakarta</option>
|
||||
<option value="Bandung">Bandung</option>
|
||||
<option value="Surabaya">Surabaya</option>
|
||||
<option value="Medan">Medan</option>
|
||||
<option value="Yogyakarta">Yogyakarta</option>
|
||||
</select>
|
||||
{formData.location.trim() === '' && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
Location is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Joined Date - Read Only - Only show for existing users */}
|
||||
{user && (
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarOutlined className="text-neutral-400" />
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Joined Date
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{new Date(formData.created_at).toLocaleDateString(
|
||||
'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bio Section - Optional */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
||||
Bio{' '}
|
||||
<span className="text-neutral-400 text-sm font-normal">
|
||||
(Optional)
|
||||
</span>
|
||||
</h3>
|
||||
<textarea
|
||||
value={formData.bio || ''}
|
||||
onChange={(e) =>
|
||||
handleInputChange('bio', e.target.value || undefined)
|
||||
}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Skills & Status */}
|
||||
<div className="space-y-6">
|
||||
{/* Account Status - Enhanced Tab Design */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Account Status
|
||||
</h3>
|
||||
<div className="flex bg-neutral-100 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => handleInputChange('is_active', true)}
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||
formData.is_active
|
||||
? 'bg-white text-success-700 shadow-sm ring-1 ring-success-200'
|
||||
: 'text-neutral-600 hover:text-neutral-800'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
formData.is_active
|
||||
? 'bg-success-500'
|
||||
: 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
Active
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleInputChange('is_active', false)}
|
||||
className={cn(
|
||||
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||
!formData.is_active
|
||||
? 'bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-600 hover:text-neutral-800'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
!formData.is_active
|
||||
? 'bg-neutral-500'
|
||||
: 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
Inactive
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
{formData.is_active
|
||||
? 'User can access their account and participate in activities'
|
||||
: 'User account is suspended and cannot access services'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Skills Section - Optional */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Skills & Expertise{' '}
|
||||
<span className="text-neutral-400 text-sm font-normal">
|
||||
(Optional)
|
||||
</span>
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2 min-h-10 p-3 border border-neutral-300 rounded-lg bg-neutral-50">
|
||||
{formData.skills.length > 0 ? (
|
||||
formData.skills.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-2xl text-sm font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{skill}
|
||||
<button
|
||||
onClick={() =>
|
||||
handleSkillsChange(
|
||||
formData.skills.filter((_, i) => i !== index)
|
||||
)
|
||||
}
|
||||
className="text-blue-600 hover:text-blue-800 ml-1 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-neutral-400 text-sm">
|
||||
No skills added yet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (
|
||||
e.target.value &&
|
||||
!formData.skills.includes(e.target.value)
|
||||
) {
|
||||
handleSkillsChange([
|
||||
...formData.skills,
|
||||
e.target.value,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white"
|
||||
>
|
||||
<option value="">Add a skill...</option>
|
||||
{availableSkills
|
||||
.filter((skill) => !formData.skills.includes(skill))
|
||||
.map((skill) => (
|
||||
<option key={skill} value={skill}>
|
||||
{skill}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Details - Read Only - Only show for existing users */}
|
||||
{user && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||
Account Details
|
||||
</h3>
|
||||
<div className="space-y-3 bg-neutral-50 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-neutral-600 text-sm">
|
||||
User ID
|
||||
</span>
|
||||
<span className="font-mono text-sm text-neutral-800">
|
||||
{formData.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-neutral-600 text-sm">
|
||||
Last Updated
|
||||
</span>
|
||||
<span className="text-sm text-neutral-800">
|
||||
{new Date(formData.updated_at).toLocaleDateString(
|
||||
'en-US',
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="border-t border-neutral-200 px-8 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-neutral-500">
|
||||
{canSave
|
||||
? 'Ready to save changes'
|
||||
: hasChanges
|
||||
? 'Please fill required fields'
|
||||
: 'No changes made'}
|
||||
</div>
|
||||
{/* Delete Account Button - Only show for existing users */}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-red-600 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
className="px-6"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-6',
|
||||
!canSave && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<SaveOutlined className="text-sm" />
|
||||
{user ? 'Save Changes' : 'Create User'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<ExclamationOutlined className="text-red-600 text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900">
|
||||
Delete Account
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-500">
|
||||
This action cannot be undone
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-neutral-700 mb-6">
|
||||
Are you sure you want to permanently delete{' '}
|
||||
<strong>{formData.fullname}</strong>'s account? This will
|
||||
remove all their data and cannot be reversed.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="px-4"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleDeleteAccount}
|
||||
className="px-4 bg-red-600 hover:bg-red-700 border-red-600"
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalUserDetail;
|
||||
@@ -0,0 +1,476 @@
|
||||
import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
|
||||
import ModalUserDetail from './_components/modal-user-detail';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
EditOutlined,
|
||||
UserOutlined,
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
// Removed unused SearchOutlined icon after schema revision
|
||||
|
||||
// Define interface outside component
|
||||
interface UserType {
|
||||
id: string; // UUID
|
||||
avatar?: string;
|
||||
fullname: string;
|
||||
bio?: string;
|
||||
location: string;
|
||||
is_active: boolean; // admin can deactivate
|
||||
skills: string[]; // Frontend Developer, Backend Developer, etc.
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Move mock data outside component to prevent recreation
|
||||
const skillsOptions = [
|
||||
'Frontend Developer',
|
||||
'Backend Developer',
|
||||
'Full Stack Developer',
|
||||
'DevOps Engineer',
|
||||
'UI/UX Designer',
|
||||
'Product Manager',
|
||||
'Data Scientist',
|
||||
'Mobile Developer',
|
||||
];
|
||||
|
||||
const locations = ['Jakarta', 'Bandung', 'Surabaya', 'Medan', 'Yogyakarta'];
|
||||
const bios = [
|
||||
'Passionate developer with 5+ years experience',
|
||||
'Tech enthusiast and problem solver',
|
||||
'Building scalable solutions for modern problems',
|
||||
'Creative designer with technical background',
|
||||
'Data-driven decision maker',
|
||||
];
|
||||
|
||||
const mockData: UserType[] = Array.from({ length: 50 }, (_, i) => {
|
||||
const randomSkillsCount = Math.floor(Math.random() * 3) + 1; // 1-3 skills
|
||||
const randomSkills = skillsOptions
|
||||
.sort(() => 0.5 - Math.random())
|
||||
.slice(0, randomSkillsCount);
|
||||
|
||||
return {
|
||||
id: `24db9e4d-ca4c-46aa-ac36-8ef04bbe01${String(i).padStart(2, '0')}`,
|
||||
avatar:
|
||||
i % 4 === 0
|
||||
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||
i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana'
|
||||
)}&background=random`
|
||||
: undefined,
|
||||
fullname:
|
||||
i % 3 === 0
|
||||
? 'Ahmad Wijuana'
|
||||
: i % 3 === 1
|
||||
? 'Sofia Wijuana'
|
||||
: 'Budi Santoso',
|
||||
bio: i % 4 === 0 ? bios[i % bios.length] : undefined,
|
||||
location: locations[i % locations.length],
|
||||
is_active: i % 7 !== 0, // More realistic distribution
|
||||
skills: randomSkills,
|
||||
created_at: new Date(
|
||||
Date.now() - i * 86400000 * (Math.random() * 30 + 1)
|
||||
).toISOString(), // Random within last 30-60 days
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [showNewUserModal, setShowNewUserModal] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
|
||||
// Advanced filtering states
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [locationFilter, setLocationFilter] = useState('all');
|
||||
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||
|
||||
// Constants
|
||||
const pageSize = 10;
|
||||
|
||||
// Memoize the callback to prevent recreation
|
||||
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||
setSelectedUser(user);
|
||||
setShowDetailModal(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseDetailModal = useCallback(() => {
|
||||
setShowDetailModal(false);
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const handleShowNewUserModal = useCallback(() => {
|
||||
setShowNewUserModal(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseNewUserModal = useCallback(() => {
|
||||
setShowNewUserModal(false);
|
||||
}, []);
|
||||
|
||||
// Filter data based on current filter states
|
||||
const filteredData = useMemo(() => {
|
||||
return mockData.filter((user) => {
|
||||
// Status filter
|
||||
if (statusFilter !== 'all') {
|
||||
const isActive = statusFilter === 'active';
|
||||
if (user.is_active !== isActive) return false;
|
||||
}
|
||||
|
||||
// Location filter
|
||||
if (locationFilter !== 'all' && user.location !== locationFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skills filter
|
||||
if (skillsFilter.length > 0) {
|
||||
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||
user.skills.includes(skill)
|
||||
);
|
||||
if (!hasMatchingSkill) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [statusFilter, locationFilter, skillsFilter]);
|
||||
|
||||
// Memoize columns to prevent recreation on every render
|
||||
const columns: ColumnDef<UserType>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'fullname',
|
||||
header: 'User',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||
{row.original.avatar ? (
|
||||
<img
|
||||
src={row.original.avatar}
|
||||
alt={row.original.fullname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<UserOutlined className="text-neutral-500 text-lg" />
|
||||
)}
|
||||
</div>
|
||||
{/* Name only */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-neutral-900 truncate">
|
||||
{row.original.fullname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'skills',
|
||||
header: 'Skills',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{row.original.skills.slice(0, 2).map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
||||
>
|
||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||
</span>
|
||||
))}
|
||||
{row.original.skills.length > 2 && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
|
||||
+{row.original.skills.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'location',
|
||||
header: 'Location',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-700">{row.original.location}</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_active',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
|
||||
)}
|
||||
>
|
||||
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const aActive = rowA.original.is_active;
|
||||
const bActive = rowB.original.is_active;
|
||||
if (aActive && !bActive) return -1;
|
||||
if (!aActive && bActive) return 1;
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Joined',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-neutral-900 text-sm">
|
||||
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: 'datetime',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
meta: { cellClassName: cn('w-48') },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||
onClick={() => handleShowDetailModal(row.original)}
|
||||
>
|
||||
<EditOutlined className="text-sm" />
|
||||
Manage
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-sm px-4 py-2"
|
||||
onClick={() => {
|
||||
// Toggle user status - implement later
|
||||
console.log(`Toggle status for ${row.original.fullname}`);
|
||||
}}
|
||||
>
|
||||
{row.original.is_active ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[handleShowDetailModal]
|
||||
);
|
||||
|
||||
return (
|
||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||
User Management
|
||||
</h1>
|
||||
{/* Filters and actions */}
|
||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||
{/* Left side - Search & filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||
<input
|
||||
type="text"
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Search users by name or location..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Location Filter */}
|
||||
<div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value={locationFilter}
|
||||
onChange={(e) => setLocationFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Locations</option>
|
||||
{locations.map((location) => (
|
||||
<option key={location} value={location}>
|
||||
{location}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Skills Filter with Icon */}
|
||||
<div className="relative">
|
||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||
<select
|
||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (
|
||||
e.target.value &&
|
||||
!skillsFilter.includes(e.target.value)
|
||||
) {
|
||||
setSkillsFilter((prev) => [...prev, e.target.value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Add Skill Filter</option>
|
||||
{skillsOptions.map((skill) => (
|
||||
<option
|
||||
key={skill}
|
||||
value={skill}
|
||||
disabled={skillsFilter.includes(skill)}
|
||||
>
|
||||
{skill}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Add User Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
onClick={handleShowNewUserModal}
|
||||
>
|
||||
<PlusOutlined className="text-sm" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{(skillsFilter.length > 0 ||
|
||||
statusFilter !== 'all' ||
|
||||
locationFilter !== 'all') && (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||
|
||||
{/* Status filter badge */}
|
||||
{statusFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||
Status: {statusFilter}
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Location filter badge */}
|
||||
{locationFilter !== 'all' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||
Location: {locationFilter}
|
||||
<button
|
||||
onClick={() => setLocationFilter('all')}
|
||||
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Skills filter badges */}
|
||||
{skillsFilter.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
||||
>
|
||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||
<button
|
||||
onClick={() =>
|
||||
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||
}
|
||||
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Clear all filters */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setLocationFilter('all');
|
||||
setSkillsFilter([]);
|
||||
setGlobalFilter('');
|
||||
}}
|
||||
className="text-sm text-neutral-600"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination-aware results display */}
|
||||
{filteredData.length > 0 && (
|
||||
<div className="text-sm text-neutral-600">
|
||||
Showing {Math.min(pageSize, filteredData.length)} of{' '}
|
||||
{filteredData.length} users
|
||||
{filteredData.length > pageSize}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<DataTable data={filteredData} columns={columns} pageSize={10} />
|
||||
</section>
|
||||
|
||||
{/* Modals component */}
|
||||
<ModalUserDetail
|
||||
isOpen={showDetailModal}
|
||||
onClose={handleCloseDetailModal}
|
||||
user={selectedUser}
|
||||
/>
|
||||
|
||||
{/* New User Modal */}
|
||||
<ModalUserDetail
|
||||
isOpen={showNewUserModal}
|
||||
onClose={handleCloseNewUserModal}
|
||||
user={null} // null indicates creating new user
|
||||
/>
|
||||
</BackofficeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HackathonUsersPage;
|
||||
@@ -1,13 +1,51 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||
<BackofficeSidebar />
|
||||
<BackofficeSidebar
|
||||
isOpen={mobileSidebarOpen}
|
||||
onClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Sticky top header */}
|
||||
<header
|
||||
className={
|
||||
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
||||
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||
}
|
||||
>
|
||||
{/* Mobile menu button (shown on small screens) */}
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-p3 font-semibold text-primary-700">
|
||||
IMPHNEN Backoffice
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
|
||||
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
||||
import { ReactElement, useState } from "react";
|
||||
import { ModalDetailUser } from "./_components/modal/detail";
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
import { cn, For } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { ReactElement, useState } from 'react';
|
||||
import { ModalDetailUser } from './_components/modal/detail';
|
||||
|
||||
type UserStatus = 'active' | 'inactive';
|
||||
|
||||
interface UserType {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
rating: number
|
||||
status: UserStatus
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
rating: number;
|
||||
status: UserStatus;
|
||||
}
|
||||
|
||||
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
@@ -22,15 +32,15 @@ const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
email: 'fullname23@gmail.com',
|
||||
rating: 4.5,
|
||||
status: i % 2 === 0 ? 'active' : 'inactive',
|
||||
}))
|
||||
}));
|
||||
|
||||
export default function Components(): ReactElement {
|
||||
const TABS = ['mentor', 'mentee'] as const
|
||||
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||
const TABS = ['mentor', 'mentee'] as const;
|
||||
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
@@ -39,7 +49,7 @@ export default function Components(): ReactElement {
|
||||
const columns: ColumnDef<UserType>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
meta: { cellClassName: cn("w-20") },
|
||||
meta: { cellClassName: cn('w-20') },
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -97,7 +107,7 @@ export default function Components(): ReactElement {
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn("w-72") },
|
||||
meta: { cellClassName: cn('w-72') },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -113,7 +123,7 @@ export default function Components(): ReactElement {
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
@@ -134,14 +144,19 @@ export default function Components(): ReactElement {
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700">User Management</h1>
|
||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
|
||||
User Management
|
||||
</h1>
|
||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||
<For data={TABS}>
|
||||
{(tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
variant="text"
|
||||
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
|
||||
className={cn(
|
||||
'px-3 py-2 capitalize',
|
||||
activeTab === tab && 'bg-white'
|
||||
)}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
@@ -163,12 +178,16 @@ export default function Components(): ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<Select>
|
||||
<option selected disabled>Rating</option>
|
||||
<option selected disabled>
|
||||
Rating
|
||||
</option>
|
||||
<option value="4.5">4.5</option>
|
||||
<option value="5">5</option>
|
||||
</Select>
|
||||
<Select>
|
||||
<option selected disabled>Status</option>
|
||||
<option selected disabled>
|
||||
Status
|
||||
</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</Select>
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
html {
|
||||
font-family: 'Bai Jamjuree', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session) return redirect('/auth/login');
|
||||
// if (!session) return redirect('/auth/login');
|
||||
|
||||
const matchedRoute = mappingRoutePermissions.find(
|
||||
(route) => route.path === pathname
|
||||
|
||||
@@ -1 +1 @@
|
||||
Tue, Nov 25, 2025 11:09:55 AM
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
Tue, Nov 25, 2025 11:09:55 AM
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
|
||||
@@ -1 +1 @@
|
||||
Tue, Nov 25, 2025 11:09:55 AM
|
||||
Tue, Nov 25, 2025 16:42:27 PM
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
@@ -2,12 +2,36 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Hackathon</title>
|
||||
<title>IMPHNEN x Kolosal.ai Hackathon 2025</title>
|
||||
<base href="/" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Ikuti IMPHNEN x Kolosal.ai Hackathon 2025! Kompetisi coding online gratis dengan total hadiah Rp14.500.000. Tema: Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="hackathon, lomba coding, kompetisi it, imphnen, kolosal.ai, ai hackathon, lomba programming 2025"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/images/imphnen-logo-simple.svg"
|
||||
/>
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://hackathon.imphnen.dev/" />
|
||||
<meta property="og:title" content="IMPHNEN x Kolosal.ai Hackathon 2025" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Total hadiah Rp14.500.000! Daftar sekarang dan wujudkan inovasi AI-mu untuk membantu usaha lokal."
|
||||
/>
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://hackathon.imphnen.dev/" />
|
||||
<meta
|
||||
property="twitter:title"
|
||||
content="IMPHNEN x Kolosal.ai Hackathon 2025"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 511 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 351 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg width="82" height="24" viewBox="0 0 82 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.9814 11.6032C15.0691 11.7133 15.1239 11.8495 15.124 12C15.124 12.1501 15.0698 12.2867 14.9825 12.3968L26.8264 24H7.79044L7.76667 23.9757L0.0248413 16.3911L0 16.3679V7.63213L0.0248413 7.60885L7.76667 0.0243365L7.79044 0H26.8264L14.9814 11.6032ZM14.8659 12.5111C14.7535 12.5968 14.6143 12.6507 14.4609 12.6507C14.3071 12.6507 14.1673 12.5971 14.0548 12.5111L10.3977 16.0938C10.5164 16.2113 10.5899 16.3726 10.5899 16.5509C10.5899 16.6459 10.5668 16.7352 10.5295 16.8165L17.6956 23.8371H25.8608C25.8683 23.78 25.8804 23.7233 25.903 23.6699C25.9358 23.5923 25.9831 23.5211 26.0434 23.4614L14.8659 12.5111ZM10.3307 8.06807C10.2184 8.15365 10.0801 8.20766 9.92678 8.20774C9.80008 8.20774 9.68295 8.17075 9.58225 8.1104L5.61305 12L9.65353 15.9584C9.73677 15.9215 9.82951 15.9012 9.92678 15.9012C10.0509 15.9013 10.1655 15.9372 10.2648 15.9954L13.9381 12.3968C13.8511 12.2868 13.7977 12.1499 13.7977 12C13.7978 11.8498 13.8518 11.7132 13.9392 11.6032L10.3307 8.06807ZM10.4906 7.22053C10.5522 7.3192 10.5899 7.4339 10.5899 7.55806C10.5899 7.70823 10.5357 7.84479 10.4485 7.95485L14.0558 11.4889C14.1682 11.4034 14.3076 11.3503 14.4609 11.3503C14.6139 11.3503 14.7536 11.4026 14.8659 11.4879L26.0434 0.537519C25.9835 0.477938 25.9356 0.407383 25.903 0.33013C25.8804 0.276608 25.8682 0.220166 25.8608 0.162949H17.6956L10.4906 7.22053Z" fill="#0D0E0F"/>
|
||||
<path d="M82 5.69697V17.3333H80.291V5.69697H82Z" fill="#0D0E0F"/>
|
||||
<path d="M73.9503 17.5265C73.3939 17.5265 72.8909 17.4242 72.4413 17.2197C71.9916 17.0114 71.6353 16.7102 71.3724 16.3163C71.1133 15.9223 70.9837 15.4394 70.9837 14.8674C70.9837 14.375 71.079 13.9697 71.2695 13.6515C71.4601 13.3333 71.7173 13.0814 72.0412 12.8958C72.3651 12.7102 72.7271 12.5701 73.1272 12.4754C73.5273 12.3807 73.935 12.3087 74.3504 12.2595C74.8762 12.1989 75.303 12.1496 75.6307 12.1117C75.9584 12.0701 76.1966 12.0038 76.3452 11.9129C76.4938 11.822 76.5681 11.6742 76.5681 11.4697V11.4299C76.5681 10.9337 76.4271 10.5492 76.1452 10.2765C75.867 10.0038 75.4516 9.86742 74.8991 9.86742C74.3237 9.86742 73.8702 9.99432 73.5387 10.2481C73.211 10.4981 72.9843 10.7765 72.8585 11.0833L71.2524 10.7197C71.4429 10.1894 71.7211 9.76136 72.0869 9.4356C72.4565 9.10606 72.8814 8.86742 73.3615 8.71969C73.8417 8.56818 74.3466 8.49242 74.8762 8.49242C75.2268 8.49242 75.5983 8.53409 75.9908 8.61742C76.3871 8.69697 76.7568 8.84469 77.0997 9.0606C77.4465 9.27651 77.7304 9.58523 77.9514 9.98674C78.1724 10.3845 78.2829 10.9015 78.2829 11.5379V17.3333H76.6139V16.1401H76.5453C76.4348 16.3598 76.269 16.5758 76.048 16.7879C75.827 17 75.5431 17.1761 75.1963 17.3163C74.8496 17.4564 74.4342 17.5265 73.9503 17.5265ZM74.3218 16.1629C74.7943 16.1629 75.1982 16.0701 75.5336 15.8845C75.8727 15.6989 76.1299 15.4564 76.3052 15.1572C76.4843 14.8542 76.5738 14.5303 76.5738 14.1856V13.0606C76.5129 13.1212 76.3947 13.178 76.2195 13.2311C76.048 13.2803 75.8517 13.3239 75.6307 13.3617C75.4097 13.3958 75.1944 13.428 74.9848 13.4583C74.7753 13.4848 74.6 13.5076 74.459 13.5265C74.1275 13.5682 73.8245 13.6383 73.5502 13.7367C73.2796 13.8352 73.0624 13.9773 72.8986 14.1629C72.7385 14.3447 72.6585 14.5871 72.6585 14.8901C72.6585 15.3106 72.8147 15.6288 73.1272 15.8447C73.4397 16.0568 73.8379 16.1629 74.3218 16.1629Z" fill="#0D0E0F"/>
|
||||
<path d="M69.613 10.7367L68.064 11.0095C67.9992 10.8125 67.8963 10.625 67.7553 10.447C67.6182 10.2689 67.4314 10.1231 67.1952 10.0095C66.9589 9.89583 66.6636 9.83901 66.3092 9.83901C65.8253 9.83901 65.4214 9.94697 65.0975 10.1629C64.7736 10.375 64.6116 10.6496 64.6116 10.9867C64.6116 11.2784 64.7202 11.5133 64.9374 11.6913C65.1546 11.8693 65.5052 12.0151 65.9891 12.1288L67.3838 12.447C68.1916 12.6326 68.7937 12.9186 69.19 13.3049C69.5863 13.6913 69.7845 14.1932 69.7845 14.8106C69.7845 15.3333 69.632 15.7992 69.3272 16.2083C69.0262 16.6136 68.6051 16.9318 68.064 17.1629C67.5267 17.3939 66.9037 17.5095 66.1949 17.5095C65.2118 17.5095 64.4097 17.3011 63.7885 16.8845C63.1674 16.464 62.7864 15.8674 62.6454 15.0947L64.2972 14.8447C64.4001 15.2727 64.6116 15.5966 64.9317 15.8163C65.2518 16.0322 65.6691 16.1401 66.1835 16.1401C66.7436 16.1401 67.1914 16.0246 67.5267 15.7936C67.862 15.5587 68.0297 15.2727 68.0297 14.9356C68.0297 14.6629 67.9268 14.4337 67.721 14.2481C67.5191 14.0625 67.2085 13.9223 66.7894 13.8276L65.3032 13.5038C64.484 13.3182 63.8781 13.0227 63.4856 12.6174C63.0969 12.2121 62.9026 11.6989 62.9026 11.0776C62.9026 10.5625 63.0474 10.1117 63.337 9.72538C63.6266 9.33901 64.0267 9.03788 64.5373 8.82197C65.0479 8.60227 65.6328 8.49242 66.2921 8.49242C67.2409 8.49242 67.9878 8.69697 68.5327 9.10606C69.0776 9.51136 69.4377 10.0549 69.613 10.7367Z" fill="#0D0E0F"/>
|
||||
<path d="M57.3021 17.5095C56.479 17.5095 55.7607 17.322 55.1472 16.947C54.5337 16.572 54.0574 16.0473 53.7182 15.3731C53.3791 14.6989 53.2095 13.911 53.2095 13.0095C53.2095 12.1042 53.3791 11.3125 53.7182 10.6345C54.0574 9.95644 54.5337 9.42992 55.1472 9.05492C55.7607 8.67992 56.479 8.49242 57.3021 8.49242C58.1252 8.49242 58.8435 8.67992 59.457 9.05492C60.0705 9.42992 60.5468 9.95644 60.8859 10.6345C61.2251 11.3125 61.3946 12.1042 61.3946 13.0095C61.3946 13.911 61.2251 14.6989 60.8859 15.3731C60.5468 16.0473 60.0705 16.572 59.457 16.947C58.8435 17.322 58.1252 17.5095 57.3021 17.5095ZM57.3078 16.0833C57.8413 16.0833 58.2833 15.9432 58.6339 15.6629C58.9845 15.3826 59.2436 15.0095 59.4112 14.5436C59.5827 14.0776 59.6685 13.5644 59.6685 13.0038C59.6685 12.447 59.5827 11.9356 59.4112 11.4697C59.2436 11 58.9845 10.6231 58.6339 10.339C58.2833 10.0549 57.8413 9.91288 57.3078 9.91288C56.7705 9.91288 56.3247 10.0549 55.9703 10.339C55.6197 10.6231 55.3587 11 55.1872 11.4697C55.0196 11.9356 54.9357 12.447 54.9357 13.0038C54.9357 13.5644 55.0196 14.0776 55.1872 14.5436C55.3587 15.0095 55.6197 15.3826 55.9703 15.6629C56.3247 15.9432 56.7705 16.0833 57.3078 16.0833Z" fill="#0D0E0F"/>
|
||||
<path d="M51.573 5.69697V17.3333H49.864V5.69697H51.573Z" fill="#0D0E0F"/>
|
||||
<path d="M44.132 17.5095C43.3089 17.5095 42.5906 17.322 41.9771 16.947C41.3636 16.572 40.8873 16.0473 40.5482 15.3731C40.209 14.6989 40.0395 13.911 40.0395 13.0095C40.0395 12.1042 40.209 11.3125 40.5482 10.6345C40.8873 9.95644 41.3636 9.42992 41.9771 9.05492C42.5906 8.67992 43.3089 8.49242 44.132 8.49242C44.9551 8.49242 45.6734 8.67992 46.2869 9.05492C46.9004 9.42992 47.3767 9.95644 47.7158 10.6345C48.055 11.3125 48.2245 12.1042 48.2245 13.0095C48.2245 13.911 48.055 14.6989 47.7158 15.3731C47.3767 16.0473 46.9004 16.572 46.2869 16.947C45.6734 17.322 44.9551 17.5095 44.132 17.5095ZM44.1377 16.0833C44.6712 16.0833 45.1132 15.9432 45.4638 15.6629C45.8144 15.3826 46.0735 15.0095 46.2412 14.5436C46.4126 14.0776 46.4984 13.5644 46.4984 13.0038C46.4984 12.447 46.4126 11.9356 46.2412 11.4697C46.0735 11 45.8144 10.6231 45.4638 10.339C45.1132 10.0549 44.6712 9.91288 44.1377 9.91288C43.6004 9.91288 43.1546 10.0549 42.8002 10.339C42.4496 10.6231 42.1886 11 42.0171 11.4697C41.8495 11.9356 41.7656 12.447 41.7656 13.0038C41.7656 13.5644 41.8495 14.0776 42.0171 14.5436C42.1886 15.0095 42.4496 15.3826 42.8002 15.6629C43.1546 15.9432 43.6004 16.0833 44.1377 16.0833Z" fill="#0D0E0F"/>
|
||||
<path d="M30.7951 17.3333V5.69697H32.5613V11.2538H32.7042L37.6141 5.69697H39.8376L35.1163 10.9356L39.8547 17.3333H37.7284L33.9445 12.1345L32.5613 13.714V17.3333H30.7951Z" fill="#0D0E0F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
@@ -2,13 +2,13 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
|
||||
<h2 className="text-3xl font-semibold text-gray-900 mb-4">
|
||||
<h2 className="text-3xl font-semibold text-gray-900 dark:text-gray-300 mb-4">
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
The page you are looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<Link
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useAuthStore, supabase } from '@imphnen-frontend-service/service';
|
||||
import { useGitHubCallback } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const CallbackPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const { setSession } = useAuthStore();
|
||||
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback();
|
||||
const [isProcessing, setIsProcessing] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasRunRef = useRef(false);
|
||||
@@ -13,118 +13,64 @@ const CallbackPage: FC = (): ReactElement => {
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
if (hasRunRef.current) {
|
||||
console.log('[Callback] Already processed, skipping...');
|
||||
return;
|
||||
}
|
||||
hasRunRef.current = true;
|
||||
|
||||
try {
|
||||
console.log('[Callback] Processing OAuth callback...');
|
||||
console.log('[Callback] Current URL:', globalThis.location.href);
|
||||
// Check URL hash for Supabase email confirmation callback
|
||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||
const urlParams = new URLSearchParams(globalThis.location.search);
|
||||
|
||||
// Supabase client is configured with detectSessionInUrl: true
|
||||
// This means Supabase automatically detects and processes OAuth tokens from the URL hash
|
||||
// We just need to wait a moment for it to complete, then check for the session
|
||||
const type = hashParams.get('type') || urlParams.get('type');
|
||||
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
||||
|
||||
console.log('[Callback] Waiting for Supabase to process OAuth callback...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
// Debug: log what we received
|
||||
console.log('[Callback] Params:', { type, accessToken: !!accessToken, hash: globalThis.location.hash, search: globalThis.location.search });
|
||||
|
||||
// Get the session that Supabase automatically created from the URL hash
|
||||
const { data: { session: sessionData }, error: sessionError } = await supabase.auth.getSession();
|
||||
// Handle Supabase email callbacks (has access_token in hash or query)
|
||||
// This includes: signup confirmation, email confirmation, password recovery
|
||||
if (accessToken) {
|
||||
setIsProcessing(false);
|
||||
|
||||
if (sessionError) {
|
||||
console.error('[Callback] Session error:', sessionError);
|
||||
throw new Error(sessionError.message || 'Failed to get session');
|
||||
// Password recovery - type is 'recovery' or we have access_token from reset email
|
||||
if (type === 'recovery' || type === 'magiclink') {
|
||||
toast.success('Email verified! Please set your new password.');
|
||||
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Signup/Email confirmation
|
||||
if (type === 'signup' || type === 'email_confirmation') {
|
||||
toast.success('Email verified successfully! Please log in to continue.');
|
||||
navigate('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have access_token but unknown type, assume it's password recovery
|
||||
// (Supabase sometimes sends without explicit type)
|
||||
toast.success('Email verified! Please set your new password.');
|
||||
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionData || !sessionData.user) {
|
||||
throw new Error('No session found after OAuth callback. Please try logging in again.');
|
||||
// Get the code from URL query params (GitHub OAuth)
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received');
|
||||
}
|
||||
|
||||
console.log('[Callback] Supabase session established:', {
|
||||
userId: sessionData.user.id,
|
||||
email: sessionData.user.email,
|
||||
});
|
||||
// Exchange the code for tokens using backend API (GitHub OAuth)
|
||||
const result = await exchangeGitHubCode({ code });
|
||||
|
||||
// Create/update user in the users table (for foreign key constraints)
|
||||
console.log('[Callback] Creating/updating user record...');
|
||||
const { data: userData, error: upsertError } = await supabase
|
||||
.from('users')
|
||||
.upsert({
|
||||
id: sessionData.user.id,
|
||||
email: sessionData.user.email || '',
|
||||
fullname: sessionData.user.user_metadata?.full_name ||
|
||||
sessionData.user.user_metadata?.name ||
|
||||
sessionData.user.email?.split('@')[0] || '',
|
||||
avatar: sessionData.user.user_metadata?.avatar_url || '',
|
||||
is_active: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
}, {
|
||||
onConflict: 'id',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (upsertError) {
|
||||
console.warn('[Callback] Failed to create user record:', upsertError);
|
||||
// Don't throw - continue with login even if user record creation fails
|
||||
} else {
|
||||
console.log('[Callback] User record created/updated successfully');
|
||||
}
|
||||
|
||||
// Store user-friendly data in Zustand for UI purposes
|
||||
// Supabase now manages the actual auth session
|
||||
// Use data from database if available, otherwise use OAuth metadata
|
||||
const userRecord = userData || {
|
||||
id: sessionData.user.id,
|
||||
email: sessionData.user.email || '',
|
||||
fullname: sessionData.user.user_metadata?.full_name ||
|
||||
sessionData.user.user_metadata?.name ||
|
||||
sessionData.user.email?.split('@')[0] || '',
|
||||
avatar: sessionData.user.user_metadata?.avatar_url || '',
|
||||
phone_number: '',
|
||||
birthdate: '',
|
||||
gender: '',
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
setSession({
|
||||
token: {
|
||||
access_token: sessionData.access_token,
|
||||
refresh_token: sessionData.refresh_token || '',
|
||||
},
|
||||
user: {
|
||||
id: userRecord.id,
|
||||
email: userRecord.email,
|
||||
fullname: userRecord.fullname,
|
||||
phone_number: userRecord.phone_number || '',
|
||||
avatar: userRecord.avatar || '',
|
||||
birthdate: userRecord.birthdate || '',
|
||||
gender: userRecord.gender || '',
|
||||
is_active: userRecord.is_active,
|
||||
location: userRecord.location,
|
||||
bio: userRecord.bio,
|
||||
skills: userRecord.skills,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[Callback] Session stored successfully');
|
||||
toast.success('Login successful!');
|
||||
setIsProcessing(false);
|
||||
|
||||
// Check if user has completed onboarding (has location)
|
||||
// Use globalThis.location.replace for hard redirect to prevent history issues
|
||||
if (userRecord.location) {
|
||||
console.log('[Callback] User has completed onboarding, redirecting to dashboard...');
|
||||
if (result.user.location) {
|
||||
globalThis.location.replace('/dashboard');
|
||||
} else {
|
||||
console.log('[Callback] User needs onboarding, redirecting...');
|
||||
globalThis.location.replace('/onboarding/user');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -141,21 +87,68 @@ const CallbackPage: FC = (): ReactElement => {
|
||||
|
||||
handleCallback();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Run only once on mount
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
// Check if error is related to private email
|
||||
const isPrivateEmailError =
|
||||
error.toLowerCase().includes('failed to create user') ||
|
||||
error.toLowerCase().includes('email') ||
|
||||
error.toLowerCase().includes('user record');
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
|
||||
<div className="bg-white w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200">
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200 dark:border-red-800">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-red-500 text-5xl mb-4">⚠️</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
GitHub Login Failed
|
||||
</h2>
|
||||
<p className="text-red-600 mb-4 whitespace-pre-line">{error}</p>
|
||||
<p className="text-red-600 dark:text-red-400 mb-4 whitespace-pre-line">{error}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mt-6 text-center">
|
||||
{isPrivateEmailError && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-semibold text-amber-800 dark:text-amber-300 mb-2">
|
||||
Is your GitHub email set to private?
|
||||
</h3>
|
||||
<p className="text-amber-700 dark:text-amber-400 text-sm mb-3">
|
||||
GitHub login requires a public email address. Please follow these steps:
|
||||
</p>
|
||||
<ol className="text-amber-700 dark:text-amber-400 text-sm list-decimal list-inside space-y-1 mb-3">
|
||||
<li>
|
||||
Go to{' '}
|
||||
<a
|
||||
href="https://github.com/settings/emails"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-amber-900 dark:hover:text-amber-200"
|
||||
>
|
||||
GitHub Email Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>Uncheck "Keep my email addresses private"</li>
|
||||
<li>
|
||||
Or go to{' '}
|
||||
<a
|
||||
href="https://github.com/settings/profile"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-amber-900 dark:hover:text-amber-200"
|
||||
>
|
||||
Profile Settings
|
||||
</a>{' '}
|
||||
and set a public email
|
||||
</li>
|
||||
<li>Try signing in with GitHub again</li>
|
||||
</ol>
|
||||
<p className="text-amber-600 dark:text-amber-500 text-xs">
|
||||
Alternatively, you can sign up using email and password instead.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mt-6 text-center">
|
||||
Redirecting to login page in 3 seconds...
|
||||
</p>
|
||||
</div>
|
||||
@@ -164,13 +157,13 @@ const CallbackPage: FC = (): ReactElement => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Completing login...
|
||||
</h2>
|
||||
<p className="text-gray-600">Please wait</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">Please wait</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
import { Link } from 'react-router';
|
||||
import { useForgotPassword } from '@imphnen-frontend-service/service';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
import ThemeToggle from '../../../components/theme-toggle';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const forgotPasswordMutation = useForgotPassword();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -17,45 +20,35 @@ export default function ForgotPasswordPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/reset-password`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
await forgotPasswordMutation.mutateAsync({ email });
|
||||
|
||||
setEmailSent(true);
|
||||
toast.success('Password reset email sent! Check your inbox.');
|
||||
} catch (err) {
|
||||
console.error('Failed to send reset email:', err);
|
||||
toast.error((err as Error).message || 'Failed to send reset email');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (emailSent) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
|
||||
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 text-center">
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<span className="text-3xl">✓</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Check Your Email
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
We've sent a password reset link to <strong>{email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Click the link in the email to reset your password. The link will expire in 1 hour.
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Click the link in the email to reset your password. The link will
|
||||
expire in 1 hour.
|
||||
</p>
|
||||
|
||||
<Link to="/auth/login">
|
||||
@@ -66,7 +59,7 @@ export default function ForgotPasswordPage() {
|
||||
|
||||
<button
|
||||
onClick={() => setEmailSent(false)}
|
||||
className="w-full py-3 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
Send another email
|
||||
</button>
|
||||
@@ -77,20 +70,33 @@ export default function ForgotPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
|
||||
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => navigate('/auth/login')}
|
||||
className="cursor-pointer text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300 text-base font-sans flex items-center"
|
||||
>
|
||||
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
|
||||
Back to Login
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Forgot Password?
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
No worries, we'll send you reset instructions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
@@ -99,26 +105,20 @@ export default function ForgotPasswordPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
disabled={forgotPasswordMutation.isPending}
|
||||
className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={forgotPasswordMutation.isPending}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send Reset Link'}
|
||||
{forgotPasswordMutation.isPending ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link to="/auth/login" className="text-primary-600 hover:text-primary-700 font-semibold">
|
||||
← Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useGitHubAuth } from '@imphnen-frontend-service/service';
|
||||
import { useSession } from '@imphnen-frontend-service/utils';
|
||||
import { GithubOutlined } from '@ant-design/icons';
|
||||
|
||||
const LoginPage: FC = (): ReactElement => {
|
||||
console.log('[LoginPage] Rendering...');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const { isAuthenticated } = useSession();
|
||||
|
||||
console.log('[LoginPage] isAuthenticated:', isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[LoginPage] useEffect - isAuthenticated:', isAuthenticated);
|
||||
if (isAuthenticated) {
|
||||
console.log('[LoginPage] Redirecting to dashboard...');
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleGithubLogin = async () => {
|
||||
try {
|
||||
setIsGithubLoading(true);
|
||||
console.log('[Login] Initiating GitHub OAuth...');
|
||||
|
||||
const result = await signInWithGitHub();
|
||||
console.log('[Login] OAuth result:', result);
|
||||
|
||||
// Check if we got a redirect URL
|
||||
if (result?.url) {
|
||||
console.log('[Login] Redirecting to GitHub OAuth:', result.url);
|
||||
// Supabase should handle the redirect automatically
|
||||
// If we're still here after 2 seconds, manually redirect
|
||||
setTimeout(() => {
|
||||
if (result.url) {
|
||||
globalThis.location.href = result.url;
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
console.error('[Login] No OAuth URL returned');
|
||||
setIsGithubLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Login] GitHub login failed:', error);
|
||||
setIsGithubLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
|
||||
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-100">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Welcome to Hackathon
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Sign in with your GitHub account to join or create your hackathon team
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full gap-2 py-3"
|
||||
variant="secondary"
|
||||
onClick={handleGithubLogin}
|
||||
disabled={isGithubLoading}
|
||||
type="button"
|
||||
>
|
||||
<GithubOutlined className="text-xl" />
|
||||
<span className="font-semibold">
|
||||
{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-500 text-sm">
|
||||
By signing in, you agree to our Terms of Service and Privacy Policy
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -1,21 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
import { useGitHubAuth, useEmailAuth, supabase, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
useGitHubAuth,
|
||||
useLogin,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { GithubOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ThemeToggle } from '../../../components/theme-toggle';
|
||||
|
||||
export default function LoginPage() {
|
||||
console.log('[LoginPage] Rendering...');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { setSession } = useAuthStore();
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const { signInWithEmail } = useEmailAuth();
|
||||
const loginMutation = useLogin();
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Check for password reset tokens in URL and redirect to reset-password page
|
||||
useEffect(() => {
|
||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||
const urlParams = new URLSearchParams(globalThis.location.search);
|
||||
|
||||
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
||||
const type = hashParams.get('type') || urlParams.get('type');
|
||||
|
||||
// If we have an access_token, this is likely a password reset redirect that landed on the wrong page
|
||||
if (accessToken) {
|
||||
console.log('[Login] Detected access_token, redirecting to reset-password page');
|
||||
|
||||
// Check if it's a password recovery
|
||||
if (type === 'recovery' || type === 'magiclink' || !type) {
|
||||
toast.info('Redirecting to password reset...');
|
||||
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||
}
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -27,51 +49,12 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
setIsEmailLoading(true);
|
||||
console.log('[Login] Attempting email login...');
|
||||
|
||||
const result = await signInWithEmail(email, password);
|
||||
console.log('[Login] Email login successful:', result);
|
||||
|
||||
// Get user data from database
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.eq('id', result.user.id)
|
||||
.single();
|
||||
|
||||
// Store session in Zustand
|
||||
setSession({
|
||||
token: {
|
||||
access_token: result.session.access_token,
|
||||
refresh_token: result.session.refresh_token || '',
|
||||
},
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email || '',
|
||||
fullname: userData?.fullname || result.user.user_metadata?.full_name || '',
|
||||
phone_number: userData?.phone_number || '',
|
||||
avatar: userData?.avatar || '',
|
||||
birthdate: userData?.birthdate || '',
|
||||
gender: userData?.gender || '',
|
||||
is_active: userData?.is_active || true,
|
||||
location: userData?.location,
|
||||
bio: userData?.bio,
|
||||
skills: userData?.skills,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await loginMutation.mutateAsync({ email, password });
|
||||
|
||||
toast.success('Login successful!');
|
||||
|
||||
// Redirect based on onboarding status
|
||||
if (userData?.location) {
|
||||
if (result.user.location) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/onboarding/user');
|
||||
@@ -79,54 +62,64 @@ export default function LoginPage() {
|
||||
} catch (err) {
|
||||
console.error('[Login] Email login failed:', err);
|
||||
setError((err as Error).message || 'Login failed');
|
||||
setIsEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGithubLogin = async () => {
|
||||
try {
|
||||
setIsGithubLoading(true);
|
||||
console.log('[Login] Initiating GitHub OAuth...');
|
||||
|
||||
const result = await signInWithGitHub();
|
||||
console.log('[Login] OAuth result:', result);
|
||||
|
||||
// Check if we got a redirect URL
|
||||
if (result?.url) {
|
||||
console.log('[Login] Redirecting to GitHub OAuth:', result.url);
|
||||
// Manually redirect immediately
|
||||
globalThis.location.href = result.url;
|
||||
} else {
|
||||
console.error('[Login] No OAuth URL returned');
|
||||
setIsGithubLoading(false);
|
||||
setError('Failed to get GitHub OAuth URL');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Login] GitHub login failed:', error);
|
||||
} catch (err) {
|
||||
console.error('[Login] GitHub login failed:', err);
|
||||
setError((err as Error).message || 'GitHub login failed');
|
||||
setIsGithubLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
|
||||
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="cursor-pointer text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300 text-base font-sans flex items-center"
|
||||
>
|
||||
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
|
||||
Back to Homepage
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Welcome Back
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-gray-600 dark:text-gray-400 font-sans">
|
||||
Sign in to join or create your hackathon team
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
<div className="mb-6 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleEmailLogin} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
@@ -135,53 +128,70 @@ export default function LoginPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<Link to="/auth/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">
|
||||
<Link
|
||||
to="/auth/forgot-password"
|
||||
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
{isEmailLoading ? 'Signing in...' : 'Sign in with Email'}
|
||||
{loginMutation.isPending ? 'Signing in...' : 'Sign in with Email'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="my-6 flex items-center">
|
||||
<div className="flex-1 border-t border-gray-300"></div>
|
||||
<span className="px-4 text-sm text-gray-500">OR</span>
|
||||
<div className="flex-1 border-t border-gray-300"></div>
|
||||
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
|
||||
<span className="px-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
OR
|
||||
</span>
|
||||
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGithubLogin}
|
||||
disabled={isGithubLoading}
|
||||
type="button"
|
||||
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors"
|
||||
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
<GithubOutlined className="text-xl" />
|
||||
<span>
|
||||
@@ -189,17 +199,33 @@ export default function LoginPage() {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-500 font-sans">
|
||||
Make sure your GitHub email is{' '}
|
||||
<a
|
||||
href="https://github.com/settings/emails"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
set to public
|
||||
</a>{' '}
|
||||
for GitHub sign in to work.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600 text-sm">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Don't have an account?{' '}
|
||||
<a href="/auth/signup" className="text-primary-600 hover:text-primary-700 font-semibold">
|
||||
<Link
|
||||
to="/auth/signup"
|
||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
||||
>
|
||||
Sign up
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-500 text-xs">
|
||||
<p className="text-gray-500 dark:text-gray-500 text-xs">
|
||||
By signing in, you agree to our Terms of Service and Privacy Policy
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
import { useResetPassword, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
const { clearSession } = useAuthStore();
|
||||
const resetPasswordMutation = useResetPassword();
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isValidToken, setIsValidToken] = useState(false);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we have a valid session (from the reset link)
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
if (session) {
|
||||
setIsValidToken(true);
|
||||
} else {
|
||||
toast.error('Invalid or expired reset link');
|
||||
setTimeout(() => navigate('/auth/forgot-password'), 2000);
|
||||
}
|
||||
});
|
||||
// Get the access_token from URL hash (Supabase sends it as hash fragment)
|
||||
// or from query params (when redirected from callback page)
|
||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||
const queryParams = new URLSearchParams(globalThis.location.search);
|
||||
const token = hashParams.get('access_token') || queryParams.get('access_token');
|
||||
|
||||
if (token) {
|
||||
setAccessToken(token);
|
||||
} else {
|
||||
toast.error('Invalid or expired reset link');
|
||||
setTimeout(() => navigate('/auth/forgot-password'), 2000);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -35,94 +42,117 @@ export default function ResetPasswordPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
toast.error('Invalid reset token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password: password
|
||||
await resetPasswordMutation.mutateAsync({
|
||||
access_token: accessToken,
|
||||
new_password: password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success('Password updated successfully!');
|
||||
|
||||
// Sign out and redirect to login
|
||||
await supabase.auth.signOut();
|
||||
// Clear session and redirect to login
|
||||
clearSession();
|
||||
navigate('/auth/login');
|
||||
} catch (err) {
|
||||
console.error('Failed to reset password:', err);
|
||||
toast.error((err as Error).message || 'Failed to reset password');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isValidToken) {
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
|
||||
<p className="text-gray-600">Verifying reset link...</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Verifying reset link...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
|
||||
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Set New Password
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Enter your new password below
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Password'}
|
||||
{resetPasswordMutation.isPending ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,235 +1,368 @@
|
||||
import { useState } from 'react';
|
||||
import { useGitHubAuth, useEmailAuth, supabase, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { useGitHubAuth, useSignup } from '@imphnen-frontend-service/service';
|
||||
import { GithubOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ThemeToggle } from '../../../components/theme-toggle';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
const signupSchema = z
|
||||
.object({
|
||||
fullname: z
|
||||
.string()
|
||||
.min(1, 'Full name is required')
|
||||
.min(2, 'Full name must be at least 2 characters'),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'Email is required')
|
||||
.email('Please enter a valid email address'),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password is required')
|
||||
.min(6, 'Password must be at least 6 characters'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type SignupFormData = z.infer<typeof signupSchema>;
|
||||
|
||||
// Registration deadline: 2025-11-30 23:29:00 WIB (UTC+7)
|
||||
const REGISTRATION_DEADLINE = new Date('2025-11-30T16:29:00Z');
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { setSession } = useAuthStore();
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const { signUpWithEmail } = useEmailAuth();
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [fullname, setFullname] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleEmailSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Check if registration is closed
|
||||
const isRegistrationClosed = new Date() >= REGISTRATION_DEADLINE;
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const signupMutation = useSignup();
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
||||
const [registeredEmail, setRegisteredEmail] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<SignupFormData>({
|
||||
resolver: zodResolver(signupSchema),
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const onSubmit = async (data: SignupFormData) => {
|
||||
setError(null);
|
||||
|
||||
if (!fullname || !email || !password || !confirmPassword) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsEmailLoading(true);
|
||||
console.log('[Signup] Attempting email signup...');
|
||||
|
||||
const result = await signUpWithEmail(email, password, fullname);
|
||||
console.log('[Signup] Email signup successful:', result);
|
||||
|
||||
if (!result.user) {
|
||||
throw new Error('Signup failed - no user returned');
|
||||
}
|
||||
|
||||
// Create user record in database
|
||||
const { error: upsertError } = await supabase
|
||||
.from('users')
|
||||
.upsert({
|
||||
id: result.user.id,
|
||||
email: result.user.email || '',
|
||||
fullname: fullname,
|
||||
is_active: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
}, {
|
||||
onConflict: 'id',
|
||||
});
|
||||
|
||||
if (upsertError) {
|
||||
console.warn('[Signup] Failed to create user record:', upsertError);
|
||||
}
|
||||
|
||||
// If session is available (email confirmation disabled), store it
|
||||
if (result.session) {
|
||||
setSession({
|
||||
token: {
|
||||
access_token: result.session.access_token,
|
||||
refresh_token: result.session.refresh_token || '',
|
||||
},
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email || '',
|
||||
fullname: fullname,
|
||||
phone_number: '',
|
||||
avatar: '',
|
||||
birthdate: '',
|
||||
gender: '',
|
||||
is_active: true,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Account created successfully!');
|
||||
navigate('/onboarding/user');
|
||||
} else {
|
||||
// Email confirmation is enabled
|
||||
toast.success('Account created! Please check your email to verify your account.');
|
||||
setTimeout(() => {
|
||||
navigate('/auth/login');
|
||||
}, 2000);
|
||||
}
|
||||
const result = await signupMutation.mutateAsync({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
fullname: data.fullname,
|
||||
});
|
||||
toast.success(result.message);
|
||||
setRegisteredEmail(data.email);
|
||||
setRegistrationSuccess(true);
|
||||
} catch (err) {
|
||||
console.error('[Signup] Email signup failed:', err);
|
||||
setError((err as Error).message || 'Signup failed');
|
||||
setIsEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show closed registration screen
|
||||
if (isRegistrationClosed) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon
|
||||
icon="mdi:clock-alert"
|
||||
className="text-3xl text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Registration Closed
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
The registration period for this hackathon has ended.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Thank you for your interest! Registration closed on November 30, 2025 at 23:29 WIB.
|
||||
</p>
|
||||
|
||||
<Link to="/auth/login">
|
||||
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer">
|
||||
Go to Login
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Back to Homepage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show success screen after registration
|
||||
if (registrationSuccess) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon
|
||||
icon="mdi:email-check"
|
||||
className="text-3xl text-green-600 dark:text-green-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Check Your Email
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
We've sent an activation link to{' '}
|
||||
<strong>{registeredEmail}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Click the link in the email to activate your account. The link
|
||||
will expire in 24 hours.
|
||||
</p>
|
||||
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
Don't forget to check your spam folder if you don't see the
|
||||
email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link to="/auth/login">
|
||||
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer">
|
||||
Go to Login
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setRegistrationSuccess(false)}
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Register with different email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleGithubLogin = async () => {
|
||||
try {
|
||||
setIsGithubLoading(true);
|
||||
console.log('[Signup] Initiating GitHub OAuth...');
|
||||
|
||||
const result = await signInWithGitHub();
|
||||
console.log('[Signup] OAuth result:', result);
|
||||
|
||||
if (result?.url) {
|
||||
console.log('[Signup] Redirecting to GitHub OAuth:', result.url);
|
||||
globalThis.location.href = result.url;
|
||||
} else {
|
||||
console.error('[Signup] No OAuth URL returned');
|
||||
setIsGithubLoading(false);
|
||||
setError('Failed to get GitHub OAuth URL');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Signup] GitHub login failed:', error);
|
||||
} catch (err) {
|
||||
console.error('[Signup] GitHub login failed:', err);
|
||||
setError((err as Error).message || 'GitHub login failed');
|
||||
setIsGithubLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputBaseClass =
|
||||
'w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed';
|
||||
const inputErrorClass = 'border-red-500 dark:border-red-500';
|
||||
const inputNormalClass = 'border-gray-300 dark:border-gray-600';
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
|
||||
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="cursor-pointer text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300 text-base font-sans flex items-center"
|
||||
>
|
||||
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
|
||||
Back to Homepage
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Create Account
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Join the hackathon community
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
<div className="mb-6 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleEmailSignup} className="space-y-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="fullname" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor="fullname"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
id="fullname"
|
||||
type="text"
|
||||
value={fullname}
|
||||
onChange={(e) => setFullname(e.target.value)}
|
||||
{...register('fullname')}
|
||||
placeholder="John Doe"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
required
|
||||
disabled={signupMutation.isPending}
|
||||
className={`${inputBaseClass} ${
|
||||
errors.fullname ? inputErrorClass : inputNormalClass
|
||||
}`}
|
||||
/>
|
||||
{errors.fullname && (
|
||||
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||
{errors.fullname.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
{...register('email')}
|
||||
placeholder="your@email.com"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
required
|
||||
disabled={signupMutation.isPending}
|
||||
className={`${inputBaseClass} ${
|
||||
errors.email ? inputErrorClass : inputNormalClass
|
||||
}`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
{...register('password')}
|
||||
placeholder="••••••••"
|
||||
disabled={signupMutation.isPending}
|
||||
className={`${inputBaseClass} pr-12 ${
|
||||
errors.password ? inputErrorClass : inputNormalClass
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
{...register('confirmPassword')}
|
||||
placeholder="••••••••"
|
||||
disabled={signupMutation.isPending}
|
||||
className={`${inputBaseClass} pr-12 ${
|
||||
errors.confirmPassword ? inputErrorClass : inputNormalClass
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
disabled={!isValid || signupMutation.isPending}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
{isEmailLoading ? 'Creating account...' : 'Create Account'}
|
||||
{signupMutation.isPending
|
||||
? 'Creating account...'
|
||||
: 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="my-6 flex items-center">
|
||||
<div className="flex-1 border-t border-gray-300"></div>
|
||||
<span className="px-4 text-sm text-gray-500">OR</span>
|
||||
<div className="flex-1 border-t border-gray-300"></div>
|
||||
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
|
||||
<span className="px-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
OR
|
||||
</span>
|
||||
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGithubLogin}
|
||||
disabled={isGithubLoading}
|
||||
type="button"
|
||||
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors"
|
||||
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
<GithubOutlined className="text-xl" />
|
||||
<span>
|
||||
@@ -237,17 +370,33 @@ export default function SignupPage() {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-500 font-sans">
|
||||
Make sure your GitHub email is{' '}
|
||||
<a
|
||||
href="https://github.com/settings/emails"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
set to public
|
||||
</a>{' '}
|
||||
for GitHub sign up to work.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600 text-sm">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Already have an account?{' '}
|
||||
<a href="/auth/login" className="text-primary-600 hover:text-primary-700 font-semibold">
|
||||
<Link
|
||||
to="/auth/login"
|
||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-500 text-xs">
|
||||
<p className="text-gray-500 dark:text-gray-500 text-xs">
|
||||
By signing up, you agree to our Terms of Service and Privacy Policy
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
import { FC, ReactElement, useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { decodeCertificateId } from '../../../utils/certificate';
|
||||
import {
|
||||
useTeamById,
|
||||
useTeamSubmission,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import QRCode from 'qrcode';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
interface DecodedCert {
|
||||
teamId: string;
|
||||
submissionId: string;
|
||||
}
|
||||
|
||||
const CertificatePage: FC = (): ReactElement => {
|
||||
const { certId } = useParams<{ certId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const teamNameRef = useRef<HTMLHeadingElement>(null);
|
||||
const userNameRef = useRef<HTMLHeadingElement>(null);
|
||||
const [teamNameFontSize, setTeamNameFontSize] = useState('2.25rem');
|
||||
const [userNameFontSize, setUserNameFontSize] = useState('2.25rem');
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
|
||||
const certificateRef = useRef<HTMLDivElement>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [certificateImage, setCertificateImage] = useState<string>('');
|
||||
const [showTemplate, setShowTemplate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (certId) {
|
||||
decodeCertificateId(certId)
|
||||
.then(setDecodedInfo)
|
||||
.catch(() => {
|
||||
setError('Invalid certificate ID');
|
||||
});
|
||||
}
|
||||
}, [certId]);
|
||||
|
||||
// Generate QR Code
|
||||
useEffect(() => {
|
||||
if (certId) {
|
||||
const certificateUrl = `${window.location.origin}/certificate/${certId}`;
|
||||
QRCode.toDataURL(certificateUrl, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
.then(setQrCodeUrl)
|
||||
.catch((err) => console.error('QR Code generation failed:', err));
|
||||
}
|
||||
}, [certId]);
|
||||
|
||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||
decodedInfo?.teamId || '',
|
||||
!!decodedInfo?.teamId
|
||||
);
|
||||
const { data: submissionData, isLoading: isLoadingSubmission } =
|
||||
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
|
||||
|
||||
const team = teamData?.data;
|
||||
const submission = submissionData?.data;
|
||||
|
||||
const isLoading =
|
||||
(!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission;
|
||||
|
||||
// Dynamic font sizing: shrink by 2px if height exceeds 80px
|
||||
useEffect(() => {
|
||||
const adjustFontSize = (
|
||||
element: HTMLElement | null,
|
||||
maxHeight: number,
|
||||
startSize: number,
|
||||
setter: (size: string) => void
|
||||
) => {
|
||||
if (!element) return;
|
||||
|
||||
let currentSize = startSize;
|
||||
element.style.fontSize = `${currentSize}px`;
|
||||
|
||||
while (element.offsetHeight > maxHeight && currentSize > 1) {
|
||||
currentSize -= 2;
|
||||
element.style.fontSize = `${currentSize}px`;
|
||||
}
|
||||
|
||||
setter(`${currentSize}px`);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
adjustFontSize(teamNameRef.current, 80, 20, setTeamNameFontSize);
|
||||
adjustFontSize(userNameRef.current, 80, 36, setUserNameFontSize);
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [team?.name, session?.user?.fullname]);
|
||||
|
||||
// Generate certificate canvas screenshot
|
||||
useEffect(() => {
|
||||
const generateCertificate = async () => {
|
||||
if (!certificateRef.current || !team || !submission || !qrCodeUrl) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
// Wait a bit for fonts and images to load
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const canvas = await html2canvas(certificateRef.current, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
backgroundColor: '#ffffff',
|
||||
logging: false,
|
||||
width: 1000,
|
||||
height: (1000 * 2480) / 3508,
|
||||
});
|
||||
|
||||
const imageUrl = canvas.toDataURL('image/png');
|
||||
setCertificateImage(imageUrl);
|
||||
setShowTemplate(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate certificate:', error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
generateCertificate();
|
||||
}, [team, submission, qrCodeUrl, session?.user?.fullname]);
|
||||
|
||||
// Download certificate
|
||||
const handleDownloadCertificate = () => {
|
||||
if (!certificateImage) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = certificateImage;
|
||||
link.download = `certificate-${team?.name || 'hackathon'}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
// Print certificate
|
||||
const handlePrintCertificate = () => {
|
||||
if (!certificateImage) return;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Certificate - ${team?.name}</title>
|
||||
<style>
|
||||
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
@media print {
|
||||
@page { size: A4 landscape; margin: 0; }
|
||||
body { margin: 0; }
|
||||
img { width: 100%; height: auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="${certificateImage}" />
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => {
|
||||
printWindow.print();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (error || !certId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">❌</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Invalid Certificate
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
{error || 'The certificate ID is invalid or malformed.'}
|
||||
</p>
|
||||
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Loading certificate...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!submission || submission.id !== decodedInfo?.submissionId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">📄</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Certificate Not Found
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
The submission associated with this certificate could not be found.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Print Styles */}
|
||||
<style>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
#certificate-wrapper {
|
||||
visibility: visible;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
}
|
||||
#certificate, #certificate * {
|
||||
visibility: visible;
|
||||
}
|
||||
#certificate {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsive - zoom out to fit */
|
||||
@media (max-width: 768px) {
|
||||
#certificate-container {
|
||||
transform-origin: top center;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Certificate
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{team?.name}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
navigate(`/teams/${decodedInfo?.teamId}/submission`)
|
||||
}
|
||||
>
|
||||
Back to Submission
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Certificate Content */}
|
||||
<div
|
||||
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||
id="certificate-wrapper"
|
||||
>
|
||||
{/* Hidden Template for Canvas Generation */}
|
||||
<div
|
||||
className={showTemplate ? 'block' : 'hidden'}
|
||||
style={{ position: 'absolute', left: '-9999px' }}
|
||||
>
|
||||
<div
|
||||
ref={certificateRef}
|
||||
id="certificate-template"
|
||||
style={{
|
||||
position: 'relative',
|
||||
backgroundImage: 'url(/images/blank_cert.png)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
width: '1000px',
|
||||
height: `${(1000 * 2480) / 3508}px`,
|
||||
}}
|
||||
>
|
||||
{/* User Name (from session) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '80%',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
ref={userNameRef}
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
color: '#111827',
|
||||
textAlign: 'center',
|
||||
fontSize: userNameFontSize,
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.1)',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{session?.user?.fullname || 'N/A'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Team Name */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '46%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '70%',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
ref={teamNameRef}
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center',
|
||||
fontSize: teamNameFontSize,
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{team?.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Participation Text */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '60%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '70%',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: '#374151',
|
||||
fontSize: '18px',
|
||||
fontWeight: '500',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 'bold' }}>
|
||||
Peserta Hackathon IMPHNEN x KOLOSAL AI
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '8%',
|
||||
left: '8%',
|
||||
}}
|
||||
>
|
||||
{qrCodeUrl && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="Certificate QR Code"
|
||||
style={{ width: '96px', height: '96px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '8%',
|
||||
right: '8%',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: '#374151',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{submission.submitted_at
|
||||
? new Date(submission.submitted_at).toLocaleDateString(
|
||||
'id-ID',
|
||||
{
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}
|
||||
)
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Certificate Image */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
||||
{isGenerating && (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Generating certificate...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{certificateImage && !isGenerating && (
|
||||
<img
|
||||
src={certificateImage}
|
||||
alt="Certificate"
|
||||
className="w-full h-auto"
|
||||
style={{ maxWidth: '100%', height: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDownloadCertificate}
|
||||
className="flex items-center gap-2"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? '⏳ Generating...' : '📥 Download'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handlePrintCertificate}
|
||||
className="flex items-center gap-2"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate(`/teams/${decodedInfo?.teamId}/submission`)
|
||||
}
|
||||
variant="secondary"
|
||||
className="col-span-2 flex items-center gap-2 xl:col-span-1"
|
||||
>
|
||||
View Submission
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Certificate Information
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
This certificate is a digital record of your hackathon participation
|
||||
and project submission. You can print or save this page as a PDF for
|
||||
your records.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificatePage;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { Sidebar } from '../../components/sidebar';
|
||||
|
||||
const DashboardLayout: FC = (): ReactElement => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Mobile Header with Hamburger */}
|
||||
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
|
||||
Hackathon
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardLayout;
|
||||
@@ -1,19 +1,103 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { useMyTeams, useMyInvitations, useRespondToInvitation, supabase, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import {
|
||||
useMyTeams,
|
||||
useMyInvitations,
|
||||
useRespondToInvitation,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Icon } from '@iconify/react';
|
||||
import ProfilePage from '../profile/page';
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
|
||||
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
||||
|
||||
type Invitation = {
|
||||
id: string;
|
||||
team: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
logo?: string;
|
||||
banner?: string;
|
||||
description?: string;
|
||||
city?: string;
|
||||
visibility?: string;
|
||||
leader_id?: string;
|
||||
};
|
||||
inviter: {
|
||||
id?: string;
|
||||
fullname?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const DashboardPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const { session, clearSession } = useAuthStore();
|
||||
const { session } = useAuthStore();
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState<{
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} | null>(null);
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
// Check if submission deadline passed
|
||||
const isSubmissionDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (isSubmissionDeadlinePassed) return;
|
||||
|
||||
const calculateTimeLeft = () => {
|
||||
const now = new Date();
|
||||
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
|
||||
|
||||
if (difference <= 0) {
|
||||
setTimeLeft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
|
||||
const minutes = Math.floor((difference / 1000 / 60) % 60);
|
||||
const seconds = Math.floor((difference / 1000) % 60);
|
||||
|
||||
setTimeLeft({ days, hours, minutes, seconds });
|
||||
};
|
||||
|
||||
calculateTimeLeft();
|
||||
const timer = setInterval(calculateTimeLeft, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isSubmissionDeadlinePassed]);
|
||||
|
||||
// Lock background scroll when profile modal is open
|
||||
useEffect(() => {
|
||||
if (showProfileModal) {
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow || '';
|
||||
};
|
||||
}
|
||||
}, [showProfileModal]);
|
||||
const { data: teamsData } = useMyTeams();
|
||||
const { data: invitationsData } = useMyInvitations();
|
||||
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
|
||||
|
||||
const user = session?.user;
|
||||
const myTeams = teamsData?.data || [];
|
||||
const invitations = invitationsData?.data || [];
|
||||
const invitations: Invitation[] = (invitationsData?.data ||
|
||||
[]) as Invitation[];
|
||||
|
||||
const handleAcceptInvitation = async (invitationId: string) => {
|
||||
try {
|
||||
@@ -35,235 +119,291 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// Clear Supabase session
|
||||
await supabase.auth.signOut();
|
||||
|
||||
// Clear Zustand store
|
||||
clearSession();
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.clear();
|
||||
|
||||
toast.success('Logged out successfully');
|
||||
|
||||
// Redirect to login
|
||||
navigate('/auth/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// Even if there's an error, clear everything and redirect
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
navigate('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Welcome, {user?.fullname || 'User'}!
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{user?.location && `📍 ${user.location}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{user?.avatar && (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt="Profile"
|
||||
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="secondary"
|
||||
className="bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
<>
|
||||
<div className="p-6 md:p-8 md:max-w-7xl w-full mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Welcome, {user?.fullname || user?.email?.split('@')[0] || 'User'}!
|
||||
</h1>
|
||||
{user?.location && (
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1 font-sans flex items-center space-x-1">
|
||||
<Icon icon="heroicons:map-pin-16-solid" width="24" height="24" />
|
||||
<span>{user.location}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Countdown Timer */}
|
||||
{timeLeft && !isSubmissionDeadlinePassed && (
|
||||
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">⏰</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
|
||||
Submission Deadline
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
|
||||
Project submissions close on December 7, 2025 at 23:59 WIB
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.days}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Days
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.hours.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Hours
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Minutes
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Seconds
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Team Invitations */}
|
||||
{invitations.length > 0 && (
|
||||
<div className="mb-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
<div className="mb-8 bg-primary-50 dark:bg-blue-900/20 border border-primary-200 dark:border-blue-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Team Invitations ({invitations.length})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{isTeamFeaturesClosed && (
|
||||
<div className="mb-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 flex items-center gap-2">
|
||||
<Icon icon="mdi:clock-alert" className="text-lg shrink-0" />
|
||||
Team features are closed. You can no longer accept invitations.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3 font-sans">
|
||||
{invitations.map((invitation) => (
|
||||
<div key={invitation.id} className="bg-white p-4 rounded-lg shadow-sm flex items-center justify-between">
|
||||
<div
|
||||
key={invitation.id}
|
||||
className="bg-white dark:bg-gray-900 p-4 rounded-lg shadow-sm flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{invitation.team.name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Invited by {invitation.inviter.fullname}
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{invitation.team?.name ?? 'Unnamed Team'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Invited by{' '}
|
||||
{invitation.inviter?.fullname ?? 'Unknown User'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAcceptInvitation(invitation.id)}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRejectInvitation(invitation.id)}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
{!isTeamFeaturesClosed && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAcceptInvitation(invitation.id)}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRejectInvitation(invitation.id)}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* My Teams */}
|
||||
{myTeams.length > 0 ? (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">My Team</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{myTeams.map((team) => (
|
||||
(() => {
|
||||
const team = myTeams[0] as any;
|
||||
return (
|
||||
<div className="mb-6 md:mb-8">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
My Team
|
||||
</h2>
|
||||
<Link
|
||||
key={team.id}
|
||||
to={`/teams/${team.id}`}
|
||||
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
|
||||
to={'/teams/' + team.id}
|
||||
className="block bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow border dark:border-gray-700"
|
||||
>
|
||||
{team.banner && (
|
||||
<img
|
||||
src={team.banner}
|
||||
alt={team.name}
|
||||
className="w-full h-32 object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo && (
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
className="w-16 h-16 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
||||
<Icon icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
|
||||
<p className="text-sm text-gray-600">📍 {team.city}</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white leading-tight line-clamp-1">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
|
||||
{team.has_submission && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
||||
Submitted
|
||||
</span>
|
||||
)}
|
||||
{team.city && (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Icon icon="mdi:map-marker" className="shrink-0" />
|
||||
<span className="truncate">{team.city}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<Icon icon="mdi:account-group" />
|
||||
{team.member_count || team.members?.length || 0} member{(team.member_count || team.members?.length || 0) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm line-clamp-2">
|
||||
{team.description}
|
||||
</p>
|
||||
{team.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
/* No Team - Show CTAs */
|
||||
<div className="bg-white rounded-lg shadow-md p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
You're not in a team yet
|
||||
<div className="mb-6 md:mb-8 bg-white dark:bg-gray-900 rounded-lg shadow-md p-6 md:p-8">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl md:text-4xl mb-2 md:mb-3">👋</div>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-1 md:mb-2">
|
||||
You are not in a team yet
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Join an existing team or create your own to get started
|
||||
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 font-sans">
|
||||
Use the sidebar to browse teams or create your own
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 max-w-2xl mx-auto">
|
||||
<Link
|
||||
to="/teams/browse"
|
||||
className="bg-blue-600 text-white rounded-lg p-6 hover:bg-blue-700 transition-colors text-center"
|
||||
>
|
||||
<div className="text-4xl mb-3">🔍</div>
|
||||
<h3 className="text-xl font-bold mb-2">Browse Teams</h3>
|
||||
<p className="text-blue-100">
|
||||
Find and join existing teams looking for members
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/teams/create"
|
||||
className="bg-green-600 text-white rounded-lg p-6 hover:bg-green-700 transition-colors text-center"
|
||||
>
|
||||
<div className="text-4xl mb-3">➕</div>
|
||||
<h3 className="text-xl font-bold mb-2">Create Team</h3>
|
||||
<p className="text-green-100">
|
||||
Start your own team and invite members
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Profile Card */}
|
||||
<div className="mt-8 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 h-24"></div>
|
||||
<div className="px-8 pb-8">
|
||||
<div className="flex items-start -mt-12 mb-6">
|
||||
{user?.avatar && (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.fullname}
|
||||
className="w-24 h-24 rounded-full border-4 border-white shadow-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="ml-6 mt-14">
|
||||
<h2 className="text-2xl font-bold text-gray-900">{user?.fullname}</h2>
|
||||
{user?.location ? (
|
||||
<p className="text-gray-600 flex items-center mt-1">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{user.location}
|
||||
</p>
|
||||
<div className="mt-8 bg-white dark:bg-gray-900 rounded-xl shadow-lg">
|
||||
<div className="bg-linear-to-r from-primary-600 to-primary-500 h-24 rounded-t-xl"></div>
|
||||
<div className="px-4 md:px-8 pb-4 md:pb-8 max-w-7xl">
|
||||
<div className="flex flex-col md:flex-row md:items-start -mt-12 mb-4 md:mb-6">
|
||||
<div className="flex flex-col md:flex-row items-start">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.fullname || 'User'}
|
||||
className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-gray-700 shadow-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Link to="/onboarding/user" className="text-blue-600 hover:text-blue-700 text-sm mt-1 inline-block">
|
||||
Complete your profile →
|
||||
</Link>
|
||||
<div className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-gray-700 shadow-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-400 dark:text-gray-500 text-3xl md:text-4xl font-sans">
|
||||
U
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:ml-6 mt-4 md:mt-14">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{user?.fullname ||
|
||||
user?.email?.split('@')[0] ||
|
||||
'Unnamed User'}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{user?.location ? (
|
||||
<p className="text-gray-600 dark:text-gray-400 flex items-center mt-1 text-sm md:text-base font-sans">
|
||||
<Icon
|
||||
icon="heroicons:map-pin-16-solid"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
<span className="ml-1">{user.location}</span>
|
||||
</p>
|
||||
) : (
|
||||
<Link
|
||||
to="/onboarding/user"
|
||||
className="text-primary-500 dark:text-blue-400 hover:text-primary-600 dark:hover:text-blue-300 text-sm md:text-sm mt-1 flex items-center"
|
||||
>
|
||||
<span className="font-sans">Complete your profile</span>
|
||||
<Icon
|
||||
icon="ic:baseline-chevron-right"
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{user?.location && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowProfileModal(true)}
|
||||
className="mt-3 md:mt-14 md:ml-auto inline-flex items-center justify-center px-3 py-1.5 md:px-4 md:py-2 bg-primary-500 text-white rounded-lg text md:text-sm font-medium shadow-sm hover:bg-primary-600 transition-colors w-full md:w-auto cursor-pointer"
|
||||
>
|
||||
Edit Profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4 md:space-y-6 w-full max-w-full">
|
||||
{user?.bio && (
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-2">About</h3>
|
||||
<p className="text-gray-800 leading-relaxed">{user.bio}</p>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm w-full max-w-full overflow-x-hidden">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
About
|
||||
</h3>
|
||||
<p className="text-gray-800 dark:text-gray-200 leading-relaxed line-clamp-3 wrap-break-word break-all overflow-hidden w-full max-w-full">
|
||||
{user.bio}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Contact</h3>
|
||||
<div className="flex items-center text-gray-700">
|
||||
<svg className="w-5 h-5 mr-3 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||
</svg>
|
||||
<span className="text-gray-900">{user?.email}</span>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
|
||||
Contact
|
||||
</h3>
|
||||
<div className="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{user?.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user?.skills && user.skills.length > 0 && (
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Skills</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
|
||||
Skills
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.skills.map((skill: string) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium shadow-sm hover:bg-blue-700 transition-colors"
|
||||
className="inline-flex items-center px-4 py-2 border border-primary-600 dark:border-gray-500 text-primary-600 dark:text-white rounded-4xl text-xs font-medium dark:bg-gray-600"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
@@ -275,7 +415,11 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfilePage
|
||||
open={showProfileModal}
|
||||
onClose={() => setShowProfileModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Outlet, ScrollRestoration, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Outlet,
|
||||
ScrollRestoration,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
import { useAuthStore, useUserMe } from '@imphnen-frontend-service/service';
|
||||
|
||||
// Define onboarding routes
|
||||
const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
|
||||
@@ -8,109 +13,81 @@ const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
|
||||
export default function RootLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const { data: userData, isLoading: isUserLoading } = useUserMe();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const pathname = location.pathname;
|
||||
|
||||
console.log('[Layout] Checking auth for route:', pathname);
|
||||
|
||||
// Allow hackathon pages without checks
|
||||
if (pathname.startsWith('/hackathons')) {
|
||||
console.log('[Layout] Public route, allowing access');
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow auth callback without checks
|
||||
if (pathname === '/auth/callback') {
|
||||
console.log('[Layout] Auth callback, allowing access');
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Supabase session
|
||||
const { data: { session }, error } = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error('[Layout] Session error:', error);
|
||||
}
|
||||
|
||||
// Public auth pages (login, signup, forgot-password, reset-password) - allow unauthenticated access
|
||||
const isPublicAuthPage = pathname === '/auth/login' || pathname === '/auth/signup' || pathname === '/auth/forgot-password' || pathname === '/auth/reset-password';
|
||||
|
||||
if (isPublicAuthPage) {
|
||||
// Public auth pages - allow unauthenticated access
|
||||
if (pathname.startsWith('/auth')) {
|
||||
// If already authenticated and not on password reset pages, redirect to dashboard
|
||||
if (session && pathname !== '/auth/reset-password') {
|
||||
console.log('[Layout] Already authenticated, redirecting to dashboard');
|
||||
navigate('/dashboard', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
// Allow unauthenticated access
|
||||
console.log('[Layout] Public auth page, allowing access');
|
||||
// Allow unauthenticated access to auth pages
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Home page - allow everyone to view the landing page
|
||||
if (pathname === '/') {
|
||||
console.log('[Layout] Landing page, allowing access');
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Require authentication for all other routes
|
||||
if (!session) {
|
||||
console.log('[Layout] No session, redirecting to login');
|
||||
navigate('/auth/login', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for user data to load before checking onboarding
|
||||
if (isUserLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has completed onboarding (skip for onboarding routes)
|
||||
if (!ONBOARDING_ROUTES.has(pathname)) {
|
||||
try {
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('location')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
const hasLocation = !!userData?.data?.location || !!session?.user?.location;
|
||||
|
||||
if (userError) {
|
||||
console.error('[Layout] Failed to fetch user data:', userError);
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLocation = !!userData?.location;
|
||||
|
||||
if (!hasLocation) {
|
||||
console.log('[Layout] User needs onboarding, redirecting');
|
||||
navigate('/onboarding/user', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Layout] Unexpected error checking onboarding:', error);
|
||||
if (!hasLocation) {
|
||||
navigate('/onboarding/user', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Layout] Auth check passed');
|
||||
setIsChecking(false);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [location.pathname, navigate]);
|
||||
}, [location.pathname, navigate, session, userData, isUserLoading]);
|
||||
|
||||
// Show loading state while checking auth
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
|
||||
<p className="text-gray-600">Loading...</p>
|
||||
<p className="text-gray-600 dark:text-neutral-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export default function MaintenancePage() {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-yellow-600 mb-4">🚧</h1>
|
||||
<h2 className="text-3xl font-semibold mb-2">We'll be back soon!</h2>
|
||||
<p className="text-gray-600">
|
||||
Our site is currently undergoing scheduled maintenance.
|
||||
<br />
|
||||
Thank you for your patience.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="mt-4 inline-block text-primary-600 hover:underline"
|
||||
>
|
||||
Back to Homepage
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,19 @@ import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { userOnboardingSchema, TUserOnboardingForm, useUpdateUserMe, useUploadAvatar, useUserMe, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import {
|
||||
userOnboardingSchema,
|
||||
TUserOnboardingForm,
|
||||
useUpdateUserMe,
|
||||
useUploadAvatar,
|
||||
useUserMe,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { CitySelect } from '../../../components/city-select';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
'Frontend Developer',
|
||||
@@ -17,12 +28,6 @@ const ROLE_OPTIONS = [
|
||||
'Mobile Developer',
|
||||
];
|
||||
|
||||
const INDONESIAN_CITIES = [
|
||||
'Jakarta', 'Surabaya', 'Bandung', 'Medan', 'Semarang',
|
||||
'Makassar', 'Palembang', 'Tangerang', 'Depok', 'Bekasi',
|
||||
'Yogyakarta', 'Malang', 'Bogor', 'Batam', 'Pekanbaru',
|
||||
];
|
||||
|
||||
const UserOnboardingPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
@@ -30,7 +35,8 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
|
||||
const { data: userData } = useUserMe();
|
||||
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
|
||||
const { mutateAsync: uploadAvatar, isPending: isUploading } = useUploadAvatar();
|
||||
const { mutateAsync: uploadAvatar, isPending: isUploading } =
|
||||
useUploadAvatar();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
const form = useForm<TUserOnboardingForm>({
|
||||
@@ -54,6 +60,20 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('The file is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('The file must be an image');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -65,49 +85,46 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
console.log('[Onboarding] Starting submission...', data);
|
||||
let avatarUrl = session?.user?.avatar || null;
|
||||
|
||||
// Upload avatar if a new file was selected
|
||||
if (avatarFile) {
|
||||
console.log('[Onboarding] Uploading avatar...');
|
||||
const uploadResult = await uploadAvatar(avatarFile);
|
||||
avatarUrl = uploadResult.data.url;
|
||||
console.log('[Onboarding] Avatar uploaded:', avatarUrl);
|
||||
}
|
||||
|
||||
// Update user in Supabase
|
||||
console.log('[Onboarding] Updating user in Supabase...');
|
||||
const result = await updateUser({
|
||||
await updateUser({
|
||||
fullname: data.fullname,
|
||||
avatar: avatarUrl,
|
||||
location: data.location,
|
||||
bio: data.bio,
|
||||
skills: data.skills,
|
||||
});
|
||||
console.log('[Onboarding] User updated successfully:', result);
|
||||
|
||||
// Wait a bit for the onSuccess handler to update localStorage
|
||||
// The updateUser mutation's onSuccess handler updates the Zustand store and localStorage
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
console.log('[Onboarding] Navigating to dashboard...');
|
||||
// Use window.location for a full page reload to ensure middleware sees updated localStorage
|
||||
globalThis.location.href = '/dashboard';
|
||||
} catch (error) {
|
||||
console.error('[Onboarding] Onboarding failed:', error);
|
||||
alert(`Onboarding failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Onboarding failed. Please try again.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center min-h-screen bg-gray-50 px-4 py-8">
|
||||
<div className="bg-white w-full max-w-2xl p-8 rounded-xl shadow-lg">
|
||||
<div className="flex flex-col justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4 py-8">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-2xl p-8 rounded-xl shadow-lg dark:shadow-gray-950/50">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Complete Your Profile
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-gray-600 font-sans dark:text-gray-400">
|
||||
Tell us more about yourself to get started
|
||||
</p>
|
||||
</div>
|
||||
@@ -120,17 +137,22 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
className="w-32 h-32 rounded-full object-cover border-4 border-gray-200"
|
||||
className="w-32 h-32 rounded-full object-cover border-4 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-4xl">👤</span>
|
||||
<div className="w-32 h-32 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<Icon
|
||||
icon="ic:baseline-person"
|
||||
width="48"
|
||||
height="48"
|
||||
className="text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-col items-center">
|
||||
<label htmlFor="avatar" className="cursor-pointer">
|
||||
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
|
||||
<span className="px-4 py-2 bg-primary-500 dark:bg-primary-600 text-white rounded-lg hover:bg-primary-600 dark:hover:bg-primary-700 inline-block">
|
||||
{avatarPreview ? 'Change Photo' : 'Upload Photo'}
|
||||
</span>
|
||||
<input
|
||||
@@ -141,7 +163,9 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-2 text-center">Optional, but highly recommended</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2 text-center">
|
||||
Optional, but highly recommended. Max 2MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,40 +175,33 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
label="Full Name"
|
||||
placeholder="Enter your full name"
|
||||
name="fullname"
|
||||
className="px-3 py-2 text-label2 rounded-lg max-h-auto text-base"
|
||||
size="md"
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
|
||||
City <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<select
|
||||
{...field}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Select your city</option>
|
||||
{INDONESIAN_CITIES.map((city) => (
|
||||
<option key={city} value={city}>
|
||||
{city}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<CitySelect
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
placeholder="Search your city..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role/Skills */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
|
||||
Role / Skills
|
||||
</label>
|
||||
<Controller
|
||||
@@ -194,7 +211,10 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<label key={role} className="flex items-center space-x-2 cursor-pointer">
|
||||
<label
|
||||
key={role}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value?.includes(role)}
|
||||
@@ -204,9 +224,11 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
: (field.value || []).filter((v) => v !== role);
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-primary-500 focus:ring-blue-500 dark:focus:ring-primary-500 dark:bg-gray-800"
|
||||
/>
|
||||
<span className="text-sm">{role}</span>
|
||||
<span className="text-sm dark:text-gray-300">
|
||||
{role}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -217,22 +239,28 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
|
||||
{/* Bio */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Bio <span className="text-gray-400">(Optional)</span>
|
||||
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
|
||||
Bio{' '}
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
(Optional)
|
||||
</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="bio"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<div className="flex">
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
className="w-full"
|
||||
className="w-full rounded-lg text-sm"
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+490
-380
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,391 @@
|
||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import {
|
||||
userEditProfileSchema,
|
||||
TUserEditProfileForm,
|
||||
useUpdateUserMe,
|
||||
useUploadAvatar,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { CitySelect } from '../../components/city-select';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
'Frontend Developer',
|
||||
'Backend Developer',
|
||||
'Full Stack Developer',
|
||||
'DevOps Engineer',
|
||||
'UI/UX Designer',
|
||||
'Product Manager',
|
||||
'Data Scientist',
|
||||
'Mobile Developer',
|
||||
];
|
||||
|
||||
type ProfileModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ProfilePage: FC<ProfileModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
}): ReactElement | null => {
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string>('');
|
||||
|
||||
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
|
||||
const { mutateAsync: uploadAvatar, isPending: isUploading } =
|
||||
useUploadAvatar();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
const form = useForm<TUserEditProfileForm>({
|
||||
resolver: zodResolver(userEditProfileSchema),
|
||||
mode: 'all',
|
||||
defaultValues: {
|
||||
fullname: session?.user?.fullname || '',
|
||||
avatar: session?.user?.avatar || null,
|
||||
location: session?.user?.location || '',
|
||||
bio: session?.user?.bio || '',
|
||||
skills: session?.user?.skills || [],
|
||||
},
|
||||
});
|
||||
|
||||
// Set initial avatar preview from current user avatar
|
||||
useEffect(() => {
|
||||
if (session?.user?.avatar && !avatarPreview) {
|
||||
setAvatarPreview(session.user.avatar);
|
||||
}
|
||||
if (session?.user?.fullname) {
|
||||
form.setValue('fullname', session.user.fullname);
|
||||
}
|
||||
if (session?.user?.location) {
|
||||
form.setValue('location', session.user.location);
|
||||
}
|
||||
if (session?.user?.bio) {
|
||||
form.setValue('bio', session.user.bio);
|
||||
}
|
||||
if (session?.user?.skills) {
|
||||
form.setValue('skills', session.user.skills);
|
||||
}
|
||||
}, [
|
||||
session?.user?.avatar,
|
||||
session?.user?.fullname,
|
||||
session?.user?.location,
|
||||
session?.user?.bio,
|
||||
session?.user?.skills,
|
||||
avatarPreview,
|
||||
form,
|
||||
]);
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('The file is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('The file must be an image');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
let avatarUrl = session?.user?.avatar || null;
|
||||
|
||||
// Upload avatar if a new file was selected
|
||||
if (avatarFile) {
|
||||
const uploadResult = await uploadAvatar(avatarFile);
|
||||
avatarUrl = uploadResult.data.url;
|
||||
}
|
||||
|
||||
// Update user profile
|
||||
await updateUser({
|
||||
fullname: data.fullname,
|
||||
avatar: avatarUrl,
|
||||
location: data.location,
|
||||
bio: data.bio,
|
||||
skills: data.skills,
|
||||
});
|
||||
|
||||
toast.success('Profile updated successfully!');
|
||||
|
||||
// Wait a bit for the onSuccess handler to update localStorage
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Close modal
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Profile update failed:', error);
|
||||
toast.error(
|
||||
`Failed to update profile: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const isLoading = isUpdating || isUploading;
|
||||
|
||||
return open ? (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-md z-50 overflow-y-auto">
|
||||
<div className="min-h-full flex items-center justify-center">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md mx-4 my-6 sm:my-8 p-8 rounded-xl border dark:boder-gray-800">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Edit Profile
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200 cursor-pointer"
|
||||
aria-label="Close profile modal"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600 font-sans dark:text-neutral-400">
|
||||
Update your photo and name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
{/* Avatar Upload */}
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative group">
|
||||
{avatarPreview ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
className="w-24 h-24 rounded-full object-cover border-4 border-gray-200 dark:border-neutral-700 group-hover:border-blue-400 dark:group-hover:border-blue-500 transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center group-hover:bg-gray-300 dark:group-hover:bg-gray-600 transition-colors">
|
||||
<Icon
|
||||
icon="ic:baseline-person"
|
||||
width="48"
|
||||
height="48"
|
||||
className="text-gray-400 dark:text-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Camera overlay */}
|
||||
<label
|
||||
htmlFor="avatar"
|
||||
className="absolute bottom-0 right-0 bg-primary-500 text-white p-2 rounded-full cursor-pointer hover:bg-primary-600 transition-colors shadow-lg"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
id="avatar"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-neutral-400 text-center font-sans">
|
||||
Click the camera icon to change your photo
|
||||
<br />
|
||||
Format: JPG, PNG. Max 2MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Full Name */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Full Name"
|
||||
placeholder="Enter your full name"
|
||||
name="fullname"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-neutral-800 dark:text-neutral-300">
|
||||
City
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field, fieldState }) => (
|
||||
<CitySelect
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
placeholder="Search your city..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role/Skills */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
|
||||
Role / Skills
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="skills"
|
||||
render={({ field }) => (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<label
|
||||
key={role}
|
||||
className="flex items-center space-x-2 cursor-pointer font-sans"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value?.includes(role)}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.checked
|
||||
? [...(field.value || []), role]
|
||||
: (field.value || []).filter((v) => v !== role);
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
className="rounded border-gray-300 dark:border-neutral-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800"
|
||||
/>
|
||||
<span className="text-sm dark:text-neutral-300">
|
||||
{role}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bio */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
|
||||
Bio{' '}
|
||||
<span className="text-gray-400 dark:text-neutral-500">
|
||||
(Optional)
|
||||
</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="bio"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
className="w-full font-sans"
|
||||
size="lg"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={isLoading || !form.formState.isValid}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Saving...
|
||||
</span>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -1,8 +1,15 @@
|
||||
import { FC, ReactElement, useState, useRef, useEffect } from 'react';
|
||||
import { FC, ReactElement, useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useTeamById, useTeamMessages, useSendMessage, useDeleteMessage, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import {
|
||||
useTeamById,
|
||||
useTeamMessages,
|
||||
useSendMessage,
|
||||
useDeleteMessage,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const TeamChatPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
@@ -13,16 +20,54 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
|
||||
const { data: teamData } = useTeamById(teamId || '');
|
||||
const { data: messages, isLoading } = useTeamMessages(teamId || '');
|
||||
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(teamId || '');
|
||||
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || '');
|
||||
|
||||
const team = teamData?.data;
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
// Message type used for UI rendering
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user?: {
|
||||
avatar?: string;
|
||||
fullname?: string;
|
||||
};
|
||||
message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Inline delete confirmation UI state
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
// Prepare messages: de-duplicate by id and sort by created_at
|
||||
const displayMessages = useMemo(() => {
|
||||
const raw: ChatMessage[] = Array.isArray(messages)
|
||||
? (messages as ChatMessage[])
|
||||
: [];
|
||||
const seen = new Set<string>();
|
||||
const dedup: ChatMessage[] = [];
|
||||
for (const m of raw) {
|
||||
const key = m.id ?? `${m.user_id}-${m.created_at}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
dedup.push(m);
|
||||
}
|
||||
}
|
||||
dedup.sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
return dedup;
|
||||
}, [messages]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
}, [displayMessages]);
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -38,11 +83,10 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async (messageId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this message?')) return;
|
||||
|
||||
try {
|
||||
await deleteMessage(messageId);
|
||||
toast.success('Message deleted');
|
||||
setDeleteTargetId(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete message:', error);
|
||||
toast.error('Failed to delete message');
|
||||
@@ -63,16 +107,23 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-50">
|
||||
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b shadow-sm">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 shadow-sm dark:shadow-gray-950/50">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Team Chat</h1>
|
||||
<p className="text-gray-600 text-sm mt-0.5">{team?.name}</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Team Chat
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mt-0.5">
|
||||
{team?.name}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${teamId}`)}
|
||||
>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
@@ -84,23 +135,29 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 dark:border-primary-400"></div>
|
||||
</div>
|
||||
) : messages && messages.length > 0 ? (
|
||||
) : displayMessages && displayMessages.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg) => {
|
||||
{displayMessages.map((msg) => {
|
||||
const isOwnMessage = msg.user_id === currentUserId;
|
||||
const isLeader = team?.leader_id === currentUserId;
|
||||
const canDelete = isOwnMessage || isLeader;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
||||
key={`${msg.id ?? 'message'}-${msg.created_at}`}
|
||||
className={`flex ${
|
||||
isOwnMessage ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex gap-3 max-w-lg ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||
<div
|
||||
className={`flex gap-3 max-w-lg ${
|
||||
isOwnMessage ? 'flex-row-reverse' : 'flex-row'
|
||||
}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="shrink-0">
|
||||
{msg.user?.avatar ? (
|
||||
<img
|
||||
src={msg.user.avatar}
|
||||
@@ -115,38 +172,76 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
{/* Message Bubble */}
|
||||
<div className={`flex-1 ${isOwnMessage ? 'text-right' : 'text-left'}`}>
|
||||
<div className={`inline-block ${isOwnMessage ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`flex-1 text-left`}>
|
||||
<div
|
||||
className={`inline-block ${
|
||||
isOwnMessage ? 'items-end' : 'items-start'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="font-semibold text-sm text-gray-900">
|
||||
<span className="font-semibold text-sm text-gray-900 dark:text-white">
|
||||
{isOwnMessage ? 'You' : msg.user?.fullname}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-500">
|
||||
{formatTime(msg.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`relative group rounded-2xl px-4 py-2.5 ${
|
||||
isOwnMessage
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-900 border border-gray-200'
|
||||
? 'bg-blue-600 dark:bg-primary-600 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{msg.message}</p>
|
||||
<p className="text-sm whitespace-pre-wrap wrap-break-word font-sans">
|
||||
{msg.message}
|
||||
</p>
|
||||
|
||||
{/* Delete button */}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => handleDeleteMessage(msg.id)}
|
||||
className={`absolute top-1 ${isOwnMessage ? 'left-1' : 'right-1'} opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-200 ${isOwnMessage ? 'hover:bg-blue-700' : ''}`}
|
||||
onClick={() =>
|
||||
setDeleteTargetId(
|
||||
deleteTargetId === msg.id ? null : msg.id
|
||||
)
|
||||
}
|
||||
className={`absolute top-1 ${
|
||||
isOwnMessage ? 'left-1' : 'right-1'
|
||||
} opacity-0 hover:opacity-100 p-1 rounded hover:bg-gray-200 dark:hover:bg-neutral-700 cursor-pointer ${
|
||||
isOwnMessage
|
||||
? 'hover:bg-blue-700 dark:hover:bg-primary-700'
|
||||
: ''
|
||||
}`}
|
||||
title="Delete message"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{/* Icon trash */}
|
||||
<Icon
|
||||
icon="mdi:trash-can-outline"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canDelete && deleteTargetId === msg.id && (
|
||||
<div
|
||||
className={`mt-2 flex gap-2 ${
|
||||
isOwnMessage ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleDeleteMessage(msg.id)}
|
||||
className="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 cursor-pointer"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTargetId(null)}
|
||||
className="px-2 py-1 text-xs rounded bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600 cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,11 +252,14 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<div className="text-6xl mb-4">💬</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
<Icon
|
||||
icon="mdi:chat-outline"
|
||||
className="text-6xl mb-4 text-gray-400"
|
||||
/>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
No messages yet
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
<p className="text-gray-600 dark:text-gray-400 font-sans">
|
||||
Be the first to start the conversation!
|
||||
</p>
|
||||
</div>
|
||||
@@ -170,7 +268,7 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
{/* Message Input */}
|
||||
<div className="bg-white border-t shadow-lg">
|
||||
<div className="bg-white dark:bg-gray-900 border-t dark:border-gray-700 shadow-lg dark:shadow-gray-950/50">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<form onSubmit={handleSendMessage} className="flex gap-3">
|
||||
<input
|
||||
@@ -178,7 +276,7 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-primary-500 focus:border-transparent"
|
||||
disabled={isSending}
|
||||
/>
|
||||
<Button
|
||||
@@ -193,8 +291,18 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
Send
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,22 @@ import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { teamUpdateSchema, TTeamUpdateForm, useUpdateTeam, useTeamById, ETeamVisibility, useUploadFile, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import {
|
||||
teamUpdateSchema,
|
||||
TTeamUpdateForm,
|
||||
useUpdateTeam,
|
||||
useTeamById,
|
||||
ETeamVisibility,
|
||||
useUploadFile,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
const INDONESIAN_CITIES = [
|
||||
'Jakarta', 'Surabaya', 'Bandung', 'Medan', 'Semarang',
|
||||
'Makassar', 'Palembang', 'Tangerang', 'Depok', 'Bekasi',
|
||||
'Yogyakarta', 'Malang', 'Bogor', 'Batam', 'Pekanbaru',
|
||||
];
|
||||
import { CitySelect } from '../../../../components/city-select';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
const EditTeamPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
@@ -21,8 +29,12 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
const [bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string>('');
|
||||
|
||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || '');
|
||||
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(teamId || '');
|
||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
|
||||
const team = teamData?.data;
|
||||
@@ -51,18 +63,24 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
|
||||
if (isLoadingTeam) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-gray-600">Loading team...</div>
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-gray-600 dark:text-gray-400">Loading team...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLeader) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
||||
<p className="text-gray-600 mb-4">Only the team leader can edit team information</p>
|
||||
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Only the team leader can edit team information
|
||||
</p>
|
||||
<Button onClick={() => navigate(`/teams/${teamId}`)}>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +88,11 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Logo image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setLogoFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -82,6 +105,11 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Banner image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setBannerFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -119,28 +147,35 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="bg-white border-b">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Edit Team Info</h1>
|
||||
<p className="text-gray-600 mt-1">Update your team details</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Edit Team Info
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Update your team details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-8">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8">
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
{/* Banner Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Team Banner
|
||||
</label>
|
||||
{bannerPreview ? (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ aspectRatio: '3 / 1' }}
|
||||
>
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt="Banner preview"
|
||||
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200"
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -149,16 +184,20 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
setBannerPreview('');
|
||||
form.setValue('banner', null);
|
||||
}}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600"
|
||||
className="absolute top-2 right-2 bg-danger-600 text-white px-3 py-1 rounded-lg text-sm hover:bg-danger-700 cursor-pointer"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500">Click to upload banner</p>
|
||||
<p className="text-xs text-gray-400 mt-1">1200x400 recommended</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Click to upload banner
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
1200x400 recommended. Max 2MB
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@@ -172,7 +211,7 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Team Logo
|
||||
</label>
|
||||
<div className="flex items-center space-x-4">
|
||||
@@ -180,39 +219,44 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo preview"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-3xl">👥</span>
|
||||
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-400 dark:text-gray-500 text-3xl">
|
||||
<Icon icon="mdi:account-group" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
form.setValue('logo', null);
|
||||
}}
|
||||
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col md:flex-row items-start justify-start gap-1">
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
form.setValue('logo', null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">Max 2MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,40 +267,32 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
label="Team Name"
|
||||
placeholder="Enter team name"
|
||||
name="name"
|
||||
size="lg"
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
City
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="city"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<select
|
||||
{...field}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Select city</option>
|
||||
{INDONESIAN_CITIES.map((city) => (
|
||||
<option key={city} value={city}>
|
||||
{city}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<CitySelect
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
placeholder="Search your city..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
</label>
|
||||
<Controller
|
||||
@@ -270,9 +306,12 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
placeholder="Tell others about your team..."
|
||||
rows={4}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -281,7 +320,7 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
|
||||
{/* Visibility */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
|
||||
Team Visibility
|
||||
</label>
|
||||
<Controller
|
||||
@@ -289,7 +328,7 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
|
||||
<label className="flex items-start space-x-3 cursor-pointer border dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<input
|
||||
type="radio"
|
||||
{...field}
|
||||
@@ -298,13 +337,15 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Public</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
Public
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
|
||||
Team will be visible in Browse Teams
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
|
||||
<label className="flex items-start space-x-3 cursor-pointer border dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<input
|
||||
type="radio"
|
||||
{...field}
|
||||
@@ -313,8 +354,10 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Private</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
Private
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
|
||||
Team hidden, invite-only
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
import { FC, ReactNode } from 'react';
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { Navigation } from '../../../components/navigation';
|
||||
import { Sidebar } from '../../../components/sidebar';
|
||||
|
||||
interface TeamLayoutProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
const TeamDetailLayout: FC = (): ReactElement => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
export const TeamLayout: FC<TeamLayoutProps> = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navigation />
|
||||
<Outlet />
|
||||
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Mobile Header with Hamburger */}
|
||||
<div className="lg:hidden sticky top-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
|
||||
Hackathon
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamLayout;
|
||||
export default TeamDetailLayout;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const ManageMembersPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
@@ -22,31 +23,58 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
const { session } = useAuthStore();
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
|
||||
const { data: teamData } = useTeamById(teamId || '');
|
||||
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || '');
|
||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||
teamId || ''
|
||||
);
|
||||
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
|
||||
teamId || ''
|
||||
);
|
||||
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '');
|
||||
|
||||
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || '');
|
||||
const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(teamId || '');
|
||||
const { mutateAsync: respondToRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || '');
|
||||
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: respondToRequest, isPending: isResponding } =
|
||||
useRespondToJoinRequest(teamId || '');
|
||||
|
||||
const team = teamData?.data;
|
||||
const members = membersData?.data || [];
|
||||
const joinRequests = Array.isArray(joinRequestsData?.data) ? joinRequestsData.data : [];
|
||||
const joinRequests = Array.isArray(joinRequestsData?.data)
|
||||
? joinRequestsData.data
|
||||
: [];
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
const hasSubmission = team?.has_submission;
|
||||
|
||||
const form = useForm<TInviteMemberForm>({
|
||||
resolver: zodResolver(inviteMemberSchema),
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
// Show loading state while fetching team data
|
||||
if (isLoadingTeam) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLeader) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
||||
<p className="text-gray-600 mb-4">Only the team leader can manage members</p>
|
||||
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Only the team leader can manage members
|
||||
</p>
|
||||
<Button onClick={() => navigate(`/teams/${teamId}`)}>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,33 +103,48 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
const handleApproveRequest = async (requestId: string) => {
|
||||
try {
|
||||
await respondToRequest({ requestId, action: 'approve' });
|
||||
toast.success('Member added successfully!');
|
||||
} catch (error) {
|
||||
console.error('Failed to approve request:', error);
|
||||
toast.error((error as Error).message || 'Failed to approve request');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectRequest = async (requestId: string) => {
|
||||
try {
|
||||
await respondToRequest({ requestId, action: 'reject' });
|
||||
toast.success('Request rejected');
|
||||
} catch (error) {
|
||||
console.error('Failed to reject request:', error);
|
||||
toast.error((error as Error).message || 'Failed to reject request');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="bg-white border-b">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col md:flex-row gap-y-4 items-start md:items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Manage Members</h1>
|
||||
<p className="text-gray-600 mt-1">{team?.name}</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Manage Members
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{team?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button onClick={() => setShowInviteModal(true)}>
|
||||
<Button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
disabled={hasSubmission}
|
||||
title={hasSubmission ? 'Cannot invite members after project submission' : undefined}
|
||||
>
|
||||
Invite Member
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${teamId}`)}
|
||||
>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
@@ -110,15 +153,35 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
{/* Submission Lock Warning */}
|
||||
{hasSubmission && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-2xl">🔒</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-100">
|
||||
Team Locked
|
||||
</h3>
|
||||
<p className="text-amber-800 dark:text-amber-200 text-sm font-sans mt-1">
|
||||
Your team has submitted a project. You cannot add or remove members after submission to maintain competition integrity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Join Requests */}
|
||||
{joinRequests.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Join Requests ({joinRequests.length})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{joinRequests.map((request) => (
|
||||
<div key={request.id} className="border rounded-lg p-4">
|
||||
<div
|
||||
key={request.id}
|
||||
className="border dark:border-gray-700 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
{request.user.avatar ? (
|
||||
@@ -128,24 +191,35 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-500">👤</span>
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
👤
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{request.user.fullname}</p>
|
||||
<p className="text-sm text-gray-600">{request.user.email}</p>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{request.user.fullname}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{request.user.email}
|
||||
</p>
|
||||
{request.user.location && (
|
||||
<p className="text-sm text-gray-500">📍 {request.user.location}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
📍 {request.user.location}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-700 mt-2 italic">"{request.message}"</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mt-2 italic">
|
||||
"{request.message}"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2 ml-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApproveRequest(request.id)}
|
||||
disabled={isResponding}
|
||||
disabled={isResponding || hasSubmission}
|
||||
title={hasSubmission ? 'Cannot accept members after project submission' : undefined}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
@@ -166,16 +240,21 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
)}
|
||||
|
||||
{/* Current Members */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Current Members ({members.length})
|
||||
</h2>
|
||||
{isLoadingMembers ? (
|
||||
<p className="text-gray-600">Loading members...</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Loading members...
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="border rounded-lg p-4 flex items-center justify-between">
|
||||
<div
|
||||
key={member.id}
|
||||
className="border dark:border-gray-700 rounded-lg p-4 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{member.user.avatar ? (
|
||||
<img
|
||||
@@ -184,31 +263,39 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-500">👤</span>
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
👤
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{member.user.fullname}</p>
|
||||
<p className="text-sm text-gray-600">{member.user.email}</p>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{member.user.fullname}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{member.user.email}
|
||||
</p>
|
||||
{member.user.location && (
|
||||
<p className="text-sm text-gray-500">📍 {member.user.location}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
📍 {member.user.location}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
{member.role === 'leader' && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-medium">
|
||||
<span className="px-2 py-1 bg-blue-100 dark:bg-primary-900/30 text-blue-800 dark:text-primary-300 rounded text-xs font-medium">
|
||||
Leader
|
||||
</span>
|
||||
)}
|
||||
{member.status === ETeamMemberStatus.PENDING && (
|
||||
<span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs font-medium">
|
||||
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 rounded text-xs font-medium">
|
||||
Pending Invitation
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{member.role !== 'leader' && (
|
||||
{member.role !== 'leader' && !hasSubmission && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@@ -225,37 +312,43 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Note:</strong> Members cannot leave the team without your approval. Only you can remove members from the team.
|
||||
</p>
|
||||
</div>
|
||||
{!hasSubmission && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> Members cannot leave the team without your
|
||||
approval. Only you can remove members from the team.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite Member Modal */}
|
||||
{showInviteModal && (
|
||||
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
<div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Invite Member
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Send an invitation to join your team. The invited member will see the invitation on their dashboard after logging in.
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4 font-sans">
|
||||
Send an invitation to join your team. The invited member will see
|
||||
the invitation on their dashboard after logging in.
|
||||
</p>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Important:</strong> The email you enter must match the GitHub email address the member uses to sign in.
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-sans">
|
||||
<strong>Important:</strong> The email you enter must match the
|
||||
GitHub email address the member uses to sign in.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleInvite} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<Input
|
||||
{...form.register('email')}
|
||||
type="email"
|
||||
placeholder="member@example.com"
|
||||
size="lg"
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { FC, ReactElement } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
|
||||
import { encodeCertificateId } from '../../../../utils/certificate';
|
||||
|
||||
const SubmissionViewPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
@@ -15,18 +16,18 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-gray-600">Loading submission...</div>
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-gray-600 dark:text-gray-400">Loading submission...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!submission) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">📄</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">No Submission Yet</h2>
|
||||
<p className="text-gray-600 mb-4">Your team hasn't submitted a project</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">Your team hasn't submitted a project</p>
|
||||
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -43,13 +44,13 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
: 'Not submitted';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="bg-white border-b">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Project Submission</h1>
|
||||
<p className="text-gray-600 mt-1">{team?.name}</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Project Submission</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
|
||||
Back to Team
|
||||
@@ -60,24 +61,78 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Status Banner */}
|
||||
<div className="bg-green-50 border border-green-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">✅</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-green-900 text-lg">Project Submitted Successfully</h3>
|
||||
<p className="text-green-700 text-sm">
|
||||
Submitted on {submittedDate}
|
||||
</p>
|
||||
<p className="text-green-600 text-xs mt-1">
|
||||
This submission is now read-only and cannot be edited
|
||||
</p>
|
||||
{submission.status === 'submitted' ? (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">✅</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-green-900 dark:text-green-100 text-lg">Project Submitted Successfully</h3>
|
||||
<p className="text-green-700 dark:text-green-300 text-sm">
|
||||
Submitted on {submittedDate}
|
||||
</p>
|
||||
<p className="text-green-600 dark:text-green-400 text-xs mt-1">
|
||||
This submission is now read-only and cannot be edited
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : submission.status === 'pending_verification' ? (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">⏳</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-yellow-900 dark:text-yellow-100 text-lg">Submission Pending Verification</h3>
|
||||
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
|
||||
Your submission is being processed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">📝</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-orange-900 dark:text-orange-100 text-lg">Draft Submission</h3>
|
||||
<p className="text-orange-700 dark:text-orange-300 text-sm">
|
||||
This submission is still in draft and has not been finalized
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{/* Certificate Banner - Only show when submission is submitted */}
|
||||
{submission.status === 'submitted' && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<span className="text-4xl shrink-0">🏆</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
|
||||
View Your Certificate
|
||||
</h3>
|
||||
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||
Congratulations! Your certificate is ready to download and share.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const certId = await encodeCertificateId(teamId || '', submission.id);
|
||||
navigate(`/certificate/${encodeURIComponent(certId)}`);
|
||||
}}
|
||||
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Get Certificate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden">
|
||||
{/* Project Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8">
|
||||
<div className="bg-linear-to-r from-blue-600 to-blue-800 text-white p-8">
|
||||
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
|
||||
<p className="text-blue-100">Team: {team?.name}</p>
|
||||
</div>
|
||||
@@ -86,9 +141,9 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
<div className="p-8 space-y-6">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-3">Project Description</h3>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{submission.description}</p>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{submission.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,12 +151,12 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Repository */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-3">Repository</h3>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Repository</h3>
|
||||
<a
|
||||
href={submission.repository_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
|
||||
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300"
|
||||
>
|
||||
<span>🔗</span>
|
||||
<span className="break-all">{submission.repository_url}</span>
|
||||
@@ -111,12 +166,12 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
{/* Demo URL */}
|
||||
{submission.demo_url && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-3">Live Demo</h3>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Live Demo</h3>
|
||||
<a
|
||||
href={submission.demo_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
|
||||
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300"
|
||||
>
|
||||
<span>🌐</span>
|
||||
<span className="break-all">{submission.demo_url}</span>
|
||||
@@ -124,27 +179,12 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Presentation URL */}
|
||||
{submission.presentation_url && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-3">Presentation</h3>
|
||||
<a
|
||||
href={submission.presentation_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<span>📊</span>
|
||||
<span className="break-all">{submission.presentation_url}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
{submission.screenshots && submission.screenshots.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-3">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">
|
||||
Screenshots ({submission.screenshots.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -159,7 +199,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
<img
|
||||
src={url}
|
||||
alt={`Screenshot ${index + 1}`}
|
||||
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 hover:border-blue-500 transition-colors cursor-pointer"
|
||||
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
@@ -168,22 +208,32 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
)}
|
||||
|
||||
{/* Submission Info */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 border-t-4 border-blue-600">
|
||||
<h3 className="text-sm font-bold text-gray-900 mb-2">Submission Information</h3>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Status:</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{submission.status === 'submitted' ? '✓ Submitted' : 'Draft'}
|
||||
<span className="text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<span className={`font-medium ${
|
||||
submission.status === 'submitted'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: submission.status === 'pending_verification'
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-orange-600 dark:text-orange-400'
|
||||
}`}>
|
||||
{submission.status === 'submitted'
|
||||
? '✓ Submitted'
|
||||
: submission.status === 'pending_verification'
|
||||
? '⏳ Pending Verification'
|
||||
: '📝 Draft'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Submitted:</span>
|
||||
<span className="font-medium text-gray-900">{submittedDate}</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Submitted:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Submission ID:</span>
|
||||
<span className="font-medium text-gray-900 font-mono text-xs">
|
||||
<span className="text-gray-600 dark:text-gray-400">Submission ID:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs">
|
||||
{submission.id}
|
||||
</span>
|
||||
</div>
|
||||
@@ -191,8 +241,8 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
{/* Read-only Notice */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> This submission is now locked and cannot be edited or deleted.
|
||||
If you need to make changes, please contact the hackathon organizers.
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
@@ -13,23 +13,71 @@ import {
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
|
||||
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
||||
|
||||
const SubmitProjectPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [screenshots, setScreenshots] = useState<string[]>([]);
|
||||
const [timeLeft, setTimeLeft] = useState<{
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} | null>(null);
|
||||
|
||||
// Check if deadline passed
|
||||
const isDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (isDeadlinePassed) return;
|
||||
|
||||
const calculateTimeLeft = () => {
|
||||
const now = new Date();
|
||||
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
|
||||
|
||||
if (difference <= 0) {
|
||||
setTimeLeft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
|
||||
const minutes = Math.floor((difference / 1000 / 60) % 60);
|
||||
const seconds = Math.floor((difference / 1000) % 60);
|
||||
|
||||
setTimeLeft({ days, hours, minutes, seconds });
|
||||
};
|
||||
|
||||
calculateTimeLeft();
|
||||
const timer = setInterval(calculateTimeLeft, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isDeadlinePassed]);
|
||||
|
||||
const { data: teamData } = useTeamById(teamId || '');
|
||||
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
|
||||
const { mutateAsync: submitProject, isPending: isSubmitting } = useSubmitProject(teamId || '');
|
||||
const { mutateAsync: submitProject, isPending: isSubmitting } =
|
||||
useSubmitProject(teamId || '');
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
|
||||
const team = teamData?.data;
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
const hasSubmission = !!submissionData?.data;
|
||||
const memberCount = team?.members?.length || 0;
|
||||
const hasEnoughMembers = memberCount >= MIN_TEAM_MEMBERS;
|
||||
|
||||
const form = useForm<TProjectSubmissionForm>({
|
||||
resolver: zodResolver(projectSubmissionSchema),
|
||||
@@ -38,25 +86,38 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
|
||||
if (!isLeader) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
||||
<p className="text-gray-600 mb-4">Only the team leader can submit projects</p>
|
||||
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Only the team leader can submit projects
|
||||
</p>
|
||||
<Button onClick={() => navigate(`/teams/${teamId}`)}>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasSubmission) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">✅</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Project Already Submitted</h2>
|
||||
<p className="text-gray-600 mb-4">Your team has already submitted a project</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Project Already Submitted
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Your team has already submitted a project
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<Button onClick={() => navigate(`/teams/${teamId}/submission`)}>
|
||||
View Submission
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${teamId}`)}
|
||||
>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
@@ -64,17 +125,72 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
);
|
||||
}
|
||||
|
||||
const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// Show deadline passed screen
|
||||
if (isDeadlinePassed) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon
|
||||
icon="mdi:clock-alert"
|
||||
className="text-3xl text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Submission Closed
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Project submissions are no longer accepted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
The submission deadline was December 7, 2025 at 23:59 WIB.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/teams/${teamId}`)}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Back to Team
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleScreenshotUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
// Validate file sizes
|
||||
const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE);
|
||||
if (oversizedFiles.length > 0) {
|
||||
toast.error(`${oversizedFiles.length} file(s) are too large. Maximum size is 2MB per file.`);
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadPromises = Array.from(files).map(file => uploadFile(file));
|
||||
const uploadPromises = Array.from(files).map((file) => uploadFile(file));
|
||||
const results = await Promise.all(uploadPromises);
|
||||
const urls = results.map(r => r.data.url);
|
||||
const urls = results.map((r) => r.data.url);
|
||||
setScreenshots([...screenshots, ...urls]);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload screenshots:', error);
|
||||
toast.error('Failed to upload screenshots. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,49 +211,133 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="bg-white border-b">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Submit Project</h1>
|
||||
<p className="text-gray-600 mt-1">{team?.name}</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Submit Project
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Countdown Timer */}
|
||||
{timeLeft && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">⏰</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
|
||||
Submission Deadline
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
|
||||
Submissions close on December 7, 2025 at 23:59 WIB
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.days}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Days
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.hours.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Hours
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Minutes
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Seconds
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Minimum Members Warning */}
|
||||
{!hasEnoughMembers && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">👥</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
|
||||
Team Members Required
|
||||
</h3>
|
||||
<p className="text-amber-800 dark:text-amber-200 mt-2 text-sm font-sans">
|
||||
Your team needs at least <strong>{MIN_TEAM_MEMBERS} members</strong> to submit a project.
|
||||
Currently you have <strong>{memberCount} member{memberCount !== 1 ? 's' : ''}</strong>.
|
||||
Please invite more members to your team before submitting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning Banner */}
|
||||
<div className="bg-red-50 border-2 border-red-500 rounded-lg p-6 mb-6">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-red-900 text-lg">IMPORTANT WARNING</h3>
|
||||
<ul className="text-red-800 mt-2 space-y-1 text-sm">
|
||||
<h3 className="font-bold text-red-900 dark:text-red-100 text-lg">
|
||||
IMPORTANT WARNING
|
||||
</h3>
|
||||
<ul className="text-red-800 dark:text-red-200 mt-2 space-y-1 text-sm font-sans">
|
||||
<li>• You can only submit your project ONCE</li>
|
||||
<li>• After submission, you CANNOT edit or change anything</li>
|
||||
<li>• Make sure all information is correct before submitting</li>
|
||||
<li>
|
||||
• Make sure all information is correct before submitting
|
||||
</li>
|
||||
<li>• Review your project details carefully</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-8">
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setShowConfirmModal(true);
|
||||
}} className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8 border dark:border-gray-800">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setShowConfirmModal(true);
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Project Name */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Project Name"
|
||||
placeholder="Enter your project name"
|
||||
name="project_name"
|
||||
size="lg"
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
|
||||
Project Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
Describe your project, its features, and what problem it solves. You can also paste your demo video link here.
|
||||
</p>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="description"
|
||||
@@ -145,12 +345,15 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
<div>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Describe your project, its features, and what problem it solves..."
|
||||
placeholder="Describe your project, its features, and what problem it solves... You can paste your demo video link (YouTube, Loom, etc.) here as well."
|
||||
rows={6}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -164,6 +367,8 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
placeholder="https://github.com/username/project"
|
||||
name="repository_url"
|
||||
type="url"
|
||||
size="lg"
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
{/* Demo URL */}
|
||||
@@ -173,21 +378,13 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
placeholder="https://your-project-demo.com"
|
||||
name="demo_url"
|
||||
type="url"
|
||||
/>
|
||||
|
||||
{/* Presentation URL */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Presentation URL (Optional)"
|
||||
placeholder="https://slides.com/your-presentation or Google Drive link"
|
||||
name="presentation_url"
|
||||
type="url"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Screenshots */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Project Screenshots <span className="text-gray-400">(Optional)</span>
|
||||
<div>
|
||||
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300">
|
||||
Project Screenshots (Optional)
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
|
||||
{screenshots.map((url, index) => (
|
||||
@@ -195,7 +392,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
<img
|
||||
src={url}
|
||||
alt={`Screenshot ${index + 1}`}
|
||||
className="w-full h-32 object-cover rounded-lg border-2 border-gray-200"
|
||||
className="w-full h-32 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -207,10 +404,14 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500">Click to upload screenshots</p>
|
||||
<p className="text-xs text-gray-400 mt-1">PNG, JPG up to 5MB each</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Click to upload screenshots
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 font-sans">
|
||||
PNG, JPG. Max 2MB each
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@@ -236,52 +437,73 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!form.formState.isValid || isUploading}
|
||||
disabled={!form.formState.isValid || isUploading || !hasEnoughMembers}
|
||||
>
|
||||
Review & Submit
|
||||
</Button>
|
||||
</div>
|
||||
{!hasEnoughMembers && (
|
||||
<p className="text-center text-sm text-amber-600 dark:text-amber-400 mt-2">
|
||||
You need at least {MIN_TEAM_MEMBERS} team members to submit
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{showConfirmModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-5xl mb-4">⚠️</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Final Confirmation
|
||||
</h2>
|
||||
<p className="text-red-600 font-medium">
|
||||
<p className="text-red-600 dark:text-red-400 font-medium">
|
||||
This action is IRREVERSIBLE!
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-gray-700 mb-3">
|
||||
By clicking "Submit Project", you confirm that:
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
By submitting, you confirm that:
|
||||
</p>
|
||||
<ul className="text-sm text-gray-600 space-y-2">
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<li>✓ All information is correct and complete</li>
|
||||
<li>✓ You understand this can only be done once</li>
|
||||
<li>✓ You cannot edit after submission</li>
|
||||
<li>✓ Your team agrees with this submission</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Type <span className="font-bold text-red-600 dark:text-red-400">SUBMIT</span> to confirm:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="Type SUBMIT here"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
onClick={() => {
|
||||
setShowConfirmModal(false);
|
||||
setConfirmText('');
|
||||
}}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || confirmText !== 'SUBMIT'}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit Project'}
|
||||
</Button>
|
||||
|
||||
@@ -1,27 +1,206 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { useTeams, useJoinTeam, useMyTeams, ETeamVisibility, joinTeamSchema, TJoinTeamForm } from '@imphnen-frontend-service/service';
|
||||
import { FC, ReactElement, useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import {
|
||||
useTeams,
|
||||
useJoinTeam,
|
||||
useMyTeams,
|
||||
ETeamVisibility,
|
||||
joinTeamSchema,
|
||||
TJoinTeamForm,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { CitySelect } from '../../../components/city-select';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const INDONESIAN_CITIES = [
|
||||
'All Cities', 'Jakarta', 'Surabaya', 'Bandung', 'Medan', 'Semarang',
|
||||
'Makassar', 'Palembang', 'Tangerang', 'Depok', 'Bekasi',
|
||||
'Yogyakarta', 'Malang', 'Bogor', 'Batam', 'Pekanbaru',
|
||||
const DEFAULT_PER_PAGE = 12;
|
||||
const PER_PAGE_OPTIONS = [6, 12, 24, 48];
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
// Member filter options
|
||||
const MEMBER_FILTER_OPTIONS = [
|
||||
{ label: 'All Teams', value: '', minMembers: undefined, maxMembers: undefined },
|
||||
{ label: 'Looking for Members (1-4)', value: 'looking', minMembers: 1, maxMembers: 4 },
|
||||
{ label: '1 Member', value: '1', minMembers: 1, maxMembers: 1 },
|
||||
{ label: '2 Members', value: '2', minMembers: 2, maxMembers: 2 },
|
||||
{ label: '3 Members', value: '3', minMembers: 3, maxMembers: 3 },
|
||||
{ label: '4 Members', value: '4', minMembers: 4, maxMembers: 4 },
|
||||
{ label: '5 Members (Full)', value: '5', minMembers: 5, maxMembers: 5 },
|
||||
];
|
||||
|
||||
// Submission status filter options
|
||||
const SUBMISSION_FILTER_OPTIONS = [
|
||||
{ label: 'All Teams', value: '' },
|
||||
{ label: 'Submitted', value: 'true' },
|
||||
{ label: 'Not Submitted', value: 'false' },
|
||||
];
|
||||
|
||||
// Skeleton card component for loading state
|
||||
const TeamCardSkeleton: FC = () => (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden flex flex-col border dark:border-gray-800 animate-pulse">
|
||||
<div className="w-full aspect-3/1 bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
|
||||
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4 flex-1">
|
||||
<div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-4 w-5/6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-4 w-4/6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3 mt-auto">
|
||||
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedCity, setSelectedCity] = useState('All Cities');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
// Initialize state from URL params
|
||||
const initialPage = parseInt(searchParams.get('page') || '1', 10);
|
||||
const initialPerPage = parseInt(
|
||||
searchParams.get('per_page') || String(DEFAULT_PER_PAGE),
|
||||
10
|
||||
);
|
||||
const initialSearch = searchParams.get('search') || '';
|
||||
const initialCity = searchParams.get('city') || '';
|
||||
const initialMembers = searchParams.get('members') || '';
|
||||
const initialSubmission = searchParams.get('submission') || '';
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(initialSearch);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(initialSearch);
|
||||
const [selectedCity, setSelectedCity] = useState(initialCity);
|
||||
const [selectedMembers, setSelectedMembers] = useState(initialMembers);
|
||||
const [selectedSubmission, setSelectedSubmission] = useState(initialSubmission);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const [showJoinModal, setShowJoinModal] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(
|
||||
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPage : 1
|
||||
);
|
||||
const [perPage, setPerPage] = useState(
|
||||
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPerPage : DEFAULT_PER_PAGE
|
||||
);
|
||||
|
||||
const { data: teamsData, isLoading } = useTeams({
|
||||
search,
|
||||
city: selectedCity === 'All Cities' ? undefined : selectedCity,
|
||||
// Update URL when pagination state changes
|
||||
const updateUrlParams = useCallback(
|
||||
(params: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
city?: string;
|
||||
members?: string;
|
||||
submission?: string;
|
||||
}) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
|
||||
if (params.page !== undefined) {
|
||||
if (params.page === 1) {
|
||||
newParams.delete('page');
|
||||
} else {
|
||||
newParams.set('page', String(params.page));
|
||||
}
|
||||
}
|
||||
|
||||
if (params.per_page !== undefined) {
|
||||
if (params.per_page === DEFAULT_PER_PAGE) {
|
||||
newParams.delete('per_page');
|
||||
} else {
|
||||
newParams.set('per_page', String(params.per_page));
|
||||
}
|
||||
}
|
||||
|
||||
if (params.search !== undefined) {
|
||||
if (params.search === '') {
|
||||
newParams.delete('search');
|
||||
} else {
|
||||
newParams.set('search', params.search);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.city !== undefined) {
|
||||
if (params.city === '') {
|
||||
newParams.delete('city');
|
||||
} else {
|
||||
newParams.set('city', params.city);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.members !== undefined) {
|
||||
if (params.members === '') {
|
||||
newParams.delete('members');
|
||||
} else {
|
||||
newParams.set('members', params.members);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.submission !== undefined) {
|
||||
if (params.submission === '') {
|
||||
newParams.delete('submission');
|
||||
} else {
|
||||
newParams.set('submission', params.submission);
|
||||
}
|
||||
}
|
||||
|
||||
setSearchParams(newParams, { replace: true });
|
||||
},
|
||||
[searchParams, setSearchParams]
|
||||
);
|
||||
|
||||
// Debounce search term and reset page
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchTerm);
|
||||
if (searchTerm !== debouncedSearch) {
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ search: searchTerm, page: 1 });
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Update URL when city filter changes
|
||||
useEffect(() => {
|
||||
if (selectedCity !== initialCity) {
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ city: selectedCity, page: 1 });
|
||||
}
|
||||
}, [selectedCity]);
|
||||
|
||||
// Get member filter values
|
||||
const memberFilter = MEMBER_FILTER_OPTIONS.find(
|
||||
(opt) => opt.value === selectedMembers
|
||||
);
|
||||
|
||||
// Convert submission filter value to boolean
|
||||
const hasSubmissionFilter = selectedSubmission === 'true' ? true : selectedSubmission === 'false' ? false : undefined;
|
||||
|
||||
const {
|
||||
data: teamsData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useTeams({
|
||||
page: currentPage,
|
||||
limit: perPage,
|
||||
search: debouncedSearch,
|
||||
city: selectedCity || undefined,
|
||||
visibility: ETeamVisibility.PUBLIC,
|
||||
minMembers: memberFilter?.minMembers,
|
||||
maxMembers: memberFilter?.maxMembers,
|
||||
hasSubmission: hasSubmissionFilter,
|
||||
});
|
||||
|
||||
const { data: myTeamsData } = useMyTeams();
|
||||
@@ -32,7 +211,9 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const teams = teamsData?.data || [];
|
||||
const teams = teamsData?.teams || [];
|
||||
const totalPages = teamsData?.totalPages || 1;
|
||||
const total = teamsData?.total || 0;
|
||||
const myTeams = myTeamsData?.data || [];
|
||||
|
||||
// Helper function to check if user is a member of a team
|
||||
@@ -58,17 +239,58 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
}
|
||||
});
|
||||
|
||||
// Generate page numbers to display
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible + 2) {
|
||||
// Show all pages if total is small
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
if (currentPage > 3) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Show pages around current
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Browse Teams</h1>
|
||||
<p className="text-gray-600 mt-1">Find and join teams looking for members</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Browse Teams
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Find and join teams looking for members
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/dashboard">
|
||||
<Link to="/dashboard" className="hidden md:block">
|
||||
<Button variant="secondary">Back to Dashboard</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -77,31 +299,66 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
|
||||
{/* Filters */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm mb-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-sm mb-6 border dark:border-gray-800">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Search Teams
|
||||
</label>
|
||||
<Input
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by team name..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Filter by City
|
||||
</label>
|
||||
<select
|
||||
<CitySelect
|
||||
value={selectedCity}
|
||||
onChange={(e) => setSelectedCity(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
onChange={setSelectedCity}
|
||||
placeholder="All Cities (search to filter...)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Filter by Members
|
||||
</label>
|
||||
<select
|
||||
value={selectedMembers}
|
||||
onChange={(e) => {
|
||||
setSelectedMembers(e.target.value);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ members: e.target.value, page: 1 });
|
||||
}}
|
||||
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{INDONESIAN_CITIES.map((city) => (
|
||||
<option key={city} value={city}>
|
||||
{city}
|
||||
{MEMBER_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Submission Status
|
||||
</label>
|
||||
<select
|
||||
value={selectedSubmission}
|
||||
onChange={(e) => {
|
||||
setSelectedSubmission(e.target.value);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ submission: e.target.value, page: 1 });
|
||||
}}
|
||||
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{SUBMISSION_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -109,94 +366,246 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teams Count */}
|
||||
{!isLoading && total > 0 && (
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {(currentPage - 1) * perPage + 1}-
|
||||
{Math.min(currentPage * perPage, total)} of {total} teams
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teams List */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600">Loading teams...</p>
|
||||
</div>
|
||||
) : teams.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||
<p className="text-gray-600 text-lg">No teams found</p>
|
||||
<p className="text-gray-500 mt-2">Try adjusting your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{teams.map((team) => (
|
||||
<div key={team.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
|
||||
{team.banner && (
|
||||
<img
|
||||
src={team.banner}
|
||||
alt={team.name}
|
||||
className="w-full h-32 object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-500 text-xl">👥</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
|
||||
<p className="text-sm text-gray-600">📍 {team.city}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-3">
|
||||
{team.description}
|
||||
</p>
|
||||
{isMyTeam(team.id) ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${team.id}`)}
|
||||
>
|
||||
Your Team
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleJoinRequest(team.id)}
|
||||
>
|
||||
Request to Join
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isLoading || isFetching ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: perPage }).map((_, index) => (
|
||||
<TeamCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : teams.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm p-12 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||
No teams found
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-500 mt-2">
|
||||
Try adjusting your filters
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{teams.map((team: any) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-300 text-xl">
|
||||
<Icon icon="mdi:account-group" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-1 leading-tight">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex items-center gap-2 mt-1">
|
||||
{team.has_submission && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
||||
Submitted
|
||||
</span>
|
||||
)}
|
||||
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:map-marker" />{' '}
|
||||
<span>{team.city}</span>
|
||||
</p>
|
||||
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:account-group" />{' '}
|
||||
{team.member_count || 0} members
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans flex-1">
|
||||
{team.description}
|
||||
</p>
|
||||
<div className="space-y-3 mt-auto">
|
||||
{(team.member_count || 0) === 1 && !team.has_submission && (
|
||||
<div className="p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 flex items-center gap-1">
|
||||
<Icon icon="mdi:alert" className="text-sm shrink-0" />
|
||||
<span>This team needs at least 2 members to submit</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isMyTeam(team.id) ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${team.id}`)}
|
||||
>
|
||||
Your Team
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{myTeams.length === 0 &&
|
||||
(team.member_count || 0) < 5 &&
|
||||
!team.has_submission &&
|
||||
!isTeamFeaturesClosed && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleJoinRequest(team.id)}
|
||||
>
|
||||
Request to Join
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${team.id}`)}
|
||||
>
|
||||
View Team
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{(totalPages > 1 || total > 6) && (
|
||||
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const newPage = Math.max(1, currentPage - 1);
|
||||
setCurrentPage(newPage);
|
||||
updateUrlParams({ page: newPage });
|
||||
}}
|
||||
disabled={currentPage === 1 || isFetching}
|
||||
className="px-3"
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" className="text-xl" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{getPageNumbers().map((page, index) =>
|
||||
typeof page === 'string' ? (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className="px-2 text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => {
|
||||
setCurrentPage(page);
|
||||
updateUrlParams({ page });
|
||||
}}
|
||||
disabled={isFetching}
|
||||
className={`min-w-10 h-10 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const newPage = Math.min(totalPages, currentPage + 1);
|
||||
setCurrentPage(newPage);
|
||||
updateUrlParams({ page: newPage });
|
||||
}}
|
||||
disabled={currentPage === totalPages || isFetching}
|
||||
className="px-3"
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" className="text-xl" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{totalPages > 1 && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Show:</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={(e) => {
|
||||
const newPerPage = Number(e.target.value);
|
||||
setPerPage(newPerPage);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ per_page: newPerPage, page: 1 });
|
||||
}}
|
||||
className="h-10 px-3 pr-8 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Join Request Modal */}
|
||||
{showJoinModal && (
|
||||
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Request to Join Team
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6 font-sans">
|
||||
Send a message to the team leader explaining why you want to join
|
||||
</p>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Your Message
|
||||
</label>
|
||||
<textarea
|
||||
{...form.register('message')}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
placeholder="Tell the team leader why you want to join their team..."
|
||||
/>
|
||||
{form.formState.errors.message && (
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
<p className="text-sm text-red-500 mt-1 font-sans">
|
||||
{form.formState.errors.message.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -5,15 +5,22 @@ import { useNavigate } from 'react-router';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { teamCreateSchema, TTeamCreateForm, useCreateTeam, ETeamVisibility, useUploadFile } from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const INDONESIAN_CITIES = [
|
||||
'Jakarta', 'Surabaya', 'Bandung', 'Medan', 'Semarang',
|
||||
'Makassar', 'Palembang', 'Tangerang', 'Depok', 'Bekasi',
|
||||
'Yogyakarta', 'Malang', 'Bogor', 'Batam', 'Pekanbaru',
|
||||
];
|
||||
import { CitySelect } from '../../../components/city-select';
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
const CreateTeamPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string>('');
|
||||
const [bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
@@ -35,6 +42,11 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Logo image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setLogoFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -47,6 +59,11 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Banner image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setBannerFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -77,36 +94,91 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
banner: bannerUrl,
|
||||
});
|
||||
|
||||
toast.success('Team created successfully!');
|
||||
navigate(`/teams/${result.data.id}`);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create team:', error);
|
||||
|
||||
// Handle specific error messages
|
||||
const message = error?.message || '';
|
||||
if (message.includes('413') || message.includes('length limit') || message.includes('too large')) {
|
||||
toast.error('Image file is too large. Please use smaller images (max 2MB each).');
|
||||
} else if (message.includes('already a member')) {
|
||||
toast.error(message);
|
||||
} else {
|
||||
toast.error(message || 'Failed to create team. Please try again.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Show closed screen if team features are closed
|
||||
if (isTeamFeaturesClosed) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon
|
||||
icon="mdi:clock-alert"
|
||||
className="text-3xl text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Team Features Closed
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Team creation is no longer available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
The deadline for team features was November 30, 2025 at 23:59 WIB.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/teams/browse')}
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Browse Teams
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Create Your Team</h1>
|
||||
<p className="text-gray-600 mt-1">Build your hackathon dream team</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Create Your Team</h1>
|
||||
<p className="text-gray-600 dark:text-neutral-400 mt-1">Build your hackathon dream team</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-8">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-900/50 p-8">
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
{/* Banner Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Team Banner <span className="text-gray-400">(Optional)</span>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2">
|
||||
Team Banner <span className="text-gray-400 dark:text-neutral-500">(Optional)</span>
|
||||
</label>
|
||||
{bannerPreview ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt="Banner preview"
|
||||
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200"
|
||||
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -120,10 +192,10 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-neutral-800">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500">Click to upload banner</p>
|
||||
<p className="text-xs text-gray-400 mt-1">1200x400 recommended</p>
|
||||
<p className="text-gray-500 dark:text-neutral-400">Click to upload banner</p>
|
||||
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended. Max 2MB</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@@ -137,46 +209,49 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Team Logo <span className="text-gray-400">(Optional, but highly recommended)</span>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2">
|
||||
Team Logo <span className="text-gray-400 dark:text-neutral-500">(Optional, but highly recommended)</span>
|
||||
</label>
|
||||
<div className="flex items-center space-x-4">
|
||||
{logoPreview ? (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo preview"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-neutral-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-3xl">👥</span>
|
||||
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center">
|
||||
<span className="text-gray-400 dark:text-neutral-500 text-3xl">👥</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
}}
|
||||
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
}}
|
||||
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-2">Max 2MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,36 +266,26 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
|
||||
{/* City */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
|
||||
City <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="city"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<select
|
||||
{...field}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Select city</option>
|
||||
{INDONESIAN_CITIES.map((city) => (
|
||||
<option key={city} value={city}>
|
||||
{city}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<CitySelect
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
error={fieldState.error?.message}
|
||||
placeholder="Search your city..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
|
||||
Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
@@ -244,7 +309,7 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
|
||||
{/* Visibility */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
|
||||
Team Visibility <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
@@ -252,7 +317,7 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
|
||||
<label className="flex items-start space-x-3 cursor-pointer border dark:border-neutral-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800">
|
||||
<input
|
||||
type="radio"
|
||||
{...field}
|
||||
@@ -261,13 +326,13 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Public</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-900 dark:text-white">Public</p>
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
Team will be visible in Browse Teams. Anyone can request to join.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
|
||||
<label className="flex items-start space-x-3 cursor-pointer border dark:border-neutral-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800">
|
||||
<input
|
||||
type="radio"
|
||||
{...field}
|
||||
@@ -276,8 +341,8 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Private</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-900 dark:text-white">Private</p>
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
Team is hidden from Browse Teams. Members can only join via invitation.
|
||||
</p>
|
||||
</div>
|
||||
@@ -288,8 +353,8 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> As team leader, you cannot leave or join another team after creating this team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { Sidebar } from '../../components/sidebar';
|
||||
|
||||
const TeamsLayout: FC = (): ReactElement => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Mobile Header with Hamburger */}
|
||||
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 z-10">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
|
||||
Hackathon
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamsLayout;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { Sidebar } from '../../../components/sidebar';
|
||||
|
||||
const UserDetailLayout: FC = (): ReactElement => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
{/* Mobile Header with Hamburger */}
|
||||
<div className="lg:hidden bg-white dark:bg-gray-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-600 dark:text-neutral-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
|
||||
Hackathon
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDetailLayout;
|
||||
@@ -0,0 +1,240 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useParams, useNavigate, Link } from 'react-router';
|
||||
import {
|
||||
useUserDetailsById,
|
||||
useTeamsByUserId,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const UserProfilePage: FC = (): ReactElement => {
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: userData, isLoading, error } = useUserDetailsById(userId || '');
|
||||
const { data: teamsData } = useTeamsByUserId(userId || '');
|
||||
|
||||
const user = userData?.data;
|
||||
const userTeams = teamsData?.data || [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Loading user profile...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !user) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
User not found
|
||||
</h2>
|
||||
{error && (
|
||||
<p className="text-red-600 dark:text-red-400 mb-4">{String(error)}</p>
|
||||
)}
|
||||
<Button onClick={() => navigate('/dashboard')}>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 md:space-x-4">
|
||||
{user.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.fullname}
|
||||
className="w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white dark:border-gray-800 shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border-4 border-white dark:border-gray-800 shadow-lg">
|
||||
<span className="text-gray-500 dark:text-gray-400 text-2xl md:text-3xl">
|
||||
<Icon icon="mdi:account-circle" className="w-10 h-10" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{user.fullname}
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 mt-1">
|
||||
{user.email}
|
||||
</p>
|
||||
{user.location && (
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-500">
|
||||
<Icon
|
||||
icon="mdi:map-marker"
|
||||
className="inline-block w-4 h-4 mr-1"
|
||||
/>
|
||||
{user.location}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/dashboard" className="hidden md:block">
|
||||
<Button variant="secondary">Back to Dashboard</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
|
||||
<div className="grid gap-4 md:gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-4 md:space-y-6">
|
||||
{/* About Section */}
|
||||
{user.bio && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6 overflow-hidden">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
About
|
||||
</h2>
|
||||
<p className="text-gray-700 dark:text-gray-300 break-all overflow-wrap-anywhere font-sans">
|
||||
{user.bio}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills Section */}
|
||||
{user.skills && user.skills.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Skills
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{user.skills.map((skill: string) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center px-4 py-2 border border-primary-600 dark:border-gray-500 text-primary-600 dark:text-white rounded-4xl text-xs font-medium dark:bg-gray-600"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Section */}
|
||||
{userTeams.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Team
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{userTeams.map((team: any) => (
|
||||
<Link
|
||||
key={team.id}
|
||||
to={'/teams/' + team.id}
|
||||
className="block bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden hover:shadow-lg transition-shadow border dark:border-gray-700"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<Icon icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white line-clamp-1 text-lg">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-1">
|
||||
{team.has_submission && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
||||
Submitted
|
||||
</span>
|
||||
)}
|
||||
{team.city && (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Icon icon="mdi:map-marker" className="shrink-0" />
|
||||
<span className="truncate">{team.city}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{team.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm line-clamp-2 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Team
|
||||
</h2>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-3">
|
||||
<Icon icon="mdi:account-group-outline" className="inline-block text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Not in any team yet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Contact Info */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Contact Information
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
{user.location && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Location
|
||||
</p>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
{user.location}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfilePage;
|
||||
@@ -0,0 +1,318 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useWinners } from '@imphnen-frontend-service/service';
|
||||
|
||||
const WinnerPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useWinners();
|
||||
|
||||
const winners = data?.data ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Loading winners...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">⚠️</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Error Loading Winners
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Unable to load winners at this time. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort winners by rank
|
||||
const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank);
|
||||
|
||||
// Medal emojis for top 3
|
||||
const getMedalEmoji = (rank: number) => {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return '🥇';
|
||||
case 2:
|
||||
return '🥈';
|
||||
case 3:
|
||||
return '🥉';
|
||||
default:
|
||||
return '🏆';
|
||||
}
|
||||
};
|
||||
|
||||
// Get rank color
|
||||
const getRankColor = (rank: number) => {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return 'from-yellow-400 to-yellow-600';
|
||||
case 2:
|
||||
return 'from-gray-300 to-gray-500';
|
||||
case 3:
|
||||
return 'from-amber-600 to-amber-800';
|
||||
default:
|
||||
return 'from-blue-500 to-blue-700';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🏆</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Hackathon Winners
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Congratulations to all the winning teams!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Winners List */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{winners.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<div className="text-6xl mb-4">🎯</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
No Winners Announced Yet
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Winners will be announced here once the hackathon concludes.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Top 3 Winners - Mobile View */}
|
||||
<div className="md:hidden space-y-6">
|
||||
{[1, 2, 3].map((position) => {
|
||||
const winner = sortedWinners[position - 1];
|
||||
|
||||
if(!winner) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={winner.id}
|
||||
className={`bg-white dark:bg-gray-800 rounded-lg p-3 shadow-lg ${
|
||||
getRankColor(winner.rank).includes('yellow')
|
||||
? 'border-4 border-yellow-400 dark:border-yellow-600'
|
||||
: getRankColor(winner.rank).includes('gray')
|
||||
? 'border-4 border-gray-400 dark:border-gray-600'
|
||||
: 'border-4 border-amber-600 dark:border-amber-500'
|
||||
}}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`aspect-square p-1 rounded-full bg-linear-to-br ${getRankColor(
|
||||
winner.rank
|
||||
)} flex items-center justify-center text-white font-bold text-2xl`}
|
||||
>
|
||||
<div className="text-4xl">
|
||||
{getMedalEmoji(winner.rank)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{winner.team.logo && (
|
||||
<img
|
||||
src={winner.team.logo}
|
||||
alt={`${winner.team.name} logo`}
|
||||
className="w-20 h-20 rounded-full object-cover border-3 border-white dark:border-gray-700 shadow-lg"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{winner.team.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{winner.team.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Top 3 Winners - Tablet/Desktop Podium View */}
|
||||
<div className="hidden md:flex gap-8 items-end justify-center w-full">
|
||||
{[2, 1, 3].map((position) => {
|
||||
const winner = sortedWinners[position - 1];
|
||||
if(!winner) return null;
|
||||
return (
|
||||
<div
|
||||
key={winner.id}
|
||||
className="w-full max-w-xs flex flex-col items-center space-y-4"
|
||||
>
|
||||
<div className="text-5xl">{getMedalEmoji(winner.rank)}</div>
|
||||
|
||||
{/* Team Logo */}
|
||||
{winner.team.logo && (
|
||||
<img
|
||||
src={winner.team.logo}
|
||||
alt={`${winner.team.name} logo`}
|
||||
className="w-24 h-24 rounded-full object-cover border-4 border-white dark:border-gray-700 shadow-lg"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-center px-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{winner.team.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{winner.team.city}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Podium */}
|
||||
<div
|
||||
className={`${
|
||||
winner.rank === 1
|
||||
? 'h-42 bg-yellow-500'
|
||||
: winner.rank === 2
|
||||
? 'h-32 bg-gray-400'
|
||||
: 'h-16 bg-amber-600'
|
||||
} w-full flex items-end justify-center rounded-t-lg shadow-lg`}
|
||||
>
|
||||
<div className="text-white font-bold text-3xl pb-4">
|
||||
#{winner.rank}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Ranks 4-23: Prize Winners */}
|
||||
{sortedWinners.filter((w) => w.rank >= 4 && w.rank <= 23).length >
|
||||
0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||
Favorite
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedWinners
|
||||
.filter((w) => w.rank >= 4 && w.rank <= 23)
|
||||
.map((winner) => (
|
||||
<div
|
||||
key={winner.id}
|
||||
className="bg-white dark:bg-gray-800 border-2 border-gray-400 dark:border-gray-600 rounded-lg p-4 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="w-12 h-12 rounded-full bg-linear-to-br from-gray-400 to-gray-600 flex items-center justify-center text-white font-bold text-lg">
|
||||
#{winner.rank}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{winner.team.logo && (
|
||||
<img
|
||||
src={winner.team.logo}
|
||||
alt={`${winner.team.name} logo`}
|
||||
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white truncate">
|
||||
{winner.team.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{winner.team.city}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 px-2 py-1 rounded-full">
|
||||
🎁 Prize Winner
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rank 24+: Remaining Participants */}
|
||||
{sortedWinners.filter((w) => w.rank >= 24).length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||
All Participants
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{sortedWinners
|
||||
.filter((w) => w.rank >= 24)
|
||||
.map((participant) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="shrink-0 w-10 text-center">
|
||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||
#{participant.rank}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{participant.team.logo && (
|
||||
<img
|
||||
src={participant.team.logo}
|
||||
alt={`${participant.team.name} logo`}
|
||||
className="w-12 h-12 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||
{participant.team.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{participant.team.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
{winners.length > 0 && (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Congratulations! 🎉
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
Thank you to all participants for making this hackathon a success.
|
||||
Every project and idea contributed to an incredible showcase of
|
||||
innovation and creativity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WinnerPage;
|
||||
@@ -0,0 +1,117 @@
|
||||
import { FC, useState, useRef, useEffect } from 'react';
|
||||
import INDONESIAN_CITIES from '../constants/cities';
|
||||
|
||||
interface CitySelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const CitySelect: FC<CitySelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
placeholder = 'Search your city...',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Filter cities based on search query
|
||||
const filteredCities = INDONESIAN_CITIES.filter((city) =>
|
||||
city.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelectCity = (city: string) => {
|
||||
onChange(city);
|
||||
setSearchQuery('');
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleInputClick = () => {
|
||||
setIsOpen(true);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
onChange('');
|
||||
setSearchQuery('');
|
||||
setIsOpen(true);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={isOpen ? searchQuery : value}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onClick={handleInputClick}
|
||||
placeholder={placeholder}
|
||||
className={`w-full h-[42px] px-3 text-[15px] border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
{value && !isOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSelection}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 hover:text-gray-600 cursor-pointer dark:hover:text-gray-300"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto text-label2">
|
||||
{filteredCities.length > 0 ? (
|
||||
<ul className="py-1">
|
||||
{filteredCities.map((city) => (
|
||||
<li
|
||||
key={city}
|
||||
onClick={() => handleSelectCity(city)}
|
||||
className={`px-3 py-2 cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/30 ${
|
||||
value === city
|
||||
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-400'
|
||||
: 'text-gray-900 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{city}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-gray-500 dark:text-gray-400 text-sm">
|
||||
No cities found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,293 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import {
|
||||
useMyTeams,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useTheme } from './theme-provider';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { session, clearSession } = useAuthStore();
|
||||
const { data: teamsData } = useMyTeams();
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const user = session?.user;
|
||||
const myTeams = teamsData?.data || [];
|
||||
const hasTeam = myTeams.length > 0;
|
||||
|
||||
// Close sidebar on route change (mobile)
|
||||
useEffect(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLogout = () => {
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
toast.success('Logged out successfully');
|
||||
navigate('/auth/login');
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
icon: <Icon icon="heroicons:home" className="w-5 h-5" />,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
name: 'My Teams',
|
||||
path: '/teams/' + myTeams[0]?.id,
|
||||
icon: <Icon icon="heroicons:users" className="w-5 h-5" />,
|
||||
show: myTeams.length > 0,
|
||||
},
|
||||
{
|
||||
name: 'Browse Teams',
|
||||
path: '/teams/browse',
|
||||
icon: <Icon icon="heroicons-outline:search" className="w-5 h-5" />,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
name: 'Create Team',
|
||||
path: '/teams/create',
|
||||
icon: <Icon icon="heroicons:plus" className="h-5 w-5" />,
|
||||
show: !hasTeam,
|
||||
},
|
||||
{
|
||||
name: 'Edit Profile',
|
||||
path: '/profile',
|
||||
icon: (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
show: false,
|
||||
},
|
||||
];
|
||||
|
||||
const cycleTheme = () => {
|
||||
if (theme === 'light') setTheme('dark');
|
||||
else if (theme === 'dark') setTheme('system');
|
||||
else setTheme('light');
|
||||
};
|
||||
|
||||
const getThemeIcon = () => {
|
||||
if (theme === 'system') {
|
||||
return (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (resolvedTheme === 'dark') {
|
||||
return (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const getThemeLabel = () => {
|
||||
if (theme === 'system') return 'System';
|
||||
if (theme === 'dark') return 'Dark';
|
||||
return 'Light';
|
||||
};
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="w-64 bg-white dark:bg-gray-900 border-r dark:border-gray-800 min-h-screen flex flex-col">
|
||||
{/* Logo / Brand with Close Button */}
|
||||
<div className="p-6 flex items-center justify-between border-b dark:border-gray-800">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Hackathon
|
||||
</h1>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="p-4 border-b dark:border-gray-800">
|
||||
<div className="flex items-center space-x-3">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.fullname || 'User'}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<Icon
|
||||
icon="ic:baseline-person"
|
||||
width="24"
|
||||
height="24"
|
||||
className="text-gray-400 dark:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{user?.fullname || user?.email?.split('@')[0] || 'User'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4">
|
||||
<ul className="space-y-2">
|
||||
{navItems
|
||||
.filter((item) => item.show)
|
||||
.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
to={item.path}
|
||||
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 dark:bg-blue-900/30 text-primary-600 dark:text-blue-400 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Theme Toggle & Logout */}
|
||||
<div className="p-4 border-t dark:border-gray-800 space-y-2">
|
||||
<button
|
||||
onClick={cycleTheme}
|
||||
className="flex items-center space-x-3 px-4 py-3 w-full rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
||||
>
|
||||
{getThemeIcon()}
|
||||
<span>{getThemeLabel()}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-3 px-4 py-3 w-full rounded-lg text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<Icon
|
||||
icon="heroicons:arrow-right-end-on-rectangle"
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Sidebar - Always visible on lg+, sticky position */}
|
||||
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar - Overlay */}
|
||||
{isOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Sidebar */}
|
||||
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createContext, useContext, useEffect, useState, FC, ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = 'hackathon-theme';
|
||||
|
||||
function getSystemTheme(): 'light' | 'dark' {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
}
|
||||
|
||||
export const ThemeProvider: FC<ThemeProviderProps> = ({ children, defaultTheme = 'system' }) => {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
|
||||
return stored || defaultTheme;
|
||||
}
|
||||
return defaultTheme;
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => {
|
||||
if (theme === 'system') {
|
||||
return getSystemTheme();
|
||||
}
|
||||
return theme;
|
||||
});
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Calculate the resolved theme
|
||||
const resolved = theme === 'system' ? getSystemTheme() : theme;
|
||||
setResolvedTheme(resolved);
|
||||
|
||||
// Apply dark class to root
|
||||
if (resolved === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
setResolvedTheme(newTheme);
|
||||
|
||||
const root = document.documentElement;
|
||||
if (newTheme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default ThemeProvider;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { FC } from 'react';
|
||||
import { useTheme } from './theme-provider';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Sun icon for light mode
|
||||
const SunIcon = () => (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Moon icon for dark mode
|
||||
const MoonIcon = () => (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// System icon (monitor)
|
||||
const SystemIcon = () => (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ThemeToggle: FC<ThemeToggleProps> = ({ className = '' }) => {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const cycleTheme = () => {
|
||||
if (theme === 'light') setTheme('dark');
|
||||
else if (theme === 'dark') setTheme('system');
|
||||
else setTheme('light');
|
||||
};
|
||||
|
||||
const getThemeLabel = () => {
|
||||
if (theme === 'system') return 'System';
|
||||
return theme === 'dark' ? 'Dark' : 'Light';
|
||||
};
|
||||
|
||||
const renderIcon = () => {
|
||||
if (theme === 'system') {
|
||||
return <SystemIcon />;
|
||||
}
|
||||
return resolvedTheme === 'dark' ? <MoonIcon /> : <SunIcon />;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycleTheme}
|
||||
className={`p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors cursor-pointer text-gray-600 dark:text-neutral-200 ${className}`}
|
||||
title={`Theme: ${getThemeLabel()}`}
|
||||
aria-label={`Current theme: ${getThemeLabel()}. Click to change.`}
|
||||
>
|
||||
{renderIcon()}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeToggle;
|
||||
@@ -0,0 +1,518 @@
|
||||
const INDONESIAN_CITIES: string[] = [
|
||||
'Aceh Selatan',
|
||||
'Aceh Tenggara',
|
||||
'Aceh Timur',
|
||||
'Aceh Tengah',
|
||||
'Aceh Barat',
|
||||
'Aceh Besar',
|
||||
'Pidie',
|
||||
'Aceh Utara',
|
||||
'Simeulue',
|
||||
'Aceh Singkil',
|
||||
'Bireuen',
|
||||
'Aceh Barat Daya',
|
||||
'Gayo Lues',
|
||||
'Aceh Jaya',
|
||||
'Nagan Raya',
|
||||
'Aceh Tamiang',
|
||||
'Bener Meriah',
|
||||
'Pidie Jaya',
|
||||
'Kota Banda Aceh',
|
||||
'Kota Sabang',
|
||||
'Kota Lhokseumawe',
|
||||
'Kota Langsa',
|
||||
'Kota Subulussalam',
|
||||
'Tapanuli Tengah',
|
||||
'Tapanuli Utara',
|
||||
'Tapanuli Selatan',
|
||||
'Nias',
|
||||
'Langkat',
|
||||
'Karo',
|
||||
'Deli Serdang',
|
||||
'Simalungun',
|
||||
'Asahan',
|
||||
'Labuhanbatu',
|
||||
'Dairi',
|
||||
'Toba',
|
||||
'Mandailing Natal',
|
||||
'Nias Selatan',
|
||||
'Pakpak Bharat',
|
||||
'Humbang Hasundutan',
|
||||
'Samosir',
|
||||
'Serdang Bedagai',
|
||||
'Batu Bara',
|
||||
'Padang Lawas Utara',
|
||||
'Padang Lawas',
|
||||
'Labuhanbatu Selatan',
|
||||
'Labuhanbatu Utara',
|
||||
'Nias Utara',
|
||||
'Nias Barat',
|
||||
'Kota Medan',
|
||||
'Kota Pematangsiantar',
|
||||
'Kota Sibolga',
|
||||
'Kota Tanjung Balai',
|
||||
'Kota Binjai',
|
||||
'Kota Tebing Tinggi',
|
||||
'Kota Padangsidimpuan',
|
||||
'Kota Gunungsitoli',
|
||||
'Pesisir Selatan',
|
||||
'Solok',
|
||||
'Sijunjung',
|
||||
'Tanah Datar',
|
||||
'Padang Pariaman',
|
||||
'Agam',
|
||||
'Lima Puluh Kota',
|
||||
'Pasaman',
|
||||
'Kepulauan Mentawai',
|
||||
'Dharmasraya',
|
||||
'Solok Selatan',
|
||||
'Pasaman Barat',
|
||||
'Kota Padang',
|
||||
'Kota Solok',
|
||||
'Kota Sawahlunto',
|
||||
'Kota Padang Panjang',
|
||||
'Kota Bukittinggi',
|
||||
'Kota Payakumbuh',
|
||||
'Kota Pariaman',
|
||||
'Kampar',
|
||||
'Indragiri Hulu',
|
||||
'Bengkalis',
|
||||
'Indragiri Hilir',
|
||||
'Pelalawan',
|
||||
'Rokan Hulu',
|
||||
'Rokan Hilir',
|
||||
'Siak',
|
||||
'Kuantan Singingi',
|
||||
'Kepulauan Meranti',
|
||||
'Kota Pekanbaru',
|
||||
'Kota Dumai',
|
||||
'Kerinci',
|
||||
'Merangin',
|
||||
'Sarolangun',
|
||||
'Batanghari',
|
||||
'Muaro Jambi',
|
||||
'Tanjung Jabung Barat',
|
||||
'Tanjung Jabung Timur',
|
||||
'Bungo',
|
||||
'Tebo',
|
||||
'Kota Jambi',
|
||||
'Kota Sungai Penuh',
|
||||
'Ogan Komering Ulu',
|
||||
'Ogan Komering Ilir',
|
||||
'Muara Enim',
|
||||
'Lahat',
|
||||
'Musi Rawas',
|
||||
'Musi Banyuasin',
|
||||
'Banyuasin',
|
||||
'Ogan Komering Ulu Timur',
|
||||
'Ogan Komering Ulu Selatan',
|
||||
'Ogan Ilir',
|
||||
'Empat Lawang',
|
||||
'Penukal Abab Lematang Ilir',
|
||||
'Musi Rawas Utara',
|
||||
'Kota Palembang',
|
||||
'Kota Pagar Alam',
|
||||
'Kota Lubuk Linggau',
|
||||
'Kota Prabumulih',
|
||||
'Bengkulu Selatan',
|
||||
'Rejang Lebong',
|
||||
'Bengkulu Utara',
|
||||
'Kaur',
|
||||
'Seluma',
|
||||
'Muko Muko',
|
||||
'Lebong',
|
||||
'Kepahiang',
|
||||
'Bengkulu Tengah',
|
||||
'Kota Bengkulu',
|
||||
'Lampung Selatan',
|
||||
'Lampung Tengah',
|
||||
'Lampung Utara',
|
||||
'Lampung Barat',
|
||||
'Tulang Bawang',
|
||||
'Tanggamus',
|
||||
'Lampung Timur',
|
||||
'Way Kanan',
|
||||
'Pesawaran',
|
||||
'Pringsewu',
|
||||
'Mesuji',
|
||||
'Tulang Bawang Barat',
|
||||
'Pesisir Barat',
|
||||
'Kota Bandar Lampung',
|
||||
'Kota Metro',
|
||||
'Bangka',
|
||||
'Belitung',
|
||||
'Bangka Selatan',
|
||||
'Bangka Tengah',
|
||||
'Bangka Barat',
|
||||
'Belitung Timur',
|
||||
'Kota Pangkal Pinang',
|
||||
'Bintan',
|
||||
'Karimun',
|
||||
'Natuna',
|
||||
'Lingga',
|
||||
'Kepulauan Anambas',
|
||||
'Kota Batam',
|
||||
'Kota Tanjung Pinang',
|
||||
'Kepulauan Seribu',
|
||||
'Kota Jakarta Pusat',
|
||||
'Kota Jakarta Utara',
|
||||
'Kota Jakarta Barat',
|
||||
'Kota Jakarta Selatan',
|
||||
'Kota Jakarta Timur',
|
||||
'Bogor',
|
||||
'Sukabumi',
|
||||
'Cianjur',
|
||||
'Bandung',
|
||||
'Garut',
|
||||
'Tasikmalaya',
|
||||
'Ciamis',
|
||||
'Kuningan',
|
||||
'Cirebon',
|
||||
'Majalengka',
|
||||
'Sumedang',
|
||||
'Indramayu',
|
||||
'Subang',
|
||||
'Purwakarta',
|
||||
'Karawang',
|
||||
'Bekasi',
|
||||
'Bandung Barat',
|
||||
'Pangandaran',
|
||||
'Kota Bogor',
|
||||
'Kota Sukabumi',
|
||||
'Kota Bandung',
|
||||
'Kota Cirebon',
|
||||
'Kota Bekasi',
|
||||
'Kota Depok',
|
||||
'Kota Cimahi',
|
||||
'Kota Tasikmalaya',
|
||||
'Kota Banjar',
|
||||
'Cilacap',
|
||||
'Banyumas',
|
||||
'Purbalingga',
|
||||
'Banjarnegara',
|
||||
'Kebumen',
|
||||
'Purworejo',
|
||||
'Wonosobo',
|
||||
'Magelang',
|
||||
'Boyolali',
|
||||
'Klaten',
|
||||
'Sukoharjo',
|
||||
'Wonogiri',
|
||||
'Karanganyar',
|
||||
'Sragen',
|
||||
'Grobogan',
|
||||
'Blora',
|
||||
'Rembang',
|
||||
'Pati',
|
||||
'Kudus',
|
||||
'Jepara',
|
||||
'Demak',
|
||||
'Semarang',
|
||||
'Temanggung',
|
||||
'Kendal',
|
||||
'Batang',
|
||||
'Pekalongan',
|
||||
'Pemalang',
|
||||
'Tegal',
|
||||
'Brebes',
|
||||
'Kota Magelang',
|
||||
'Kota Surakarta',
|
||||
'Kota Salatiga',
|
||||
'Kota Semarang',
|
||||
'Kota Pekalongan',
|
||||
'Kota Tegal',
|
||||
'Kulon Progo',
|
||||
'Bantul',
|
||||
'Gunungkidul',
|
||||
'Sleman',
|
||||
'Kota Yogyakarta',
|
||||
'Pacitan',
|
||||
'Ponorogo',
|
||||
'Trenggalek',
|
||||
'Tulungagung',
|
||||
'Blitar',
|
||||
'Kediri',
|
||||
'Malang',
|
||||
'Lumajang',
|
||||
'Jember',
|
||||
'Banyuwangi',
|
||||
'Bondowoso',
|
||||
'Situbondo',
|
||||
'Probolinggo',
|
||||
'Pasuruan',
|
||||
'Sidoarjo',
|
||||
'Mojokerto',
|
||||
'Jombang',
|
||||
'Nganjuk',
|
||||
'Madiun',
|
||||
'Magetan',
|
||||
'Ngawi',
|
||||
'Bojonegoro',
|
||||
'Tuban',
|
||||
'Lamongan',
|
||||
'Gresik',
|
||||
'Bangkalan',
|
||||
'Sampang',
|
||||
'Pamekasan',
|
||||
'Sumenep',
|
||||
'Kota Kediri',
|
||||
'Kota Blitar',
|
||||
'Kota Malang',
|
||||
'Kota Probolinggo',
|
||||
'Kota Pasuruan',
|
||||
'Kota Mojokerto',
|
||||
'Kota Madiun',
|
||||
'Kota Surabaya',
|
||||
'Kota Batu',
|
||||
'Pandeglang',
|
||||
'Lebak',
|
||||
'Tangerang',
|
||||
'Serang',
|
||||
'Kota Tangerang',
|
||||
'Kota Cilegon',
|
||||
'Kota Serang',
|
||||
'Kota Tangerang Selatan',
|
||||
'Jembrana',
|
||||
'Tabanan',
|
||||
'Badung',
|
||||
'Gianyar',
|
||||
'Klungkung',
|
||||
'Bangli',
|
||||
'Karangasem',
|
||||
'Buleleng',
|
||||
'Kota Denpasar',
|
||||
'Lombok Barat',
|
||||
'Lombok Tengah',
|
||||
'Lombok Timur',
|
||||
'Sumbawa',
|
||||
'Dompu',
|
||||
'Bima',
|
||||
'Sumbawa Barat',
|
||||
'Lombok Utara',
|
||||
'Kota Mataram',
|
||||
'Kota Bima',
|
||||
'Kupang',
|
||||
'Timor Tengah Selatan',
|
||||
'Timor Tengah Utara',
|
||||
'Belu',
|
||||
'Alor',
|
||||
'Flores Timur',
|
||||
'Sikka',
|
||||
'Ende',
|
||||
'Ngada',
|
||||
'Manggarai',
|
||||
'Sumba Timur',
|
||||
'Sumba Barat',
|
||||
'Lembata',
|
||||
'Rote Ndao',
|
||||
'Manggarai Barat',
|
||||
'Nagekeo',
|
||||
'Sumba Tengah',
|
||||
'Sumba Barat Daya',
|
||||
'Manggarai Timur',
|
||||
'Sabu Raijua',
|
||||
'Malaka',
|
||||
'Kota Kupang',
|
||||
'Sambas',
|
||||
'Mempawah',
|
||||
'Sanggau',
|
||||
'Ketapang',
|
||||
'Sintang',
|
||||
'Kapuas Hulu',
|
||||
'Bengkayang',
|
||||
'Landak',
|
||||
'Sekadau',
|
||||
'Melawi',
|
||||
'Kayong Utara',
|
||||
'Kubu Raya',
|
||||
'Kota Pontianak',
|
||||
'Kota Singkawang',
|
||||
'Kotawaringin Barat',
|
||||
'Kotawaringin Timur',
|
||||
'Kapuas',
|
||||
'Barito Selatan',
|
||||
'Barito Utara',
|
||||
'Katingan',
|
||||
'Seruyan',
|
||||
'Sukamara',
|
||||
'Lamandau',
|
||||
'Gunung Mas',
|
||||
'Pulang Pisau',
|
||||
'Murung Raya',
|
||||
'Barito Timur',
|
||||
'Kota Palangkaraya',
|
||||
'Tanah Laut',
|
||||
'Kotabaru',
|
||||
'Banjar',
|
||||
'Barito Kuala',
|
||||
'Tapin',
|
||||
'Hulu Sungai Selatan',
|
||||
'Hulu Sungai Tengah',
|
||||
'Hulu Sungai Utara',
|
||||
'Tabalong',
|
||||
'Tanah Bumbu',
|
||||
'Balangan',
|
||||
'Kota Banjarmasin',
|
||||
'Kota Banjarbaru',
|
||||
'Paser',
|
||||
'Kutai Kartanegara',
|
||||
'Berau',
|
||||
'Kutai Barat',
|
||||
'Kutai Timur',
|
||||
'Penajam Paser Utara',
|
||||
'Mahakam Ulu',
|
||||
'Kota Balikpapan',
|
||||
'Kota Samarinda',
|
||||
'Kota Bontang',
|
||||
'Bulungan',
|
||||
'Malinau',
|
||||
'Nunukan',
|
||||
'Tana Tidung',
|
||||
'Kota Tarakan',
|
||||
'Bolaang Mongondow',
|
||||
'Minahasa',
|
||||
'Kepulauan Sangihe',
|
||||
'Kepulauan Talaud',
|
||||
'Minahasa Selatan',
|
||||
'Minahasa Utara',
|
||||
'Minahasa Tenggara',
|
||||
'Bolaang Mongondow Utara',
|
||||
'Kepulauan Siau Tagulandang Biaro (Sitaro)',
|
||||
'Bolaang Mongondow Timur',
|
||||
'Bolaang Mongondow Selatan',
|
||||
'Kota Manado',
|
||||
'Kota Bitung',
|
||||
'Kota Tomohon',
|
||||
'Kota Kotamobagu',
|
||||
'Banggai',
|
||||
'Poso',
|
||||
'Donggala',
|
||||
'Toli Toli',
|
||||
'Buol',
|
||||
'Morowali',
|
||||
'Banggai Kepulauan',
|
||||
'Parigi Moutong',
|
||||
'Tojo Una Una',
|
||||
'Sigi',
|
||||
'Banggai Laut',
|
||||
'Morowali Utara',
|
||||
'Kota Palu',
|
||||
'Kepulauan Selayar',
|
||||
'Bulukumba',
|
||||
'Bantaeng',
|
||||
'Jeneponto',
|
||||
'Takalar',
|
||||
'Gowa',
|
||||
'Sinjai',
|
||||
'Bone',
|
||||
'Maros',
|
||||
'Pangkajene Kepulauan',
|
||||
'Barru',
|
||||
'Soppeng',
|
||||
'Wajo',
|
||||
'Sidenreng Rappang',
|
||||
'Pinrang',
|
||||
'Enrekang',
|
||||
'Luwu',
|
||||
'Tana Toraja',
|
||||
'Luwu Utara',
|
||||
'Luwu Timur',
|
||||
'Toraja Utara',
|
||||
'Kota Makassar',
|
||||
'Kota Pare Pare',
|
||||
'Kota Palopo',
|
||||
'Kolaka',
|
||||
'Konawe',
|
||||
'Muna',
|
||||
'Buton',
|
||||
'Konawe Selatan',
|
||||
'Bombana',
|
||||
'Wakatobi',
|
||||
'Kolaka Utara',
|
||||
'Konawe Utara',
|
||||
'Buton Utara',
|
||||
'Kolaka Timur',
|
||||
'Konawe Kepulauan',
|
||||
'Muna Barat',
|
||||
'Buton Tengah',
|
||||
'Buton Selatan',
|
||||
'Kota Kendari',
|
||||
'Kota Bau Bau',
|
||||
'Gorontalo',
|
||||
'Boalemo',
|
||||
'Bone Bolango',
|
||||
'Pahuwato',
|
||||
'Gorontalo Utara',
|
||||
'Kota Gorontalo',
|
||||
'Pasangkayu (Mamuju Utara)',
|
||||
'Mamuju',
|
||||
'Mamasa',
|
||||
'Polewali Mandar',
|
||||
'Majene',
|
||||
'Mamuju Tengah',
|
||||
'Maluku Tengah',
|
||||
'Maluku Tenggara',
|
||||
'Kepulauan Tanimbar (Maluku Tenggara Barat)',
|
||||
'Buru',
|
||||
'Seram Bagian Timur',
|
||||
'Seram Bagian Barat',
|
||||
'Kepulauan Aru',
|
||||
'Maluku Barat Daya',
|
||||
'Buru Selatan',
|
||||
'Kota Ambon',
|
||||
'Kota Tual',
|
||||
'Halmahera Barat',
|
||||
'Halmahera Tengah',
|
||||
'Halmahera Utara',
|
||||
'Halmahera Selatan',
|
||||
'Kepulauan Sula',
|
||||
'Halmahera Timur',
|
||||
'Pulau Morotai',
|
||||
'Pulau Taliabu',
|
||||
'Kota Ternate',
|
||||
'Kota Tidore Kepulauan',
|
||||
'Jayapura',
|
||||
'Kepulauan Yapen',
|
||||
'Biak Numfor',
|
||||
'Sarmi',
|
||||
'Keerom',
|
||||
'Waropen',
|
||||
'Supiori',
|
||||
'Mamberamo Raya',
|
||||
'Kota Jayapura',
|
||||
'Manokwari',
|
||||
'Fak Fak',
|
||||
'Teluk Bintuni',
|
||||
'Teluk Wondama',
|
||||
'Kaimana',
|
||||
'Manokwari Selatan',
|
||||
'Pegunungan Arfak',
|
||||
'Merauke',
|
||||
'Boven Digoel',
|
||||
'Mappi',
|
||||
'Asmat',
|
||||
'Nabire',
|
||||
'Puncak Jaya',
|
||||
'Paniai',
|
||||
'Mimika',
|
||||
'Puncak',
|
||||
'Dogiyai',
|
||||
'Intan Jaya',
|
||||
'Deiyai',
|
||||
'Jayawijaya',
|
||||
'Pegunungan Bintang',
|
||||
'Yahukimo',
|
||||
'Tolikara',
|
||||
'Mamberamo Tengah',
|
||||
'Yalimo',
|
||||
'Lanny Jaya',
|
||||
'Nduga',
|
||||
'Sorong',
|
||||
'Sorong Selatan',
|
||||
'Raja Ampat',
|
||||
'Tambrauw',
|
||||
'Maybrat',
|
||||
'Kota Sorong',
|
||||
];
|
||||
|
||||
export default INDONESIAN_CITIES;
|
||||
@@ -2,6 +2,9 @@
|
||||
@import 'tailwindcss';
|
||||
@source "../../../libs/ui/**/*.{ts,tsx}";
|
||||
|
||||
/* Enable class-based dark mode (using .dark class on html element) */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-primary-50: #f0f8ff;
|
||||
--color-primary-100: #e1f0fd;
|
||||
@@ -20,12 +23,12 @@
|
||||
--color-neutral-200: #d1d1d1;
|
||||
--color-neutral-300: #b0b0b0;
|
||||
--color-neutral-400: #888888;
|
||||
--color-neutral-500: #6d6d6d;
|
||||
--color-neutral-600: #5d5d5d;
|
||||
--color-neutral-700: #4f4f4f;
|
||||
--color-neutral-800: #454545;
|
||||
--color-neutral-900: #3d3d3d;
|
||||
--color-neutral-950: #2b2b2b;
|
||||
--color-neutral-500: #6e6a86;
|
||||
--color-neutral-600: #56526e;
|
||||
--color-neutral-700: #44415a;
|
||||
--color-neutral-800: #393552;
|
||||
--color-neutral-900: #2a273f;
|
||||
--color-neutral-950: #232136;
|
||||
|
||||
--color-success-100: #e0fbd8;
|
||||
--color-success-200: #bcf8b0;
|
||||
@@ -108,6 +111,14 @@
|
||||
border-color: var(--color-neutral-200, currentColor);
|
||||
}
|
||||
|
||||
.dark *,
|
||||
.dark ::after,
|
||||
.dark ::before,
|
||||
.dark ::backdrop,
|
||||
.dark ::file-selector-button {
|
||||
border-color: var(--color-neutral-700, currentColor);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -118,19 +129,41 @@
|
||||
background: var(--color-neutral-50);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cccccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: var(--color-neutral-600);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: 'Bai Jamjuree', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode base styles */
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: var(--color-neutral-950);
|
||||
color: var(--color-neutral-100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
QueryProvider,
|
||||
} from '@imphnen-frontend-service/utils';
|
||||
import { Toaster } from 'sonner';
|
||||
import { ThemeProvider } from './components/theme-provider';
|
||||
import './index.css';
|
||||
|
||||
const files = import.meta.glob('./app/**/*(page|layout).tsx');
|
||||
@@ -36,11 +37,13 @@ if (!rootElement) throw new Error('Failed to find the root element');
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<QueryProvider>
|
||||
<ModalLoginProvider>
|
||||
<Toaster position="top-right" />
|
||||
<RouterProvider router={router} />
|
||||
</ModalLoginProvider>
|
||||
</QueryProvider>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<QueryProvider>
|
||||
<ModalLoginProvider>
|
||||
<Toaster position="top-right" richColors />
|
||||
<RouterProvider router={router} />
|
||||
</ModalLoginProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SessionUser } from '@imphnen-frontend-service/utils';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
import { hackathonApi, SessionToken } from '@imphnen-frontend-service/service';
|
||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||
|
||||
const mappingPublicRoutes = [
|
||||
@@ -37,16 +37,10 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname;
|
||||
|
||||
console.log('[Middleware] Checking route:', pathname);
|
||||
|
||||
// Get session from Supabase (authoritative source)
|
||||
const { data: { session: supabaseSession }, error: sessionError } = await supabase.auth.getSession();
|
||||
|
||||
// Handle session errors
|
||||
if (sessionError) {
|
||||
console.error('[Middleware] Session error:', sessionError);
|
||||
// Don't redirect on session errors, let the app handle it
|
||||
}
|
||||
// Get token from cookies and user from local storage
|
||||
const tokenData = SessionToken.get();
|
||||
const user = SessionUser.get();
|
||||
const isAuthenticated = !!tokenData?.token?.access_token;
|
||||
|
||||
// Allow to access the hackathon pages without authentication
|
||||
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
|
||||
@@ -65,20 +59,24 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
|
||||
// Auth routes (all /auth/* paths) - redirect to dashboard if already authenticated
|
||||
if (pathname.startsWith('/auth')) {
|
||||
if (supabaseSession) return redirect('/dashboard');
|
||||
if (isAuthenticated) return redirect('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Require authentication for all other routes - ONLY check Supabase session
|
||||
if (!supabaseSession) {
|
||||
// Require authentication for all other routes
|
||||
if (!isAuthenticated) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
|
||||
// Check if user has completed onboarding by querying database (not localStorage!)
|
||||
// Check if user has completed onboarding
|
||||
// Skip onboarding check for onboarding routes themselves
|
||||
if (!mappingOnboardingRoutes.includes(pathname)) {
|
||||
try {
|
||||
const userId = supabaseSession.user.id;
|
||||
const userId = user?.id;
|
||||
if (!userId) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache first
|
||||
@@ -86,24 +84,22 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
let hasLocation = false;
|
||||
|
||||
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
||||
console.log('[Middleware] Using cached onboarding status');
|
||||
hasLocation = cached.hasLocation;
|
||||
} else {
|
||||
console.log('[Middleware] Fetching fresh onboarding status');
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('location')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (userError) {
|
||||
console.error('[Middleware] Failed to fetch user data:', userError);
|
||||
// If we can't fetch user data, allow access (don't break the app)
|
||||
return null;
|
||||
// First check user data (faster)
|
||||
if (user?.location) {
|
||||
hasLocation = true;
|
||||
} else {
|
||||
// Fetch from backend API
|
||||
try {
|
||||
const response = await hackathonApi.get('/users/me');
|
||||
hasLocation = !!response.data?.data?.location;
|
||||
} catch {
|
||||
// If API fails, check user data as fallback
|
||||
hasLocation = !!user?.location;
|
||||
}
|
||||
}
|
||||
|
||||
hasLocation = !!userData?.location;
|
||||
|
||||
// Update cache
|
||||
onboardingCache.set(userId, { hasLocation, timestamp: now });
|
||||
}
|
||||
@@ -118,10 +114,9 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check route permissions using fresh user data from Zustand (for UI metadata)
|
||||
const session = SessionUser.get();
|
||||
// Check route permissions using user data
|
||||
const userPermissions =
|
||||
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||
user?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||
|
||||
const matchedRoute = mappingRoutePermissions.find(
|
||||
(route) => route.path === pathname
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
|
||||
function randBytes(len: number): Uint8Array {
|
||||
const b = new Uint8Array(len);
|
||||
crypto.getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
function bufToBase64(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let s = '';
|
||||
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
function base64ToBuf(b64: string): ArrayBuffer {
|
||||
const s = atob(b64);
|
||||
const arr = new Uint8Array(s.length);
|
||||
for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);
|
||||
return arr.buffer;
|
||||
}
|
||||
|
||||
async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, iterations = 100_000) {
|
||||
const passKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(passphrase),
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations,
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
passKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts plaintext with passphrase -> returns base64(salt||iv||ciphertext)
|
||||
*/
|
||||
export async function encryptText(plaintext: string, passphrase: string) {
|
||||
const salt = randBytes(16); // 128-bit salt
|
||||
const iv = randBytes(12); // 96-bit IV recommended for GCM
|
||||
const key = await deriveKeyFromPassphrase(passphrase, salt);
|
||||
|
||||
const cipher = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
enc.encode(plaintext)
|
||||
);
|
||||
|
||||
// concat salt + iv + ciphertext
|
||||
const out = new Uint8Array(salt.length + iv.length + cipher.byteLength);
|
||||
out.set(salt, 0);
|
||||
out.set(iv, salt.length);
|
||||
out.set(new Uint8Array(cipher), salt.length + iv.length);
|
||||
|
||||
return bufToBase64(out.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts base64(salt||iv||ciphertext) with passphrase -> plaintext
|
||||
*/
|
||||
export async function decryptText(b64combined: string, passphrase: string) {
|
||||
const combined = new Uint8Array(base64ToBuf(b64combined));
|
||||
const salt = combined.slice(0, 16);
|
||||
const iv = combined.slice(16, 28);
|
||||
const cipher = combined.slice(28);
|
||||
|
||||
const key = await deriveKeyFromPassphrase(passphrase, salt);
|
||||
const plainBuf = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
cipher
|
||||
);
|
||||
return dec.decode(plainBuf);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { decryptText, encryptText } from "./aesclient";
|
||||
|
||||
const SECRET_KEY = 'imphnen-hackathon-2025';
|
||||
|
||||
/**
|
||||
* Encode teamId and submissionId into a certificate ID
|
||||
* Uses base64 encoding for simple obfuscation
|
||||
* @param teamId - The team ID
|
||||
* @param submissionId - The submission ID
|
||||
* @returns Encoded certificate ID
|
||||
*/
|
||||
export const encodeCertificateId = async (teamId: string, submissionId: string): Promise<string> => {
|
||||
const combined = `${teamId}::${submissionId}`;
|
||||
return encryptText(combined, SECRET_KEY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode certificate ID back to teamId and submissionId
|
||||
* @param certId - The encoded certificate ID
|
||||
* @returns Object containing teamId and submissionId
|
||||
*/
|
||||
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string }> => {
|
||||
try {
|
||||
const decoded = await decryptText(certId, SECRET_KEY);
|
||||
const [teamId, submissionId] = decoded.split('::');
|
||||
return { teamId, submissionId };
|
||||
} catch {
|
||||
throw new Error('Invalid certificate ID');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* For development: Create a certId using created_at timestamp
|
||||
* @param teamId - The team ID
|
||||
* @param createdAt - The creation timestamp
|
||||
* @returns Encoded certificate ID
|
||||
*/
|
||||
export const encodeCertificateIdWithTimestamp = (teamId: string, createdAt: string): string => {
|
||||
const combined = `${teamId}::${createdAt}`;
|
||||
return Buffer.from(combined).toString('base64');
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
Tue, Nov 25, 2025 11:09:55 AM
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
|
||||
@@ -7,6 +7,7 @@ FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NX_DAEMON=false
|
||||
RUN npm run backoffice:build
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
|
||||
@@ -7,6 +7,7 @@ FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NX_DAEMON=false
|
||||
RUN npm run dimentorin:build
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
|
||||
@@ -7,6 +7,7 @@ FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NX_DAEMON=false
|
||||
RUN npm run gacha:build
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
|
||||
@@ -16,6 +16,7 @@ ARG VITE_SUPABASE_ANON_KEY
|
||||
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
||||
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
|
||||
|
||||
ENV NX_DAEMON=false
|
||||
RUN npm run hackathon:build
|
||||
|
||||
FROM nginx:alpine AS runner
|
||||
|
||||
@@ -4,6 +4,7 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --prefer-offline --no-audit --no-fund
|
||||
COPY . .
|
||||
ENV NX_DAEMON=false
|
||||
RUN npm run landing:build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
# Hackathon Backoffice API Contract
|
||||
|
||||
## GET /api/v1/admin/dashboard
|
||||
|
||||
### Purpose
|
||||
|
||||
Single endpoint delivering aggregated metrics for the IMPHNEN x Kolosal.ai Hackathon dashboard.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||
- 401 if unauthenticated, 403 if authenticated but lacking required scope.
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
GET /api/v1/admin/dashboard
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"total_participants": 1261,
|
||||
"total_teams": 206,
|
||||
"total_submissions": 0 // Total project submitted
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field Types
|
||||
|
||||
| Path | Type | Notes |
|
||||
| ------------------------- | ------- | --------------------- |
|
||||
| `data.total_participants` | integer | >= 0 |
|
||||
| `data.total_teams` | integer | >= 0 |
|
||||
| `data.total_submissions` | integer | <= `data.total_teams` |
|
||||
|
||||
### Errors
|
||||
|
||||
| Status | Code | Message | Notes |
|
||||
| ------ | ---------------- | ----------------------------- | --------------------- |
|
||||
| 401 | `unauthorized` | `authentication required` | Missing/invalid token |
|
||||
| 403 | `forbidden` | `insufficient permissions` | Lacks required scope |
|
||||
| 429 | `rate_limited` | `too many dashboard requests` | Rate limiting |
|
||||
| 500 | `internal_error` | `unexpected server error` | Unhandled exception |
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/admin/users
|
||||
|
||||
### Purpose
|
||||
|
||||
Retrieve paginated list of hackathon participants with filtering, searching, and sorting capabilities for backoffice user management.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||
- 401 if unauthenticated, 403 if authenticated but lacking required scope.
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
| ------------ | ------- | -------- | ------------ | ------------------------------------------------------------- |
|
||||
| `page` | integer | No | 1 | Page number (1-based) |
|
||||
| `limit` | integer | No | 10 | Items per page (1-100) |
|
||||
| `search` | string | No | - | Search by name or location (case-insensitive) |
|
||||
| `status` | string | No | `all` | Filter by status: `all`, `active`, `inactive` |
|
||||
| `location` | string | No | `all` | Filter by location or `all` |
|
||||
| `skills` | string | No | - | Comma-separated skill filters |
|
||||
| `sort_by` | string | No | `created_at` | Sort field: `fullname`, `location`, `is_active`, `created_at` |
|
||||
| `sort_order` | string | No | `desc` | Sort order: `asc`, `desc` |
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
GET /api/v1/admin/users
|
||||
GET /api/v1/admin/users?page=2&limit=10
|
||||
GET /api/v1/admin/users?search=john&status=active
|
||||
GET /api/v1/admin/users?location=Jakarta&skills=Frontend Developer,UI/UX Designer
|
||||
GET /api/v1/admin/users?sort_by=fullname&sort_order=asc
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"users": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"avatar": "https://example.com/avatars/user1.jpg", // Optional
|
||||
"fullname": "Budi Santoso",
|
||||
"bio": "Passionate developer with 5+ years experience", // Optional
|
||||
"location": "Jakarta",
|
||||
"is_active": true,
|
||||
"skills": ["Frontend Developer", "UI/UX Designer"], // Optional
|
||||
"created_at": "2024-11-15T08:30:00Z",
|
||||
"updated_at": "2024-11-30T14:22:00Z"
|
||||
}
|
||||
// ... more users
|
||||
],
|
||||
"pagination": {
|
||||
"current_page": 1,
|
||||
"total_pages": 15,
|
||||
"total_items": 287,
|
||||
"items_per_page": 20,
|
||||
"has_next": true,
|
||||
"has_prev": false
|
||||
},
|
||||
"filters": {
|
||||
"available_locations": ["Jakarta", "Bandung", "Surabaya", "Medan", "Yogyakarta"],
|
||||
"available_skills": ["Frontend Developer", "Backend Developer", "Full Stack Developer", "DevOps Engineer", "UI/UX Designer", "Product Manager", "Data Scientist", "Mobile Developer"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field Types
|
||||
|
||||
| Path | Type | Notes |
|
||||
| ------------------------- | ------- | ------------------------------ |
|
||||
| `data.users[].id` | string | UUID format |
|
||||
| `data.users[].avatar` | string | URL, nullable |
|
||||
| `data.users[].fullname` | string | Required |
|
||||
| `data.users[].bio` | string | Optional, max 500 chars |
|
||||
| `data.users[].location` | string | Required, from predefined list |
|
||||
| `data.users[].is_active` | boolean | Account status |
|
||||
| `data.users[].skills` | array | Array of skill strings |
|
||||
| `data.users[].created_at` | string | ISO 8601 timestamp |
|
||||
| `data.users[].updated_at` | string | ISO 8601 timestamp |
|
||||
| `data.pagination.*` | integer | Pagination metadata |
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/admin/users/{user_id}
|
||||
|
||||
### Purpose
|
||||
|
||||
Retrieve detailed information for a specific user by ID.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | ----------- |
|
||||
| `user_id` | string | Yes | User UUID |
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
GET /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"avatar": "https://example.com/avatars/user1.jpg",
|
||||
"fullname": "Budi Santoso",
|
||||
"bio": "Passionate developer with 5+ years experience",
|
||||
"location": "Jakarta",
|
||||
"is_active": true,
|
||||
"skills": ["Frontend Developer", "UI/UX Designer"],
|
||||
"created_at": "2024-11-15T08:30:00Z",
|
||||
"updated_at": "2024-11-30T14:22:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/admin/users
|
||||
|
||||
### Purpose
|
||||
|
||||
Create a new user account in the hackathon system.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||
|
||||
### Request Body Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"fullname": "Jane Doe", // Required, 1-100 chars
|
||||
"bio": "Experienced developer", // Optional, max 500 chars
|
||||
"location": "Jakarta", // Required, from predefined list
|
||||
"is_active": true, // Required, boolean
|
||||
"skills": ["Backend Developer"], // Optional, array of valid skills
|
||||
"avatar": "https://example.com/images/..." // Optional, URL
|
||||
}
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
POST /api/v1/admin/users
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"fullname": "Jane Doe",
|
||||
"bio": "Experienced developer passionate about AI and machine learning",
|
||||
"location": "Jakarta",
|
||||
"is_active": true,
|
||||
"skills": ["Backend Developer", "Data Scientist"],
|
||||
"avatar": "https://example.com/images/..."
|
||||
}
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"avatar": "https://example.com/avatars/generated_url.jpg",
|
||||
"fullname": "Jane Doe",
|
||||
"bio": "Experienced developer passionate about AI and machine learning",
|
||||
"location": "Jakarta",
|
||||
"is_active": true,
|
||||
"skills": ["Backend Developer", "Data Scientist"],
|
||||
"created_at": "2024-12-01T10:30:00Z",
|
||||
"updated_at": "2024-12-01T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /api/v1/admin/users/{user_id}
|
||||
|
||||
### Purpose
|
||||
|
||||
Update an existing user's profile information.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | ----------- |
|
||||
| `user_id` | string | Yes | User UUID |
|
||||
|
||||
### Request Body Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"fullname": "Jane Smith", // Optional, 1-100 chars
|
||||
"bio": "Senior developer", // Optional, max 500 chars, null to clear
|
||||
"location": "Bandung", // Optional, from predefined list
|
||||
"is_active": false, // Optional, boolean
|
||||
"skills": ["Full Stack Developer"], // Optional, array of valid skills
|
||||
"avatar": "https://example.com/images/..." // Optional, URL, null to remove
|
||||
}
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
PUT /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"fullname": "Jane Smith",
|
||||
"location": "Bandung",
|
||||
"is_active": false,
|
||||
"skills": ["Full Stack Developer", "Product Manager"]
|
||||
}
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"avatar": "https://example.com/avatars/user1.jpg",
|
||||
"fullname": "Jane Smith",
|
||||
"bio": "Experienced developer passionate about AI and machine learning",
|
||||
"location": "Bandung",
|
||||
"is_active": false,
|
||||
"skills": ["Full Stack Developer", "Product Manager"],
|
||||
"created_at": "2024-11-15T08:30:00Z",
|
||||
"updated_at": "2024-12-01T10:45:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DELETE /api/v1/admin/users/{user_id}
|
||||
|
||||
### Purpose
|
||||
|
||||
(Soft) Delete a user account from the hackathon system.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- Requires admin (backoffice) scope: e.g. `role=admin`
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | ----------- |
|
||||
| `user_id` | string | Yes | User UUID |
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
DELETE /api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"data": {
|
||||
"message": "User successfully deleted",
|
||||
"deleted_user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"deleted_at": "2024-12-01T10:50:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Error Responses
|
||||
|
||||
### User Management Endpoints
|
||||
|
||||
| Status | Code | Message | Notes |
|
||||
| ------ | --------------------- | -------------------------------- | ---------------------------- |
|
||||
| 400 | `validation_error` | `Invalid request data` | Field validation failures |
|
||||
| 401 | `unauthorized` | `Authentication required` | Missing/invalid token |
|
||||
| 403 | `forbidden` | `Insufficient permissions` | Lacks required scope |
|
||||
| 404 | `user_not_found` | `User not found` | Invalid user ID |
|
||||
| 409 | `user_already_exists` | `User with email already exists` | Duplicate user creation |
|
||||
| 413 | `payload_too_large` | `Avatar file too large` | Avatar exceeds size limit |
|
||||
| 422 | `invalid_skill` | `Invalid skill specified` | Skill not in allowed list |
|
||||
| 422 | `invalid_location` | `Invalid location specified` | Location not in allowed list |
|
||||
| 429 | `rate_limited` | `Too many requests` | Rate limiting |
|
||||
| 500 | `internal_error` | `Unexpected server error` | Unhandled exception |
|
||||
|
||||
### Validation Error Details
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"error": {
|
||||
"code": "validation_error",
|
||||
"message": "Invalid request data",
|
||||
"details": [
|
||||
{
|
||||
"field": "fullname",
|
||||
"code": "required",
|
||||
"message": "Full name is required"
|
||||
},
|
||||
{
|
||||
"field": "location",
|
||||
"code": "invalid_choice",
|
||||
"message": "Location must be one of: Jakarta, Bandung, Surabaya, Medan, Yogyakarta"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
- **Dashboard**: 30 requests / minute / admin user
|
||||
- **User Management**: 100 requests / minute / admin user
|
||||
- **File Upload**: 10 avatar uploads / minute / admin user
|
||||
- Return 429 with `Retry-After` header
|
||||
|
||||
## Avatar Handling
|
||||
|
||||
- **Supported formats**: JPEG, PNG, WebP
|
||||
- **Max file size**: 5MB
|
||||
- **Recommended dimensions**: 400x400px
|
||||
- **Storage**: Uploaded avatars are processed and stored with generated URLs
|
||||
- **URL response**: Always return publicly accessible HTTPS URLs
|
||||
|
||||
---
|
||||
|
||||
**Revision History**
|
||||
|
||||
- v1.0.0 (2025-11-30): Initial contract drafted.
|
||||
- v2.0.0 (2025-12-01): Added user management endpoints with filtering, pagination, CRUD operations, and avatar handling.
|
||||
@@ -0,0 +1,61 @@
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../hooks/auth';
|
||||
|
||||
// Hackathon Backend API Base URL
|
||||
const HACKATHON_API_URL = 'https://api.hackathon.imphnen.dev/api/v1';
|
||||
|
||||
// Create axios instance for hackathon backend
|
||||
export const hackathonApi = axios.create({
|
||||
baseURL: HACKATHON_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token interceptor
|
||||
hackathonApi.interceptors.request.use(
|
||||
(config) => {
|
||||
const { session } = useAuthStore.getState();
|
||||
if (session?.token?.access_token) {
|
||||
config.headers.Authorization = `Bearer ${session.token.access_token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(new Error(error.message || 'Request failed'));
|
||||
}
|
||||
);
|
||||
|
||||
// Error handling interceptor
|
||||
hackathonApi.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Handle 401 - clear session and redirect to login
|
||||
// But skip redirect if already on auth pages (to avoid reload on login failure)
|
||||
if (error.response?.status === 401) {
|
||||
const isAuthPage = globalThis.window !== undefined && globalThis.location.pathname.startsWith('/auth');
|
||||
|
||||
// Only clear session and redirect if not on auth page
|
||||
if (!isAuthPage) {
|
||||
useAuthStore.getState().clearSession();
|
||||
if (globalThis.window !== undefined) {
|
||||
globalThis.location.href = '/auth/login';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If backend sends a message, use it
|
||||
const backendMsg = error?.response?.data?.message;
|
||||
if (backendMsg && typeof backendMsg === 'string') {
|
||||
return Promise.reject(new Error(backendMsg));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(error.message || 'Request failed'));
|
||||
}
|
||||
);
|
||||
|
||||
// API Response wrapper type
|
||||
export interface HackathonApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export * from './gacha';
|
||||
export * from './users';
|
||||
export * from './mentors';
|
||||
export * from './upload';
|
||||
export * from './hackathon';
|
||||
|
||||
// Common API response wrapper interface
|
||||
export interface ApiResponse<T> {
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
export const INDONESIAN_CITIES: string[] = [
|
||||
'Aceh Selatan',
|
||||
'Aceh Tenggara',
|
||||
'Aceh Timur',
|
||||
'Aceh Tengah',
|
||||
'Aceh Barat',
|
||||
'Aceh Besar',
|
||||
'Pidie',
|
||||
'Aceh Utara',
|
||||
'Simeulue',
|
||||
'Aceh Singkil',
|
||||
'Bireuen',
|
||||
'Aceh Barat Daya',
|
||||
'Gayo Lues',
|
||||
'Aceh Jaya',
|
||||
'Nagan Raya',
|
||||
'Aceh Tamiang',
|
||||
'Bener Meriah',
|
||||
'Pidie Jaya',
|
||||
'Kota Banda Aceh',
|
||||
'Kota Sabang',
|
||||
'Kota Lhokseumawe',
|
||||
'Kota Langsa',
|
||||
'Kota Subulussalam',
|
||||
'Tapanuli Tengah',
|
||||
'Tapanuli Utara',
|
||||
'Tapanuli Selatan',
|
||||
'Nias',
|
||||
'Langkat',
|
||||
'Karo',
|
||||
'Deli Serdang',
|
||||
'Simalungun',
|
||||
'Asahan',
|
||||
'Labuhanbatu',
|
||||
'Dairi',
|
||||
'Toba',
|
||||
'Mandailing Natal',
|
||||
'Nias Selatan',
|
||||
'Pakpak Bharat',
|
||||
'Humbang Hasundutan',
|
||||
'Samosir',
|
||||
'Serdang Bedagai',
|
||||
'Batu Bara',
|
||||
'Padang Lawas Utara',
|
||||
'Padang Lawas',
|
||||
'Labuhanbatu Selatan',
|
||||
'Labuhanbatu Utara',
|
||||
'Nias Utara',
|
||||
'Nias Barat',
|
||||
'Kota Medan',
|
||||
'Kota Pematangsiantar',
|
||||
'Kota Sibolga',
|
||||
'Kota Tanjung Balai',
|
||||
'Kota Binjai',
|
||||
'Kota Tebing Tinggi',
|
||||
'Kota Padangsidimpuan',
|
||||
'Kota Gunungsitoli',
|
||||
'Pesisir Selatan',
|
||||
'Solok',
|
||||
'Sijunjung',
|
||||
'Tanah Datar',
|
||||
'Padang Pariaman',
|
||||
'Agam',
|
||||
'Lima Puluh Kota',
|
||||
'Pasaman',
|
||||
'Kepulauan Mentawai',
|
||||
'Dharmasraya',
|
||||
'Solok Selatan',
|
||||
'Pasaman Barat',
|
||||
'Kota Padang',
|
||||
'Kota Solok',
|
||||
'Kota Sawahlunto',
|
||||
'Kota Padang Panjang',
|
||||
'Kota Bukittinggi',
|
||||
'Kota Payakumbuh',
|
||||
'Kota Pariaman',
|
||||
'Kampar',
|
||||
'Indragiri Hulu',
|
||||
'Bengkalis',
|
||||
'Indragiri Hilir',
|
||||
'Pelalawan',
|
||||
'Rokan Hulu',
|
||||
'Rokan Hilir',
|
||||
'Siak',
|
||||
'Kuantan Singingi',
|
||||
'Kepulauan Meranti',
|
||||
'Kota Pekanbaru',
|
||||
'Kota Dumai',
|
||||
'Kerinci',
|
||||
'Merangin',
|
||||
'Sarolangun',
|
||||
'Batanghari',
|
||||
'Muaro Jambi',
|
||||
'Tanjung Jabung Barat',
|
||||
'Tanjung Jabung Timur',
|
||||
'Bungo',
|
||||
'Tebo',
|
||||
'Kota Jambi',
|
||||
'Kota Sungai Penuh',
|
||||
'Ogan Komering Ulu',
|
||||
'Ogan Komering Ilir',
|
||||
'Muara Enim',
|
||||
'Lahat',
|
||||
'Musi Rawas',
|
||||
'Musi Banyuasin',
|
||||
'Banyuasin',
|
||||
'Ogan Komering Ulu Timur',
|
||||
'Ogan Komering Ulu Selatan',
|
||||
'Ogan Ilir',
|
||||
'Empat Lawang',
|
||||
'Penukal Abab Lematang Ilir',
|
||||
'Musi Rawas Utara',
|
||||
'Kota Palembang',
|
||||
'Kota Pagar Alam',
|
||||
'Kota Lubuk Linggau',
|
||||
'Kota Prabumulih',
|
||||
'Bengkulu Selatan',
|
||||
'Rejang Lebong',
|
||||
'Bengkulu Utara',
|
||||
'Kaur',
|
||||
'Seluma',
|
||||
'Muko Muko',
|
||||
'Lebong',
|
||||
'Kepahiang',
|
||||
'Bengkulu Tengah',
|
||||
'Kota Bengkulu',
|
||||
'Lampung Selatan',
|
||||
'Lampung Tengah',
|
||||
'Lampung Utara',
|
||||
'Lampung Barat',
|
||||
'Tulang Bawang',
|
||||
'Tanggamus',
|
||||
'Lampung Timur',
|
||||
'Way Kanan',
|
||||
'Pesawaran',
|
||||
'Pringsewu',
|
||||
'Mesuji',
|
||||
'Tulang Bawang Barat',
|
||||
'Pesisir Barat',
|
||||
'Kota Bandar Lampung',
|
||||
'Kota Metro',
|
||||
'Bangka',
|
||||
'Belitung',
|
||||
'Bangka Selatan',
|
||||
'Bangka Tengah',
|
||||
'Bangka Barat',
|
||||
'Belitung Timur',
|
||||
'Kota Pangkal Pinang',
|
||||
'Bintan',
|
||||
'Karimun',
|
||||
'Natuna',
|
||||
'Lingga',
|
||||
'Kepulauan Anambas',
|
||||
'Kota Batam',
|
||||
'Kota Tanjung Pinang',
|
||||
'Kepulauan Seribu',
|
||||
'Kota Jakarta Pusat',
|
||||
'Kota Jakarta Utara',
|
||||
'Kota Jakarta Barat',
|
||||
'Kota Jakarta Selatan',
|
||||
'Kota Jakarta Timur',
|
||||
'Bogor',
|
||||
'Sukabumi',
|
||||
'Cianjur',
|
||||
'Bandung',
|
||||
'Garut',
|
||||
'Tasikmalaya',
|
||||
'Ciamis',
|
||||
'Kuningan',
|
||||
'Cirebon',
|
||||
'Majalengka',
|
||||
'Sumedang',
|
||||
'Indramayu',
|
||||
'Subang',
|
||||
'Purwakarta',
|
||||
'Karawang',
|
||||
'Bekasi',
|
||||
'Bandung Barat',
|
||||
'Pangandaran',
|
||||
'Kota Bogor',
|
||||
'Kota Sukabumi',
|
||||
'Kota Bandung',
|
||||
'Kota Cirebon',
|
||||
'Kota Bekasi',
|
||||
'Kota Depok',
|
||||
'Kota Cimahi',
|
||||
'Kota Tasikmalaya',
|
||||
'Kota Banjar',
|
||||
'Cilacap',
|
||||
'Banyumas',
|
||||
'Purbalingga',
|
||||
'Banjarnegara',
|
||||
'Kebumen',
|
||||
'Purworejo',
|
||||
'Wonosobo',
|
||||
'Magelang',
|
||||
'Boyolali',
|
||||
'Klaten',
|
||||
'Sukoharjo',
|
||||
'Wonogiri',
|
||||
'Karanganyar',
|
||||
'Sragen',
|
||||
'Grobogan',
|
||||
'Blora',
|
||||
'Rembang',
|
||||
'Pati',
|
||||
'Kudus',
|
||||
'Jepara',
|
||||
'Demak',
|
||||
'Semarang',
|
||||
'Temanggung',
|
||||
'Kendal',
|
||||
'Batang',
|
||||
'Pekalongan',
|
||||
'Pemalang',
|
||||
'Tegal',
|
||||
'Brebes',
|
||||
'Kota Magelang',
|
||||
'Kota Surakarta',
|
||||
'Kota Salatiga',
|
||||
'Kota Semarang',
|
||||
'Kota Pekalongan',
|
||||
'Kota Tegal',
|
||||
'Kulon Progo',
|
||||
'Bantul',
|
||||
'Gunungkidul',
|
||||
'Sleman',
|
||||
'Kota Yogyakarta',
|
||||
'Pacitan',
|
||||
'Ponorogo',
|
||||
'Trenggalek',
|
||||
'Tulungagung',
|
||||
'Blitar',
|
||||
'Kediri',
|
||||
'Malang',
|
||||
'Lumajang',
|
||||
'Jember',
|
||||
'Banyuwangi',
|
||||
'Bondowoso',
|
||||
'Situbondo',
|
||||
'Probolinggo',
|
||||
'Pasuruan',
|
||||
'Sidoarjo',
|
||||
'Mojokerto',
|
||||
'Jombang',
|
||||
'Nganjuk',
|
||||
'Madiun',
|
||||
'Magetan',
|
||||
'Ngawi',
|
||||
'Bojonegoro',
|
||||
'Tuban',
|
||||
'Lamongan',
|
||||
'Gresik',
|
||||
'Bangkalan',
|
||||
'Sampang',
|
||||
'Pamekasan',
|
||||
'Sumenep',
|
||||
'Kota Kediri',
|
||||
'Kota Blitar',
|
||||
'Kota Malang',
|
||||
'Kota Probolinggo',
|
||||
'Kota Pasuruan',
|
||||
'Kota Mojokerto',
|
||||
'Kota Madiun',
|
||||
'Kota Surabaya',
|
||||
'Kota Batu',
|
||||
'Pandeglang',
|
||||
'Lebak',
|
||||
'Tangerang',
|
||||
'Serang',
|
||||
'Kota Tangerang',
|
||||
'Kota Cilegon',
|
||||
'Kota Serang',
|
||||
'Kota Tangerang Selatan',
|
||||
'Jembrana',
|
||||
'Tabanan',
|
||||
'Badung',
|
||||
'Gianyar',
|
||||
'Klungkung',
|
||||
'Bangli',
|
||||
'Karangasem',
|
||||
'Buleleng',
|
||||
'Kota Denpasar',
|
||||
'Lombok Barat',
|
||||
'Lombok Tengah',
|
||||
'Lombok Timur',
|
||||
'Sumbawa',
|
||||
'Dompu',
|
||||
'Bima',
|
||||
'Sumbawa Barat',
|
||||
'Lombok Utara',
|
||||
'Kota Mataram',
|
||||
'Kota Bima',
|
||||
'Kupang',
|
||||
'Timor Tengah Selatan',
|
||||
'Timor Tengah Utara',
|
||||
'Belu',
|
||||
'Alor',
|
||||
'Flores Timur',
|
||||
'Sikka',
|
||||
'Ende',
|
||||
'Ngada',
|
||||
'Manggarai',
|
||||
'Sumba Timur',
|
||||
'Sumba Barat',
|
||||
'Lembata',
|
||||
'Rote Ndao',
|
||||
'Manggarai Barat',
|
||||
'Nagekeo',
|
||||
'Sumba Tengah',
|
||||
'Sumba Barat Daya',
|
||||
'Manggarai Timur',
|
||||
'Sabu Raijua',
|
||||
'Malaka',
|
||||
'Kota Kupang',
|
||||
'Sambas',
|
||||
'Mempawah',
|
||||
'Sanggau',
|
||||
'Ketapang',
|
||||
'Sintang',
|
||||
'Kapuas Hulu',
|
||||
'Bengkayang',
|
||||
'Landak',
|
||||
'Sekadau',
|
||||
'Melawi',
|
||||
'Kayong Utara',
|
||||
'Kubu Raya',
|
||||
'Kota Pontianak',
|
||||
'Kota Singkawang',
|
||||
'Kotawaringin Barat',
|
||||
'Kotawaringin Timur',
|
||||
'Kapuas',
|
||||
'Barito Selatan',
|
||||
'Barito Utara',
|
||||
'Katingan',
|
||||
'Seruyan',
|
||||
'Sukamara',
|
||||
'Lamandau',
|
||||
'Gunung Mas',
|
||||
'Pulang Pisau',
|
||||
'Murung Raya',
|
||||
'Barito Timur',
|
||||
'Kota Palangkaraya',
|
||||
'Tanah Laut',
|
||||
'Kotabaru',
|
||||
'Banjar',
|
||||
'Barito Kuala',
|
||||
'Tapin',
|
||||
'Hulu Sungai Selatan',
|
||||
'Hulu Sungai Tengah',
|
||||
'Hulu Sungai Utara',
|
||||
'Tabalong',
|
||||
'Tanah Bumbu',
|
||||
'Balangan',
|
||||
'Kota Banjarmasin',
|
||||
'Kota Banjarbaru',
|
||||
'Paser',
|
||||
'Kutai Kartanegara',
|
||||
'Berau',
|
||||
'Kutai Barat',
|
||||
'Kutai Timur',
|
||||
'Penajam Paser Utara',
|
||||
'Mahakam Ulu',
|
||||
'Kota Balikpapan',
|
||||
'Kota Samarinda',
|
||||
'Kota Bontang',
|
||||
'Bulungan',
|
||||
'Malinau',
|
||||
'Nunukan',
|
||||
'Tana Tidung',
|
||||
'Kota Tarakan',
|
||||
'Bolaang Mongondow',
|
||||
'Minahasa',
|
||||
'Kepulauan Sangihe',
|
||||
'Kepulauan Talaud',
|
||||
'Minahasa Selatan',
|
||||
'Minahasa Utara',
|
||||
'Minahasa Tenggara',
|
||||
'Bolaang Mongondow Utara',
|
||||
'Kepulauan Siau Tagulandang Biaro (Sitaro)',
|
||||
'Bolaang Mongondow Timur',
|
||||
'Bolaang Mongondow Selatan',
|
||||
'Kota Manado',
|
||||
'Kota Bitung',
|
||||
'Kota Tomohon',
|
||||
'Kota Kotamobagu',
|
||||
'Banggai',
|
||||
'Poso',
|
||||
'Donggala',
|
||||
'Toli Toli',
|
||||
'Buol',
|
||||
'Morowali',
|
||||
'Banggai Kepulauan',
|
||||
'Parigi Moutong',
|
||||
'Tojo Una Una',
|
||||
'Sigi',
|
||||
'Banggai Laut',
|
||||
'Morowali Utara',
|
||||
'Kota Palu',
|
||||
'Kepulauan Selayar',
|
||||
'Bulukumba',
|
||||
'Bantaeng',
|
||||
'Jeneponto',
|
||||
'Takalar',
|
||||
'Gowa',
|
||||
'Sinjai',
|
||||
'Bone',
|
||||
'Maros',
|
||||
'Pangkajene Kepulauan',
|
||||
'Barru',
|
||||
'Soppeng',
|
||||
'Wajo',
|
||||
'Sidenreng Rappang',
|
||||
'Pinrang',
|
||||
'Enrekang',
|
||||
'Luwu',
|
||||
'Tana Toraja',
|
||||
'Luwu Utara',
|
||||
'Luwu Timur',
|
||||
'Toraja Utara',
|
||||
'Kota Makassar',
|
||||
'Kota Pare Pare',
|
||||
'Kota Palopo',
|
||||
'Kolaka',
|
||||
'Konawe',
|
||||
'Muna',
|
||||
'Buton',
|
||||
'Konawe Selatan',
|
||||
'Bombana',
|
||||
'Wakatobi',
|
||||
'Kolaka Utara',
|
||||
'Konawe Utara',
|
||||
'Buton Utara',
|
||||
'Kolaka Timur',
|
||||
'Konawe Kepulauan',
|
||||
'Muna Barat',
|
||||
'Buton Tengah',
|
||||
'Buton Selatan',
|
||||
'Kota Kendari',
|
||||
'Kota Bau Bau',
|
||||
'Gorontalo',
|
||||
'Boalemo',
|
||||
'Bone Bolango',
|
||||
'Pahuwato',
|
||||
'Gorontalo Utara',
|
||||
'Kota Gorontalo',
|
||||
'Pasangkayu (Mamuju Utara)',
|
||||
'Mamuju',
|
||||
'Mamasa',
|
||||
'Polewali Mandar',
|
||||
'Majene',
|
||||
'Mamuju Tengah',
|
||||
'Maluku Tengah',
|
||||
'Maluku Tenggara',
|
||||
'Kepulauan Tanimbar (Maluku Tenggara Barat)',
|
||||
'Buru',
|
||||
'Seram Bagian Timur',
|
||||
'Seram Bagian Barat',
|
||||
'Kepulauan Aru',
|
||||
'Maluku Barat Daya',
|
||||
'Buru Selatan',
|
||||
'Kota Ambon',
|
||||
'Kota Tual',
|
||||
'Halmahera Barat',
|
||||
'Halmahera Tengah',
|
||||
'Halmahera Utara',
|
||||
'Halmahera Selatan',
|
||||
'Kepulauan Sula',
|
||||
'Halmahera Timur',
|
||||
'Pulau Morotai',
|
||||
'Pulau Taliabu',
|
||||
'Kota Ternate',
|
||||
'Kota Tidore Kepulauan',
|
||||
'Jayapura',
|
||||
'Kepulauan Yapen',
|
||||
'Biak Numfor',
|
||||
'Sarmi',
|
||||
'Keerom',
|
||||
'Waropen',
|
||||
'Supiori',
|
||||
'Mamberamo Raya',
|
||||
'Kota Jayapura',
|
||||
'Manokwari',
|
||||
'Fak Fak',
|
||||
'Teluk Bintuni',
|
||||
'Teluk Wondama',
|
||||
'Kaimana',
|
||||
'Manokwari Selatan',
|
||||
'Pegunungan Arfak',
|
||||
'Merauke',
|
||||
'Boven Digoel',
|
||||
'Mappi',
|
||||
'Asmat',
|
||||
'Nabire',
|
||||
'Puncak Jaya',
|
||||
'Paniai',
|
||||
'Mimika',
|
||||
'Puncak',
|
||||
'Dogiyai',
|
||||
'Intan Jaya',
|
||||
'Deiyai',
|
||||
'Jayawijaya',
|
||||
'Pegunungan Bintang',
|
||||
'Yahukimo',
|
||||
'Tolikara',
|
||||
'Mamberamo Tengah',
|
||||
'Yalimo',
|
||||
'Lanny Jaya',
|
||||
'Nduga',
|
||||
'Sorong',
|
||||
'Sorong Selatan',
|
||||
'Raja Ampat',
|
||||
'Tambrauw',
|
||||
'Maybrat',
|
||||
'Kota Sorong',
|
||||
];
|
||||
|
||||
export const INDONESIAN_CITIES_SET = new Set(INDONESIAN_CITIES);
|
||||
|
||||
export function isValidIndonesianCity(city: string): boolean {
|
||||
return INDONESIAN_CITIES_SET.has(city);
|
||||
}
|
||||
@@ -1,57 +1,251 @@
|
||||
import { supabase } from '../../supabase';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import * as authApi from '../../api/auth';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||
import { useAuthStore } from './use-auth-store';
|
||||
|
||||
export * from './use-auth-store';
|
||||
|
||||
// React Query hooks for auth API
|
||||
export const usePostLogin = () => {
|
||||
// Types matching backend response
|
||||
interface TokenInfo {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
phone_number?: string;
|
||||
avatar?: string;
|
||||
birthdate?: string;
|
||||
gender?: string;
|
||||
is_active: boolean;
|
||||
location?: string;
|
||||
bio?: string;
|
||||
skills?: string[];
|
||||
role_id?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
token: TokenInfo;
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface SessionResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface MessageResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Login request type
|
||||
interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Signup request type
|
||||
interface SignupRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
fullname: string;
|
||||
}
|
||||
|
||||
// GitHub auth request type
|
||||
interface GitHubAuthRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// Forgot password request type
|
||||
interface ForgotPasswordRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Reset password request type
|
||||
interface ResetPasswordRequest {
|
||||
access_token: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
// Backend API-based auth hooks
|
||||
|
||||
// Email/Password Login
|
||||
export const useLogin = () => {
|
||||
const { setSession } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: authApi.postLogin,
|
||||
mutationFn: async (data: LoginRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
|
||||
'/auth/login',
|
||||
data
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSession({
|
||||
token: data.token,
|
||||
user: {
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
fullname: data.user.fullname,
|
||||
phone_number: data.user.phone_number || '',
|
||||
avatar: data.user.avatar || '',
|
||||
birthdate: data.user.birthdate || '',
|
||||
gender: data.user.gender || '',
|
||||
is_active: data.user.is_active,
|
||||
location: data.user.location,
|
||||
bio: data.user.bio,
|
||||
skills: data.user.skills,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePostRegister = () => {
|
||||
// Email/Password Signup - returns message only (user needs to activate via email)
|
||||
export const useSignup = () => {
|
||||
return useMutation({
|
||||
mutationFn: authApi.postRegister,
|
||||
mutationFn: async (data: SignupRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
|
||||
'/auth/signup',
|
||||
data
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePostVerifyEmail = () => {
|
||||
// GitHub OAuth - exchange code for token
|
||||
export const useGitHubCallback = () => {
|
||||
const { setSession } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: authApi.postVerifyEmail,
|
||||
mutationFn: async (data: GitHubAuthRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
|
||||
'/auth/github',
|
||||
data
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSession({
|
||||
token: data.token,
|
||||
user: {
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
fullname: data.user.fullname,
|
||||
phone_number: data.user.phone_number || '',
|
||||
avatar: data.user.avatar || '',
|
||||
birthdate: data.user.birthdate || '',
|
||||
gender: data.user.gender || '',
|
||||
is_active: data.user.is_active,
|
||||
location: data.user.location,
|
||||
bio: data.user.bio,
|
||||
skills: data.user.skills,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePostSendOtp = () => {
|
||||
return useMutation({
|
||||
mutationFn: authApi.postSendOtp,
|
||||
// Get current session (protected)
|
||||
export const useSession = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['auth-session'],
|
||||
queryFn: async () => {
|
||||
const response = await hackathonApi.get<HackathonApiResponse<SessionResponse>>(
|
||||
'/auth/session'
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
enabled: !!session?.token,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGoogleCallback = () => {
|
||||
// Forgot password
|
||||
export const useForgotPassword = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ code, state }: { code: string; state: string }) =>
|
||||
authApi.postGoogleCallback(code, state),
|
||||
mutationFn: async (data: ForgotPasswordRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
|
||||
'/auth/forgot-password',
|
||||
data
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Supabase GitHub OAuth hook
|
||||
// Reset password
|
||||
export const useResetPassword = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (data: ResetPasswordRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
|
||||
'/auth/reset-password',
|
||||
data
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Sign out (clears local session)
|
||||
export const useSignOut = () => {
|
||||
const { clearSession } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
// No backend call needed - just clear local session
|
||||
clearSession();
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// GitHub OAuth URL helper
|
||||
// The frontend needs to redirect to GitHub with the client_id
|
||||
// After GitHub redirects back with a code, use useGitHubCallback
|
||||
export const getGitHubOAuthUrl = (clientId: string, redirectUri: string) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'read:user user:email',
|
||||
});
|
||||
return `https://github.com/login/oauth/authorize?${params.toString()}`;
|
||||
};
|
||||
|
||||
// Backward compatibility hooks - these wrap the new backend API
|
||||
|
||||
// GitHub OAuth hook (backward compatible)
|
||||
export const useGitHubAuth = () => {
|
||||
const signInWithGitHub = async () => {
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'github',
|
||||
options: {
|
||||
redirectTo: `${globalThis.location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
// Get GitHub client ID from environment
|
||||
const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID || '';
|
||||
if (!clientId) {
|
||||
throw new Error('GitHub Client ID not configured. Set VITE_GITHUB_CLIENT_ID environment variable.');
|
||||
}
|
||||
|
||||
// Return the OAuth URL for debugging
|
||||
return data;
|
||||
const redirectUri = `${globalThis.location.origin}/auth/callback`;
|
||||
const url = getGitHubOAuthUrl(clientId, redirectUri);
|
||||
|
||||
return { url };
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -59,45 +253,33 @@ export const useGitHubAuth = () => {
|
||||
};
|
||||
};
|
||||
|
||||
// Supabase Email/Password authentication hook
|
||||
// Email/Password auth hook (backward compatible)
|
||||
export const useEmailAuth = () => {
|
||||
const loginMutation = useLogin();
|
||||
const signupMutation = useSignup();
|
||||
const { clearSession } = useAuthStore();
|
||||
|
||||
const signInWithEmail = async (email: string, password: string) => {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
const result = await loginMutation.mutateAsync({ email, password });
|
||||
return {
|
||||
user: result.user,
|
||||
session: {
|
||||
access_token: result.token.access_token,
|
||||
refresh_token: result.token.refresh_token,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const signUpWithEmail = async (email: string, password: string, fullname: string) => {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
full_name: fullname,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
const result = await signupMutation.mutateAsync({ email, password, fullname });
|
||||
// Signup only returns a message (user needs to verify email first)
|
||||
return {
|
||||
message: result.message,
|
||||
};
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
clearSession();
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -106,3 +288,58 @@ export const useEmailAuth = () => {
|
||||
signOut,
|
||||
};
|
||||
};
|
||||
|
||||
// Legacy hooks for old API compatibility (deprecated)
|
||||
|
||||
/** @deprecated Use useLogin instead */
|
||||
export const usePostLogin = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (data: LoginRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
|
||||
'/auth/login',
|
||||
data
|
||||
);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** @deprecated Use useSignup instead */
|
||||
export const usePostRegister = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (data: SignupRequest) => {
|
||||
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
|
||||
'/auth/signup',
|
||||
data
|
||||
);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** @deprecated Not needed with new backend */
|
||||
export const usePostVerifyEmail = () => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
throw new Error('Email verification not required with new backend');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** @deprecated Not needed with new backend */
|
||||
export const usePostSendOtp = () => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
throw new Error('OTP not required with new backend');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** @deprecated Use useGitHubCallback instead */
|
||||
export const useGoogleCallback = () => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
throw new Error('Google OAuth not supported. Use GitHub OAuth instead.');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './mentors';
|
||||
export * from './upload';
|
||||
export * from './teams';
|
||||
export * from './messages';
|
||||
export * from './winners';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { supabase } from '../../supabase';
|
||||
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||
import { useAuthStore } from '../auth';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
@@ -24,101 +23,22 @@ export const messageKeys = {
|
||||
team: (teamId: string) => [...messageKeys.all, 'team', teamId] as const,
|
||||
};
|
||||
|
||||
// Fetch messages for a team
|
||||
// Fetch messages for a team with polling
|
||||
export const useTeamMessages = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
return useQuery({
|
||||
queryKey: messageKeys.team(teamId),
|
||||
queryFn: async () => {
|
||||
const { data: messages, error } = await supabase
|
||||
.from('team_messages')
|
||||
.select(`
|
||||
id,
|
||||
team_id,
|
||||
user_id,
|
||||
message,
|
||||
created_at,
|
||||
updated_at,
|
||||
user:users(id, fullname, avatar, email)
|
||||
`)
|
||||
.eq('team_id', teamId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch messages:', error);
|
||||
throw new Error(error.message || 'Failed to fetch messages');
|
||||
}
|
||||
|
||||
return messages as Message[];
|
||||
const response = await hackathonApi.get<HackathonApiResponse<Message[]>>(
|
||||
`/chat/teams/${teamId}`
|
||||
);
|
||||
return response.data.data || [];
|
||||
},
|
||||
enabled: !!teamId,
|
||||
// Poll every 3 seconds for new messages
|
||||
refetchInterval: 3000,
|
||||
// Keep refetching even when window loses focus
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
// Subscribe to realtime updates
|
||||
useEffect(() => {
|
||||
if (!teamId) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`team_messages:${teamId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'team_messages',
|
||||
filter: `team_id=eq.${teamId}`,
|
||||
},
|
||||
async (payload) => {
|
||||
console.log('[Realtime] New message:', payload);
|
||||
|
||||
// Fetch the full message with user data
|
||||
const { data: newMessage } = await supabase
|
||||
.from('team_messages')
|
||||
.select(`
|
||||
id,
|
||||
team_id,
|
||||
user_id,
|
||||
message,
|
||||
created_at,
|
||||
updated_at,
|
||||
user:users(id, fullname, avatar, email)
|
||||
`)
|
||||
.eq('id', payload.new.id)
|
||||
.single();
|
||||
|
||||
if (newMessage) {
|
||||
queryClient.setQueryData<Message[]>(
|
||||
messageKeys.team(teamId),
|
||||
(old) => [...(old || []), newMessage as Message]
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'DELETE',
|
||||
schema: 'public',
|
||||
table: 'team_messages',
|
||||
filter: `team_id=eq.${teamId}`,
|
||||
},
|
||||
(payload) => {
|
||||
console.log('[Realtime] Message deleted:', payload);
|
||||
queryClient.setQueryData<Message[]>(
|
||||
messageKeys.team(teamId),
|
||||
(old) => old?.filter((msg) => msg.id !== payload.old.id) || []
|
||||
);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [teamId, queryClient]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
// Send a message
|
||||
@@ -132,26 +52,15 @@ export const useSendMessage = (teamId: string) => {
|
||||
throw new Error('You must be logged in to send messages');
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('team_messages')
|
||||
.insert({
|
||||
team_id: teamId,
|
||||
user_id: session.user.id,
|
||||
message,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
const response = await hackathonApi.post<HackathonApiResponse<Message>>(
|
||||
`/chat/teams/${teamId}`,
|
||||
{ message }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
throw new Error(error.message || 'Failed to send message');
|
||||
}
|
||||
|
||||
return data;
|
||||
return response.data.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Realtime will handle adding the message to the list
|
||||
// But we can invalidate to ensure consistency
|
||||
// Invalidate to trigger immediate refetch
|
||||
queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) });
|
||||
},
|
||||
});
|
||||
@@ -163,18 +72,9 @@ export const useDeleteMessage = (teamId: string) => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (messageId: string) => {
|
||||
const { error } = await supabase
|
||||
.from('team_messages')
|
||||
.delete()
|
||||
.eq('id', messageId);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to delete message:', error);
|
||||
throw new Error(error.message || 'Failed to delete message');
|
||||
}
|
||||
await hackathonApi.delete(`/chat/messages/${messageId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Realtime will handle removing the message from the list
|
||||
queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as teamsApi from '../../api/teams';
|
||||
import { supabase, getAuthenticatedClient } from '../../supabase';
|
||||
import { useMutation, useQuery, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||
import { useAuthStore } from '../auth';
|
||||
import type {
|
||||
TCreateTeamRequest,
|
||||
@@ -24,6 +23,101 @@ export const teamKeys = {
|
||||
myInvitations: () => [...teamKeys.all, 'my-invitations'] as const,
|
||||
};
|
||||
|
||||
// API response types
|
||||
interface TeamMember {
|
||||
id: string;
|
||||
team_id: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
status: string;
|
||||
joined_at: string;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
banner?: string;
|
||||
description?: string;
|
||||
city?: string;
|
||||
visibility: string;
|
||||
leader_id: string;
|
||||
created_at: string;
|
||||
leader?: {
|
||||
id: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
avatar: string;
|
||||
};
|
||||
members?: TeamMember[];
|
||||
member_count?: number;
|
||||
has_submission?: boolean;
|
||||
}
|
||||
|
||||
interface JoinRequest {
|
||||
id: string;
|
||||
team_id: string;
|
||||
user_id: string;
|
||||
message?: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Invitation {
|
||||
id: string;
|
||||
team_id: string;
|
||||
inviter_id: string;
|
||||
invitee_email: string;
|
||||
invitee_id?: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
team?: Team;
|
||||
inviter?: {
|
||||
id: string;
|
||||
fullname: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Submission {
|
||||
id: string;
|
||||
team_id: string;
|
||||
project_name: string;
|
||||
description?: string;
|
||||
repository_url?: string;
|
||||
demo_url?: string;
|
||||
video_url?: string;
|
||||
presentation_url?: string;
|
||||
status: string;
|
||||
submitted_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// List response type with pagination
|
||||
interface ListResponseWithMeta<T> {
|
||||
message: string;
|
||||
data: T[];
|
||||
meta: {
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_page: number;
|
||||
total_data: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Team CRUD Hooks
|
||||
export const useTeams = (params?: {
|
||||
page?: number;
|
||||
@@ -31,97 +125,78 @@ export const useTeams = (params?: {
|
||||
city?: string;
|
||||
visibility?: string;
|
||||
search?: string;
|
||||
minMembers?: number;
|
||||
maxMembers?: number;
|
||||
hasSubmission?: boolean;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.list(params),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// Supabase client now has auth context from setSession()
|
||||
let query = supabase.from('teams').select('*');
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.page) queryParams.append('page', String(params.page));
|
||||
if (params?.limit) queryParams.append('per_page', String(params.limit));
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
if (params?.city) queryParams.append('city', params.city);
|
||||
if (params?.visibility) queryParams.append('visibility', params.visibility);
|
||||
if (params?.minMembers) queryParams.append('min_members', String(params.minMembers));
|
||||
if (params?.maxMembers) queryParams.append('max_members', String(params.maxMembers));
|
||||
if (params?.hasSubmission !== undefined) queryParams.append('has_submission', String(params.hasSubmission));
|
||||
|
||||
// Filter by visibility
|
||||
if (params?.visibility) {
|
||||
query = query.eq('visibility', params.visibility);
|
||||
}
|
||||
const response = await hackathonApi.get<ListResponseWithMeta<Team>>(
|
||||
`/teams/browse${queryParams.toString() ? `?${queryParams.toString()}` : ''}`
|
||||
);
|
||||
|
||||
// Filter by city
|
||||
if (params?.city) {
|
||||
query = query.eq('city', params.city);
|
||||
}
|
||||
|
||||
// Search by name
|
||||
if (params?.search) {
|
||||
query = query.ilike('name', `%${params.search}%`);
|
||||
}
|
||||
|
||||
// Pagination
|
||||
if (params?.page && params?.limit) {
|
||||
const from = (params.page - 1) * params.limit;
|
||||
const to = from + params.limit - 1;
|
||||
query = query.range(from, to);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
// If error is 401/403, it means RLS policies need to be set up
|
||||
// Return empty array for now
|
||||
console.warn('Teams query error (RLS policies may need to be configured):', error);
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
return { data: data || [] };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch teams:', err);
|
||||
return { data: [] };
|
||||
}
|
||||
const { data, meta } = response.data;
|
||||
return {
|
||||
teams: data || [],
|
||||
total: meta?.total_data || 0,
|
||||
page: meta?.page || 1,
|
||||
perPage: meta?.per_page || 12,
|
||||
totalPages: meta?.total_page || 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Infinite scroll teams hook
|
||||
const TEAMS_PAGE_SIZE = 12;
|
||||
|
||||
export const useInfiniteTeams = (params?: {
|
||||
city?: string;
|
||||
visibility?: string;
|
||||
search?: string;
|
||||
}) => {
|
||||
return useInfiniteQuery({
|
||||
queryKey: [...teamKeys.lists(), 'infinite', params],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('page', String(pageParam));
|
||||
queryParams.append('limit', String(TEAMS_PAGE_SIZE));
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
if (params?.city) queryParams.append('city', params.city);
|
||||
if (params?.visibility) queryParams.append('visibility', params.visibility);
|
||||
|
||||
const response = await hackathonApi.get<HackathonApiResponse<Team[]>>(
|
||||
`/teams/browse?${queryParams.toString()}`
|
||||
);
|
||||
|
||||
const teams = response.data.data || [];
|
||||
return {
|
||||
data: teams,
|
||||
nextPage: teams.length === TEAMS_PAGE_SIZE ? pageParam + 1 : undefined,
|
||||
};
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTeamById = (teamId: string, enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.detail(teamId),
|
||||
queryFn: async () => {
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { data: team, error } = await supabase
|
||||
.from('teams')
|
||||
.select(`
|
||||
*,
|
||||
leader:users!leader_id(id, email, fullname, avatar),
|
||||
members:team_members(
|
||||
id,
|
||||
role,
|
||||
status,
|
||||
joined_at,
|
||||
user:users(id, email, fullname, avatar)
|
||||
)
|
||||
`)
|
||||
.eq('id', teamId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch team:', error);
|
||||
throw new Error(error.message || 'Failed to fetch team');
|
||||
}
|
||||
|
||||
// Count active members
|
||||
const activeMemberCount = team?.members?.filter((m: any) => m.status === 'active').length || 0;
|
||||
|
||||
// Check if team has a submission
|
||||
const { data: submission } = await supabase
|
||||
.from('project_submissions')
|
||||
.select('id')
|
||||
.eq('team_id', teamId)
|
||||
.maybeSingle();
|
||||
|
||||
return {
|
||||
data: {
|
||||
...team,
|
||||
member_count: activeMemberCount,
|
||||
has_submission: !!submission,
|
||||
},
|
||||
};
|
||||
const response = await hackathonApi.get<HackathonApiResponse<Team>>(`/teams/${teamId}`);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
enabled: enabled && !!teamId,
|
||||
});
|
||||
@@ -137,50 +212,16 @@ export const useCreateTeam = () => {
|
||||
throw new Error('You must be logged in to create a team');
|
||||
}
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { data: team, error: teamError } = await supabase
|
||||
.from('teams')
|
||||
.insert({
|
||||
name: data.name,
|
||||
logo: data.logo,
|
||||
banner: data.banner,
|
||||
description: data.description,
|
||||
city: data.city,
|
||||
visibility: data.visibility,
|
||||
leader_id: session.user.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
const response = await hackathonApi.post<HackathonApiResponse<Team>>('/teams', {
|
||||
name: data.name,
|
||||
logo: data.logo,
|
||||
banner: data.banner,
|
||||
description: data.description,
|
||||
city: data.city,
|
||||
visibility: data.visibility,
|
||||
});
|
||||
|
||||
if (teamError) {
|
||||
console.error('Failed to create team:', teamError);
|
||||
throw new Error(teamError.message || 'Failed to create team');
|
||||
}
|
||||
|
||||
// Insert team creator as leader in team_members table
|
||||
const { error: memberError } = await supabase
|
||||
.from('team_members')
|
||||
.insert({
|
||||
team_id: team.id,
|
||||
user_id: session.user.id,
|
||||
role: 'leader',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
// If duplicate key error (23505), it means leader is already a member (possibly by trigger)
|
||||
// This is acceptable, so we can ignore it
|
||||
if (memberError.code === '23505') {
|
||||
console.log('Team leader already exists in team_members (likely added by trigger)');
|
||||
} else {
|
||||
// For other errors, clean up and throw
|
||||
console.error('Failed to add team leader as member:', memberError);
|
||||
await supabase.from('teams').delete().eq('id', team.id);
|
||||
throw new Error('Failed to set up team membership');
|
||||
}
|
||||
}
|
||||
|
||||
return { data: team };
|
||||
return { data: response.data.data };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
|
||||
@@ -199,27 +240,16 @@ export const useUpdateTeam = (teamId: string) => {
|
||||
throw new Error('You must be logged in to update a team');
|
||||
}
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { data: team, error } = await supabase
|
||||
.from('teams')
|
||||
.update({
|
||||
name: data.name,
|
||||
logo: data.logo,
|
||||
banner: data.banner,
|
||||
description: data.description,
|
||||
city: data.city,
|
||||
visibility: data.visibility,
|
||||
})
|
||||
.eq('id', teamId)
|
||||
.select()
|
||||
.single();
|
||||
const response = await hackathonApi.put<HackathonApiResponse<Team>>(`/teams/${teamId}`, {
|
||||
name: data.name,
|
||||
logo: data.logo,
|
||||
banner: data.banner,
|
||||
description: data.description,
|
||||
city: data.city,
|
||||
visibility: data.visibility,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to update team:', error);
|
||||
throw new Error(error.message || 'Failed to update team');
|
||||
}
|
||||
|
||||
return { data: team };
|
||||
return { data: response.data.data };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
|
||||
@@ -228,32 +258,13 @@ export const useUpdateTeam = (teamId: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Team Members Hooks
|
||||
// Team Members Hooks - using team detail endpoint which includes members
|
||||
export const useTeamMembers = (teamId: string, enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.members(teamId),
|
||||
queryFn: async () => {
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { data: members, error } = await supabase
|
||||
.from('team_members')
|
||||
.select(`
|
||||
id,
|
||||
team_id,
|
||||
user_id,
|
||||
role,
|
||||
status,
|
||||
joined_at,
|
||||
user:users(id, email, fullname, avatar)
|
||||
`)
|
||||
.eq('team_id', teamId)
|
||||
.order('joined_at', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch team members:', error);
|
||||
throw new Error(error.message || 'Failed to fetch team members');
|
||||
}
|
||||
|
||||
return { data: members || [] };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<Team>>(`/teams/${teamId}`);
|
||||
return { data: response.data.data?.members || [] };
|
||||
},
|
||||
enabled: enabled && !!teamId,
|
||||
});
|
||||
@@ -269,24 +280,12 @@ export const useInviteMember = (teamId: string) => {
|
||||
throw new Error('You must be logged in to invite a member');
|
||||
}
|
||||
|
||||
// Insert invitation into team_invitations table
|
||||
const { data: invitation, error } = await supabase
|
||||
.from('team_invitations')
|
||||
.insert({
|
||||
team_id: teamId,
|
||||
inviter_id: session.user.id,
|
||||
invitee_email: data.email,
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
const response = await hackathonApi.post<HackathonApiResponse<Invitation>>(
|
||||
`/teams/${teamId}/invite`,
|
||||
{ invitee_email: data.email }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to create invitation:', error);
|
||||
throw new Error(error.message || 'Failed to send invitation');
|
||||
}
|
||||
|
||||
return { data: invitation };
|
||||
return { data: response.data.data };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
|
||||
@@ -298,8 +297,11 @@ export const useManageMember = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, data }: { userId: string; data: any }) =>
|
||||
teamsApi.manageMember(teamId, userId, data),
|
||||
mutationFn: async ({ userId, data }: { userId: string; data: { role?: string; status?: string } }) => {
|
||||
// This endpoint may not exist in the backend yet
|
||||
// For now, we'll throw an error indicating it's not implemented
|
||||
throw new Error('Manage member functionality not yet implemented in backend');
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
|
||||
@@ -309,9 +311,17 @@ export const useManageMember = (teamId: string) => {
|
||||
|
||||
export const useRemoveMember = (teamId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => teamsApi.removeMember(teamId, userId),
|
||||
mutationFn: async (userId: string) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to remove a member');
|
||||
}
|
||||
|
||||
await hackathonApi.delete(`/teams/${teamId}/members/${userId}`);
|
||||
return { success: true };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
|
||||
@@ -330,24 +340,12 @@ export const useJoinTeam = () => {
|
||||
throw new Error('You must be logged in to join a team');
|
||||
}
|
||||
|
||||
// Insert join request into team_join_requests table
|
||||
const { data: joinRequest, error } = await supabase
|
||||
.from('team_join_requests')
|
||||
.insert({
|
||||
team_id: teamId,
|
||||
user_id: session.user.id,
|
||||
message: data.message,
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
const response = await hackathonApi.post<HackathonApiResponse<JoinRequest>>(
|
||||
`/join-requests/teams/${teamId}`,
|
||||
{ message: data.message }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to create join request:', error);
|
||||
throw new Error(error.message || 'Failed to send join request');
|
||||
}
|
||||
|
||||
return { data: joinRequest };
|
||||
return { data: response.data.data };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
|
||||
@@ -359,27 +357,10 @@ export const useTeamJoinRequests = (teamId: string, enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.joinRequests(teamId),
|
||||
queryFn: async () => {
|
||||
// Fetch join requests with user information
|
||||
const { data: requests, error } = await supabase
|
||||
.from('team_join_requests')
|
||||
.select(`
|
||||
id,
|
||||
team_id,
|
||||
user_id,
|
||||
message,
|
||||
status,
|
||||
created_at,
|
||||
user:users(id, email, fullname, avatar)
|
||||
`)
|
||||
.eq('team_id', teamId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch join requests:', error);
|
||||
throw new Error(error.message || 'Failed to fetch join requests');
|
||||
}
|
||||
|
||||
return { data: requests || [] };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<JoinRequest[]>>(
|
||||
`/join-requests/teams/${teamId}/pending`
|
||||
);
|
||||
return { data: response.data.data || [] };
|
||||
},
|
||||
enabled: enabled && !!teamId,
|
||||
});
|
||||
@@ -395,66 +376,14 @@ export const useRespondToJoinRequest = (teamId: string) => {
|
||||
throw new Error('You must be logged in to respond to join requests');
|
||||
}
|
||||
|
||||
// First, get the join request details
|
||||
const { data: joinRequest, error: fetchError } = await supabase
|
||||
.from('team_join_requests')
|
||||
.select('id, team_id, user_id, status')
|
||||
.eq('id', requestId)
|
||||
.single();
|
||||
// Backend uses 'accept' instead of 'approve'
|
||||
const backendAction = action === 'approve' ? 'accept' : 'reject';
|
||||
|
||||
if (fetchError || !joinRequest) {
|
||||
throw new Error('Join request not found');
|
||||
}
|
||||
await hackathonApi.post(`/join-requests/${requestId}/respond`, {
|
||||
action: backendAction,
|
||||
});
|
||||
|
||||
if (joinRequest.status !== 'pending') {
|
||||
throw new Error('Join request has already been processed');
|
||||
}
|
||||
|
||||
if (action === 'approve') {
|
||||
// Update join request status
|
||||
const { error: updateError } = await supabase
|
||||
.from('team_join_requests')
|
||||
.update({ status: 'accepted' })
|
||||
.eq('id', requestId);
|
||||
|
||||
if (updateError) {
|
||||
throw new Error('Failed to update join request: ' + updateError.message);
|
||||
}
|
||||
|
||||
// Add user as team member
|
||||
const { error: memberError } = await supabase
|
||||
.from('team_members')
|
||||
.insert({
|
||||
team_id: joinRequest.team_id,
|
||||
user_id: joinRequest.user_id,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
// If member creation fails, rollback join request update
|
||||
await supabase
|
||||
.from('team_join_requests')
|
||||
.update({ status: 'pending' })
|
||||
.eq('id', requestId);
|
||||
|
||||
throw new Error('Failed to add member to team: ' + memberError.message);
|
||||
}
|
||||
|
||||
return { success: true, action: 'accepted' };
|
||||
} else {
|
||||
// Reject join request
|
||||
const { error: updateError } = await supabase
|
||||
.from('team_join_requests')
|
||||
.update({ status: 'rejected' })
|
||||
.eq('id', requestId);
|
||||
|
||||
if (updateError) {
|
||||
throw new Error('Failed to update join request: ' + updateError.message);
|
||||
}
|
||||
|
||||
return { success: true, action: 'rejected' };
|
||||
}
|
||||
return { success: true, action };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.joinRequests(teamId) });
|
||||
@@ -471,49 +400,10 @@ export const useMyInvitations = () => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.myInvitations(),
|
||||
queryFn: async () => {
|
||||
if (!session?.user?.email) {
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
// Query team_invitations where invitee_email matches current user's email
|
||||
const { data: invitations, error } = await supabase
|
||||
.from('team_invitations')
|
||||
.select(`
|
||||
id,
|
||||
team_id,
|
||||
inviter_id,
|
||||
invitee_email,
|
||||
invitee_id,
|
||||
status,
|
||||
created_at,
|
||||
team:teams(
|
||||
id,
|
||||
name,
|
||||
logo,
|
||||
banner,
|
||||
description,
|
||||
city,
|
||||
visibility,
|
||||
leader_id
|
||||
),
|
||||
inviter:users!team_invitations_inviter_id_fkey(
|
||||
id,
|
||||
fullname,
|
||||
email,
|
||||
avatar
|
||||
)
|
||||
`)
|
||||
.eq('invitee_email', session.user.email)
|
||||
.eq('status', 'pending');
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch invitations:', error);
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
return { data: invitations || [] };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<Invitation[]>>('/invitations/my');
|
||||
return { data: response.data.data || [] };
|
||||
},
|
||||
enabled: !!session?.user?.email,
|
||||
enabled: !!session?.user?.id,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -527,75 +417,9 @@ export const useRespondToInvitation = () => {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
// First, get the invitation details
|
||||
const { data: invitation, error: fetchError } = await supabase
|
||||
.from('team_invitations')
|
||||
.select('id, team_id, invitee_email, status')
|
||||
.eq('id', invitationId)
|
||||
.single();
|
||||
await hackathonApi.post(`/invitations/${invitationId}/respond`, { action });
|
||||
|
||||
if (fetchError || !invitation) {
|
||||
throw new Error('Invitation not found');
|
||||
}
|
||||
|
||||
if (invitation.status !== 'pending') {
|
||||
throw new Error('Invitation has already been responded to');
|
||||
}
|
||||
|
||||
if (action === 'accept') {
|
||||
// Update invitation status and set invitee_id
|
||||
const { error: updateError } = await supabase
|
||||
.from('team_invitations')
|
||||
.update({
|
||||
status: 'accepted',
|
||||
invitee_id: session.user.id,
|
||||
})
|
||||
.eq('id', invitationId);
|
||||
|
||||
if (updateError) {
|
||||
throw new Error('Failed to update invitation: ' + updateError.message);
|
||||
}
|
||||
|
||||
// Create team_members record
|
||||
const { error: memberError } = await supabase
|
||||
.from('team_members')
|
||||
.insert({
|
||||
team_id: invitation.team_id,
|
||||
user_id: session.user.id,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
if (memberError) {
|
||||
// If member creation fails, rollback invitation update
|
||||
await supabase
|
||||
.from('team_invitations')
|
||||
.update({
|
||||
status: 'pending',
|
||||
invitee_id: null,
|
||||
})
|
||||
.eq('id', invitationId);
|
||||
|
||||
throw new Error('Failed to add member to team: ' + memberError.message);
|
||||
}
|
||||
|
||||
return { success: true, action: 'accepted' };
|
||||
} else {
|
||||
// Reject invitation
|
||||
const { error: updateError } = await supabase
|
||||
.from('team_invitations')
|
||||
.update({
|
||||
status: 'rejected',
|
||||
invitee_id: session.user.id,
|
||||
})
|
||||
.eq('id', invitationId);
|
||||
|
||||
if (updateError) {
|
||||
throw new Error('Failed to update invitation: ' + updateError.message);
|
||||
}
|
||||
|
||||
return { success: true, action: 'rejected' };
|
||||
}
|
||||
return { success: true, action };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.myInvitations() });
|
||||
@@ -612,38 +436,10 @@ export const useMyTeams = () => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.myTeams(),
|
||||
queryFn: async () => {
|
||||
if (!session?.user?.id) {
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
// Query team_members to find teams where user is a member
|
||||
const { data: memberships, error: membershipsError } = await supabase
|
||||
.from('team_members')
|
||||
.select(`
|
||||
team_id,
|
||||
team:teams(
|
||||
id,
|
||||
name,
|
||||
logo,
|
||||
banner,
|
||||
description,
|
||||
city,
|
||||
visibility,
|
||||
leader_id,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.eq('user_id', session.user.id)
|
||||
.eq('status', 'active');
|
||||
|
||||
if (membershipsError) {
|
||||
console.error('Failed to fetch user teams:', membershipsError);
|
||||
return { data: [] };
|
||||
}
|
||||
|
||||
// Extract teams from memberships
|
||||
const teams = memberships?.map((m: any) => m.team).filter(Boolean) || [];
|
||||
|
||||
const response = await hackathonApi.get<HackathonApiResponse<any[]>>('/teams/my');
|
||||
const rawData = response.data.data || [];
|
||||
// Unwrap nested team data if present (API returns [{team: {...}}] or [{id, name, ...}])
|
||||
const teams = rawData.map((item: any) => item.team || item);
|
||||
return { data: teams };
|
||||
},
|
||||
enabled: !!session?.user?.id,
|
||||
@@ -656,7 +452,60 @@ export const useSubmitProject = (teamId: string) => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: TSubmitProjectRequest) => {
|
||||
return await teamsApi.submitProject(teamId, data);
|
||||
let submissionId: string;
|
||||
|
||||
// First, check if submission exists
|
||||
try {
|
||||
const existingResponse = await hackathonApi.get<HackathonApiResponse<Submission | null>>(
|
||||
`/submissions/teams/${teamId}`
|
||||
);
|
||||
|
||||
if (existingResponse.data.data?.id) {
|
||||
// Update existing submission
|
||||
const response = await hackathonApi.put<HackathonApiResponse<Submission>>(
|
||||
`/submissions/${existingResponse.data.data.id}`,
|
||||
{
|
||||
project_name: data.project_name,
|
||||
description: data.description,
|
||||
repository_url: data.repository_url,
|
||||
demo_url: data.demo_url,
|
||||
video_url: data.video_url,
|
||||
presentation_url: data.presentation_url,
|
||||
screenshots: data.screenshots,
|
||||
}
|
||||
);
|
||||
submissionId = response.data.data.id;
|
||||
} else {
|
||||
throw new Error('No existing submission');
|
||||
}
|
||||
} catch {
|
||||
// No existing submission, create new one
|
||||
const response = await hackathonApi.post<HackathonApiResponse<Submission>>(
|
||||
`/submissions/teams/${teamId}`,
|
||||
{
|
||||
project_name: data.project_name,
|
||||
description: data.description,
|
||||
repository_url: data.repository_url,
|
||||
demo_url: data.demo_url,
|
||||
video_url: data.video_url,
|
||||
presentation_url: data.presentation_url,
|
||||
screenshots: data.screenshots,
|
||||
}
|
||||
);
|
||||
submissionId = response.data.data.id;
|
||||
}
|
||||
|
||||
// Step 2: Submit the project (draft -> pending_verification)
|
||||
await hackathonApi.post<HackathonApiResponse<Submission>>(
|
||||
`/submissions/${submissionId}/submit`
|
||||
);
|
||||
|
||||
// Step 3: Confirm the submission (pending_verification -> submitted)
|
||||
const finalResponse = await hackathonApi.post<HackathonApiResponse<Submission>>(
|
||||
`/submissions/${submissionId}/confirm`
|
||||
);
|
||||
|
||||
return { data: finalResponse.data.data };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.submission(teamId) });
|
||||
@@ -669,8 +518,10 @@ export const useTeamSubmission = (teamId: string, enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: teamKeys.submission(teamId),
|
||||
queryFn: async () => {
|
||||
const submission = await teamsApi.getTeamSubmission(teamId);
|
||||
return { data: submission };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<Submission | null>>(
|
||||
`/submissions/teams/${teamId}`
|
||||
);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
enabled: enabled && !!teamId,
|
||||
});
|
||||
@@ -679,10 +530,17 @@ export const useTeamSubmission = (teamId: string, enabled = true) => {
|
||||
// Leave Team Hook
|
||||
export const useLeaveTeam = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (teamId: string) => {
|
||||
return await teamsApi.leaveTeam(teamId);
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to leave a team');
|
||||
}
|
||||
|
||||
// Use the dedicated leave team endpoint
|
||||
await hackathonApi.post(`/teams/${teamId}/leave`);
|
||||
return { success: true };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
|
||||
@@ -690,3 +548,39 @@ export const useLeaveTeam = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Delete Team Hook (Leader only)
|
||||
export const useDeleteTeam = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (teamId: string) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to delete a team');
|
||||
}
|
||||
|
||||
await hackathonApi.delete(`/teams/${teamId}`);
|
||||
return { success: true };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Get Teams by User ID - uses /users/{user_id}/teams
|
||||
export const useTeamsByUserId = (userId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['teams-by-user', userId],
|
||||
queryFn: async () => {
|
||||
const response = await hackathonApi.get<HackathonApiResponse<any[]>>(`/users/${userId}/teams`);
|
||||
const rawData = response.data.data || [];
|
||||
// Unwrap nested team data if present (API returns [{team: {...}}] or [{id, name, ...}])
|
||||
const teams = rawData.map((item: any) => item.team || item);
|
||||
return { data: teams };
|
||||
},
|
||||
enabled: !!userId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { supabase, getAuthenticatedClient } from '../../supabase';
|
||||
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||
import { useAuthStore } from '../auth';
|
||||
|
||||
// Supabase Storage-based upload hooks
|
||||
// Upload response type from backend
|
||||
interface UploadResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Helper function to convert File to base64
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
// Remove the data:image/xxx;base64, prefix
|
||||
const base64 = (reader.result as string).split(',')[1];
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
// Backend API-based upload hooks
|
||||
|
||||
export const useUploadFile = () => {
|
||||
const { session } = useAuthStore();
|
||||
@@ -14,30 +33,18 @@ export const useUploadFile = () => {
|
||||
throw new Error('You must be logged in to upload files');
|
||||
}
|
||||
|
||||
// Generate a unique file name
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
|
||||
const filePath = `teams/${fileName}`;
|
||||
const base64Data = await fileToBase64(file);
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { error } = await supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: true,
|
||||
});
|
||||
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
|
||||
'/upload/team',
|
||||
{
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
data: base64Data,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to upload file:', error);
|
||||
throw new Error(error.message || 'Failed to upload file');
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: publicUrlData } = supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
return { data: { url: publicUrlData.publicUrl } };
|
||||
return { data: { url: response.data.data.url } };
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -52,34 +59,131 @@ export const useUploadAvatar = () => {
|
||||
throw new Error('You must be logged in to upload avatar');
|
||||
}
|
||||
|
||||
// Generate a unique file name
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
|
||||
const filePath = `avatars/${fileName}`;
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { error } = await supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to upload avatar:', error);
|
||||
throw new Error(error.message || 'Failed to upload avatar');
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error('Invalid file type. Allowed types: JPEG, PNG, WebP, GIF');
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: publicUrlData } = supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.getPublicUrl(filePath);
|
||||
// Validate file size (max 5MB)
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('File too large. Maximum size: 5MB');
|
||||
}
|
||||
|
||||
return { data: { url: publicUrlData.publicUrl } };
|
||||
const base64Data = await fileToBase64(file);
|
||||
|
||||
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
|
||||
'/upload/avatar',
|
||||
{
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
data: base64Data,
|
||||
}
|
||||
);
|
||||
|
||||
return { data: { url: response.data.data.url } };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUploadTeamFile = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['upload-team-file'],
|
||||
mutationFn: async (file: File) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to upload files');
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'application/pdf',
|
||||
];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error('Invalid file type. Allowed types: JPEG, PNG, WebP, GIF, PDF');
|
||||
}
|
||||
|
||||
// Validate file size (max 20MB)
|
||||
const maxSize = 20 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('File too large. Maximum size: 20MB');
|
||||
}
|
||||
|
||||
const base64Data = await fileToBase64(file);
|
||||
|
||||
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
|
||||
'/upload/team',
|
||||
{
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
data: base64Data,
|
||||
}
|
||||
);
|
||||
|
||||
return { data: { url: response.data.data.url } };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUploadSubmission = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['upload-submission'],
|
||||
mutationFn: async (file: File) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to upload submissions');
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'application/pdf',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error(
|
||||
'Invalid file type. Allowed types: Images, PDF, ZIP, MP4, WebM'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file size (max 50MB)
|
||||
const maxSize = 50 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('File too large. Maximum size: 50MB');
|
||||
}
|
||||
|
||||
const base64Data = await fileToBase64(file);
|
||||
|
||||
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
|
||||
'/upload/submission',
|
||||
{
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
data: base64Data,
|
||||
}
|
||||
);
|
||||
|
||||
return { data: { url: response.data.data.url } };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Keep useUploadCV for compatibility, using team upload endpoint
|
||||
export const useUploadCV = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
@@ -90,30 +194,29 @@ export const useUploadCV = () => {
|
||||
throw new Error('You must be logged in to upload CV');
|
||||
}
|
||||
|
||||
// Generate a unique file name
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
|
||||
const filePath = `cvs/${fileName}`;
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { error } = await supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to upload CV:', error);
|
||||
throw new Error(error.message || 'Failed to upload CV');
|
||||
// Validate file type
|
||||
if (file.type !== 'application/pdf') {
|
||||
throw new Error('CV must be a PDF file');
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: publicUrlData } = supabase.storage
|
||||
.from('hackathon-uploads')
|
||||
.getPublicUrl(filePath);
|
||||
// Validate file size (max 20MB)
|
||||
const maxSize = 20 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error('File too large. Maximum size: 20MB');
|
||||
}
|
||||
|
||||
return { data: { url: publicUrlData.publicUrl } };
|
||||
const base64Data = await fileToBase64(file);
|
||||
|
||||
const response = await hackathonApi.post<HackathonApiResponse<UploadResponse>>(
|
||||
'/upload/team',
|
||||
{
|
||||
filename: file.name,
|
||||
content_type: file.type,
|
||||
data: base64Data,
|
||||
}
|
||||
);
|
||||
|
||||
return { data: { url: response.data.data.url } };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { userService } from '../../api/users';
|
||||
import { supabase, getAuthenticatedClient } from '../../supabase';
|
||||
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||
import { useAuthStore } from '../auth';
|
||||
|
||||
// Supabase-based user hooks
|
||||
// User type
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
bio?: string;
|
||||
location?: string;
|
||||
avatar?: string;
|
||||
skills?: string[];
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// Update user request type
|
||||
interface UpdateUserRequest {
|
||||
fullname?: string;
|
||||
bio?: string;
|
||||
location?: string;
|
||||
avatar?: string;
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
// Backend API-based user hooks
|
||||
|
||||
export const useUserMe = () => {
|
||||
const { session } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['user-me'],
|
||||
queryFn: async () => {
|
||||
const user = await userService.getUserMe();
|
||||
return { data: user };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<User>>('/users/me');
|
||||
return { data: response.data.data };
|
||||
},
|
||||
enabled: !!session?.user?.id,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -19,8 +43,8 @@ export const useUserById = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['user-by-id', id],
|
||||
queryFn: async () => {
|
||||
const user = await userService.getUserById(id);
|
||||
return { data: user };
|
||||
const response = await hackathonApi.get<HackathonApiResponse<User>>(`/users/${id}`);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
@@ -32,35 +56,24 @@ export const useUpdateUserMe = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['update-user-me'],
|
||||
mutationFn: async (data: any) => {
|
||||
mutationFn: async (data: UpdateUserRequest) => {
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to update profile');
|
||||
}
|
||||
|
||||
// Supabase client now has auth context from setSession()
|
||||
const { data: updatedUser, error } = await supabase
|
||||
.from('users')
|
||||
.update({
|
||||
fullname: data.fullname,
|
||||
bio: data.bio,
|
||||
location: data.location,
|
||||
avatar: data.avatar,
|
||||
skills: data.skills,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', session.user.id)
|
||||
.select()
|
||||
.single();
|
||||
const response = await hackathonApi.put<HackathonApiResponse<User>>('/users/me', {
|
||||
fullname: data.fullname,
|
||||
bio: data.bio,
|
||||
location: data.location,
|
||||
avatar: data.avatar,
|
||||
skills: data.skills,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to update user profile:', error);
|
||||
throw new Error(error.message || 'Failed to update profile');
|
||||
}
|
||||
return { data: updatedUser };
|
||||
return { data: response.data.data };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
// Update Zustand session store with new user data
|
||||
if (session && result.data) {
|
||||
if (session?.user && result.data) {
|
||||
setSession({
|
||||
token: session.token,
|
||||
user: {
|
||||
@@ -83,12 +96,25 @@ export const useUpdateUserById = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ['update-user-by-id'],
|
||||
mutationFn: async ({ id, data }: { id: string; data: any }) => {
|
||||
const updated = await userService.updateUserById(id, data);
|
||||
return { data: updated };
|
||||
mutationFn: async ({ id, data }: { id: string; data: UpdateUserRequest }) => {
|
||||
// Note: This might not be supported by backend (only /users/me for updates)
|
||||
// Keeping for API compatibility but it will likely fail
|
||||
const response = await hackathonApi.put<HackathonApiResponse<User>>(`/users/${id}`, data);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-by-id', variables.id] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUserDetailsById = (userId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['user-details', userId],
|
||||
queryFn: async () => {
|
||||
const response = await hackathonApi.get<HackathonApiResponse<User>>(`/users/${userId}`);
|
||||
return { data: response.data.data };
|
||||
},
|
||||
enabled: !!userId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||
|
||||
// Query keys
|
||||
export const winnerKeys = {
|
||||
all: ['winners'] as const,
|
||||
lists: () => [...winnerKeys.all, 'list'] as const,
|
||||
};
|
||||
|
||||
// API response types
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
city: string;
|
||||
visibility: string;
|
||||
logo: string;
|
||||
banner: string;
|
||||
leader_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Winner {
|
||||
id: string;
|
||||
team_id: string;
|
||||
team: Team;
|
||||
rank: number;
|
||||
prize: string;
|
||||
announced_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const useWinners = () => {
|
||||
return useQuery<HackathonApiResponse<Winner[]>>({
|
||||
queryKey: winnerKeys.lists(),
|
||||
queryFn: async () => {
|
||||
const response = await hackathonApi.get('/winners');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -2,5 +2,5 @@ export * from './api';
|
||||
export * from './hooks';
|
||||
export * from './types';
|
||||
export * from './schemas';
|
||||
export * from './supabase';
|
||||
export * from './storage';
|
||||
// Note: Supabase export removed - using backend API instead
|
||||
|
||||
@@ -73,9 +73,18 @@ export const userOnboardingSchema = z.object({
|
||||
skills: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const userEditProfileSchema = z.object({
|
||||
fullname: z.string().min(3, 'Nama lengkap minimal 3 karakter'),
|
||||
avatar: z.string().url('Avatar harus berupa URL yang valid').nullable().optional(),
|
||||
location: z.string().optional(),
|
||||
bio: z.string().max(500, 'Bio maksimal 500 karakter').optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type TTeamCreateForm = z.infer<typeof teamCreateSchema>;
|
||||
export type TTeamUpdateForm = z.infer<typeof teamUpdateSchema>;
|
||||
export type TInviteMemberForm = z.infer<typeof inviteMemberSchema>;
|
||||
export type TJoinTeamForm = z.infer<typeof joinTeamSchema>;
|
||||
export type TProjectSubmissionForm = z.infer<typeof projectSubmissionSchema>;
|
||||
export type TUserOnboardingForm = z.infer<typeof userOnboardingSchema>;
|
||||
export type TUserEditProfileForm = z.infer<typeof userEditProfileSchema>;
|
||||
|
||||
@@ -36,6 +36,7 @@ export type TTeamItem = {
|
||||
city: string;
|
||||
visibility: ETeamVisibility;
|
||||
leader_id: string;
|
||||
member_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -43,7 +44,6 @@ export type TTeamItem = {
|
||||
export type TTeamDetailItem = TTeamItem & {
|
||||
leader: TUserItem;
|
||||
members: TTeamMemberItem[];
|
||||
member_count: number;
|
||||
has_submission: boolean;
|
||||
};
|
||||
|
||||
@@ -125,6 +125,7 @@ export type TSubmitProjectRequest = {
|
||||
description: string;
|
||||
repository_url: string;
|
||||
demo_url?: string;
|
||||
video_url?: string;
|
||||
presentation_url?: string;
|
||||
screenshots?: string[];
|
||||
};
|
||||
|
||||
@@ -26,12 +26,13 @@ type TButtonProps = DetailedHTMLProps<
|
||||
const variantClasses: Record<TButtonVariant, string> = {
|
||||
primary: 'bg-primary-500 hover:bg-primary-600 text-white shadow-md',
|
||||
secondary:
|
||||
'bg-white hover:text-primary-600 hover:bg-gray-50 text-primary-500 shadow-md',
|
||||
text: 'bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500',
|
||||
'bg-white dark:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700 text-primary-500 dark:text-primary-400 shadow-md dark:shadow-gray-900/50 border dark:border-gray-700',
|
||||
text: 'bg-transparent hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-800 text-primary-500 dark:text-primary-400',
|
||||
bordered:
|
||||
'border border-primary-500 hover:border-primary-600 bg-transparent hover:text-primary-600 hover:bg-gray-50 text-primary-500',
|
||||
'border border-primary-500 dark:border-primary-400 hover:border-primary-600 dark:hover:border-primary-300 bg-transparent hover:text-primary-600 dark:hover:text-primary-300 hover:bg-gray-50 dark:hover:bg-gray-800 text-primary-500 dark:text-primary-400',
|
||||
success: 'bg-success-500 hover:bg-success-600 text-white shadow-md',
|
||||
danger: 'bg-danger-100 hover:bg-danger-200 text-danger-500 shadow-md',
|
||||
danger:
|
||||
'bg-danger-100 dark:bg-danger-500/20 hover:bg-danger-200 dark:hover:bg-danger-500/30 text-danger-500 shadow-md dark:shadow-gray-900/50',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<TButtonSize, string> = {
|
||||
|
||||
@@ -32,9 +32,9 @@ type TInputProps = Omit<
|
||||
|
||||
const sizeClasses: Record<TInputSize, { textSize: string; iconSize: string }> =
|
||||
{
|
||||
sm: { textSize: 'text-[10px] max-h-[28px]', iconSize: 'text-[10px]' },
|
||||
md: { textSize: 'text-[12px] max-h-[30px]', iconSize: 'text-[12px]' },
|
||||
lg: { textSize: 'text-[15px] max-h-[34px]', iconSize: 'text-[15px]' },
|
||||
sm: { textSize: 'text-[10px] h-[28px]', iconSize: 'text-[10px]' },
|
||||
md: { textSize: 'text-[12px] h-[30px]', iconSize: 'text-[12px]' },
|
||||
lg: { textSize: 'text-[15px] h-[34px]', iconSize: 'text-[15px]' },
|
||||
};
|
||||
|
||||
const disabledClass = 'opacity-50 hover:border-neutral-200 cursor-not-allowed';
|
||||
@@ -56,7 +56,7 @@ export const Input: FC<TInputProps> = ({
|
||||
};
|
||||
|
||||
const mergedClassName = cn(
|
||||
`px-[12px] py-[8px] text-neutral-800 bg-white placeholder:text-neutral-300 border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 rounded-md font-bai-jamjuree w-full ${
|
||||
`px-[12px] py-[8px] text-neutral-800 dark:text-white bg-white dark:bg-gray-800 placeholder:text-neutral-300 dark:placeholder:text-neutral-500 border border-neutral-200 dark:border-neutral-600 hover:border-blue-300 dark:hover:border-blue-500 focus:outline-1 focus:outline-blue-500 dark:focus:outline-primary-500 rounded-md font-bai-jamjuree w-full ${
|
||||
widthform === 'standard' ? 'min-w-70' : ''
|
||||
}`,
|
||||
sizeClasses[size].textSize,
|
||||
|
||||
@@ -38,7 +38,7 @@ export const Textarea: FC<TTextareaProps> = ({
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
const mergedClassName = cn(
|
||||
'rounded-md border border-neutral-200 hover:border-blue-300 focus:outline-1 focus:outline-blue-500 px-[12px] py-[8px] invalid:border-danger-500 invalid:text-danger-500',
|
||||
'rounded-md border border-neutral-200 dark:border-neutral-600 hover:border-blue-300 dark:hover:border-blue-500 focus:outline-1 focus:outline-blue-500 dark:focus:outline-primary-500 px-[12px] py-[8px] bg-white dark:bg-gray-800 text-neutral-800 dark:text-white placeholder:text-neutral-300 dark:placeholder:text-neutral-500 invalid:border-danger-500 invalid:text-danger-500',
|
||||
sizeClasses[size],
|
||||
disabled && disabledClass,
|
||||
error && errorClass,
|
||||
|
||||
@@ -20,19 +20,20 @@ export type TInputFieldProps = Omit<
|
||||
disabled?: boolean;
|
||||
helperText?: string;
|
||||
htmlFor?: string;
|
||||
isRequired?: boolean;
|
||||
};
|
||||
|
||||
const sizeClasses: Record<TInputSize, { label: string; helperText: string }> = {
|
||||
lg: {
|
||||
label: 'text-p3 font-medium',
|
||||
label: 'text-label1 font-medium',
|
||||
helperText: 'text-label3 font-normal',
|
||||
},
|
||||
md: {
|
||||
label: 'text-label1 font-medium',
|
||||
label: 'text-label2 font-medium',
|
||||
helperText: 'text-label2 font-normal',
|
||||
},
|
||||
sm: {
|
||||
label: 'text-label2 font-medium',
|
||||
label: 'text-label3 font-medium',
|
||||
helperText: 'text-label2 font-normal',
|
||||
},
|
||||
};
|
||||
@@ -47,6 +48,7 @@ export const InputField: FC<TInputFieldProps> = ({
|
||||
htmlFor,
|
||||
className,
|
||||
disabled,
|
||||
isRequired = false,
|
||||
...rest
|
||||
}): ReactElement => {
|
||||
return (
|
||||
@@ -54,11 +56,11 @@ export const InputField: FC<TInputFieldProps> = ({
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
'items-start justify-item-start text-start !text-neutral-800',
|
||||
'items-start justify-item-start text-start text-neutral-800! dark:text-neutral-300!',
|
||||
sizeClasses[size].label
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{label} {isRequired ? <span className="text-red-500">*</span> : null}
|
||||
</label>
|
||||
<Input
|
||||
{...(htmlFor && { id: htmlFor })}
|
||||
@@ -75,7 +77,7 @@ export const InputField: FC<TInputFieldProps> = ({
|
||||
{...rest}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="text-danger-500 text-label1 text-left">{error}</p>
|
||||
<p className="text-danger-500 text-label2 text-left">{error}</p>
|
||||
) : (
|
||||
helperText && (
|
||||
<p
|
||||
|
||||
@@ -3,64 +3,257 @@ import {
|
||||
AuditOutlined,
|
||||
BookOutlined,
|
||||
CommentOutlined,
|
||||
DownOutlined,
|
||||
InboxOutlined,
|
||||
LogoutOutlined,
|
||||
ReadOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
ScheduleOutlined,
|
||||
SettingOutlined,
|
||||
StockOutlined,
|
||||
UsergroupAddOutlined,
|
||||
UserOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button } from '../../atoms';
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { cn, For, useSession } from '@imphnen-frontend-service/utils';
|
||||
|
||||
const MENUS = [
|
||||
{ label: 'Dashboard & Set Gacha', href: '/dashboard', icon: <AppstoreOutlined className="text-[20px]" /> },
|
||||
{ label: 'Dashboard - Dimentorin', href: '/dashboard-dimentorin', icon: <AppstoreOutlined className="text-[20px]" /> },
|
||||
{ label: 'Gacha Roll', href: '/gacha-roll', icon: <ReloadOutlined className="text-[20px]" /> },
|
||||
{ label: 'Permissions', href: '/permissions', icon: <UserSwitchOutlined className="text-[20px]" /> },
|
||||
{ label: 'Roles', href: '/roles', icon: <UsergroupAddOutlined className="text-[20px]" /> },
|
||||
{ label: 'Data Akun', href: '/accounts', icon: <UserOutlined className="text-[20px]" /> },
|
||||
{ label: 'Validasi Transaksi', href: '/transactions', icon: <AuditOutlined className="text-[20px]" /> },
|
||||
{ label: 'Data Pengiriman Hadiah', href: '/prizes', icon: <InboxOutlined className="text-[20px]" /> },
|
||||
{ label: 'User - Dimentorin', href: '/users-dimentorin', icon: <UserSwitchOutlined className="text-[20px]" /> },
|
||||
{ label: 'Session - Dimentorin', href: '/session-dimentorin', icon: <ScheduleOutlined className="text-[20px]" /> },
|
||||
{ label: 'Content & Roadmap', href: '/roadmap-dimentorin', icon: <BookOutlined className="text-[20px]" /> },
|
||||
{ label: 'Feedback & Review', href: '/feedback-review-dimentorin', icon: <CommentOutlined className="text-[20px]" /> },
|
||||
{ label: 'Settings - Dimentorin', href: '/settings-dimentorin', icon: <SettingOutlined className="text-[20px]" /> },
|
||||
]
|
||||
type MenuItem = {
|
||||
label: string;
|
||||
href?: string;
|
||||
icon?: ReactElement;
|
||||
children?: Array<{ label: string; href: string; icon?: ReactElement }>;
|
||||
};
|
||||
|
||||
export const BackofficeSidebar: FC = (): ReactElement => {
|
||||
const MENUS: MenuItem[] = [
|
||||
{
|
||||
label: 'Hackathon',
|
||||
icon: <StockOutlined className="text-p3" />,
|
||||
children: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/hackathon-dashboard',
|
||||
icon: <AppstoreOutlined className="text-p3" />,
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
href: '/hackathon-users',
|
||||
icon: <UserOutlined className="text-p3" />,
|
||||
},
|
||||
{
|
||||
label: 'Teams',
|
||||
href: '/hackathon-teams',
|
||||
icon: <UsergroupAddOutlined className="text-p3" />,
|
||||
},
|
||||
{
|
||||
label: 'Submissions',
|
||||
href: '/hackathon-submissions',
|
||||
icon: <AuditOutlined className="text-p3" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Dimentorin',
|
||||
icon: <ReadOutlined className="text-p3" />,
|
||||
children: [
|
||||
{
|
||||
label: 'Dashboard - Dimentorin',
|
||||
href: '/dashboard-dimentorin',
|
||||
icon: <AppstoreOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'User - Dimentorin',
|
||||
href: '/users-dimentorin',
|
||||
icon: <UserSwitchOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Session - Dimentorin',
|
||||
href: '/session-dimentorin',
|
||||
icon: <ScheduleOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Content & Roadmap',
|
||||
href: '/roadmap-dimentorin',
|
||||
icon: <BookOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Feedback & Review',
|
||||
href: '/feedback-review-dimentorin',
|
||||
icon: <CommentOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Settings - Dimentorin',
|
||||
href: '/settings-dimentorin',
|
||||
icon: <SettingOutlined className="text-[20px]" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Gacha',
|
||||
icon: <ReloadOutlined className="text-[20px]" />,
|
||||
children: [
|
||||
{
|
||||
label: 'Dashboard & Set Gacha',
|
||||
href: '/dashboard',
|
||||
icon: <AppstoreOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Gacha Roll',
|
||||
href: '/gacha-roll',
|
||||
icon: <ReloadOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Validasi Transaksi',
|
||||
href: '/transactions',
|
||||
icon: <AuditOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Data Pengiriman Hadiah',
|
||||
href: '/prizes',
|
||||
icon: <InboxOutlined className="text-[20px]" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Permissions',
|
||||
href: '/permissions',
|
||||
icon: <UserSwitchOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Roles',
|
||||
href: '/roles',
|
||||
icon: <UsergroupAddOutlined className="text-[20px]" />,
|
||||
},
|
||||
{
|
||||
label: 'Data Akun',
|
||||
href: '/accounts',
|
||||
icon: <UserOutlined className="text-[20px]" />,
|
||||
},
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const BackofficeSidebar: FC<SidebarProps> = ({
|
||||
isOpen = false,
|
||||
onClose,
|
||||
}): ReactElement => {
|
||||
const { signOut } = useSession();
|
||||
const location = useLocation();
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin') return false
|
||||
return location.pathname.includes(path)
|
||||
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin')
|
||||
return false;
|
||||
return location.pathname.includes(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="sticky top-0 left-0 w-[280px] bg-white h-svh py-[60px] px-[28px] shadow-xl flex flex-col justify-between">
|
||||
<div className="flex flex-col gap-20 justify-between items-center">
|
||||
<img src="/logos/simple.svg" alt="IMPHNEN Logo" className="w-[150px]" />
|
||||
const toggleGroup = (groupLabel: string) => {
|
||||
setOpenGroups((prev) => ({ ...prev, [groupLabel]: !prev[groupLabel] }));
|
||||
};
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="w-[280px] bg-white h-svh py-10 lg:py-[60px] px-7 shadow-xl flex flex-col justify-between">
|
||||
<div className="flex flex-col gap-10 lg:gap-20 justify-between items-center">
|
||||
<div className="flex justify-around lg:justify-center items-center w-full">
|
||||
<img
|
||||
src="/logos/simple.svg"
|
||||
alt="IMPHNEN Logo"
|
||||
className="w-[150px]"
|
||||
/>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-4 w-full h-[calc(100svh-20rem)] overflow-y-auto">
|
||||
<For data={MENUS}>
|
||||
{({ label, href, icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
to={href}
|
||||
className={cn(
|
||||
"flex items-center justify-items-start gap-3 px-[8px] py-[10px]",
|
||||
isActive(href) ? "bg-primary-500 text-white rounded-md" : "text-gray-700 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-p3 font-medium">{label}</span>
|
||||
</Link>
|
||||
)}
|
||||
{(menu) =>
|
||||
menu.children && menu.children.length > 0 ? (
|
||||
<div key={menu.label} className="w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(menu.label)}
|
||||
className={cn(
|
||||
'flex items-center justify-between w-full gap-3 px-2 py-2.5 rounded-md cursor-pointer',
|
||||
openGroups[menu.label]
|
||||
? 'bg-primary-400 hover:bg-primary-500 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{menu.icon}
|
||||
<span className="text-p3 font-medium">{menu.label}</span>
|
||||
</div>
|
||||
<span className="text-label2">
|
||||
{openGroups[menu.label] ? (
|
||||
<DownOutlined className="text-label1" />
|
||||
) : (
|
||||
<RightOutlined className="text-label1" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{openGroups[menu.label] && (
|
||||
<div className="mt-2 ml-6 flex flex-col gap-2">
|
||||
{menu.children.map((child) => (
|
||||
<Link
|
||||
key={child.href}
|
||||
to={child.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-2 py-2.5 rounded-md',
|
||||
isActive(child.href)
|
||||
? 'bg-primary-100 text-primary-700 hover:bg-primary-200'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
{child.icon}
|
||||
<span className="text-label1 font-medium">
|
||||
{child.label}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
key={menu.href ?? menu.label}
|
||||
to={menu.href ?? '#'}
|
||||
className={cn(
|
||||
'flex items-center justify-items-start gap-3 px-2 py-2.5',
|
||||
menu.href && isActive(menu.href)
|
||||
? 'bg-primary-500 text-white rounded-md'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
{menu.icon}
|
||||
<span className="text-p3 font-medium">{menu.label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
</For>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -70,12 +263,36 @@ export const BackofficeSidebar: FC = (): ReactElement => {
|
||||
<Button
|
||||
onClick={signOut}
|
||||
variant="text"
|
||||
className="items-start justify-start gap-3 px-[8px] py-[10px] text-gray-700 hover:text-red-500 transition-colors w-full"
|
||||
className="items-start justify-start gap-3 px-2 py-2.5 text-gray-700 hover:text-red-500 transition-colors w-full"
|
||||
>
|
||||
<LogoutOutlined className="text-[20px]" />
|
||||
<LogoutOutlined className="text-p3" />
|
||||
<span className="text-p3 font-medium">Log Out</span>
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Sidebar - visible on lg+, sticky */}
|
||||
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto shadow">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar - overlay */}
|
||||
{isOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Sidebar */}
|
||||
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { cn } from "@imphnen-frontend-service/utils"
|
||||
import { Icon } from '@iconify/react'
|
||||
import { FC, ReactElement, ReactNode } from "react"
|
||||
import { Button } from "../../atoms"
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { FC, ReactElement, ReactNode } from 'react';
|
||||
import { Button } from '../../atoms';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/service';
|
||||
|
||||
export type TBackofficeWrapperProps = {
|
||||
children: ReactNode
|
||||
title?: string
|
||||
className?: string
|
||||
classHeader?: string
|
||||
classTitle?: string
|
||||
}
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
className?: string;
|
||||
classHeader?: string;
|
||||
classTitle?: string;
|
||||
};
|
||||
|
||||
export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
|
||||
children,
|
||||
@@ -18,34 +19,52 @@ export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
|
||||
classHeader,
|
||||
classTitle,
|
||||
}): ReactElement => {
|
||||
const { session } = useAuthStore();
|
||||
const user = session?.user;
|
||||
|
||||
return (
|
||||
<main className={cn("w-full px-[48px] py-[40px] flex flex-col gap-8", className)}>
|
||||
<header className={cn("bg-white py-5 px-7 rounded-md shadow flex items-center justify-between", classHeader)}>
|
||||
<h1 className={cn("text-[19px] text-primary-500 font-semibold", classTitle)}>{title}</h1>
|
||||
<main
|
||||
className={cn(
|
||||
'w-full px-[48px] py-[40px] flex flex-col gap-8',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<header
|
||||
className={cn(
|
||||
'bg-white py-5 px-7 rounded-md shadow flex items-center justify-between',
|
||||
classHeader
|
||||
)}
|
||||
>
|
||||
<h1
|
||||
className={cn(
|
||||
'text-[19px] text-primary-500 font-semibold',
|
||||
classTitle
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-x-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="max-h-full p-3"
|
||||
>
|
||||
{/* <Button type="button" variant="secondary" className="max-h-full p-3">
|
||||
<Icon icon="mdi:bell-outline" className="size-6" />
|
||||
</Button>
|
||||
</Button> */}
|
||||
<div className="flex items-center gap-x-6">
|
||||
<div className="text-neutral-600 font-medium">
|
||||
<p className="text-p3">Rizal Syaepulloh</p>
|
||||
<p className="text-label1">Super Admin</p>
|
||||
<p className="text-p3">{user?.fullname || 'Full Name'}</p>
|
||||
<p className="text-label1">Admin</p>
|
||||
</div>
|
||||
<div className="size-12 rounded-full overflow-hidden">
|
||||
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="size-full object-cover" />
|
||||
<img
|
||||
src={user?.avatar || '/images/asd687hwq6nds4dfjj2983.webp'}
|
||||
alt="Profile"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
{children}
|
||||
</section>
|
||||
<section>{children}</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,6 +39,8 @@ export const ControlledInputField = <T extends FieldValues>(
|
||||
error={fieldState.error?.message}
|
||||
{...inputProps}
|
||||
onChange={handleChange}
|
||||
isRequired={props.isRequired}
|
||||
size={props.size}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,57 +1,113 @@
|
||||
import {
|
||||
PaginationState,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
flexRender,
|
||||
ColumnDef,
|
||||
Table,
|
||||
RowData,
|
||||
TableOptions,
|
||||
} from '@tanstack/react-table';
|
||||
import { Pagination } from '../../molecules';
|
||||
|
||||
import React from 'react';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: ColumnDef<T>[];
|
||||
table: Table<T>;
|
||||
interface DataTableProps<T extends RowData> {
|
||||
table?: Table<T>;
|
||||
data?: T[];
|
||||
columns?: ColumnDef<T, unknown>[];
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DataTable = <T extends RowData>({
|
||||
data,
|
||||
columns,
|
||||
table,
|
||||
data = [],
|
||||
columns = [],
|
||||
pageSize = 9,
|
||||
className,
|
||||
}: DataTableProps<T>) => {
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize,
|
||||
});
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
});
|
||||
// Update pagination state when pageSize prop changes
|
||||
React.useEffect(() => {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
pageSize,
|
||||
}));
|
||||
}, [pageSize]);
|
||||
|
||||
// Reset pagination when data changes to prevent out-of-bounds errors
|
||||
React.useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
pageIndex: 0, // Reset to first page when data changes
|
||||
}));
|
||||
}
|
||||
}, [data.length]);
|
||||
|
||||
// Memoize data and columns to prevent unnecessary re-renders
|
||||
const memoizedData = React.useMemo(() => data, [data]);
|
||||
const memoizedColumns = React.useMemo(() => columns, [columns]);
|
||||
|
||||
// Memoize table configuration to prevent recreation on every render
|
||||
const tableConfig = React.useMemo(() => {
|
||||
const config: TableOptions<T> = {
|
||||
data: memoizedData,
|
||||
columns: memoizedColumns,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [memoizedData, memoizedColumns, pagination, sorting]);
|
||||
|
||||
// Prefer external table instance if provided; otherwise create an internal one
|
||||
const internalTable = useReactTable(tableConfig);
|
||||
const t = table ?? internalTable;
|
||||
|
||||
// Handle empty data state
|
||||
const isEmpty = t.getRowModel().rows.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className={cn('flex flex-col gap-8', className)}>
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full min-w-full text-base">
|
||||
<thead className="bg-primary-50 mb-3 text-left text-nowrap">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
{t.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className={cn("py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg", header?.column?.columnDef?.meta?.headerClassName)}
|
||||
onClick={
|
||||
header.column.getCanSort()
|
||||
? header.column.getToggleSortingHandler()
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'py-4 px-5 font-normal first:rounded-l-lg last:rounded-r-lg',
|
||||
header.column.getCanSort() &&
|
||||
'cursor-pointer select-none hover:bg-primary-100 transition-colors',
|
||||
header?.column?.columnDef?.meta?.headerClassName
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -59,28 +115,58 @@ export const DataTable = <T extends RowData>({
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getCanSort() && (
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
{header.column.getIsSorted() === 'asc' && '▲'}
|
||||
{header.column.getIsSorted() === 'desc' && '▼'}
|
||||
{!header.column.getIsSorted() && <span>⇅</span>}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="bg-primary-100 odd:bg-white">
|
||||
{row.getVisibleCells().map((cell, index) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cn("py-3 px-5 first:rounded-l-lg last:rounded-r-lg", cell?.column?.columnDef?.meta?.cellClassName)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
{isEmpty ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={t.getAllColumns().length}
|
||||
className="py-8 px-5 text-center text-neutral-500"
|
||||
>
|
||||
No data available
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
) : (
|
||||
t.getRowModel().rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'hover:bg-primary-50 transition-colors',
|
||||
rowIndex % 2 === 0 ? 'bg-white' : 'bg-primary-100'
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'py-3 px-5 first:rounded-l-lg last:rounded-r-lg',
|
||||
cell?.column?.columnDef?.meta?.cellClassName
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination table={table} />
|
||||
<Pagination table={t} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { supabase, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export const useSession = () => {
|
||||
@@ -6,9 +6,9 @@ export const useSession = () => {
|
||||
const { clearSession, session, status } = useAuthStore();
|
||||
const isAuthenticated = status === 'authenticated';
|
||||
|
||||
const signOut = async () => {
|
||||
await supabase.auth.signOut();
|
||||
const signOut = () => {
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
navigate('/auth/login');
|
||||
};
|
||||
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { lazy, LazyExoticComponent, ReactNode } from 'react';
|
||||
import { ActionFunction, LoaderFunction, RouteObject } from 'react-router';
|
||||
|
||||
type TPermissionItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
interface PageModuleExports {
|
||||
default: () => ReactNode;
|
||||
loader?: LoaderFunction;
|
||||
action?: ActionFunction;
|
||||
permissions?: Array<string>;
|
||||
}
|
||||
|
||||
interface LoadingModuleExports {
|
||||
@@ -58,18 +50,8 @@ export function convertPagesToRoute(
|
||||
return 'loader' in result ? result.loader?.(args) : null;
|
||||
},
|
||||
async guard() {
|
||||
const result = (await importer()) as PageModuleExports;
|
||||
const localStoragePermission = localStorage.getItem('permissions');
|
||||
const permissions: TPermissionItem[] | undefined =
|
||||
localStoragePermission
|
||||
? JSON.parse(localStoragePermission)
|
||||
: undefined;
|
||||
return 'permissions' in result
|
||||
? result.permissions?.every(
|
||||
(permission) =>
|
||||
permissions?.some((item) => permission === item.name) || false
|
||||
) || false
|
||||
: true;
|
||||
// Permission checking removed - always allow access
|
||||
return true;
|
||||
},
|
||||
});
|
||||
routes = mergeRoutes(routes, route);
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
import { FC, PropsWithChildren, ReactNode, useMemo } from 'react';
|
||||
import { useSession } from '../../hooks/use-session';
|
||||
import { FC, PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
type TProps = PropsWithChildren<{
|
||||
permissions: Array<string>;
|
||||
permissions?: Array<string>;
|
||||
fallback?: ReactNode;
|
||||
}>;
|
||||
|
||||
export const Guard: FC<TProps> = (props): ReactNode => {
|
||||
const { session } = useSession();
|
||||
|
||||
const permissionKeys = useMemo(() => {
|
||||
return session?.user?.role?.permissions.map((perm) => perm.name) ?? [];
|
||||
}, [session?.user?.role]);
|
||||
|
||||
const allowed = useMemo(() => {
|
||||
return props.permissions.every((permission) =>
|
||||
permissionKeys.includes(permission)
|
||||
);
|
||||
}, [permissionKeys, props.permissions]);
|
||||
|
||||
if (allowed) {
|
||||
return props.children;
|
||||
}
|
||||
|
||||
return props.fallback ?? null;
|
||||
// Permission checking removed - always allow access
|
||||
return props.children;
|
||||
};
|
||||
|
||||
Generated
+115
-146
@@ -4491,9 +4491,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.3.tgz",
|
||||
"integrity": "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==",
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.4.tgz",
|
||||
"integrity": "sha512-FDPaVoB1kYhtOz6Le0Jn2QV7RZJ3Ngxzqri7YX4yu3Ini+l5lciR7nA9eNDpKTmDm7LWZtxSju+/CQnwRBn2pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -4507,9 +4507,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.3.tgz",
|
||||
"integrity": "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==",
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.4.tgz",
|
||||
"integrity": "sha512-TN0cfB4HT2YyEio9fLwZY33J+s+vMIgC84gQCOLZOYusW7ptgjIn8RwxQt0BUpoo9XRRVVWEHLld0uhyux1ZcA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4523,9 +4523,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.3.tgz",
|
||||
"integrity": "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==",
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.4.tgz",
|
||||
"integrity": "sha512-XsfI23jvimCaA7e+9f3yMCoVjrny2D11G6H8NCcgv+Ina/TQhKPXB9P4q0WjTuEoyZmcNvPdrZ+XtTh3uPfH7Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4539,9 +4539,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.3.tgz",
|
||||
"integrity": "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==",
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.4.tgz",
|
||||
"integrity": "sha512-uo8X7qHDy4YdJUhaoJDMAbL8VT5Ed3lijip2DdBHIB4tfKAvB1XBih6INH2L4qIi4jA0Qq1J0ErxcOocBmUSwg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4555,9 +4555,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.3.tgz",
|
||||
"integrity": "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==",
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.4.tgz",
|
||||
"integrity": "sha512-pvR/AjNIAxsIz0PCNcZYpH+WmNIKNLcL4XYEfo+ArDi7GsxKWFO5BvVBLXbhti8Coyv3DE983NsitzUsGH5yTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4571,9 +4571,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.3.tgz",
|
||||
"integrity": "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==",
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.4.tgz",
|
||||
"integrity": "sha512-2hebpsd5MRRtgqmT7Jj/Wze+wG+ZEXUK2KFFL4IlZ0amEEFADo4ywsifJNeFTQGsamH3/aXkKWymDvgEi+pc2Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4587,9 +4587,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.3.tgz",
|
||||
"integrity": "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==",
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.4.tgz",
|
||||
"integrity": "sha512-pzRXf0LZZ8zMljH78j8SeLncg9ifIOp3ugAFka+Bq8qMzw6hPXOc7wydY7ardIELlczzzreahyTpwsim/WL3Sg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4603,9 +4603,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.3.tgz",
|
||||
"integrity": "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==",
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.4.tgz",
|
||||
"integrity": "sha512-7G/yJVzum52B5HOqqbQYX9bJHkN+c4YyZ2AIvEssMHQlbAWOn3iIJjD4sM6ihWsBxuljiTKJovEYlD1K8lCUHw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4619,9 +4619,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.3.tgz",
|
||||
"integrity": "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==",
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.4.tgz",
|
||||
"integrity": "sha512-0Vy4g8SSeVkuU89g2OFHqGKM4rxsQtihGfenjx2tRckPrge5+gtFnRWGAAwvGXr0ty3twQvcnYjEyOrLHJ4JWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -9178,9 +9178,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9314,17 +9314,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz",
|
||||
"integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz",
|
||||
"integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.47.0",
|
||||
"@typescript-eslint/type-utils": "8.47.0",
|
||||
"@typescript-eslint/utils": "8.47.0",
|
||||
"@typescript-eslint/visitor-keys": "8.47.0",
|
||||
"@typescript-eslint/scope-manager": "8.48.0",
|
||||
"@typescript-eslint/type-utils": "8.48.0",
|
||||
"@typescript-eslint/utils": "8.48.0",
|
||||
"@typescript-eslint/visitor-keys": "8.48.0",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
@@ -9338,7 +9338,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.47.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
@@ -9354,16 +9354,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz",
|
||||
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz",
|
||||
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.47.0",
|
||||
"@typescript-eslint/types": "8.47.0",
|
||||
"@typescript-eslint/typescript-estree": "8.47.0",
|
||||
"@typescript-eslint/visitor-keys": "8.47.0",
|
||||
"@typescript-eslint/scope-manager": "8.48.0",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
"@typescript-eslint/typescript-estree": "8.48.0",
|
||||
"@typescript-eslint/visitor-keys": "8.48.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -9379,14 +9379,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz",
|
||||
"integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz",
|
||||
"integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.47.0",
|
||||
"@typescript-eslint/types": "^8.47.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.48.0",
|
||||
"@typescript-eslint/types": "^8.48.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -9401,14 +9401,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
|
||||
"integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz",
|
||||
"integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.47.0",
|
||||
"@typescript-eslint/visitor-keys": "8.47.0"
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
"@typescript-eslint/visitor-keys": "8.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -9419,9 +9419,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz",
|
||||
"integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz",
|
||||
"integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9436,15 +9436,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz",
|
||||
"integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz",
|
||||
"integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.47.0",
|
||||
"@typescript-eslint/typescript-estree": "8.47.0",
|
||||
"@typescript-eslint/utils": "8.47.0",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
"@typescript-eslint/typescript-estree": "8.48.0",
|
||||
"@typescript-eslint/utils": "8.48.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
@@ -9461,9 +9461,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
|
||||
"integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz",
|
||||
"integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9475,21 +9475,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz",
|
||||
"integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz",
|
||||
"integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.47.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.47.0",
|
||||
"@typescript-eslint/types": "8.47.0",
|
||||
"@typescript-eslint/visitor-keys": "8.47.0",
|
||||
"@typescript-eslint/project-service": "8.48.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.48.0",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
"@typescript-eslint/visitor-keys": "8.48.0",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
"minimatch": "^9.0.4",
|
||||
"semver": "^7.6.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -9513,36 +9512,6 @@
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
"@nodelib/fs.walk": "^1.2.3",
|
||||
"glob-parent": "^5.1.2",
|
||||
"merge2": "^1.3.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
@@ -9573,16 +9542,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz",
|
||||
"integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz",
|
||||
"integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.47.0",
|
||||
"@typescript-eslint/types": "8.47.0",
|
||||
"@typescript-eslint/typescript-estree": "8.47.0"
|
||||
"@typescript-eslint/scope-manager": "8.48.0",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
"@typescript-eslint/typescript-estree": "8.48.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -9597,13 +9566,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
|
||||
"integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
|
||||
"version": "8.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz",
|
||||
"integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.47.0",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -10681,28 +10650,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz",
|
||||
"integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
|
||||
"integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/shared": "3.5.24",
|
||||
"@vue/shared": "3.5.25",
|
||||
"entities": "^4.5.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz",
|
||||
"integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
"@vue/compiler-core": "3.5.25",
|
||||
"@vue/shared": "3.5.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-vue2": {
|
||||
@@ -10768,9 +10737,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz",
|
||||
"integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==",
|
||||
"version": "3.5.25",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz",
|
||||
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -12710,9 +12679,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001756",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz",
|
||||
"integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==",
|
||||
"version": "1.0.30001757",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
|
||||
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -14597,9 +14566,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.259",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz",
|
||||
"integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==",
|
||||
"version": "1.5.260",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz",
|
||||
"integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -20731,12 +20700,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
|
||||
"integrity": "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==",
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.4.tgz",
|
||||
"integrity": "sha512-vICcxKusY8qW7QFOzTvnRL1ejz2ClTqDKtm1AcUjm2mPv/lVAdgpGNsftsPRIDJOXOjRQO68i1dM8Lp8GZnqoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.0.3",
|
||||
"@next/env": "16.0.4",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -20749,14 +20718,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.0.3",
|
||||
"@next/swc-darwin-x64": "16.0.3",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.3",
|
||||
"@next/swc-linux-arm64-musl": "16.0.3",
|
||||
"@next/swc-linux-x64-gnu": "16.0.3",
|
||||
"@next/swc-linux-x64-musl": "16.0.3",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.3",
|
||||
"@next/swc-win32-x64-msvc": "16.0.3",
|
||||
"@next/swc-darwin-arm64": "16.0.4",
|
||||
"@next/swc-darwin-x64": "16.0.4",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.4",
|
||||
"@next/swc-linux-arm64-musl": "16.0.4",
|
||||
"@next/swc-linux-x64-gnu": "16.0.4",
|
||||
"@next/swc-linux-x64-musl": "16.0.4",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.4",
|
||||
"@next/swc-win32-x64-msvc": "16.0.4",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -49,11 +49,13 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"framer-motion": "^12.9.2",
|
||||
"graphql": "^16.11.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "~16.0.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"openapi-fetch": "^0.15.0",
|
||||
"openapi-react-query": "^0.5.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
@@ -96,6 +98,7 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
|
||||
Reference in New Issue
Block a user