Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4b92b357 | ||
|
|
8e7c6e6c21 | ||
|
|
6960b17c41 |
No files matched your search
@@ -1,22 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx nx run-many:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm view:*)",
|
||||
"Bash(npx nx build landing)",
|
||||
"Bash(npm uninstall:*)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(ren page.tsx page-original.tsx)",
|
||||
"Bash(ren:*)",
|
||||
"Bash(npx supabase:*)",
|
||||
"Bash(libs/service/src/types/supabase.ts)",
|
||||
"Bash(npx nx build:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(ls:*)"
|
||||
"Bash(npm run typecheck:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
.next
|
||||
out
|
||||
build
|
||||
coverage
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Nx
|
||||
.nx
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Misc
|
||||
*.md
|
||||
!README.md
|
||||
.editorconfig
|
||||
.prettierrc
|
||||
.eslintrc
|
||||
@@ -1,3 +0,0 @@
|
||||
|
||||
VITE_SUPABASE_URL=https://your-project-id.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
||||
@@ -7,10 +7,6 @@ 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
|
||||
@@ -25,20 +21,15 @@ 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
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
|
||||
else
|
||||
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
|
||||
cd ~/imphnen-frontend-service-backoffice && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << 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
|
||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env
|
||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
|
||||
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 build
|
||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
|
||||
docker image prune -af
|
||||
@@ -7,10 +7,6 @@ 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
|
||||
@@ -25,20 +21,15 @@ 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
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
|
||||
else
|
||||
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
|
||||
cd ~/imphnen-frontend-service-dimentorin && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << 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
|
||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env
|
||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
|
||||
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 build
|
||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
|
||||
docker image prune -af
|
||||
@@ -7,10 +7,6 @@ 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
|
||||
@@ -25,20 +21,15 @@ 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
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
|
||||
else
|
||||
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
|
||||
cd ~/imphnen-frontend-service-gacha && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << 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
|
||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-gacha/apps/gacha/.env
|
||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
|
||||
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 build
|
||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
|
||||
docker image prune -af
|
||||
@@ -1,45 +0,0 @@
|
||||
name: Deploy Hackathon
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
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
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
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
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
|
||||
else
|
||||
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
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 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,10 +7,6 @@ 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
|
||||
@@ -25,20 +21,15 @@ 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
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
||||
else
|
||||
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
|
||||
cd ~/imphnen-frontend-service && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
|
||||
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
||||
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 build
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
||||
docker image prune -af
|
||||
@@ -62,7 +62,3 @@ storybook-static
|
||||
# Next.js
|
||||
.next
|
||||
out
|
||||
.cursor/rules/nx-rules.mdc
|
||||
.github/instructions/nx.instructions.md
|
||||
|
||||
.env.local
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"nx-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["nx", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<!-- 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 +0,0 @@
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
@@ -1,2 +0,0 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
@@ -1,32 +0,0 @@
|
||||
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;
|
||||
@@ -1,128 +0,0 @@
|
||||
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;
|
||||
@@ -1,210 +0,0 @@
|
||||
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
@@ -1,651 +0,0 @@
|
||||
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;
|
||||
@@ -1,476 +0,0 @@
|
||||
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,51 +1,13 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { FC, ReactElement } 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
|
||||
isOpen={mobileSidebarOpen}
|
||||
onClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<BackofficeSidebar />
|
||||
<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,29 +1,19 @@
|
||||
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) => ({
|
||||
@@ -32,15 +22,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,
|
||||
@@ -49,7 +39,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"
|
||||
@@ -107,7 +97,7 @@ export default function Components(): ReactElement {
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-72') },
|
||||
meta: { cellClassName: cn("w-72") },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -123,7 +113,7 @@ export default function Components(): ReactElement {
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
@@ -144,19 +134,14 @@ 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 mb-8">
|
||||
User Management
|
||||
</h1>
|
||||
<h1 className="text-p1 font-semibold text-neutral-700">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}
|
||||
@@ -178,16 +163,12 @@ 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: 14px;
|
||||
font-size: 12px;
|
||||
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 +0,0 @@
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
@@ -1,2 +0,0 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
@@ -3,8 +3,9 @@
|
||||
import React, { createContext, useContext, useMemo, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||
import {
|
||||
useAuthStore,
|
||||
|
||||
useUserMe,
|
||||
useUserById,
|
||||
useUpdateUserMe,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export interface GoogleLoginResponse {
|
||||
access_token?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FC, ReactElement, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useGoogleCallback, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||
import { useGoogleCallback } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const GoogleCallbackPage: FC = (): ReactElement => {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
@@ -1,2 +0,0 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
authLoginSchema,
|
||||
TLoginRequest,
|
||||
usePostLogin,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useVerifyEmail } from './use-verify-email';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Tue, Nov 25, 2025 16:42:27 PM
|
||||
@@ -1,14 +0,0 @@
|
||||
import nx from '@nx/eslint-plugin';
|
||||
import baseConfig from '../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
...nx.configs['flat/react'],
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
||||
// Override or add rules here
|
||||
rules: {
|
||||
'jsx-a11y/accessible-emoji': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,40 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>IMPHNEN x Kolosal.ai Hackathon 2025</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +0,0 @@
|
||||
import { join } from 'path';
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {
|
||||
base: join(import.meta.dirname, '../../'),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg width="138" height="52" viewBox="0 0 138 52" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<rect width="137.505" height="51.7538" fill="url(#pattern0_164_316)"/>
|
||||
<defs>
|
||||
<pattern id="pattern0_164_316" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_164_316" transform="matrix(0.000440723 0 0 0.00117096 -0.173204 -1.3185)"/>
|
||||
</pattern>
|
||||
<image id="image0_164_316" width="3000" height="3000" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAC7gAAAu4CAYAAABxFfV5AAAACXBIWXMAAC4jAAAuIwF4pT92AAX6AElEQVR4nOzaMQEAIAzAsIF/z0MGPRIFFdCzuwMAAAAAAAAAAAAAAL/d3wEAAAAAAAAAAAAAADBjcAcAAAAAAAAAAAAAIMLgDgAAAAAAAAAAAABAgsEdAAAAAAAAAAAAAIAEgzsAAAAAAAAAAAAAAAkGdwAAAAAAAAAAAAAAEgzuAAAAAAAAAAAAAAAkGNwBAAAAAAAAAAAAAEgwuAMAAAAAAAAAAAAAkGBwBwAAAAAAAAAAAAAgweAOAAAAAAAAAAAAAECCwR0AAAAAAAAAAAAAgASDOwAAAAAAAAAAAAAACQZ3AAAALine truncated
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 511 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg width="317" height="170" viewBox="0 0 317 170" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<rect width="317" height="169.613" fill="url(#pattern0_164_317)"/>
|
||||
<defs>
|
||||
<pattern id="pattern0_164_317" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_164_317" transform="scale(0.00184502 0.00344828)"/>
|
||||
</pattern>
|
||||
<image id="image0_164_317" width="542" height="290" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAh4AAAEiCAYAAAC2k9sBAAAABGdBTUEAALGPC/xhBQAACklpQ0NQc1JHQiBJRUM2MTk2Ni0yLjEAAEiJnVN3WJP3Fj7f92UPVkLY8LGXbIEAIiOsCMgQWaIQkgBhhBASQMWFiApWFBURnEhVxILVCkidiOKgKLhnQYqIWotVXDjuH9yntX167+3t+9f7vOec5/zOec8PgBESJpHmomoAOVKFPDrYH49PSMTJvYACFUjgBCAQ5svCZwXFAADwA3l4fnSwP/wBr28AAgBw1S4kEsfh/4O6UCZXACCRAOAiEucLAZBSAMguVMgUAMgYALBTs2QKAJQAAGx5fEIiAKoNAOz0ST4FANipk9wXANiiHKLine truncated
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 351 KiB |
@@ -1,10 +0,0 @@
|
||||
<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 16Line truncated
|
||||
<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.469Line truncated
|
||||
<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 6Line truncated
|
||||
<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.3731C6Line truncated
|
||||
<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.3Line truncated
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 7.2 KiB |
@@ -1,23 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<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 dark:text-gray-300 mb-4">
|
||||
Page Not Found
|
||||
</h2>
|
||||
<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
|
||||
to="/"
|
||||
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Go Back Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useGitHubCallback } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const CallbackPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback();
|
||||
const [isProcessing, setIsProcessing] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasRunRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
if (hasRunRef.current) {
|
||||
return;
|
||||
}
|
||||
hasRunRef.current = true;
|
||||
|
||||
try {
|
||||
// Check URL hash for Supabase email confirmation callback
|
||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||
const urlParams = new URLSearchParams(globalThis.location.search);
|
||||
|
||||
const type = hashParams.get('type') || urlParams.get('type');
|
||||
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
||||
|
||||
// Debug: log what we received
|
||||
console.log('[Callback] Params:', { type, accessToken: !!accessToken, hash: globalThis.location.hash, search: globalThis.location.search });
|
||||
|
||||
// Handle Supabase email callbacks (has access_token in hash or query)
|
||||
// This includes: signup confirmation, email confirmation, password recovery
|
||||
if (accessToken) {
|
||||
setIsProcessing(false);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Get the code from URL query params (GitHub OAuth)
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received');
|
||||
}
|
||||
|
||||
// Exchange the code for tokens using backend API (GitHub OAuth)
|
||||
const result = await exchangeGitHubCode({ code });
|
||||
|
||||
toast.success('Login successful!');
|
||||
setIsProcessing(false);
|
||||
|
||||
// Check if user has completed onboarding (has location)
|
||||
if (result.user.location) {
|
||||
globalThis.location.replace('/dashboard');
|
||||
} else {
|
||||
globalThis.location.replace('/onboarding/user');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Callback] Error:', err);
|
||||
setError((err as Error).message);
|
||||
setIsProcessing(false);
|
||||
toast.error('An error occurred during login');
|
||||
|
||||
setTimeout(() => {
|
||||
navigate('/auth/login');
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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 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 dark:text-white mb-2">
|
||||
GitHub Login Failed
|
||||
</h2>
|
||||
<p className="text-red-600 dark:text-red-400 mb-4 whitespace-pre-line">{error}</p>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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 dark:text-white mb-2">
|
||||
Completing login...
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">Please wait</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CallbackPage;
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
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 [emailSent, setEmailSent] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const forgotPasswordMutation = useForgotPassword();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email) {
|
||||
toast.error('Please enter your email');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await forgotPasswordMutation.mutateAsync({ email });
|
||||
|
||||
setEmailSent(true);
|
||||
toast.success('Password reset email sent! Check your inbox.');
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || 'Failed to send reset email');
|
||||
}
|
||||
};
|
||||
|
||||
if (emailSent) {
|
||||
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">
|
||||
<span className="text-3xl">✓</span>
|
||||
</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 a password reset link to <strong>{email}</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 reset your password. The link will
|
||||
expire in 1 hour.
|
||||
</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">
|
||||
Back to Login
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setEmailSent(false)}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<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 dark:text-white mb-2">
|
||||
Forgot Password?
|
||||
</h2>
|
||||
<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 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
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={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"
|
||||
>
|
||||
{forgotPasswordMutation.isPending ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const loginMutation = useLogin();
|
||||
const [isGithubLoading, setIsGithubLoading] = 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();
|
||||
setError(null);
|
||||
|
||||
if (!email || !password) {
|
||||
setError('Please enter both email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await loginMutation.mutateAsync({ email, password });
|
||||
|
||||
toast.success('Login successful!');
|
||||
|
||||
// Redirect based on onboarding status
|
||||
if (result.user.location) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/onboarding/user');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login] Email login failed:', err);
|
||||
setError((err as Error).message || 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGithubLogin = async () => {
|
||||
try {
|
||||
setIsGithubLoading(true);
|
||||
|
||||
const result = await signInWithGitHub();
|
||||
|
||||
// Check if we got a redirect URL
|
||||
if (result?.url) {
|
||||
globalThis.location.href = result.url;
|
||||
} else {
|
||||
setIsGithubLoading(false);
|
||||
setError('Failed to get GitHub OAuth URL');
|
||||
}
|
||||
} 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 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 dark:text-white mb-2">
|
||||
Welcome Back
|
||||
</h2>
|
||||
<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 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 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
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 dark:text-gray-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<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>
|
||||
<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={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"
|
||||
>
|
||||
{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 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 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>
|
||||
{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}
|
||||
</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 dark:text-gray-400 text-sm">
|
||||
Don't have an account?{' '}
|
||||
<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
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
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 [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 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) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
toast.error('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
toast.error('Invalid reset token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await resetPasswordMutation.mutateAsync({
|
||||
access_token: accessToken,
|
||||
new_password: password,
|
||||
});
|
||||
|
||||
toast.success('Password updated successfully!');
|
||||
|
||||
// Clear session and redirect to login
|
||||
clearSession();
|
||||
navigate('/auth/login');
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || 'Failed to reset password');
|
||||
}
|
||||
};
|
||||
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<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 dark:text-gray-400">
|
||||
Verifying reset link...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Set New Password
|
||||
</h2>
|
||||
<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 dark:text-gray-300 mb-1"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<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 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<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={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"
|
||||
>
|
||||
{resetPasswordMutation.isPending ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useGitHubAuth, useSignup } 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';
|
||||
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();
|
||||
|
||||
// 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);
|
||||
|
||||
try {
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
const result = await signInWithGitHub();
|
||||
|
||||
if (result?.url) {
|
||||
globalThis.location.href = result.url;
|
||||
} else {
|
||||
setIsGithubLoading(false);
|
||||
setError('Failed to get GitHub OAuth URL');
|
||||
}
|
||||
} 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 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 dark:text-white mb-2">
|
||||
Create Account
|
||||
</h2>
|
||||
<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 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={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<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"
|
||||
{...register('fullname')}
|
||||
placeholder="John Doe"
|
||||
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 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
placeholder="your@email.com"
|
||||
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 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<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 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<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={!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"
|
||||
>
|
||||
{signupMutation.isPending
|
||||
? 'Creating account...'
|
||||
: 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="my-6 flex items-center">
|
||||
<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 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>
|
||||
{isGithubLoading ? 'Connecting...' : 'Sign up with GitHub'}
|
||||
</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 dark:text-gray-400 text-sm">
|
||||
Already have an account?{' '}
|
||||
<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
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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,335 +0,0 @@
|
||||
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');
|
||||
|
||||
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 { session } = useAuthStore();
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
// 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: Invitation[] = (invitationsData?.data ||
|
||||
[]) as Invitation[];
|
||||
|
||||
const handleAcceptInvitation = async (invitationId: string) => {
|
||||
try {
|
||||
await respondToInvitation({ invitationId, action: 'accept' });
|
||||
toast.success('Invitation accepted! You are now a team member.');
|
||||
} catch (error) {
|
||||
console.error('Failed to accept invitation:', error);
|
||||
toast.error('Failed to accept invitation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectInvitation = async (invitationId: string) => {
|
||||
try {
|
||||
await respondToInvitation({ invitationId, action: 'reject' });
|
||||
toast.success('Invitation declined');
|
||||
} catch (error) {
|
||||
console.error('Failed to reject invitation:', error);
|
||||
toast.error('Failed to decline invitation');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{invitations.length > 0 && (
|
||||
<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>
|
||||
{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 dark:bg-gray-900 p-4 rounded-lg shadow-sm flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<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>
|
||||
{!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>
|
||||
)}
|
||||
|
||||
{myTeams.length > 0 ? (
|
||||
(() => {
|
||||
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
|
||||
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"
|
||||
>
|
||||
<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-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 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>
|
||||
{team.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<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-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>
|
||||
)}
|
||||
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<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 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>
|
||||
|
||||
{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 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 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 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 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfilePage
|
||||
open={showProfileModal}
|
||||
onClose={() => setShowProfileModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
|
||||
|
||||
export default function ErrorPage() {
|
||||
const error = useRouteError();
|
||||
let errorMessage: string;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
errorMessage = error.statusText;
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error;
|
||||
} else {
|
||||
console.error(error);
|
||||
errorMessage = 'Unknown error';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
|
||||
<p className="text-xl text-gray-700 mb-2">
|
||||
Sorry, an unexpected error has occurred.
|
||||
</p>
|
||||
<p className="text-gray-500 italic">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import {
|
||||
Outlet,
|
||||
ScrollRestoration,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore, useUserMe } from '@imphnen-frontend-service/service';
|
||||
|
||||
// Define onboarding routes
|
||||
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;
|
||||
|
||||
// Allow hackathon pages without checks
|
||||
if (pathname.startsWith('/hackathons')) {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow auth callback without checks
|
||||
if (pathname === '/auth/callback') {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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') {
|
||||
navigate('/dashboard', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
// Allow unauthenticated access to auth pages
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Home page - allow everyone to view the landing page
|
||||
if (pathname === '/') {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Require authentication for all other routes
|
||||
if (!session) {
|
||||
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)) {
|
||||
const hasLocation = !!userData?.data?.location || !!session?.user?.location;
|
||||
|
||||
if (!hasLocation) {
|
||||
navigate('/onboarding/user', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsChecking(false);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [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 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 dark:text-neutral-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<ScrollRestoration />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Loaded 50 of 178 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user