Compare commits

..
Author SHA1 Message Date
Hafid Nur 029c852290 feat: add Indonesian cities constants 2025-11-25 11:08:04 +07:00
92 changed files with 22141 additions and 8994 deletions
+1 -7
View File
@@ -10,13 +10,7 @@
"Bash(ren page.tsx page-original.tsx)", "Bash(ren page.tsx page-original.tsx)",
"Bash(ren:*)", "Bash(ren:*)",
"Bash(npx supabase:*)", "Bash(npx supabase:*)",
"Bash(libs/service/src/types/supabase.ts)", "Bash(libs/service/src/types/supabase.ts)"
"Bash(npx nx build:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push)",
"Bash(findstr:*)",
"Bash(ls:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
+1 -6
View File
@@ -7,10 +7,6 @@ on:
paths: paths:
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY - 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,7 +21,6 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }} port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: | script: |
set -e set -e
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
@@ -39,6 +34,6 @@ jobs:
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }} VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF EOF
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml 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 compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
docker image prune -af docker image prune -af
+1 -6
View File
@@ -7,10 +7,6 @@ on:
paths: paths:
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY - 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,7 +21,6 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }} port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: | script: |
set -e set -e
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
@@ -39,6 +34,6 @@ jobs:
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }} VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF EOF
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml 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 compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
docker image prune -af docker image prune -af
+1 -6
View File
@@ -7,10 +7,6 @@ on:
paths: paths:
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY - 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,7 +21,6 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }} port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: | script: |
set -e set -e
if [ ! -d ~/imphnen-frontend-service-gacha ]; then if [ ! -d ~/imphnen-frontend-service-gacha ]; then
@@ -39,6 +34,6 @@ jobs:
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }} VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF EOF
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml 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 compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
docker image prune -af docker image prune -af
+2 -8
View File
@@ -7,10 +7,6 @@ on:
paths: paths:
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon - 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,7 +21,6 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }} port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: | script: |
set -e set -e
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
@@ -33,13 +28,12 @@ jobs:
else else
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
fi fi
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF cat > ~/imphnen-frontend-service-hackathon/apps/hackathon/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }} VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }} VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }} VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF EOF
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
docker builder prune -af || true docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build
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 compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
docker image prune -af docker image prune -af
+1 -6
View File
@@ -7,10 +7,6 @@ on:
paths: paths:
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY - 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,7 +21,6 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }} port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: | script: |
set -e set -e
if [ ! -d ~/imphnen-frontend-service ]; then if [ ! -d ~/imphnen-frontend-service ]; then
@@ -39,6 +34,6 @@ jobs:
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
EOF EOF
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true 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 compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
docker image prune -af docker image prune -af
-9
View File
@@ -1,9 +0,0 @@
{
"mcpServers": {
"nx-mcp": {
"type": "stdio",
"command": "npx",
"args": ["nx", "mcp"]
}
}
}
-13
View File
@@ -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
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 4:21:27 PM
@@ -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;
@@ -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;
+2 -40
View File
@@ -1,51 +1,13 @@
import { FC, ReactElement, useState } from 'react'; import { FC, ReactElement } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms'; import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
export const AppLayout: FC = (): ReactElement => { export const AppLayout: FC = (): ReactElement => {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
return ( return (
<div className="bg-primary-50 min-h-screen flex justify-center"> <div className="bg-primary-50 min-h-screen flex justify-center">
<div className="bg-primary-50 min-h-screen w-full flex"> <div className="bg-primary-50 min-h-screen w-full flex">
<BackofficeSidebar <BackofficeSidebar />
isOpen={mobileSidebarOpen}
onClose={() => setMobileSidebarOpen(false)}
/>
<div className="flex-1 overflow-auto"> <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 /> <Outlet />
</div> </div>
</div> </div>
@@ -1,29 +1,19 @@
import { SearchOutlined } from '@ant-design/icons'; import { SearchOutlined } from "@ant-design/icons";
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms'; import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
import { import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
BackofficeWrapper, import { cn, For } from "@imphnen-frontend-service/utils";
DataTable, import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
} from '@imphnen-frontend-service/ui/organisms'; import { ReactElement, useState } from "react";
import { cn, For } from '@imphnen-frontend-service/utils'; import { ModalDetailUser } from "./_components/modal/detail";
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'; type UserStatus = 'active' | 'inactive';
interface UserType { interface UserType {
id: number; id: number
name: string; name: string
email: string; email: string
rating: number; rating: number
status: UserStatus; status: UserStatus
} }
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({ const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
@@ -32,15 +22,15 @@ const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
email: 'fullname23@gmail.com', email: 'fullname23@gmail.com',
rating: 4.5, rating: 4.5,
status: i % 2 === 0 ? 'active' : 'inactive', status: i % 2 === 0 ? 'active' : 'inactive',
})); }))
export default function Components(): ReactElement { export default function Components(): ReactElement {
const TABS = ['mentor', 'mentee'] as const; const TABS = ['mentor', 'mentee'] as const
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor'); const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
const [showDetail, setShowDetail] = useState(false); const [showDetail, setShowDetail] = useState(false)
const [selectedUserId, setSelectedUserId] = useState<number | null>(null); const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({ const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 9, pageSize: 9,
@@ -49,7 +39,7 @@ export default function Components(): ReactElement {
const columns: ColumnDef<UserType>[] = [ const columns: ColumnDef<UserType>[] = [
{ {
id: 'select', id: 'select',
meta: { cellClassName: cn('w-20') }, meta: { cellClassName: cn("w-20") },
header: ({ table }) => ( header: ({ table }) => (
<input <input
type="checkbox" type="checkbox"
@@ -107,7 +97,7 @@ export default function Components(): ReactElement {
}, },
{ {
header: 'Action', header: 'Action',
meta: { cellClassName: cn('w-72') }, meta: { cellClassName: cn("w-72") },
cell: ({ row }) => ( cell: ({ row }) => (
<Button <Button
variant="primary" variant="primary"
@@ -123,7 +113,7 @@ export default function Components(): ReactElement {
</Button> </Button>
), ),
}, },
]; ]
const table = useReactTable({ const table = useReactTable({
data: mockData, data: mockData,
@@ -144,19 +134,14 @@ export default function Components(): ReactElement {
return ( return (
<BackofficeWrapper title="Dimentorin.dev"> <BackofficeWrapper title="Dimentorin.dev">
<div className="mb-8 flex justify-between items-center"> <div className="mb-8 flex justify-between items-center">
<h1 className="text-p1 font-semibold text-neutral-700 mb-8"> <h1 className="text-p1 font-semibold text-neutral-700">User Management</h1>
User Management
</h1>
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md"> <div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
<For data={TABS}> <For data={TABS}>
{(tab) => ( {(tab) => (
<Button <Button
key={tab} key={tab}
variant="text" variant="text"
className={cn( className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
'px-3 py-2 capitalize',
activeTab === tab && 'bg-white'
)}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
> >
{tab} {tab}
@@ -178,16 +163,12 @@ export default function Components(): ReactElement {
</div> </div>
</div> </div>
<Select> <Select>
<option selected disabled> <option selected disabled>Rating</option>
Rating
</option>
<option value="4.5">4.5</option> <option value="4.5">4.5</option>
<option value="5">5</option> <option value="5">5</option>
</Select> </Select>
<Select> <Select>
<option selected disabled> <option selected disabled>Status</option>
Status
</option>
<option value="active">Active</option> <option value="active">Active</option>
<option value="inactive">Inactive</option> <option value="inactive">Inactive</option>
</Select> </Select>
+1 -1
View File
@@ -130,7 +130,7 @@
html { html {
font-family: 'Bai Jamjuree', sans-serif; font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400; font-weight: 400;
font-size: 14px; font-size: 12px;
line-height: 1.2; line-height: 1.2;
} }
} }
+1 -1
View File
@@ -88,7 +88,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
return null; return null;
} }
// if (!session) return redirect('/auth/login'); if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find( const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname (route) => route.path === pathname
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 4:21:27 PM
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 4:21:27 PM
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 16:42:27 PM
+2
View File
@@ -0,0 +1,2 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
+3 -27
View File
@@ -2,36 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>IMPHNEN x Kolosal.ai Hackathon 2025</title> <title>Hackathon</title>
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta <link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
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" /> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 511 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 351 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

+3 -3
View File
@@ -2,13 +2,13 @@ import { Link } from 'react-router-dom';
export default function NotFoundPage() { export default function NotFoundPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center"> <div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1> <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"> <h2 className="text-3xl font-semibold text-gray-900 mb-4">
Page Not Found Page Not Found
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-8"> <p className="text-gray-600 mb-8">
The page you are looking for doesn't exist or has been moved. The page you are looking for doesn't exist or has been moved.
</p> </p>
<Link <Link
+101 -94
View File
@@ -1,11 +1,11 @@
import { FC, ReactElement, useEffect, useState, useRef } from 'react'; import { FC, ReactElement, useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useGitHubCallback } from '@imphnen-frontend-service/service'; import { useAuthStore, supabase } from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
const CallbackPage: FC = (): ReactElement => { const CallbackPage: FC = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback(); const { setSession } = useAuthStore();
const [isProcessing, setIsProcessing] = useState(true); const [isProcessing, setIsProcessing] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const hasRunRef = useRef(false); const hasRunRef = useRef(false);
@@ -13,64 +13,118 @@ const CallbackPage: FC = (): ReactElement => {
useEffect(() => { useEffect(() => {
const handleCallback = async () => { const handleCallback = async () => {
if (hasRunRef.current) { if (hasRunRef.current) {
console.log('[Callback] Already processed, skipping...');
return; return;
} }
hasRunRef.current = true; hasRunRef.current = true;
try { try {
// Check URL hash for Supabase email confirmation callback console.log('[Callback] Processing OAuth callback...');
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1)); console.log('[Callback] Current URL:', globalThis.location.href);
const urlParams = new URLSearchParams(globalThis.location.search);
const type = hashParams.get('type') || urlParams.get('type'); // Supabase client is configured with detectSessionInUrl: true
const accessToken = hashParams.get('access_token') || urlParams.get('access_token'); // This means Supabase automatically detects and processes OAuth tokens from the URL hash
// We just need to wait a moment for it to complete, then check for the session
// Debug: log what we received console.log('[Callback] Waiting for Supabase to process OAuth callback...');
console.log('[Callback] Params:', { type, accessToken: !!accessToken, hash: globalThis.location.hash, search: globalThis.location.search }); await new Promise(resolve => setTimeout(resolve, 1000));
// Handle Supabase email callbacks (has access_token in hash or query) // Get the session that Supabase automatically created from the URL hash
// This includes: signup confirmation, email confirmation, password recovery const { data: { session: sessionData }, error: sessionError } = await supabase.auth.getSession();
if (accessToken) {
setIsProcessing(false);
// Password recovery - type is 'recovery' or we have access_token from reset email if (sessionError) {
if (type === 'recovery' || type === 'magiclink') { console.error('[Callback] Session error:', sessionError);
toast.success('Email verified! Please set your new password.'); throw new Error(sessionError.message || 'Failed to get session');
navigate('/auth/reset-password?access_token=' + accessToken);
return;
} }
// Signup/Email confirmation if (!sessionData || !sessionData.user) {
if (type === 'signup' || type === 'email_confirmation') { throw new Error('No session found after OAuth callback. Please try logging in again.');
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 console.log('[Callback] Supabase session established:', {
// (Supabase sometimes sends without explicit type) userId: sessionData.user.id,
toast.success('Email verified! Please set your new password.'); email: sessionData.user.email,
navigate('/auth/reset-password?access_token=' + accessToken); });
return;
// Create/update user in the users table (for foreign key constraints)
console.log('[Callback] Creating/updating user record...');
const { data: userData, error: upsertError } = await supabase
.from('users')
.upsert({
id: sessionData.user.id,
email: sessionData.user.email || '',
fullname: sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.name ||
sessionData.user.email?.split('@')[0] || '',
avatar: sessionData.user.user_metadata?.avatar_url || '',
is_active: true,
updated_at: new Date().toISOString(),
}, {
onConflict: 'id',
})
.select()
.single();
if (upsertError) {
console.warn('[Callback] Failed to create user record:', upsertError);
// Don't throw - continue with login even if user record creation fails
} else {
console.log('[Callback] User record created/updated successfully');
} }
// Get the code from URL query params (GitHub OAuth) // Store user-friendly data in Zustand for UI purposes
const code = urlParams.get('code'); // Supabase now manages the actual auth session
// Use data from database if available, otherwise use OAuth metadata
const userRecord = userData || {
id: sessionData.user.id,
email: sessionData.user.email || '',
fullname: sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.name ||
sessionData.user.email?.split('@')[0] || '',
avatar: sessionData.user.user_metadata?.avatar_url || '',
phone_number: '',
birthdate: '',
gender: '',
is_active: true,
};
if (!code) { setSession({
throw new Error('No authorization code received'); token: {
} access_token: sessionData.access_token,
refresh_token: sessionData.refresh_token || '',
// Exchange the code for tokens using backend API (GitHub OAuth) },
const result = await exchangeGitHubCode({ code }); user: {
id: userRecord.id,
email: userRecord.email,
fullname: userRecord.fullname,
phone_number: userRecord.phone_number || '',
avatar: userRecord.avatar || '',
birthdate: userRecord.birthdate || '',
gender: userRecord.gender || '',
is_active: userRecord.is_active,
location: userRecord.location,
bio: userRecord.bio,
skills: userRecord.skills,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
console.log('[Callback] Session stored successfully');
toast.success('Login successful!'); toast.success('Login successful!');
setIsProcessing(false); setIsProcessing(false);
// Check if user has completed onboarding (has location) // Check if user has completed onboarding (has location)
if (result.user.location) { // Use globalThis.location.replace for hard redirect to prevent history issues
if (userRecord.location) {
console.log('[Callback] User has completed onboarding, redirecting to dashboard...');
globalThis.location.replace('/dashboard'); globalThis.location.replace('/dashboard');
} else { } else {
console.log('[Callback] User needs onboarding, redirecting...');
globalThis.location.replace('/onboarding/user'); globalThis.location.replace('/onboarding/user');
} }
} catch (err) { } catch (err) {
@@ -87,68 +141,21 @@ const CallbackPage: FC = (): ReactElement => {
handleCallback(); handleCallback();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []); // Run only once on mount
if (error) { 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 ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 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="bg-white w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200">
<div className="text-center mb-6"> <div className="text-center mb-6">
<div className="text-red-500 text-5xl mb-4"></div> <div className="text-red-500 text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-2xl font-bold text-gray-900 mb-2">
GitHub Login Failed GitHub Login Failed
</h2> </h2>
<p className="text-red-600 dark:text-red-400 mb-4 whitespace-pre-line">{error}</p> <p className="text-red-600 mb-4 whitespace-pre-line">{error}</p>
</div> </div>
{isPrivateEmailError && ( <p className="text-gray-600 text-sm mt-6 text-center">
<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... Redirecting to login page in 3 seconds...
</p> </p>
</div> </div>
@@ -157,13 +164,13 @@ const CallbackPage: FC = (): ReactElement => {
} }
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center"> <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> <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"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Completing login... Completing login...
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400">Please wait</p> <p className="text-gray-600">Please wait</p>
</div> </div>
</div> </div>
); );
@@ -1,15 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { useForgotPassword } from '@imphnen-frontend-service/service'; import { supabase } from '@imphnen-frontend-service/service';
import { Link, useNavigate } from 'react-router'; import { Link } from 'react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import ThemeToggle from '../../../components/theme-toggle';
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [emailSent, setEmailSent] = useState(false); const [emailSent, setEmailSent] = useState(false);
const navigate = useNavigate();
const forgotPasswordMutation = useForgotPassword();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -20,35 +17,45 @@ export default function ForgotPasswordPage() {
} }
try { try {
await forgotPasswordMutation.mutateAsync({ email }); setIsLoading(true);
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
});
if (error) {
throw error;
}
setEmailSent(true); setEmailSent(true);
toast.success('Password reset email sent! Check your inbox.'); toast.success('Password reset email sent! Check your inbox.');
} catch (err) { } catch (err) {
console.error('Failed to send reset email:', err);
toast.error((err as Error).message || 'Failed to send reset email'); toast.error((err as Error).message || 'Failed to send reset email');
} finally {
setIsLoading(false);
} }
}; };
if (emailSent) { if (emailSent) {
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 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="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 text-center">
<div className="mb-6"> <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"> <div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<span className="text-3xl"></span> <span className="text-3xl"></span>
</div> </div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-3xl font-bold text-gray-900 mb-2">
Check Your Email Check Your Email
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400"> <p className="text-gray-600">
We've sent a password reset link to <strong>{email}</strong> We've sent a password reset link to <strong>{email}</strong>
</p> </p>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600">
Click the link in the email to reset your password. The link will Click the link in the email to reset your password. The link will expire in 1 hour.
expire in 1 hour.
</p> </p>
<Link to="/auth/login"> <Link to="/auth/login">
@@ -59,7 +66,7 @@ export default function ForgotPasswordPage() {
<button <button
onClick={() => setEmailSent(false)} 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" className="w-full py-3 text-gray-600 hover:text-gray-900 transition-colors"
> >
Send another email Send another email
</button> </button>
@@ -70,33 +77,20 @@ export default function ForgotPasswordPage() {
} }
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 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="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<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"> <div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-3xl font-bold text-gray-900 mb-2">
Forgot Password? Forgot Password?
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400"> <p className="text-gray-600">
No worries, we'll send you reset instructions No worries, we'll send you reset instructions
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email Address Email Address
</label> </label>
<input <input
@@ -105,20 +99,26 @@ export default function ForgotPasswordPage() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com" placeholder="your@email.com"
disabled={forgotPasswordMutation.isPending} disabled={isLoading}
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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={forgotPasswordMutation.isPending} disabled={isLoading}
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" 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'} {isLoading ? 'Sending...' : 'Send Reset Link'}
</button> </button>
</form> </form>
<div className="mt-6 text-center">
<Link to="/auth/login" className="text-primary-600 hover:text-primary-700 font-semibold">
Back to Login
</Link>
</div>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,89 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate } from 'react-router';
import { useGitHubAuth } from '@imphnen-frontend-service/service';
import { useSession } from '@imphnen-frontend-service/utils';
import { GithubOutlined } from '@ant-design/icons';
const LoginPage: FC = (): ReactElement => {
console.log('[LoginPage] Rendering...');
const navigate = useNavigate();
const { signInWithGitHub } = useGitHubAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const { isAuthenticated } = useSession();
console.log('[LoginPage] isAuthenticated:', isAuthenticated);
useEffect(() => {
console.log('[LoginPage] useEffect - isAuthenticated:', isAuthenticated);
if (isAuthenticated) {
console.log('[LoginPage] Redirecting to dashboard...');
navigate('/dashboard');
}
}, [isAuthenticated, navigate]);
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
console.log('[Login] Initiating GitHub OAuth...');
const result = await signInWithGitHub();
console.log('[Login] OAuth result:', result);
// Check if we got a redirect URL
if (result?.url) {
console.log('[Login] Redirecting to GitHub OAuth:', result.url);
// Supabase should handle the redirect automatically
// If we're still here after 2 seconds, manually redirect
setTimeout(() => {
if (result.url) {
globalThis.location.href = result.url;
}
}, 2000);
} else {
console.error('[Login] No OAuth URL returned');
setIsGithubLoading(false);
}
} catch (error) {
console.error('[Login] GitHub login failed:', error);
setIsGithubLoading(false);
}
};
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-100">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Welcome to Hackathon
</h2>
<p className="text-gray-600">
Sign in with your GitHub account to join or create your hackathon team
</p>
</div>
<Button
className="w-full gap-2 py-3"
variant="secondary"
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
>
<GithubOutlined className="text-xl" />
<span className="font-semibold">
{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}
</span>
</Button>
<div className="mt-6 text-center">
<p className="text-gray-500 text-sm">
By signing in, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
};
export default LoginPage;
+81 -107
View File
@@ -1,43 +1,21 @@
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { import { useGitHubAuth, useEmailAuth, supabase, useAuthStore } from '@imphnen-frontend-service/service';
useGitHubAuth,
useLogin,
} from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons'; import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router'; import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { ThemeToggle } from '../../../components/theme-toggle';
export default function LoginPage() { export default function LoginPage() {
console.log('[LoginPage] Rendering...');
const navigate = useNavigate(); const navigate = useNavigate();
const { setSession } = useAuthStore();
const { signInWithGitHub } = useGitHubAuth(); const { signInWithGitHub } = useGitHubAuth();
const loginMutation = useLogin(); const { signInWithEmail } = useEmailAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false); const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null); 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) => { const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -49,12 +27,51 @@ export default function LoginPage() {
} }
try { try {
const result = await loginMutation.mutateAsync({ email, password }); setIsEmailLoading(true);
console.log('[Login] Attempting email login...');
const result = await signInWithEmail(email, password);
console.log('[Login] Email login successful:', result);
// Get user data from database
const { data: userData } = await supabase
.from('users')
.select('*')
.eq('id', result.user.id)
.single();
// Store session in Zustand
setSession({
token: {
access_token: result.session.access_token,
refresh_token: result.session.refresh_token || '',
},
user: {
id: result.user.id,
email: result.user.email || '',
fullname: userData?.fullname || result.user.user_metadata?.full_name || '',
phone_number: userData?.phone_number || '',
avatar: userData?.avatar || '',
birthdate: userData?.birthdate || '',
gender: userData?.gender || '',
is_active: userData?.is_active || true,
location: userData?.location,
bio: userData?.bio,
skills: userData?.skills,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
toast.success('Login successful!'); toast.success('Login successful!');
// Redirect based on onboarding status // Redirect based on onboarding status
if (result.user.location) { if (userData?.location) {
navigate('/dashboard'); navigate('/dashboard');
} else { } else {
navigate('/onboarding/user'); navigate('/onboarding/user');
@@ -62,64 +79,54 @@ export default function LoginPage() {
} catch (err) { } catch (err) {
console.error('[Login] Email login failed:', err); console.error('[Login] Email login failed:', err);
setError((err as Error).message || 'Login failed'); setError((err as Error).message || 'Login failed');
setIsEmailLoading(false);
} }
}; };
const handleGithubLogin = async () => { const handleGithubLogin = async () => {
try { try {
setIsGithubLoading(true); setIsGithubLoading(true);
console.log('[Login] Initiating GitHub OAuth...');
const result = await signInWithGitHub(); const result = await signInWithGitHub();
console.log('[Login] OAuth result:', result);
// Check if we got a redirect URL // Check if we got a redirect URL
if (result?.url) { if (result?.url) {
console.log('[Login] Redirecting to GitHub OAuth:', result.url);
// Manually redirect immediately
globalThis.location.href = result.url; globalThis.location.href = result.url;
} else { } else {
console.error('[Login] No OAuth URL returned');
setIsGithubLoading(false); setIsGithubLoading(false);
setError('Failed to get GitHub OAuth URL');
} }
} catch (err) { } catch (error) {
console.error('[Login] GitHub login failed:', err); console.error('[Login] GitHub login failed:', error);
setError((err as Error).message || 'GitHub login failed');
setIsGithubLoading(false); setIsGithubLoading(false);
} }
}; };
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 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="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<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"> <div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-3xl font-bold text-gray-900 mb-2">
Welcome Back Welcome Back
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400 font-sans"> <p className="text-gray-600">
Sign in to join or create your hackathon team Sign in to join or create your hackathon team
</p> </p>
</div> </div>
{error && ( {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"> <div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p> <p className="text-red-600 text-sm">{error}</p>
</div> </div>
)} )}
<form onSubmit={handleEmailLogin} className="space-y-4"> <form onSubmit={handleEmailLogin} className="space-y-4">
<div> <div>
<label <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email Email
</label> </label>
<input <input
@@ -128,70 +135,53 @@ export default function LoginPage() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com" placeholder="your@email.com"
disabled={loginMutation.isPending} disabled={isEmailLoading}
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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
/> />
</div> </div>
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<label <label htmlFor="password" className="block text-sm font-medium text-gray-700">
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Password Password
</label> </label>
<Link <Link to="/auth/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">
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? Forgot password?
</Link> </Link>
</div> </div>
<div className="relative">
<input <input
id="password" id="password"
type={showPassword ? 'text' : 'password'} type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={loginMutation.isPending} disabled={isEmailLoading}
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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required 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> </div>
<button <button
type="submit" type="submit"
disabled={loginMutation.isPending} disabled={isEmailLoading}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer" 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"
> >
{loginMutation.isPending ? 'Signing in...' : 'Sign in with Email'} {isEmailLoading ? 'Signing in...' : 'Sign in with Email'}
</button> </button>
</form> </form>
<div className="my-6 flex items-center"> <div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div> <div className="flex-1 border-t border-gray-300"></div>
<span className="px-4 text-sm text-gray-500 dark:text-gray-400"> <span className="px-4 text-sm text-gray-500">OR</span>
OR <div className="flex-1 border-t border-gray-300"></div>
</span>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div> </div>
<button <button
onClick={handleGithubLogin} onClick={handleGithubLogin}
disabled={isGithubLoading} disabled={isGithubLoading}
type="button" 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" className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors"
> >
<GithubOutlined className="text-xl" /> <GithubOutlined className="text-xl" />
<span> <span>
@@ -199,33 +189,17 @@ export default function LoginPage() {
</span> </span>
</button> </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"> <div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm"> <p className="text-gray-600 text-sm">
Don't have an account?{' '} Don't have an account?{' '}
<Link <a href="/auth/signup" className="text-primary-600 hover:text-primary-700 font-semibold">
to="/auth/signup"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign up Sign up
</Link> </a>
</p> </p>
</div> </div>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-gray-500 dark:text-gray-500 text-xs"> <p className="text-gray-500 text-xs">
By signing in, you agree to our Terms of Service and Privacy Policy By signing in, you agree to our Terms of Service and Privacy Policy
</p> </p>
</div> </div>
@@ -1,32 +1,25 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useResetPassword, useAuthStore } from '@imphnen-frontend-service/service'; import { supabase } from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { clearSession } = useAuthStore();
const resetPasswordMutation = useResetPassword();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [accessToken, setAccessToken] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [isValidToken, setIsValidToken] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
useEffect(() => { useEffect(() => {
// Get the access_token from URL hash (Supabase sends it as hash fragment) // Check if we have a valid session (from the reset link)
// or from query params (when redirected from callback page) supabase.auth.getSession().then(({ data: { session } }) => {
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1)); if (session) {
const queryParams = new URLSearchParams(globalThis.location.search); setIsValidToken(true);
const token = hashParams.get('access_token') || queryParams.get('access_token');
if (token) {
setAccessToken(token);
} else { } else {
toast.error('Invalid or expired reset link'); toast.error('Invalid or expired reset link');
setTimeout(() => navigate('/auth/forgot-password'), 2000); setTimeout(() => navigate('/auth/forgot-password'), 2000);
} }
});
}, [navigate]); }, [navigate]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@@ -42,117 +35,94 @@ export default function ResetPasswordPage() {
return; return;
} }
if (!accessToken) {
toast.error('Invalid reset token');
return;
}
try { try {
await resetPasswordMutation.mutateAsync({ setIsLoading(true);
access_token: accessToken,
new_password: password, const { error } = await supabase.auth.updateUser({
password: password
}); });
if (error) {
throw error;
}
toast.success('Password updated successfully!'); toast.success('Password updated successfully!');
// Clear session and redirect to login // Sign out and redirect to login
clearSession(); await supabase.auth.signOut();
navigate('/auth/login'); navigate('/auth/login');
} catch (err) { } catch (err) {
console.error('Failed to reset password:', err);
toast.error((err as Error).message || 'Failed to reset password'); toast.error((err as Error).message || 'Failed to reset password');
} finally {
setIsLoading(false);
} }
}; };
if (!accessToken) { if (!isValidToken) {
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center"> <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> <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"> <p className="text-gray-600">Verifying reset link...</p>
Verifying reset link...
</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 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="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-3xl font-bold text-gray-900 mb-2">
Set New Password Set New Password
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400"> <p className="text-gray-600">
Enter your new password below Enter your new password below
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label <label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
New Password New Password
</label> </label>
<div className="relative">
<input <input
id="password" id="password"
type={showPassword ? 'text' : 'password'} type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={resetPasswordMutation.isPending} disabled={isLoading}
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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
minLength={6} 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>
<div> <div>
<label <label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Confirm New Password Confirm New Password
</label> </label>
<div className="relative">
<input <input
id="confirmPassword" id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'} type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={resetPasswordMutation.isPending} disabled={isLoading}
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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
minLength={6} 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> </div>
<button <button
type="submit" type="submit"
disabled={resetPasswordMutation.isPending} disabled={isLoading}
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" 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'} {isLoading ? 'Updating...' : 'Update Password'}
</button> </button>
</form> </form>
</div> </div>
+138 -287
View File
@@ -1,368 +1,235 @@
import { useState } from 'react'; import { useState } from 'react';
import { useGitHubAuth, useSignup } from '@imphnen-frontend-service/service'; import { useGitHubAuth, useEmailAuth, supabase, useAuthStore } from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons'; import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router'; import { useNavigate } from 'react-router';
import { toast } from 'sonner'; 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() { export default function SignupPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setSession } = useAuthStore();
// Check if registration is closed
const isRegistrationClosed = new Date() >= REGISTRATION_DEADLINE;
const { signInWithGitHub } = useGitHubAuth(); const { signInWithGitHub } = useGitHubAuth();
const signupMutation = useSignup(); const { signUpWithEmail } = useEmailAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false); const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [fullname, setFullname] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null); const [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 { const handleEmailSignup = async (e: React.FormEvent) => {
register, e.preventDefault();
handleSubmit,
formState: { errors, isValid },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
mode: 'onChange',
});
const onSubmit = async (data: SignupFormData) => {
setError(null); setError(null);
if (!fullname || !email || !password || !confirmPassword) {
setError('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters long');
return;
}
try { try {
const result = await signupMutation.mutateAsync({ setIsEmailLoading(true);
email: data.email, console.log('[Signup] Attempting email signup...');
password: data.password,
fullname: data.fullname, const result = await signUpWithEmail(email, password, fullname);
console.log('[Signup] Email signup successful:', result);
if (!result.user) {
throw new Error('Signup failed - no user returned');
}
// Create user record in database
const { error: upsertError } = await supabase
.from('users')
.upsert({
id: result.user.id,
email: result.user.email || '',
fullname: fullname,
is_active: true,
updated_at: new Date().toISOString(),
}, {
onConflict: 'id',
}); });
toast.success(result.message);
setRegisteredEmail(data.email); if (upsertError) {
setRegistrationSuccess(true); console.warn('[Signup] Failed to create user record:', upsertError);
}
// If session is available (email confirmation disabled), store it
if (result.session) {
setSession({
token: {
access_token: result.session.access_token,
refresh_token: result.session.refresh_token || '',
},
user: {
id: result.user.id,
email: result.user.email || '',
fullname: fullname,
phone_number: '',
avatar: '',
birthdate: '',
gender: '',
is_active: true,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
toast.success('Account created successfully!');
navigate('/onboarding/user');
} else {
// Email confirmation is enabled
toast.success('Account created! Please check your email to verify your account.');
setTimeout(() => {
navigate('/auth/login');
}, 2000);
}
} catch (err) { } catch (err) {
console.error('[Signup] Email signup failed:', err); console.error('[Signup] Email signup failed:', err);
setError((err as Error).message || 'Signup failed'); setError((err as Error).message || 'Signup failed');
setIsEmailLoading(false);
} }
}; };
// Show closed registration screen
if (isRegistrationClosed) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
<Icon
icon="mdi:clock-alert"
className="text-3xl text-red-600 dark:text-red-400"
/>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Registration Closed
</h2>
<p className="text-gray-600 dark:text-gray-400">
The registration period for this hackathon has ended.
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Thank you for your interest! Registration closed on November 30, 2025 at 23:29 WIB.
</p>
<Link to="/auth/login">
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer">
Go to Login
</button>
</Link>
<button
onClick={() => navigate('/')}
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
>
Back to Homepage
</button>
</div>
</div>
</div>
);
}
// Show success screen after registration
if (registrationSuccess) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4">
<Icon
icon="mdi:email-check"
className="text-3xl text-green-600 dark:text-green-400"
/>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Check Your Email
</h2>
<p className="text-gray-600 dark:text-gray-400">
We've sent an activation link to{' '}
<strong>{registeredEmail}</strong>
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Click the link in the email to activate your account. The link
will expire in 24 hours.
</p>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-700 dark:text-amber-300">
Don't forget to check your spam folder if you don't see the
email.
</p>
</div>
<Link to="/auth/login">
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer">
Go to Login
</button>
</Link>
<button
onClick={() => setRegistrationSuccess(false)}
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
>
Register with different email
</button>
</div>
</div>
</div>
);
}
const handleGithubLogin = async () => { const handleGithubLogin = async () => {
try { try {
setIsGithubLoading(true); setIsGithubLoading(true);
console.log('[Signup] Initiating GitHub OAuth...');
const result = await signInWithGitHub(); const result = await signInWithGitHub();
console.log('[Signup] OAuth result:', result);
if (result?.url) { if (result?.url) {
console.log('[Signup] Redirecting to GitHub OAuth:', result.url);
globalThis.location.href = result.url; globalThis.location.href = result.url;
} else { } else {
console.error('[Signup] No OAuth URL returned');
setIsGithubLoading(false); setIsGithubLoading(false);
setError('Failed to get GitHub OAuth URL');
} }
} catch (err) { } catch (error) {
console.error('[Signup] GitHub login failed:', err); console.error('[Signup] GitHub login failed:', error);
setError((err as Error).message || 'GitHub login failed');
setIsGithubLoading(false); 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 ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 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="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<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"> <div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-3xl font-bold text-gray-900 mb-2">
Create Account Create Account
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400"> <p className="text-gray-600">
Join the hackathon community Join the hackathon community
</p> </p>
</div> </div>
{error && ( {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"> <div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p> <p className="text-red-600 text-sm">{error}</p>
</div> </div>
)} )}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleEmailSignup} className="space-y-4">
<div> <div>
<label <label htmlFor="fullname" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="fullname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Full Name Full Name
</label> </label>
<input <input
id="fullname" id="fullname"
type="text" type="text"
{...register('fullname')} value={fullname}
onChange={(e) => setFullname(e.target.value)}
placeholder="John Doe" placeholder="John Doe"
disabled={signupMutation.isPending} disabled={isEmailLoading}
className={`${inputBaseClass} ${ className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
errors.fullname ? inputErrorClass : inputNormalClass required
}`}
/> />
{errors.fullname && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.fullname.message}
</p>
)}
</div> </div>
<div> <div>
<label <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email Email
</label> </label>
<input <input
id="email" id="email"
type="email" type="email"
{...register('email')} value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com" placeholder="your@email.com"
disabled={signupMutation.isPending} disabled={isEmailLoading}
className={`${inputBaseClass} ${ className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
errors.email ? inputErrorClass : inputNormalClass required
}`}
/> />
{errors.email && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.email.message}
</p>
)}
</div> </div>
<div> <div>
<label <label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Password Password
</label> </label>
<div className="relative">
<input <input
id="password" id="password"
type={showPassword ? 'text' : 'password'} type="password"
{...register('password')} value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={signupMutation.isPending} disabled={isEmailLoading}
className={`${inputBaseClass} pr-12 ${ className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
errors.password ? inputErrorClass : inputNormalClass 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>
{errors.password && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.password.message}
</p>
)}
</div> </div>
<div> <div>
<label <label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Confirm Password Confirm Password
</label> </label>
<div className="relative">
<input <input
id="confirmPassword" id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'} type="password"
{...register('confirmPassword')} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={signupMutation.isPending} disabled={isEmailLoading}
className={`${inputBaseClass} pr-12 ${ className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
errors.confirmPassword ? inputErrorClass : inputNormalClass required
}`}
/> />
<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> </div>
<button <button
type="submit" type="submit"
disabled={!isValid || signupMutation.isPending} disabled={isEmailLoading}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer" 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"
> >
{signupMutation.isPending {isEmailLoading ? 'Creating account...' : 'Create Account'}
? 'Creating account...'
: 'Create Account'}
</button> </button>
</form> </form>
<div className="my-6 flex items-center"> <div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div> <div className="flex-1 border-t border-gray-300"></div>
<span className="px-4 text-sm text-gray-500 dark:text-gray-400"> <span className="px-4 text-sm text-gray-500">OR</span>
OR <div className="flex-1 border-t border-gray-300"></div>
</span>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div> </div>
<button <button
onClick={handleGithubLogin} onClick={handleGithubLogin}
disabled={isGithubLoading} disabled={isGithubLoading}
type="button" 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" className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors"
> >
<GithubOutlined className="text-xl" /> <GithubOutlined className="text-xl" />
<span> <span>
@@ -370,33 +237,17 @@ export default function SignupPage() {
</span> </span>
</button> </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"> <div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm"> <p className="text-gray-600 text-sm">
Already have an account?{' '} Already have an account?{' '}
<Link <a href="/auth/login" className="text-primary-600 hover:text-primary-700 font-semibold">
to="/auth/login"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign in Sign in
</Link> </a>
</p> </p>
</div> </div>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-gray-500 dark:text-gray-500 text-xs"> <p className="text-gray-500 text-xs">
By signing up, you agree to our Terms of Service and Privacy Policy By signing up, you agree to our Terms of Service and Privacy Policy
</p> </p>
</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;
+158 -211
View File
@@ -1,63 +1,19 @@
import { FC, ReactElement, useEffect, useState } from 'react'; import { FC, ReactElement } 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 { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react'; import { Link, useNavigate } from 'react-router';
import ProfilePage from '../profile/page'; import { useMyTeams, useMyInvitations, useRespondToInvitation, supabase, useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
// 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 DashboardPage: FC = (): ReactElement => {
const { session } = useAuthStore(); const navigate = useNavigate();
const [showProfileModal, setShowProfileModal] = useState(false); const { session, clearSession } = useAuthStore();
// 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: teamsData } = useMyTeams();
const { data: invitationsData } = useMyInvitations(); const { data: invitationsData } = useMyInvitations();
const { mutateAsync: respondToInvitation } = useRespondToInvitation(); const { mutateAsync: respondToInvitation } = useRespondToInvitation();
const user = session?.user; const user = session?.user;
const myTeams = teamsData?.data || []; const myTeams = teamsData?.data || [];
const invitations: Invitation[] = (invitationsData?.data || const invitations = invitationsData?.data || [];
[]) as Invitation[];
const handleAcceptInvitation = async (invitationId: string) => { const handleAcceptInvitation = async (invitationId: string) => {
try { try {
@@ -79,50 +35,80 @@ const DashboardPage: FC = (): ReactElement => {
} }
}; };
const handleLogout = async () => {
try {
// Clear Supabase session
await supabase.auth.signOut();
// Clear Zustand store
clearSession();
// Clear localStorage
localStorage.clear();
toast.success('Logged out successfully');
// Redirect to login
navigate('/auth/login');
} catch (error) {
console.error('Logout error:', error);
// Even if there's an error, clear everything and redirect
clearSession();
localStorage.clear();
navigate('/auth/login');
}
};
return ( return (
<> <div className="min-h-screen bg-gray-50">
<div className="p-6 md:p-8 md:max-w-7xl w-full mx-auto"> {/* Header */}
<div className="mb-8"> <div className="bg-white border-b">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
Welcome, {user?.fullname || user?.email?.split('@')[0] || 'User'}! <div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">
Welcome, {user?.fullname || 'User'}!
</h1> </h1>
{user?.location && ( <p className="text-gray-600 mt-1">
<p className="text-gray-600 dark:text-gray-400 mt-1 font-sans flex items-center space-x-1"> {user?.location && `📍 ${user.location}`}
<Icon icon="heroicons:map-pin-16-solid" width="24" height="24" />
<span>{user.location}</span>
</p> </p>
</div>
<div className="flex items-center space-x-3">
{user?.avatar && (
<img
src={user.avatar}
alt="Profile"
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200"
/>
)} )}
<Button
onClick={handleLogout}
variant="secondary"
className="bg-red-500 hover:bg-red-600 text-white"
>
Logout
</Button>
</div>
</div>
</div>
</div> </div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Team Invitations */}
{invitations.length > 0 && ( {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"> <div className="mb-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-xl font-bold text-gray-900 mb-4">
Team Invitations ({invitations.length}) Team Invitations ({invitations.length})
</h2> </h2>
{isTeamFeaturesClosed && ( <div className="space-y-3">
<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) => ( {invitations.map((invitation) => (
<div <div key={invitation.id} className="bg-white p-4 rounded-lg shadow-sm flex items-center justify-between">
key={invitation.id}
className="bg-white dark:bg-gray-900 p-4 rounded-lg shadow-sm flex items-center justify-between"
>
<div> <div>
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900">{invitation.team.name}</p>
{invitation.team?.name ?? 'Unnamed Team'} <p className="text-sm text-gray-600">
</p> Invited by {invitation.inviter.fullname}
<p className="text-sm text-gray-600 dark:text-gray-400">
Invited by{' '}
{invitation.inviter?.fullname ?? 'Unknown User'}
</p> </p>
</div> </div>
{!isTeamFeaturesClosed && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <Button
size="sm" size="sm"
@@ -138,181 +124,146 @@ const DashboardPage: FC = (): ReactElement => {
Decline Decline
</Button> </Button>
</div> </div>
)}
</div> </div>
))} ))}
</div> </div>
</div> </div>
)} )}
{/* My Teams */}
{myTeams.length > 0 ? ( {myTeams.length > 0 ? (
(() => { <div className="mb-8">
const team = myTeams[0] as any; <h2 className="text-2xl font-bold text-gray-900 mb-4">My Team</h2>
return ( <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="mb-6 md:mb-8"> {myTeams.map((team) => (
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
My Team
</h2>
<Link <Link
to={'/teams/' + team.id} key={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" to={`/teams/${team.id}`}
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
> >
{team.banner && (
<img <img
src={team.banner || '/images/banner-imphnen.webp'} src={team.banner}
alt={team.name} alt={team.name}
className="w-full aspect-3/1 object-cover" className="w-full h-32 object-cover"
/> />
<div className="p-4 md:p-6"> )}
<div className="flex items-center space-x-4 mb-4"> <div className="p-6">
{team.logo ? ( <div className="flex items-center space-x-3 mb-3">
{team.logo && (
<img <img
src={team.logo} src={team.logo}
alt={team.name} alt={team.name}
className="w-16 h-16 rounded-full object-cover shrink-0" className="w-12 h-12 rounded-full object-cover"
/> />
) : (
<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"> <div>
<h3 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white leading-tight line-clamp-1"> <h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
{team.name} <p className="text-sm text-gray-600">📍 {team.city}</p>
</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> </div>
</div> <p className="text-gray-600 text-sm line-clamp-2">
{team.description && (
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 font-sans">
{team.description} {team.description}
</p> </p>
)}
</div> </div>
</Link> </Link>
))}
</div>
</div> </div>
);
})()
) : ( ) : (
<div className="mb-6 md:mb-8 bg-white dark:bg-gray-900 rounded-lg shadow-md p-6 md:p-8"> /* No Team - Show CTAs */
<div className="text-center"> <div className="bg-white rounded-lg shadow-md p-8">
<div className="text-3xl md:text-4xl mb-2 md:mb-3">👋</div> <div className="text-center mb-8">
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-1 md:mb-2"> <h2 className="text-2xl font-bold text-gray-900 mb-2">
You are not in a team yet You're not in a team yet
</h2> </h2>
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 font-sans"> <p className="text-gray-600">
Use the sidebar to browse teams or create your own Join an existing team or create your own to get started
</p> </p>
</div> </div>
<div className="grid gap-6 md:grid-cols-2 max-w-2xl mx-auto">
<Link
to="/teams/browse"
className="bg-blue-600 text-white rounded-lg p-6 hover:bg-blue-700 transition-colors text-center"
>
<div className="text-4xl mb-3">🔍</div>
<h3 className="text-xl font-bold mb-2">Browse Teams</h3>
<p className="text-blue-100">
Find and join existing teams looking for members
</p>
</Link>
<Link
to="/teams/create"
className="bg-green-600 text-white rounded-lg p-6 hover:bg-green-700 transition-colors text-center"
>
<div className="text-4xl mb-3"></div>
<h3 className="text-xl font-bold mb-2">Create Team</h3>
<p className="text-green-100">
Start your own team and invite members
</p>
</Link>
</div>
</div> </div>
)} )}
<div className="mt-8 bg-white dark:bg-gray-900 rounded-xl shadow-lg"> {/* User Profile Card */}
<div className="bg-linear-to-r from-primary-600 to-primary-500 h-24 rounded-t-xl"></div> <div className="mt-8 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl shadow-lg overflow-hidden">
<div className="px-4 md:px-8 pb-4 md:pb-8 max-w-7xl"> <div className="bg-gradient-to-r from-blue-600 to-indigo-600 h-24"></div>
<div className="flex flex-col md:flex-row md:items-start -mt-12 mb-4 md:mb-6"> <div className="px-8 pb-8">
<div className="flex flex-col md:flex-row items-start"> <div className="flex items-start -mt-12 mb-6">
{user?.avatar ? ( {user?.avatar && (
<img <img
src={user.avatar} src={user.avatar}
alt={user.fullname || 'User'} alt={user.fullname}
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" className="w-24 h-24 rounded-full border-4 border-white 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"> <div className="ml-6 mt-14">
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white"> <h2 className="text-2xl font-bold text-gray-900">{user?.fullname}</h2>
{user?.fullname ||
user?.email?.split('@')[0] ||
'Unnamed User'}
</h2>
{user?.location ? ( {user?.location ? (
<p className="text-gray-600 dark:text-gray-400 flex items-center mt-1 text-sm md:text-base font-sans"> <p className="text-gray-600 flex items-center mt-1">
<Icon <svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
icon="heroicons:map-pin-16-solid" <path fillRule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd" />
width="16" </svg>
height="16" {user.location}
/>
<span className="ml-1">{user.location}</span>
</p> </p>
) : ( ) : (
<Link <Link to="/onboarding/user" className="text-blue-600 hover:text-blue-700 text-sm mt-1 inline-block">
to="/onboarding/user" Complete your profile
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> </Link>
)} )}
</div> </div>
</div> </div>
{user?.location && (
<button <div className="space-y-6">
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 && ( {user?.bio && (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm w-full max-w-full overflow-x-hidden"> <div className="bg-white rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2"> <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-2">About</h3>
About <p className="text-gray-800 leading-relaxed">{user.bio}</p>
</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>
)} )}
<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"> <div className="bg-white rounded-lg p-4 shadow-sm">
Contact <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Contact</h3>
</h3> <div className="flex items-center text-gray-700">
<div className="flex items-center text-gray-700 dark:text-gray-300"> <svg className="w-5 h-5 mr-3 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<span className="text-gray-900 dark:text-white"> <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
{user?.email} <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</span> </svg>
<span className="text-gray-900">{user?.email}</span>
</div> </div>
</div> </div>
{user?.skills && user.skills.length > 0 && ( {user?.skills && user.skills.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm"> <div className="bg-white rounded-lg p-4 shadow-sm">
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3"> <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Skills</h3>
Skills <div className="flex flex-wrap gap-3">
</h3>
<div className="flex flex-wrap gap-2">
{user.skills.map((skill: string) => ( {user.skills.map((skill: string) => (
<span <span
key={skill} 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" className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium shadow-sm hover:bg-blue-700 transition-colors"
> >
{skill} {skill}
</span> </span>
@@ -324,11 +275,7 @@ const DashboardPage: FC = (): ReactElement => {
</div> </div>
</div> </div>
</div> </div>
<ProfilePage </div>
open={showProfileModal}
onClose={() => setShowProfileModal(false)}
/>
</>
); );
}; };
+43 -20
View File
@@ -1,11 +1,6 @@
import { import { Outlet, ScrollRestoration, useLocation, useNavigate } from 'react-router-dom';
Outlet,
ScrollRestoration,
useLocation,
useNavigate,
} from 'react-router-dom';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAuthStore, useUserMe } from '@imphnen-frontend-service/service'; import { supabase } from '@imphnen-frontend-service/service';
// Define onboarding routes // Define onboarding routes
const ONBOARDING_ROUTES = new Set(['/onboarding/user']); const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
@@ -13,81 +8,109 @@ const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
export default function RootLayout() { export default function RootLayout() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { session } = useAuthStore();
const { data: userData, isLoading: isUserLoading } = useUserMe();
const [isChecking, setIsChecking] = useState(true); const [isChecking, setIsChecking] = useState(true);
useEffect(() => { useEffect(() => {
const checkAuth = async () => { const checkAuth = async () => {
const pathname = location.pathname; const pathname = location.pathname;
console.log('[Layout] Checking auth for route:', pathname);
// Allow hackathon pages without checks // Allow hackathon pages without checks
if (pathname.startsWith('/hackathons')) { if (pathname.startsWith('/hackathons')) {
console.log('[Layout] Public route, allowing access');
setIsChecking(false); setIsChecking(false);
return; return;
} }
// Allow auth callback without checks // Allow auth callback without checks
if (pathname === '/auth/callback') { if (pathname === '/auth/callback') {
console.log('[Layout] Auth callback, allowing access');
setIsChecking(false); setIsChecking(false);
return; return;
} }
// Public auth pages - allow unauthenticated access // Check Supabase session
if (pathname.startsWith('/auth')) { const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
console.error('[Layout] Session error:', error);
}
// Public auth pages (login, signup, forgot-password, reset-password) - allow unauthenticated access
const isPublicAuthPage = pathname === '/auth/login' || pathname === '/auth/signup' || pathname === '/auth/forgot-password' || pathname === '/auth/reset-password';
if (isPublicAuthPage) {
// If already authenticated and not on password reset pages, redirect to dashboard // If already authenticated and not on password reset pages, redirect to dashboard
if (session && pathname !== '/auth/reset-password') { if (session && pathname !== '/auth/reset-password') {
console.log('[Layout] Already authenticated, redirecting to dashboard');
navigate('/dashboard', { replace: true }); navigate('/dashboard', { replace: true });
setIsChecking(false); setIsChecking(false);
return; return;
} }
// Allow unauthenticated access to auth pages // Allow unauthenticated access
console.log('[Layout] Public auth page, allowing access');
setIsChecking(false); setIsChecking(false);
return; return;
} }
// Home page - allow everyone to view the landing page // Home page - allow everyone to view the landing page
if (pathname === '/') { if (pathname === '/') {
console.log('[Layout] Landing page, allowing access');
setIsChecking(false); setIsChecking(false);
return; return;
} }
// Require authentication for all other routes // Require authentication for all other routes
if (!session) { if (!session) {
console.log('[Layout] No session, redirecting to login');
navigate('/auth/login', { replace: true }); navigate('/auth/login', { replace: true });
setIsChecking(false); setIsChecking(false);
return; return;
} }
// Wait for user data to load before checking onboarding // Check if user has completed onboarding (skip for onboarding routes)
if (isUserLoading) { if (!ONBOARDING_ROUTES.has(pathname)) {
try {
const { data: userData, error: userError } = await supabase
.from('users')
.select('location')
.eq('id', session.user.id)
.single();
if (userError) {
console.error('[Layout] Failed to fetch user data:', userError);
setIsChecking(false);
return; return;
} }
// Check if user has completed onboarding (skip for onboarding routes) const hasLocation = !!userData?.location;
if (!ONBOARDING_ROUTES.has(pathname)) {
const hasLocation = !!userData?.data?.location || !!session?.user?.location;
if (!hasLocation) { if (!hasLocation) {
console.log('[Layout] User needs onboarding, redirecting');
navigate('/onboarding/user', { replace: true }); navigate('/onboarding/user', { replace: true });
setIsChecking(false); setIsChecking(false);
return; return;
} }
} catch (error) {
console.error('[Layout] Unexpected error checking onboarding:', error);
}
} }
console.log('[Layout] Auth check passed');
setIsChecking(false); setIsChecking(false);
}; };
checkAuth(); checkAuth();
}, [location.pathname, navigate, session, userData, isUserLoading]); }, [location.pathname, navigate]);
// Show loading state while checking auth // Show loading state while checking auth
if (isChecking) { if (isChecking) {
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center"> <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> <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> <p className="text-gray-600">Loading...</p>
</div> </div>
</div> </div>
); );
@@ -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>
);
}
+50 -61
View File
@@ -12,10 +12,8 @@ import {
useAuthStore, useAuthStore,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CitySelect } from '../../../components/city-select'; import INDONESIAN_CITIES from '../../../constants/cities';
import { Icon } from '@iconify/react';
const ROLE_OPTIONS = [ const ROLE_OPTIONS = [
'Frontend Developer', 'Frontend Developer',
@@ -60,20 +58,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error('The file is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('The file must be an image');
e.target.value = '';
return;
}
setAvatarFile(file); setAvatarFile(file);
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
@@ -85,46 +69,53 @@ const UserOnboardingPage: FC = (): ReactElement => {
const onSubmit = form.handleSubmit(async (data) => { const onSubmit = form.handleSubmit(async (data) => {
try { try {
console.log('[Onboarding] Starting submission...', data);
let avatarUrl = session?.user?.avatar || null; let avatarUrl = session?.user?.avatar || null;
// Upload avatar if a new file was selected // Upload avatar if a new file was selected
if (avatarFile) { if (avatarFile) {
console.log('[Onboarding] Uploading avatar...');
const uploadResult = await uploadAvatar(avatarFile); const uploadResult = await uploadAvatar(avatarFile);
avatarUrl = uploadResult.data.url; avatarUrl = uploadResult.data.url;
console.log('[Onboarding] Avatar uploaded:', avatarUrl);
} }
// Update user in Supabase // Update user in Supabase
await updateUser({ console.log('[Onboarding] Updating user in Supabase...');
const result = await updateUser({
fullname: data.fullname, fullname: data.fullname,
avatar: avatarUrl, avatar: avatarUrl,
location: data.location, location: data.location,
bio: data.bio, bio: data.bio,
skills: data.skills, skills: data.skills,
}); });
console.log('[Onboarding] User updated successfully:', result);
// Wait a bit for the onSuccess handler to update localStorage // Wait a bit for the onSuccess handler to update localStorage
// The updateUser mutation's onSuccess handler updates the Zustand store and localStorage // The updateUser mutation's onSuccess handler updates the Zustand store and localStorage
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
console.log('[Onboarding] Navigating to dashboard...');
// Use window.location for a full page reload to ensure middleware sees updated localStorage // Use window.location for a full page reload to ensure middleware sees updated localStorage
globalThis.location.href = '/dashboard'; globalThis.location.href = '/dashboard';
} catch (error) { } catch (error) {
toast.error( console.error('[Onboarding] Onboarding failed:', error);
error instanceof Error alert(
? error.message `Onboarding failed: ${
: 'Onboarding failed. Please try again.' error instanceof Error ? error.message : 'Unknown error'
}`
); );
} }
}); });
return ( return (
<div className="flex flex-col justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4 py-8"> <div className="flex flex-col justify-center items-center min-h-screen bg-gray-50 px-4 py-8">
<div className="bg-white dark:bg-gray-900 w-full max-w-2xl p-8 rounded-xl shadow-lg dark:shadow-gray-950/50"> <div className="bg-white w-full max-w-2xl p-8 rounded-xl shadow-lg">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h1 className="text-3xl font-bold text-gray-900 mb-2">
Complete Your Profile Complete Your Profile
</h1> </h1>
<p className="text-gray-600 font-sans dark:text-gray-400"> <p className="text-gray-600">
Tell us more about yourself to get started Tell us more about yourself to get started
</p> </p>
</div> </div>
@@ -137,22 +128,17 @@ const UserOnboardingPage: FC = (): ReactElement => {
<img <img
src={avatarPreview} src={avatarPreview}
alt="Avatar preview" alt="Avatar preview"
className="w-32 h-32 rounded-full object-cover border-4 border-gray-200 dark:border-gray-700" className="w-32 h-32 rounded-full object-cover border-4 border-gray-200"
/> />
) : ( ) : (
<div className="w-32 h-32 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"> <div className="w-32 h-32 rounded-full bg-gray-200 flex items-center justify-center">
<Icon <span className="text-gray-400 text-4xl">👤</span>
icon="ic:baseline-person"
width="48"
height="48"
className="text-gray-400 dark:text-gray-500"
/>
</div> </div>
)} )}
</div> </div>
<div className="flex flex-col items-center"> <div>
<label htmlFor="avatar" className="cursor-pointer"> <label htmlFor="avatar" className="cursor-pointer">
<span className="px-4 py-2 bg-primary-500 dark:bg-primary-600 text-white rounded-lg hover:bg-primary-600 dark:hover:bg-primary-700 inline-block"> <span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{avatarPreview ? 'Change Photo' : 'Upload Photo'} {avatarPreview ? 'Change Photo' : 'Upload Photo'}
</span> </span>
<input <input
@@ -163,8 +149,8 @@ const UserOnboardingPage: FC = (): ReactElement => {
onChange={handleAvatarChange} onChange={handleAvatarChange}
/> />
</label> </label>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2 text-center"> <p className="text-xs text-gray-500 mt-2 text-center">
Optional, but highly recommended. Max 2MB Optional, but highly recommended
</p> </p>
</div> </div>
</div> </div>
@@ -175,33 +161,42 @@ const UserOnboardingPage: FC = (): ReactElement => {
label="Full Name" label="Full Name"
placeholder="Enter your full name" placeholder="Enter your full name"
name="fullname" name="fullname"
className="px-3 py-2 text-label2 rounded-lg max-h-auto text-base"
size="md"
isRequired={true}
/> />
{/* City */} {/* City */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
City <span className="text-red-500">*</span> City <span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
control={form.control} control={form.control}
name="location" name="location"
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<CitySelect <div>
value={field.value} <select
onChange={field.onChange} {...field}
error={fieldState.error?.message} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Search your city..." >
/> <option value="">Select your city</option>
{INDONESIAN_CITIES.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)} )}
/> />
</div> </div>
{/* Role/Skills */} {/* Role/Skills */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
Role / Skills Role / Skills
</label> </label>
<Controller <Controller
@@ -224,11 +219,9 @@ const UserOnboardingPage: FC = (): ReactElement => {
: (field.value || []).filter((v) => v !== role); : (field.value || []).filter((v) => v !== role);
field.onChange(newValue); field.onChange(newValue);
}} }}
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-primary-500 focus:ring-blue-500 dark:focus:ring-primary-500 dark:bg-gray-800" className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/> />
<span className="text-sm dark:text-gray-300"> <span className="text-sm">{role}</span>
{role}
</span>
</label> </label>
))} ))}
</div> </div>
@@ -239,23 +232,19 @@ const UserOnboardingPage: FC = (): ReactElement => {
{/* Bio */} {/* Bio */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
Bio{' '} Bio <span className="text-gray-400">(Optional)</span>
<span className="text-gray-400 dark:text-gray-500">
(Optional)
</span>
</label> </label>
<Controller <Controller
control={form.control} control={form.control}
name="bio" name="bio"
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<div className="flex"> <div>
<Textarea <Textarea
{...field} {...field}
placeholder="Tell us about yourself..." placeholder="Tell us about yourself..."
rows={4} rows={4}
className="w-full rounded-lg text-sm" className="w-full"
style={{ resize: 'vertical' }}
/> />
{fieldState.error && ( {fieldState.error && (
<p className="text-sm text-red-500 mt-1"> <p className="text-sm text-red-500 mt-1">
File diff suppressed because one or more lines are too long
-391
View File
@@ -1,391 +0,0 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useForm, Controller } from 'react-hook-form';
import {
userEditProfileSchema,
TUserEditProfileForm,
useUpdateUserMe,
useUploadAvatar,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CitySelect } from '../../components/city-select';
import { Icon } from '@iconify/react';
const ROLE_OPTIONS = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
type ProfileModalProps = {
open: boolean;
onClose: () => void;
};
const ProfilePage: FC<ProfileModalProps> = ({
open,
onClose,
}): ReactElement | null => {
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>('');
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
const { mutateAsync: uploadAvatar, isPending: isUploading } =
useUploadAvatar();
const { session } = useAuthStore();
const form = useForm<TUserEditProfileForm>({
resolver: zodResolver(userEditProfileSchema),
mode: 'all',
defaultValues: {
fullname: session?.user?.fullname || '',
avatar: session?.user?.avatar || null,
location: session?.user?.location || '',
bio: session?.user?.bio || '',
skills: session?.user?.skills || [],
},
});
// Set initial avatar preview from current user avatar
useEffect(() => {
if (session?.user?.avatar && !avatarPreview) {
setAvatarPreview(session.user.avatar);
}
if (session?.user?.fullname) {
form.setValue('fullname', session.user.fullname);
}
if (session?.user?.location) {
form.setValue('location', session.user.location);
}
if (session?.user?.bio) {
form.setValue('bio', session.user.bio);
}
if (session?.user?.skills) {
form.setValue('skills', session.user.skills);
}
}, [
session?.user?.avatar,
session?.user?.fullname,
session?.user?.location,
session?.user?.bio,
session?.user?.skills,
avatarPreview,
form,
]);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error('The file is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('The file must be an image');
e.target.value = '';
return;
}
setAvatarFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let avatarUrl = session?.user?.avatar || null;
// Upload avatar if a new file was selected
if (avatarFile) {
const uploadResult = await uploadAvatar(avatarFile);
avatarUrl = uploadResult.data.url;
}
// Update user profile
await updateUser({
fullname: data.fullname,
avatar: avatarUrl,
location: data.location,
bio: data.bio,
skills: data.skills,
});
toast.success('Profile updated successfully!');
// Wait a bit for the onSuccess handler to update localStorage
await new Promise((resolve) => setTimeout(resolve, 100));
// Close modal
onClose();
} catch (error) {
console.error('Profile update failed:', error);
toast.error(
`Failed to update profile: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
});
const isLoading = isUpdating || isUploading;
return open ? (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-md z-50 overflow-y-auto">
<div className="min-h-full flex items-center justify-center">
<div className="bg-white dark:bg-gray-900 w-full max-w-md mx-4 my-6 sm:my-8 p-8 rounded-xl border dark:boder-gray-800">
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Edit Profile
</h1>
<button
type="button"
onClick={onClose}
className="text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200 cursor-pointer"
aria-label="Close profile modal"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<p className="text-gray-600 font-sans dark:text-neutral-400">
Update your photo and name
</p>
</div>
<form onSubmit={onSubmit} className="space-y-6">
{/* Avatar Upload */}
<div className="flex flex-col items-center space-y-4">
<div className="relative group">
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar preview"
className="w-24 h-24 rounded-full object-cover border-4 border-gray-200 dark:border-neutral-700 group-hover:border-blue-400 dark:group-hover:border-blue-500 transition-colors"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center group-hover:bg-gray-300 dark:group-hover:bg-gray-600 transition-colors">
<Icon
icon="ic:baseline-person"
width="48"
height="48"
className="text-gray-400 dark:text-neutral-500"
/>
</div>
)}
{/* Camera overlay */}
<label
htmlFor="avatar"
className="absolute bottom-0 right-0 bg-primary-500 text-white p-2 rounded-full cursor-pointer hover:bg-primary-600 transition-colors shadow-lg"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<input
id="avatar"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
disabled={isLoading}
/>
</label>
</div>
<p className="text-sm text-gray-500 dark:text-neutral-400 text-center font-sans">
Click the camera icon to change your photo
<br />
Format: JPG, PNG. Max 2MB
</p>
</div>
{/* Full Name */}
<ControlledInputField
control={form.control}
label="Full Name"
placeholder="Enter your full name"
name="fullname"
size="lg"
/>
{/* City */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-neutral-800 dark:text-neutral-300">
City
</label>
<Controller
control={form.control}
name="location"
render={({ field, fieldState }) => (
<CitySelect
value={field.value ?? ''}
onChange={field.onChange}
error={fieldState.error?.message}
placeholder="Search your city..."
/>
)}
/>
</div>
{/* Role/Skills */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
Role / Skills
</label>
<Controller
control={form.control}
name="skills"
render={({ field }) => (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((role) => (
<label
key={role}
className="flex items-center space-x-2 cursor-pointer font-sans"
>
<input
type="checkbox"
checked={field.value?.includes(role)}
onChange={(e) => {
const newValue = e.target.checked
? [...(field.value || []), role]
: (field.value || []).filter((v) => v !== role);
field.onChange(newValue);
}}
className="rounded border-gray-300 dark:border-neutral-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800"
/>
<span className="text-sm dark:text-neutral-300">
{role}
</span>
</label>
))}
</div>
</div>
)}
/>
</div>
{/* Bio */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
Bio{' '}
<span className="text-gray-400 dark:text-neutral-500">
(Optional)
</span>
</label>
<Controller
control={form.control}
name="bio"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
placeholder="Tell us about yourself..."
rows={4}
className="w-full font-sans"
size="lg"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
{/* Action Buttons */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={isLoading || !form.formState.isValid}
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Saving...
</span>
) : (
'Save'
)}
</Button>
</div>
</form>
</div>
</div>
</div>
) : null;
};
export default ProfilePage;
@@ -1,15 +1,8 @@
import { FC, ReactElement, useState, useRef, useEffect, useMemo } from 'react'; import { FC, ReactElement, useState, useRef, useEffect } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { import { useTeamById, useTeamMessages, useSendMessage, useDeleteMessage, useAuthStore } from '@imphnen-frontend-service/service';
useTeamById,
useTeamMessages,
useSendMessage,
useDeleteMessage,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
const TeamChatPage: FC = (): ReactElement => { const TeamChatPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
@@ -20,54 +13,16 @@ const TeamChatPage: FC = (): ReactElement => {
const { data: teamData } = useTeamById(teamId || ''); const { data: teamData } = useTeamById(teamId || '');
const { data: messages, isLoading } = useTeamMessages(teamId || ''); const { data: messages, isLoading } = useTeamMessages(teamId || '');
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage( const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(teamId || '');
teamId || ''
);
const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || ''); const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || '');
const team = teamData?.data; const team = teamData?.data;
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
// Message type used for UI rendering
interface ChatMessage {
id: string;
user_id: string;
user?: {
avatar?: string;
fullname?: string;
};
message: string;
created_at: string;
}
// Inline delete confirmation UI state
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// Prepare messages: de-duplicate by id and sort by created_at
const displayMessages = useMemo(() => {
const raw: ChatMessage[] = Array.isArray(messages)
? (messages as ChatMessage[])
: [];
const seen = new Set<string>();
const dedup: ChatMessage[] = [];
for (const m of raw) {
const key = m.id ?? `${m.user_id}-${m.created_at}`;
if (!seen.has(key)) {
seen.add(key);
dedup.push(m);
}
}
dedup.sort(
(a, b) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
return dedup;
}, [messages]);
// Auto-scroll to bottom when new messages arrive // Auto-scroll to bottom when new messages arrive
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [displayMessages]); }, [messages]);
const handleSendMessage = async (e: React.FormEvent) => { const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -83,10 +38,11 @@ const TeamChatPage: FC = (): ReactElement => {
}; };
const handleDeleteMessage = async (messageId: string) => { const handleDeleteMessage = async (messageId: string) => {
if (!confirm('Are you sure you want to delete this message?')) return;
try { try {
await deleteMessage(messageId); await deleteMessage(messageId);
toast.success('Message deleted'); toast.success('Message deleted');
setDeleteTargetId(null);
} catch (error) { } catch (error) {
console.error('Failed to delete message:', error); console.error('Failed to delete message:', error);
toast.error('Failed to delete message'); toast.error('Failed to delete message');
@@ -107,23 +63,16 @@ const TeamChatPage: FC = (): ReactElement => {
}; };
return ( return (
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex flex-col h-screen bg-gray-50">
{/* Header */} {/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 shadow-sm dark:shadow-gray-950/50"> <div className="bg-white border-b shadow-sm">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"> <h1 className="text-2xl font-bold text-gray-900">Team Chat</h1>
Team Chat <p className="text-gray-600 text-sm mt-0.5">{team?.name}</p>
</h1>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-0.5">
{team?.name}
</p>
</div> </div>
<Button <Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team Back to Team
</Button> </Button>
</div> </div>
@@ -135,29 +84,23 @@ const TeamChatPage: FC = (): ReactElement => {
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 dark:border-primary-400"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div> </div>
) : displayMessages && displayMessages.length > 0 ? ( ) : messages && messages.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{displayMessages.map((msg) => { {messages.map((msg) => {
const isOwnMessage = msg.user_id === currentUserId; const isOwnMessage = msg.user_id === currentUserId;
const isLeader = team?.leader_id === currentUserId; const isLeader = team?.leader_id === currentUserId;
const canDelete = isOwnMessage || isLeader; const canDelete = isOwnMessage || isLeader;
return ( return (
<div <div
key={`${msg.id ?? 'message'}-${msg.created_at}`} key={msg.id}
className={`flex ${ className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
isOwnMessage ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`flex gap-3 max-w-lg ${
isOwnMessage ? 'flex-row-reverse' : 'flex-row'
}`}
> >
<div className={`flex gap-3 max-w-lg ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
{/* Avatar */} {/* Avatar */}
<div className="shrink-0"> <div className="flex-shrink-0">
{msg.user?.avatar ? ( {msg.user?.avatar ? (
<img <img
src={msg.user.avatar} src={msg.user.avatar}
@@ -172,76 +115,38 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
{/* Message Bubble */} {/* Message Bubble */}
<div className={`flex-1 text-left`}> <div className={`flex-1 ${isOwnMessage ? 'text-right' : 'text-left'}`}>
<div <div className={`inline-block ${isOwnMessage ? 'items-end' : 'items-start'}`}>
className={`inline-block ${
isOwnMessage ? 'items-end' : 'items-start'
}`}
>
<div className="flex items-baseline gap-2 mb-1"> <div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900 dark:text-white"> <span className="font-semibold text-sm text-gray-900">
{isOwnMessage ? 'You' : msg.user?.fullname} {isOwnMessage ? 'You' : msg.user?.fullname}
</span> </span>
<span className="text-xs text-gray-500 dark:text-gray-500"> <span className="text-xs text-gray-500">
{formatTime(msg.created_at)} {formatTime(msg.created_at)}
</span> </span>
</div> </div>
<div <div
className={`relative group rounded-2xl px-4 py-2.5 ${ className={`relative group rounded-2xl px-4 py-2.5 ${
isOwnMessage isOwnMessage
? 'bg-blue-600 dark:bg-primary-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700' : 'bg-white text-gray-900 border border-gray-200'
}`} }`}
> >
<p className="text-sm whitespace-pre-wrap wrap-break-word font-sans"> <p className="text-sm whitespace-pre-wrap break-words">{msg.message}</p>
{msg.message}
</p>
{/* Delete button */} {/* Delete button */}
{canDelete && ( {canDelete && (
<button <button
onClick={() => onClick={() => handleDeleteMessage(msg.id)}
setDeleteTargetId( className={`absolute top-1 ${isOwnMessage ? 'left-1' : 'right-1'} opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-200 ${isOwnMessage ? 'hover:bg-blue-700' : ''}`}
deleteTargetId === msg.id ? null : msg.id
)
}
className={`absolute top-1 ${
isOwnMessage ? 'left-1' : 'right-1'
} opacity-0 hover:opacity-100 p-1 rounded hover:bg-gray-200 dark:hover:bg-neutral-700 cursor-pointer ${
isOwnMessage
? 'hover:bg-blue-700 dark:hover:bg-primary-700'
: ''
}`}
title="Delete message" title="Delete message"
> >
{/* Icon trash */} <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<Icon <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
icon="mdi:trash-can-outline" </svg>
className="w-4 h-4"
/>
</button> </button>
)} )}
</div> </div>
{canDelete && deleteTargetId === msg.id && (
<div
className={`mt-2 flex gap-2 ${
isOwnMessage ? 'justify-end' : 'justify-start'
}`}
>
<button
onClick={() => handleDeleteMessage(msg.id)}
className="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 cursor-pointer"
>
Delete
</button>
<button
onClick={() => setDeleteTargetId(null)}
className="px-2 py-1 text-xs rounded bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600 cursor-pointer"
>
Cancel
</button>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -252,14 +157,11 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-64 text-center"> <div className="flex flex-col items-center justify-center h-64 text-center">
<Icon <div className="text-6xl mb-4">💬</div>
icon="mdi:chat-outline" <h3 className="text-xl font-semibold text-gray-900 mb-2">
className="text-6xl mb-4 text-gray-400"
/>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
No messages yet No messages yet
</h3> </h3>
<p className="text-gray-600 dark:text-gray-400 font-sans"> <p className="text-gray-600">
Be the first to start the conversation! Be the first to start the conversation!
</p> </p>
</div> </div>
@@ -268,7 +170,7 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
{/* Message Input */} {/* Message Input */}
<div className="bg-white dark:bg-gray-900 border-t dark:border-gray-700 shadow-lg dark:shadow-gray-950/50"> <div className="bg-white border-t shadow-lg">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<form onSubmit={handleSendMessage} className="flex gap-3"> <form onSubmit={handleSendMessage} className="flex gap-3">
<input <input
@@ -276,7 +178,7 @@ const TeamChatPage: FC = (): ReactElement => {
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..." placeholder="Type your message..."
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-primary-500 focus:border-transparent" className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isSending} disabled={isSending}
/> />
<Button <Button
@@ -291,18 +193,8 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-5 h-5" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg> </svg>
Send Send
</div> </div>
@@ -3,22 +3,9 @@ import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms'; import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { import { teamUpdateSchema, TTeamUpdateForm, useUpdateTeam, useTeamById, ETeamVisibility, useUploadFile, useAuthStore } from '@imphnen-frontend-service/service';
teamUpdateSchema,
TTeamUpdateForm,
useUpdateTeam,
useTeamById,
ETeamVisibility,
useUploadFile,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import INDONESIAN_CITIES from '../../../../constants/cities';
import { CitySelect } from '../../../../components/city-select';
import { Icon } from '@iconify/react';
import { toast } from 'sonner';
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const EditTeamPage: FC = (): ReactElement => { const EditTeamPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
@@ -29,12 +16,8 @@ const EditTeamPage: FC = (): ReactElement => {
const [bannerFile, setBannerFile] = useState<File | null>(null); const [bannerFile, setBannerFile] = useState<File | null>(null);
const [bannerPreview, setBannerPreview] = useState<string>(''); const [bannerPreview, setBannerPreview] = useState<string>('');
const { data: teamData, isLoading: isLoadingTeam } = useTeamById( const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || '');
teamId || '' const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(teamId || '');
);
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(
teamId || ''
);
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data; const team = teamData?.data;
@@ -63,24 +46,18 @@ const EditTeamPage: FC = (): ReactElement => {
if (isLoadingTeam) { if (isLoadingTeam) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600 dark:text-gray-400">Loading team...</div> <div className="text-gray-600">Loading team...</div>
</div> </div>
); );
} }
if (!isLeader) { if (!isLeader) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
Access Denied <p className="text-gray-600 mb-4">Only the team leader can edit team information</p>
</h2> <Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can edit team information
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div> </div>
); );
} }
@@ -88,11 +65,6 @@ const EditTeamPage: FC = (): ReactElement => {
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
if (file.size > MAX_FILE_SIZE) {
toast.error('Logo image is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
setLogoFile(file); setLogoFile(file);
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
@@ -105,11 +77,6 @@ const EditTeamPage: FC = (): ReactElement => {
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
if (file.size > MAX_FILE_SIZE) {
toast.error('Banner image is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
setBannerFile(file); setBannerFile(file);
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
@@ -147,35 +114,28 @@ const EditTeamPage: FC = (): ReactElement => {
}); });
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen bg-gray-50">
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700"> <div className="bg-white border-b">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"> <h1 className="text-3xl font-bold text-gray-900">Edit Team Info</h1>
Edit Team Info <p className="text-gray-600 mt-1">Update your team details</p>
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Update your team details
</p>
</div> </div>
</div> </div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8"> <div className="bg-white rounded-lg shadow-md p-8">
<form onSubmit={onSubmit} className="space-y-6"> <form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */} {/* Banner Upload */}
<div> <div>
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Team Banner Team Banner
</label> </label>
{bannerPreview ? ( {bannerPreview ? (
<div <div className="relative">
className="relative w-full"
style={{ aspectRatio: '3 / 1' }}
>
<img <img
src={bannerPreview} src={bannerPreview}
alt="Banner preview" alt="Banner preview"
className="absolute inset-0 w-full h-full object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700" className="w-full h-48 object-cover rounded-lg border-2 border-gray-200"
/> />
<button <button
type="button" type="button"
@@ -184,20 +144,16 @@ const EditTeamPage: FC = (): ReactElement => {
setBannerPreview(''); setBannerPreview('');
form.setValue('banner', null); form.setValue('banner', null);
}} }}
className="absolute top-2 right-2 bg-danger-600 text-white px-3 py-1 rounded-lg text-sm hover:bg-danger-700 cursor-pointer" className="absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600"
> >
Remove Remove
</button> </button>
</div> </div>
) : ( ) : (
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800"> <label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<div className="text-center"> <div className="text-center">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500">Click to upload banner</p>
Click to upload banner <p className="text-xs text-gray-400 mt-1">1200x400 recommended</p>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
1200x400 recommended. Max 2MB
</p>
</div> </div>
<input <input
type="file" type="file"
@@ -211,7 +167,7 @@ const EditTeamPage: FC = (): ReactElement => {
{/* Logo Upload */} {/* Logo Upload */}
<div> <div>
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Team Logo Team Logo
</label> </label>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
@@ -219,19 +175,16 @@ const EditTeamPage: FC = (): ReactElement => {
<img <img
src={logoPreview} src={logoPreview}
alt="Logo preview" alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700" className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
/> />
) : ( ) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"> <div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 dark:text-gray-500 text-3xl"> <span className="text-gray-400 text-3xl">👥</span>
<Icon icon="mdi:account-group" />
</span>
</div> </div>
)} )}
<div> <div>
<div className="flex flex-col md:flex-row items-start justify-start gap-1">
<label htmlFor="logo" className="cursor-pointer"> <label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 inline-block"> <span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'} {logoPreview ? 'Change Logo' : 'Upload Logo'}
</span> </span>
<input <input
@@ -250,14 +203,12 @@ const EditTeamPage: FC = (): ReactElement => {
setLogoPreview(''); setLogoPreview('');
form.setValue('logo', null); form.setValue('logo', null);
}} }}
className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer" className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
> >
Remove Remove
</button> </button>
)} )}
</div> </div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">Max 2MB</p>
</div>
</div> </div>
</div> </div>
@@ -267,32 +218,40 @@ const EditTeamPage: FC = (): ReactElement => {
label="Team Name" label="Team Name"
placeholder="Enter team name" placeholder="Enter team name"
name="name" name="name"
size="lg"
isRequired={true}
/> />
{/* City */} {/* City */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
City City
</label> </label>
<Controller <Controller
control={form.control} control={form.control}
name="city" name="city"
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<CitySelect <div>
value={field.value || ''} <select
onChange={field.onChange} {...field}
error={fieldState.error?.message} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Search your city..." >
/> <option value="">Select city</option>
{INDONESIAN_CITIES.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)} )}
/> />
</div> </div>
{/* Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
Description Description
</label> </label>
<Controller <Controller
@@ -306,12 +265,9 @@ const EditTeamPage: FC = (): ReactElement => {
placeholder="Tell others about your team..." placeholder="Tell others about your team..."
rows={4} rows={4}
className="w-full" className="w-full"
size="lg"
/> />
{fieldState.error && ( {fieldState.error && (
<p className="text-sm text-red-500 mt-1"> <p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
{fieldState.error.message}
</p>
)} )}
</div> </div>
)} )}
@@ -320,7 +276,7 @@ const EditTeamPage: FC = (): ReactElement => {
{/* Visibility */} {/* Visibility */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
Team Visibility Team Visibility
</label> </label>
<Controller <Controller
@@ -328,7 +284,7 @@ const EditTeamPage: FC = (): ReactElement => {
name="visibility" name="visibility"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-3"> <div className="space-y-3">
<label className="flex items-start space-x-3 cursor-pointer border dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800"> <label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input <input
type="radio" type="radio"
{...field} {...field}
@@ -337,15 +293,13 @@ const EditTeamPage: FC = (): ReactElement => {
className="mt-1" className="mt-1"
/> />
<div> <div>
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900">Public</p>
Public <p className="text-sm text-gray-600">
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
Team will be visible in Browse Teams Team will be visible in Browse Teams
</p> </p>
</div> </div>
</label> </label>
<label className="flex items-start space-x-3 cursor-pointer border dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800"> <label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input <input
type="radio" type="radio"
{...field} {...field}
@@ -354,10 +308,8 @@ const EditTeamPage: FC = (): ReactElement => {
className="mt-1" className="mt-1"
/> />
<div> <div>
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900">Private</p>
Private <p className="text-sm text-gray-600">
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
Team hidden, invite-only Team hidden, invite-only
</p> </p>
</div> </div>
@@ -1,46 +1,18 @@
import { FC, ReactElement, useState } from 'react'; import { FC, ReactNode } from 'react';
import { Outlet } from 'react-router'; import { Outlet } from 'react-router';
import { Sidebar } from '../../../components/sidebar'; import { Navigation } from '../../../components/navigation';
const TeamDetailLayout: FC = (): ReactElement => { interface TeamLayoutProps {
const [sidebarOpen, setSidebarOpen] = useState(false); children?: ReactNode;
}
export const TeamLayout: FC<TeamLayoutProps> = () => {
return ( return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen bg-gray-50">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} /> <Navigation />
<div className="flex-1 flex flex-col">
{/* Mobile Header with Hamburger */}
<div className="lg:hidden sticky top-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet /> <Outlet />
</main>
</div>
</div> </div>
); );
}; };
export default TeamDetailLayout; export default TeamLayout;
@@ -15,7 +15,6 @@ import {
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
const ManageMembersPage: FC = (): ReactElement => { const ManageMembersPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
@@ -23,58 +22,31 @@ const ManageMembersPage: FC = (): ReactElement => {
const { session } = useAuthStore(); const { session } = useAuthStore();
const [showInviteModal, setShowInviteModal] = useState(false); const [showInviteModal, setShowInviteModal] = useState(false);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById( const { data: teamData } = useTeamById(teamId || '');
teamId || '' const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || '');
);
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
teamId || ''
);
const { data: joinRequestsData } = useTeamJoinRequests(teamId || ''); const { data: joinRequestsData } = useTeamJoinRequests(teamId || '');
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember( const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || '');
teamId || '' const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(teamId || '');
); const { mutateAsync: respondToRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || '');
const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(
teamId || ''
);
const { mutateAsync: respondToRequest, isPending: isResponding } =
useRespondToJoinRequest(teamId || '');
const team = teamData?.data; const team = teamData?.data;
const members = membersData?.data || []; const members = membersData?.data || [];
const joinRequests = Array.isArray(joinRequestsData?.data) const joinRequests = Array.isArray(joinRequestsData?.data) ? joinRequestsData.data : [];
? joinRequestsData.data
: [];
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id; const isLeader = currentUserId === team?.leader_id;
const hasSubmission = team?.has_submission;
const form = useForm<TInviteMemberForm>({ const form = useForm<TInviteMemberForm>({
resolver: zodResolver(inviteMemberSchema), resolver: zodResolver(inviteMemberSchema),
mode: 'all', mode: 'all',
}); });
// Show loading state while fetching team data
if (isLoadingTeam) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
);
}
if (!isLeader) { if (!isLeader) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
Access Denied <p className="text-gray-600 mb-4">Only the team leader can manage members</p>
</h2> <Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can manage members
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div> </div>
); );
} }
@@ -103,48 +75,33 @@ const ManageMembersPage: FC = (): ReactElement => {
const handleApproveRequest = async (requestId: string) => { const handleApproveRequest = async (requestId: string) => {
try { try {
await respondToRequest({ requestId, action: 'approve' }); await respondToRequest({ requestId, action: 'approve' });
toast.success('Member added successfully!');
} catch (error) { } catch (error) {
console.error('Failed to approve request:', error); console.error('Failed to approve request:', error);
toast.error((error as Error).message || 'Failed to approve request');
} }
}; };
const handleRejectRequest = async (requestId: string) => { const handleRejectRequest = async (requestId: string) => {
try { try {
await respondToRequest({ requestId, action: 'reject' }); await respondToRequest({ requestId, action: 'reject' });
toast.success('Request rejected');
} catch (error) { } catch (error) {
console.error('Failed to reject request:', error); console.error('Failed to reject request:', error);
toast.error((error as Error).message || 'Failed to reject request');
} }
}; };
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen bg-gray-50">
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700"> <div className="bg-white border-b">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex flex-col md:flex-row gap-y-4 items-start md:items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"> <h1 className="text-3xl font-bold text-gray-900">Manage Members</h1>
Manage Members <p className="text-gray-600 mt-1">{team?.name}</p>
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{team?.name}
</p>
</div> </div>
<div className="flex space-x-3"> <div className="flex space-x-3">
<Button <Button onClick={() => setShowInviteModal(true)}>
onClick={() => setShowInviteModal(true)}
disabled={hasSubmission}
title={hasSubmission ? 'Cannot invite members after project submission' : undefined}
>
Invite Member Invite Member
</Button> </Button>
<Button <Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team Back to Team
</Button> </Button>
</div> </div>
@@ -153,35 +110,15 @@ const ManageMembersPage: FC = (): ReactElement => {
</div> </div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Submission Lock Warning */}
{hasSubmission && (
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-4">
<div className="flex items-start space-x-3">
<span className="text-2xl">🔒</span>
<div>
<h3 className="font-bold text-amber-900 dark:text-amber-100">
Team Locked
</h3>
<p className="text-amber-800 dark:text-amber-200 text-sm font-sans mt-1">
Your team has submitted a project. You cannot add or remove members after submission to maintain competition integrity.
</p>
</div>
</div>
</div>
)}
{/* Join Requests */} {/* Join Requests */}
{joinRequests.length > 0 && ( {joinRequests.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-xl font-bold text-gray-900 mb-4">
Join Requests ({joinRequests.length}) Join Requests ({joinRequests.length})
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
{joinRequests.map((request) => ( {joinRequests.map((request) => (
<div <div key={request.id} className="border rounded-lg p-4">
key={request.id}
className="border dark:border-gray-700 rounded-lg p-4"
>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center space-x-3 flex-1"> <div className="flex items-center space-x-3 flex-1">
{request.user.avatar ? ( {request.user.avatar ? (
@@ -191,35 +128,24 @@ const ManageMembersPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" className="w-12 h-12 rounded-full object-cover"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"> <div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400"> <span className="text-gray-500">👤</span>
👤
</span>
</div> </div>
)} )}
<div className="flex-1"> <div className="flex-1">
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900">{request.user.fullname}</p>
{request.user.fullname} <p className="text-sm text-gray-600">{request.user.email}</p>
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{request.user.email}
</p>
{request.user.location && ( {request.user.location && (
<p className="text-sm text-gray-500 dark:text-gray-500"> <p className="text-sm text-gray-500">📍 {request.user.location}</p>
📍 {request.user.location}
</p>
)} )}
<p className="text-sm text-gray-700 dark:text-gray-300 mt-2 italic"> <p className="text-sm text-gray-700 mt-2 italic">"{request.message}"</p>
"{request.message}"
</p>
</div> </div>
</div> </div>
<div className="flex space-x-2 ml-4"> <div className="flex space-x-2 ml-4">
<Button <Button
size="sm" size="sm"
onClick={() => handleApproveRequest(request.id)} onClick={() => handleApproveRequest(request.id)}
disabled={isResponding || hasSubmission} disabled={isResponding}
title={hasSubmission ? 'Cannot accept members after project submission' : undefined}
> >
Approve Approve
</Button> </Button>
@@ -240,21 +166,16 @@ const ManageMembersPage: FC = (): ReactElement => {
)} )}
{/* Current Members */} {/* Current Members */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-xl font-bold text-gray-900 mb-4">
Current Members ({members.length}) Current Members ({members.length})
</h2> </h2>
{isLoadingMembers ? ( {isLoadingMembers ? (
<p className="text-gray-600 dark:text-gray-400"> <p className="text-gray-600">Loading members...</p>
Loading members...
</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{members.map((member) => ( {members.map((member) => (
<div <div key={member.id} className="border rounded-lg p-4 flex items-center justify-between">
key={member.id}
className="border dark:border-gray-700 rounded-lg p-4 flex items-center justify-between"
>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{member.user.avatar ? ( {member.user.avatar ? (
<img <img
@@ -263,39 +184,31 @@ const ManageMembersPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" className="w-12 h-12 rounded-full object-cover"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"> <div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400"> <span className="text-gray-500">👤</span>
👤
</span>
</div> </div>
)} )}
<div> <div>
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900">{member.user.fullname}</p>
{member.user.fullname} <p className="text-sm text-gray-600">{member.user.email}</p>
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{member.user.email}
</p>
{member.user.location && ( {member.user.location && (
<p className="text-sm text-gray-500 dark:text-gray-500"> <p className="text-sm text-gray-500">📍 {member.user.location}</p>
📍 {member.user.location}
</p>
)} )}
<div className="flex items-center space-x-2 mt-1"> <div className="flex items-center space-x-2 mt-1">
{member.role === 'leader' && ( {member.role === 'leader' && (
<span className="px-2 py-1 bg-blue-100 dark:bg-primary-900/30 text-blue-800 dark:text-primary-300 rounded text-xs font-medium"> <span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-medium">
Leader Leader
</span> </span>
)} )}
{member.status === ETeamMemberStatus.PENDING && ( {member.status === ETeamMemberStatus.PENDING && (
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 rounded text-xs font-medium"> <span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs font-medium">
Pending Invitation Pending Invitation
</span> </span>
)} )}
</div> </div>
</div> </div>
</div> </div>
{member.role !== 'leader' && !hasSubmission && ( {member.role !== 'leader' && (
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
@@ -312,43 +225,37 @@ const ManageMembersPage: FC = (): ReactElement => {
</div> </div>
{/* Warning */} {/* Warning */}
{!hasSubmission && ( <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4"> <p className="text-sm text-yellow-800">
<p className="text-sm text-yellow-800 dark:text-yellow-200"> <strong>Note:</strong> Members cannot leave the team without your approval. Only you can remove members from the team.
<strong>Note:</strong> Members cannot leave the team without your
approval. Only you can remove members from the team.
</p> </p>
</div> </div>
)}
</div> </div>
{/* Invite Member Modal */} {/* Invite Member Modal */}
{showInviteModal && ( {showInviteModal && (
<div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6"> <div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">
Invite Member Invite Member
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-4 font-sans"> <p className="text-gray-600 mb-4">
Send an invitation to join your team. The invited member will see Send an invitation to join your team. The invited member will see the invitation on their dashboard after logging in.
the invitation on their dashboard after logging in.
</p> </p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-sans"> <p className="text-sm text-yellow-800">
<strong>Important:</strong> The email you enter must match the <strong>Important:</strong> The email you enter must match the GitHub email address the member uses to sign in.
GitHub email address the member uses to sign in.
</p> </p>
</div> </div>
<form onSubmit={handleInvite} className="space-y-4"> <form onSubmit={handleInvite} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Email Address Email Address
</label> </label>
<Input <Input
{...form.register('email')} {...form.register('email')}
type="email" type="email"
placeholder="member@example.com" placeholder="member@example.com"
size="lg"
/> />
{form.formState.errors.email && ( {form.formState.errors.email && (
<p className="text-sm text-red-500 mt-1"> <p className="text-sm text-red-500 mt-1">
+124 -587
View File
@@ -1,129 +1,36 @@
import { FC, ReactElement, useState, useEffect } from 'react'; import { FC, ReactElement, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useParams, useNavigate } from 'react-router'; import { Link, useParams, useNavigate } from 'react-router';
import { import { useTeamById, useTeamMembers, useInviteMember, useTeamJoinRequests, useRespondToJoinRequest, ETeamMemberRole, useAuthStore } from '@imphnen-frontend-service/service';
useTeamById,
useTeamMembers,
useInviteMember,
useTeamJoinRequests,
useRespondToJoinRequest,
useLeaveTeam,
useDeleteTeam,
ETeamMemberRole,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
const MAX_TEAM_MEMBERS = 5; const MAX_TEAM_MEMBERS = 5;
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
// Image component with loading state
const ImageWithLoader: FC<{
src: string;
alt: string;
className?: string;
onLoad?: () => void;
}> = ({ src, alt, className, onLoad }) => {
const [isLoaded, setIsLoaded] = useState(false);
return (
<div className="relative">
{!isLoaded && (
<div
className={`absolute inset-0 bg-gray-200 animate-pulse ${className}`}
/>
)}
<img
src={src}
alt={alt}
className={`${className} ${
isLoaded ? 'opacity-100' : 'opacity-0'
} transition-opacity duration-300`}
onLoad={() => {
setIsLoaded(true);
onLoad?.();
}}
/>
</div>
);
};
const TeamDashboardPage: FC = (): ReactElement => { const TeamDashboardPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { session } = useAuthStore(); const { session } = useAuthStore();
// Check if team features are closed
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
const [showInviteModal, setShowInviteModal] = useState(false); const [showInviteModal, setShowInviteModal] = useState(false);
const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false); const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false);
const [showLeaveModal, setShowLeaveModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [inviteEmail, setInviteEmail] = useState(''); const [inviteEmail, setInviteEmail] = useState('');
const [imagesLoaded, setImagesLoaded] = useState(false);
const [imageLoadCount, setImageLoadCount] = useState(0);
// Calculate total images to load (banner + logo + member avatars) const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || '');
const totalImagesToLoad = 0; // Simplified - disable image loading overlay const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || '');
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '', !!teamId);
const handleImageLoad = () => { const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || '');
setImageLoadCount((prev) => { const { mutateAsync: respondToJoinRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || '');
const newCount = prev + 1;
if (newCount >= totalImagesToLoad) {
setImagesLoaded(true);
}
return newCount;
});
};
const handleImageError = () => {
// Treat error as loaded to not block the UI
handleImageLoad();
};
// Set images as loaded immediately since we disabled the loading overlay
useEffect(() => {
setImagesLoaded(true);
}, []);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
teamId || ''
);
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
teamId || ''
);
const team = teamData?.data; const team = teamData?.data;
const members = membersData?.data || []; const members = membersData?.data || [];
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
// Only fetch join requests if user is the team leader
const { data: joinRequestsData } = useTeamJoinRequests(
teamId || '',
!!teamId && isLeader
);
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
teamId || ''
);
const { mutateAsync: respondToJoinRequest, isPending: isResponding } =
useRespondToJoinRequest(teamId || '');
const { mutateAsync: leaveTeam, isPending: isLeaving } = useLeaveTeam();
const { mutateAsync: deleteTeam, isPending: isDeleting } = useDeleteTeam();
const joinRequests = joinRequestsData?.data || []; const joinRequests = joinRequestsData?.data || [];
const pendingJoinRequests = joinRequests.filter( const pendingJoinRequests = joinRequests.filter((req: any) => req.status === 'pending');
(req: any) => req.status === 'pending' const currentUserId = session?.user?.id;
);
const isMember = members.some( const isLeader = currentUserId === team?.leader_id;
(member: any) => member.user_id === currentUserId
);
const canInvite = isLeader && members.length < MAX_TEAM_MEMBERS; const canInvite = isLeader && members.length < MAX_TEAM_MEMBERS;
console.log('Leader check:', { currentUserId, leaderId: team?.leader_id, isLeader });
const handleInviteMember = async (e: React.FormEvent) => { const handleInviteMember = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!inviteEmail.trim() || isInviting) return; if (!inviteEmail.trim() || isInviting) return;
@@ -139,110 +46,20 @@ const TeamDashboardPage: FC = (): ReactElement => {
} }
}; };
const handleRespondToJoinRequest = async ( const handleRespondToJoinRequest = async (requestId: string, action: 'approve' | 'reject') => {
requestId: string,
action: 'approve' | 'reject'
) => {
try { try {
await respondToJoinRequest({ requestId, action }); await respondToJoinRequest({ requestId, action });
toast.success( toast.success(action === 'approve' ? 'Request approved!' : 'Request rejected');
action === 'approve' ? 'Request approved!' : 'Request rejected'
);
} catch (error) { } catch (error) {
console.error('Failed to respond to join request:', error); console.error('Failed to respond to join request:', error);
toast.error('Failed to process request'); toast.error('Failed to process request');
} }
}; };
const handleLeaveTeam = async () => {
if (!teamId) return;
try {
await leaveTeam(teamId);
toast.success('You have left the team');
navigate('/dashboard');
} catch (error) {
console.error('Failed to leave team:', error);
toast.error('Failed to leave team');
}
};
const handleDeleteTeam = async () => {
if (!teamId) return;
try {
await deleteTeam(teamId);
toast.success('Team deleted successfully');
navigate('/dashboard');
} catch (error: any) {
console.error('Failed to delete team:', error);
toast.error(error?.message || 'Failed to delete team');
}
};
if (isLoadingTeam || isLoadingMembers) { if (isLoadingTeam || isLoadingMembers) {
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex items-center justify-center min-h-screen">
{/* Skeleton Header */} <div className="text-gray-600">Loading team...</div>
<div className="bg-white dark:bg-gray-950 border-b">
<div className="w-full h-48 bg-gray-200 dark:bg-gray-800 animate-pulse" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-4">
<div className="w-20 h-20 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse -mt-10" />
<div>
<div className="h-8 w-48 bg-gray-300 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-2" />
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-2" />
</div>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content Skeleton */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-6">
<div className="h-6 w-32 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
<div className="space-y-2">
<div className="h-4 w-full bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
<div className="h-4 w-3/4 bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
<div className="h-4 w-5/6 bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
</div>
</div>
</div>
{/* Sidebar Skeleton */}
<div className="space-y-6">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-6">
<div className="h-6 w-40 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center space-x-3">
<div className="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse" />
<div>
<div className="h-4 w-24 bg-gray-300 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-1" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Loading Overlay */}
<div className="fixed inset-0 bg-white/60 dark:bg-black/50 flex items-center justify-center z-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600 dark:text-white">
Loading team data...
</p>
</div>
</div>
</div> </div>
); );
} }
@@ -250,282 +67,153 @@ const TeamDashboardPage: FC = (): ReactElement => {
if (!team) { if (!team) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">Team not found</h2>
Team not found <Button onClick={() => navigate('/dashboard')}>Back to Dashboard</Button>
</h2>
<Button onClick={() => navigate('/dashboard')}>
Back to Dashboard
</Button>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-gray-50 relative dark:bg-gray-950"> <div className="min-h-screen bg-gray-50">
{/* Loading overlay while images are loading */}
{!imagesLoaded && totalImagesToLoad > 0 && (
<div className="fixed inset-0 bg-white/80 flex items-center justify-center z-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600 font-medium">Loading images...</p>
<p className="text-sm text-gray-400 mt-1">
{imageLoadCount} / {totalImagesToLoad}
</p>
</div>
</div>
)}
{/* Header with Banner */} {/* Header with Banner */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700"> <div className="bg-white border-b">
{team.banner && ( {team.banner && (
<div className="relative w-full aspect-3/1 overflow-hidden"> <div className="w-full h-48 overflow-hidden">
<div
className={`absolute inset-0 bg-gray-200 animate-pulse ${
imagesLoaded ? 'hidden' : ''
}`}
/>
<img <img
src={team.banner} src={team.banner}
alt={team.name} alt={team.name}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${ className="w-full h-full object-cover"
imagesLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/> />
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent via-black/20 to-black/60" />
</div> </div>
)} )}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center space-x-3 md:space-x-4"> <div className="flex items-center space-x-4">
{team.logo && ( {team.logo && (
<div className="relative shrink-0">
{!imagesLoaded && (
<div className="absolute inset-0 w-20 h-20 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse -mt-10" />
)}
<img <img
src={team.logo} src={team.logo}
alt={team.name} alt={team.name}
className={`w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-8 md:-mt-10 transition-opacity duration-300 shrink-0 ${ className="w-20 h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-10"
imagesLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/> />
</div>
)} )}
<div> <div>
<h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white line-clamp-2"> <h1 className="text-3xl font-bold text-gray-900">{team.name}</h1>
{team.name} <p className="text-gray-600 mt-1">📍 {team.city}</p>
</h1> <div className="flex items-center space-x-4 mt-2">
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 mt-1 line-clamp-1 font-sans flex items-center"> <span className="text-sm text-gray-500">
<Icon icon="mdi:map-marker" className="inline-block mr-1" /> {members.length} {members.length === 1 ? 'Member' : 'Members'}
<span>{team.city}</span>
</p>
<div className="flex flex-wrap items-center gap-2 md:space-x-4 mt-2">
<span className="text-sm text-gray-500 dark:text-gray-400 flex items-center">
<Icon
icon="mdi:account-multiple"
className="inline-block mr-1"
/>
{members.length}{' '}
{members.length === 1 ? 'Member' : 'Members'}
</span> </span>
<span className="text-sm text-gray-500 dark:text-gray-400 flex items-center"> <span className="text-sm text-gray-500">
<Icon {team.visibility === 'public' ? '🌐 Public' : '🔒 Private'}
icon={
team.visibility === 'public' ? 'mdi:web' : 'mdi:lock'
}
className="inline-block mr-1"
/>
{team.visibility === 'public' ? 'Public' : 'Private'}
</span> </span>
{isLeader && ( {isLeader && (
<span className="hidden md:inline-flex px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm items-center"> <span className="px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm">
<Icon icon="mdi:crown" className="inline-block mr-1" /> 👑 Team Leader
<span>Team Leader</span>
</span> </span>
)} )}
</div> </div>
</div> </div>
</div> </div>
<Link to="/dashboard">
<Button variant="secondary">Back to Dashboard</Button>
</Link>
</div> </div>
</div> </div>
</div> </div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid gap-4 md:gap-6 xl:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */} {/* Main Content */}
<div className="lg:col-span-2 space-y-4 md:space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Team Description */} {/* Team Description */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h2 className="text-xl font-bold text-gray-900 mb-4">About Team</h2>
About Team <p className="text-gray-700 whitespace-pre-wrap">{team.description}</p>
</h2>
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-all font-sans">
{team.description}
</p>
</div> </div>
{/* Team Actions - Only for Leader */} {/* Team Actions - Only for Leader */}
{isLeader && ( {isLeader && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h2 className="text-xl font-bold text-gray-900 mb-4">Team Management</h2>
Team Management
</h2>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{/* Hide invite/join management after submission or deadline */}
{!team.has_submission && !isTeamFeaturesClosed && (
<>
<Button <Button
className="w-full" className="w-full"
variant="secondary" variant="secondary"
onClick={() => setShowInviteModal(true)} onClick={() => setShowInviteModal(true)}
disabled={!canInvite} disabled={!canInvite}
> >
<Icon icon="mdi:plus" className="inline-block mr-2" /> Invite Member {!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
Invite Member{' '}
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
</Button> </Button>
<Button <Button
className="w-full relative" className="w-full relative"
variant="secondary" variant="secondary"
onClick={() => setShowJoinRequestsModal(true)} onClick={() => setShowJoinRequestsModal(true)}
> >
<Icon icon="mdi:email" className="inline-block mr-2" /> Join 📩 Join Requests
Requests
{pendingJoinRequests.length > 0 && ( {pendingJoinRequests.length > 0 && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center"> <span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
{pendingJoinRequests.length} {pendingJoinRequests.length}
</span> </span>
)} )}
</Button> </Button>
</>
)}
{!team.has_submission && !isTeamFeaturesClosed && (
<Link to={`/teams/${teamId}/edit`}> <Link to={`/teams/${teamId}/edit`}>
<Button className="w-full" variant="secondary"> <Button className="w-full" variant="secondary">
<Icon icon="mdi:pencil" className="inline-block mr-2" /> Edit Team Info
Edit Team Info
</Button> </Button>
</Link> </Link>
)}
<Link to={`/teams/${teamId}/members`}> <Link to={`/teams/${teamId}/members`}>
<Button className="w-full" variant="secondary"> <Button className="w-full" variant="secondary">
<Icon 👥 Manage Members
icon="mdi:account-group"
className="inline-block mr-2"
/>
View Members
</Button> </Button>
</Link> </Link>
<Link to={`/teams/${teamId}/chat`}> <Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary"> <Button className="w-full" variant="secondary">
<Icon icon="mdi:chat" className="inline-block mr-2" /> 💬 Team Chat
Team Chat
</Button> </Button>
</Link> </Link>
{team.has_submission ? (
<Link to={`/teams/${teamId}/submission`}>
<Button className="w-full" variant="secondary">
<Icon icon="mdi:file-document" className="inline-block mr-2" />
View Submission
</Button>
</Link>
) : (
<Link to={`/teams/${teamId}/submit`}> <Link to={`/teams/${teamId}/submit`}>
<Button className="w-full" disabled={members.length < 2}> <Button className="w-full">
<Icon icon="mdi:rocket" className="inline-block mr-2" /> 🚀 Submit Project
Submit Project
</Button> </Button>
</Link> </Link>
)}
</div> </div>
{!team.has_submission && !canInvite && members.length >= MAX_TEAM_MEMBERS && ( {!canInvite && members.length >= MAX_TEAM_MEMBERS && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-3 text-center"> <p className="text-sm text-gray-600 mt-3 text-center">
Maximum team size reached ({MAX_TEAM_MEMBERS} members) Maximum team size reached ({MAX_TEAM_MEMBERS} members)
</p> </p>
)} )}
{/* Danger Zone - Only show when leader is alone, no submission, and features not closed */}
{members.length === 1 && !team.has_submission && !isTeamFeaturesClosed && (
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-red-600 dark:text-red-400 mb-3">
Danger Zone
</h3>
<Button
className="w-full bg-red-600 hover:bg-red-700 text-white"
onClick={() => setShowDeleteModal(true)}
>
<Icon icon="mdi:delete" className="inline-block mr-2" />
Delete Team
</Button>
</div>
)}
</div> </div>
)} )}
{/* Quick Actions for Members (non-leaders) */} {/* Quick Actions for Members */}
{!isLeader && isMember && ( {!isLeader && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h2 className="text-xl font-bold text-gray-900 mb-4">Quick Actions</h2>
Quick Actions
</h2>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<Link to={`/teams/${teamId}/chat`}> <Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary"> <Button className="w-full" variant="secondary">
<Icon icon="mdi:chat" className="inline-block mr-2" /> 💬 Team Chat
Team Chat
</Button> </Button>
</Link> </Link>
{team.has_submission && ( {team.has_submission && (
<Link to={`/teams/${teamId}/submission`}> <Link to={`/teams/${teamId}/submission`}>
<Button className="w-full" variant="secondary"> <Button className="w-full" variant="secondary">
<Icon icon="mdi:file-document" className="inline-block mr-2" /> 📄 View Submission
View Submission
</Button> </Button>
</Link> </Link>
)} )}
{!team.has_submission && !isTeamFeaturesClosed && (
<Button
className="w-full bg-red-600 hover:bg-red-700 text-white"
onClick={() => setShowLeaveModal(true)}
>
<Icon icon="mdi:exit-run" className="inline-block mr-2" />
Leave Team
</Button>
)}
</div> </div>
</div> </div>
)} )}
{/* Warning for single member teams */} {/* Submission Status */}
{members.length === 1 && !team.has_submission && isMember && ( {team.has_submission && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 md:p-6"> <div className="bg-green-50 border border-green-200 rounded-lg p-6">
<div className="flex items-center space-x-3">
<span className="text-3xl"></span>
<div>
<h3 className="font-bold text-amber-900 dark:text-amber-400">
Team Needs More Members
</h3>
<p className="text-amber-700 dark:text-amber-300 text-sm">
Your team needs at least 2 members to submit a project. Invite someone or wait for join requests!
</p>
</div>
</div>
</div>
)}
{/* Submission Status - Only show to members */}
{team.has_submission && isMember && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 md:p-6">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<span className="text-3xl"></span> <span className="text-3xl"></span>
<div> <div>
<h3 className="font-bold text-green-900 dark:text-green-400"> <h3 className="font-bold text-green-900">Project Submitted</h3>
Project Submitted <p className="text-green-700 text-sm">
</h3>
<p className="text-green-700 dark:text-green-300 text-sm">
Your team has successfully submitted a project Your team has successfully submitted a project
</p> </p>
</div> </div>
@@ -540,17 +228,12 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div> </div>
{/* Sidebar */} {/* Sidebar */}
<div className="space-y-4 md:space-y-6"> <div className="space-y-6">
{/* Team Leader */} {/* Team Leader */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h3 className="font-bold text-gray-900 mb-4">Team Leader</h3>
Team Leader
</h3>
{team.leader && ( {team.leader && (
<button <div className="flex items-center space-x-3">
onClick={() => navigate(`/users/${team.leader.id}`)}
className="w-full flex items-center space-x-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2 transition-colors text-left cursor-pointer"
>
{team.leader.avatar ? ( {team.leader.avatar ? (
<img <img
src={team.leader.avatar} src={team.leader.avatar}
@@ -558,62 +241,46 @@ const TeamDashboardPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" className="w-12 h-12 rounded-full object-cover"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"> <div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400"> <span className="text-gray-500">👤</span>
👤
</span>
</div> </div>
)} )}
<div> <div>
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900">{team.leader.fullname}</p>
{team.leader.fullname} <p className="text-sm text-gray-600">{team.leader.email}</p>
</p> </div>
<p className="text-sm text-gray-600 dark:text-gray-400">
{team.leader.email}
</p>
</div> </div>
</button>
)} )}
</div> </div>
{/* Team Members */} {/* Team Members */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h3 className="font-bold text-gray-900 mb-4">
Members ({members.length}) Members ({members.length})
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
{members.map((member: any) => ( {members.map((member) => (
<button <div key={member.id} className="flex items-center space-x-3">
key={member.id} {member.user.avatar ? (
onClick={() => navigate(`/users/${member.user.id}`)}
className="w-full flex items-center space-x-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2 transition-colors text-left cursor-pointer"
>
{member.user?.avatar ? (
<img <img
src={member.user.avatar} src={member.user.avatar}
alt={member.user.fullname || 'Member'} alt={member.user.fullname}
className="w-10 h-10 rounded-full object-cover" className="w-10 h-10 rounded-full object-cover"
onLoad={handleImageLoad}
onError={handleImageError}
/> />
) : ( ) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"> <div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400 text-sm"> <span className="text-gray-500 text-sm">👤</span>
👤
</span>
</div> </div>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 dark:text-white truncate"> <p className="font-medium text-gray-900 truncate">
{member.user?.fullname || 'Unknown'} {member.user.fullname}
</p> </p>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500">
{member.role === ETeamMemberRole.LEADER {member.role === ETeamMemberRole.LEADER ? 'Leader' : 'Member'}
? 'Leader'
: 'Member'}
</p> </p>
</div> </div>
</button> </div>
))} ))}
</div> </div>
</div> </div>
@@ -623,27 +290,22 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Invite Member Modal */} {/* Invite Member Modal */}
{showInviteModal && ( {showInviteModal && (
<div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6"> <div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">
Invite Team Member Invite Team Member
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-4 font-sans"> <p className="text-gray-600 mb-4">
Send an invitation to join your team. The invited member will see Send an invitation to join your team. The invited member will see the invitation on their dashboard after logging in.
the invitation on their dashboard after logging in.
</p> </p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800 dark:text-yellow-300 font-sans"> <p className="text-sm text-yellow-800">
<strong>Important:</strong> The email you enter must match the <strong>Important:</strong> The email you enter must match the GitHub email address the member uses to sign in.
GitHub email address the member uses to sign in.
</p> </p>
</div> </div>
<form onSubmit={handleInviteMember} className="space-y-4"> <form onSubmit={handleInviteMember} className="space-y-4">
<div> <div>
<label <label htmlFor="invite-email" className="block text-sm font-medium text-gray-700 mb-2">
htmlFor="invite-email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email Address Email Address
</label> </label>
<input <input
@@ -652,10 +314,10 @@ const TeamDashboardPage: FC = (): ReactElement => {
value={inviteEmail} value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)} onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email address..." placeholder="Enter email address..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required required
/> />
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 mt-1">
Current members: {members.length}/{MAX_TEAM_MEMBERS} Current members: {members.length}/{MAX_TEAM_MEMBERS}
</p> </p>
</div> </div>
@@ -686,15 +348,15 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Join Requests Modal */} {/* Join Requests Modal */}
{showJoinRequestsModal && ( {showJoinRequestsModal && (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[80vh] overflow-y-auto"> <div className="bg-white rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white"> <h2 className="text-2xl font-bold text-gray-900">
Join Requests ({pendingJoinRequests.length}) Join Requests ({pendingJoinRequests.length})
</h2> </h2>
<button <button
onClick={() => setShowJoinRequestsModal(false)} onClick={() => setShowJoinRequestsModal(false)}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-2xl cursor-pointer" className="text-gray-400 hover:text-gray-600 text-2xl"
> >
</button> </button>
@@ -702,20 +364,15 @@ const TeamDashboardPage: FC = (): ReactElement => {
{pendingJoinRequests.length === 0 ? ( {pendingJoinRequests.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400 text-lg"> <p className="text-gray-600 text-lg">No pending join requests</p>
No pending join requests <p className="text-gray-500 text-sm mt-2">
</p>
<p className="text-gray-500 dark:text-gray-500 text-sm mt-2 font-sans">
When users request to join your team, they'll appear here When users request to join your team, they'll appear here
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{pendingJoinRequests.map((request: any) => ( {pendingJoinRequests.map((request: any) => (
<div <div key={request.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
key={request.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1"> <div className="flex items-start space-x-3 flex-1">
{request.user?.avatar ? ( {request.user?.avatar ? (
@@ -725,67 +382,51 @@ const TeamDashboardPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" className="w-12 h-12 rounded-full object-cover"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0"> <div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center shrink-0">
<span className="text-gray-500 dark:text-gray-400 text-xl"> <span className="text-gray-500 text-xl">👤</span>
<Icon
icon="mdi:account-circle"
className="text-3xl"
/>
</span>
</div> </div>
)} )}
<div className="flex-1"> <div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-white"> <p className="font-semibold text-gray-900">
{request.user?.fullname || 'Unknown User'} {request.user?.fullname || 'Unknown User'}
</p> </p>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600">
{request.user?.email} {request.user?.email}
</p> </p>
{request.message && ( {request.message && (
<div className="mt-2 bg-gray-50 dark:bg-gray-800 rounded-lg p-3"> <div className="mt-2 bg-gray-50 rounded-lg p-3">
<p className="text-sm text-gray-700 dark:text-gray-300"> <p className="text-sm text-gray-700">
<strong>Message:</strong> {request.message} <strong>Message:</strong> {request.message}
</p> </p>
</div> </div>
)} )}
<p className="text-sm text-gray-500 dark:text-gray-500 mt-2 font-sans"> <p className="text-xs text-gray-500 mt-2">
Requested{' '} Requested {new Date(request.created_at).toLocaleDateString()}
{new Date(request.created_at).toLocaleDateString()}
</p> </p>
</div> </div>
</div> </div>
</div> <div className="flex space-x-2 ml-4">
<div className="flex space-x-2 mt-4 justify-end">
<Button <Button
onClick={() => onClick={() => handleRespondToJoinRequest(request.id, 'approve')}
handleRespondToJoinRequest(request.id, 'approve') disabled={isResponding || members.length >= MAX_TEAM_MEMBERS}
}
disabled={
isResponding || members.length >= MAX_TEAM_MEMBERS
}
className="px-4 py-2 text-sm" className="px-4 py-2 text-sm"
> >
Accept Accept
</Button> </Button>
<Button <Button
onClick={() => onClick={() => handleRespondToJoinRequest(request.id, 'reject')}
handleRespondToJoinRequest(request.id, 'reject')
}
disabled={isResponding} disabled={isResponding}
variant="secondary" variant="secondary"
className="px-4 py-2 text-sm" className="px-4 py-2 text-sm"
> >
Reject Reject
</Button> </Button>
</div> </div>
</div>
{members.length >= MAX_TEAM_MEMBERS && ( {members.length >= MAX_TEAM_MEMBERS && (
<div className="mt-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3"> <div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
<p className="text-sm text-yellow-800 dark:text-yellow-300 font-sans"> <p className="text-xs text-yellow-800">
Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS}{' '} Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS} members). Remove a member before accepting new requests.
members). Remove a member before accepting new
requests.
</p> </p>
</div> </div>
)} )}
@@ -796,110 +437,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div> </div>
</div> </div>
)} )}
{/* Leave Team Confirmation Modal */}
{showLeaveModal && (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
<div className="text-center 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:exit-run"
className="text-3xl text-red-600 dark:text-red-400"
/>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Leave Team?
</h2>
<p className="text-gray-600 dark:text-gray-400">
Are you sure you want to leave <strong>{team?.name}</strong>?
</p>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6">
<p className="text-sm text-amber-800 dark:text-amber-300 font-sans">
<strong>Warning:</strong> If you leave, you will need to request to join again or be re-invited by the team leader.
</p>
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setShowLeaveModal(false)}
>
Cancel
</Button>
<Button
onClick={handleLeaveTeam}
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={isLeaving}
>
{isLeaving ? 'Leaving...' : 'Leave Team'}
</Button>
</div>
</div>
</div>
)}
{/* Delete Team Confirmation Modal */}
{showDeleteModal && (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
<div className="text-center 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:delete-alert"
className="text-3xl text-red-600 dark:text-red-400"
/>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Delete Team?
</h2>
<p className="text-gray-600 dark:text-gray-400">
This action is <strong className="text-red-600 dark:text-red-400">permanent</strong> and cannot be undone.
</p>
</div>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
<p className="text-sm text-red-800 dark:text-red-300 font-sans">
<strong>Warning:</strong> All team data, chat messages, and submissions will be permanently deleted.
</p>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Type <span className="font-bold text-red-600 dark:text-red-400">{team?.name}</span> to confirm:
</label>
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="Type team name here"
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
autoComplete="off"
/>
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowDeleteModal(false);
setDeleteConfirmText('');
}}
>
Cancel
</Button>
<Button
onClick={handleDeleteTeam}
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={isDeleting || deleteConfirmText !== team?.name}
>
{isDeleting ? 'Deleting...' : 'Delete Team'}
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };
@@ -15,18 +15,18 @@ const SubmissionViewPage: FC = (): ReactElement => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600 dark:text-neutral-400">Loading submission...</div> <div className="text-gray-600">Loading submission...</div>
</div> </div>
); );
} }
if (!submission) { if (!submission) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex flex-col items-center justify-center min-h-screen">
<div className="text-6xl mb-4">📄</div> <div className="text-6xl mb-4">📄</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">No Submission Yet</h2>
<p className="text-gray-600 dark:text-neutral-400 mb-4">Your team hasn't submitted a project</p> <p className="text-gray-600 mb-4">Your team hasn't submitted a project</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button> <Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div> </div>
); );
@@ -43,13 +43,13 @@ const SubmissionViewPage: FC = (): ReactElement => {
: 'Not submitted'; : 'Not submitted';
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="min-h-screen bg-gray-50">
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700"> <div className="bg-white border-b">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Project Submission</h1> <h1 className="text-3xl font-bold text-gray-900">Project Submission</h1>
<p className="text-gray-600 dark:text-neutral-400 mt-1">{team?.name}</p> <p className="text-gray-600 mt-1">{team?.name}</p>
</div> </div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}> <Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team Back to Team
@@ -60,48 +60,22 @@ const SubmissionViewPage: FC = (): ReactElement => {
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Status Banner */} {/* Status Banner */}
{submission.status === 'submitted' ? ( <div className="bg-green-50 border border-green-500 rounded-lg p-6 mb-6">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<span className="text-4xl">✅</span> <span className="text-4xl">✅</span>
<div> <div>
<h3 className="font-bold text-green-900 dark:text-green-100 text-lg">Project Submitted Successfully</h3> <h3 className="font-bold text-green-900 text-lg">Project Submitted Successfully</h3>
<p className="text-green-700 dark:text-green-300 text-sm"> <p className="text-green-700 text-sm">
Submitted on {submittedDate} Submitted on {submittedDate}
</p> </p>
<p className="text-green-600 dark:text-green-400 text-xs mt-1"> <p className="text-green-600 text-xs mt-1">
This submission is now read-only and cannot be edited This submission is now read-only and cannot be edited
</p> </p>
</div> </div>
</div> </div>
</div> </div>
) : submission.status === 'pending_verification' ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-500 rounded-lg p-6 mb-6">
<div className="flex items-center space-x-3">
<span className="text-4xl">⏳</span>
<div>
<h3 className="font-bold text-yellow-900 dark:text-yellow-100 text-lg">Submission Pending Verification</h3>
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
Your submission is being processed
</p>
</div>
</div>
</div>
) : (
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-500 rounded-lg p-6 mb-6">
<div className="flex items-center space-x-3">
<span className="text-4xl">📝</span>
<div>
<h3 className="font-bold text-orange-900 dark:text-orange-100 text-lg">Draft Submission</h3>
<p className="text-orange-700 dark:text-orange-300 text-sm">
This submission is still in draft and has not been finalized
</p>
</div>
</div>
</div>
)}
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 overflow-hidden"> <div className="bg-white rounded-lg shadow-md overflow-hidden">
{/* Project Header */} {/* Project Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8"> <div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8">
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2> <h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
@@ -112,9 +86,9 @@ const SubmissionViewPage: FC = (): ReactElement => {
<div className="p-8 space-y-6"> <div className="p-8 space-y-6">
{/* Description */} {/* Description */}
<div> <div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3> <h3 className="text-lg font-bold text-gray-900 mb-3">Project Description</h3>
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<p className="text-gray-700 dark:text-neutral-300 whitespace-pre-wrap">{submission.description}</p> <p className="text-gray-700 whitespace-pre-wrap">{submission.description}</p>
</div> </div>
</div> </div>
@@ -122,12 +96,12 @@ const SubmissionViewPage: FC = (): ReactElement => {
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{/* Repository */} {/* Repository */}
<div> <div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Repository</h3> <h3 className="text-lg font-bold text-gray-900 mb-3">Repository</h3>
<a <a
href={submission.repository_url} href={submission.repository_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300" className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
> >
<span>🔗</span> <span>🔗</span>
<span className="break-all">{submission.repository_url}</span> <span className="break-all">{submission.repository_url}</span>
@@ -137,12 +111,12 @@ const SubmissionViewPage: FC = (): ReactElement => {
{/* Demo URL */} {/* Demo URL */}
{submission.demo_url && ( {submission.demo_url && (
<div> <div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Live Demo</h3> <h3 className="text-lg font-bold text-gray-900 mb-3">Live Demo</h3>
<a <a
href={submission.demo_url} href={submission.demo_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300" className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
> >
<span>🌐</span> <span>🌐</span>
<span className="break-all">{submission.demo_url}</span> <span className="break-all">{submission.demo_url}</span>
@@ -150,12 +124,27 @@ const SubmissionViewPage: FC = (): ReactElement => {
</div> </div>
)} )}
{/* Presentation URL */}
{submission.presentation_url && (
<div>
<h3 className="text-lg font-bold text-gray-900 mb-3">Presentation</h3>
<a
href={submission.presentation_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
>
<span>📊</span>
<span className="break-all">{submission.presentation_url}</span>
</a>
</div>
)}
</div> </div>
{/* Screenshots */} {/* Screenshots */}
{submission.screenshots && submission.screenshots.length > 0 && ( {submission.screenshots && submission.screenshots.length > 0 && (
<div> <div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3"> <h3 className="text-lg font-bold text-gray-900 mb-3">
Screenshots ({submission.screenshots.length}) Screenshots ({submission.screenshots.length})
</h3> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -170,7 +159,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
<img <img
src={url} src={url}
alt={`Screenshot ${index + 1}`} alt={`Screenshot ${index + 1}`}
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer" className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 hover:border-blue-500 transition-colors cursor-pointer"
/> />
</a> </a>
))} ))}
@@ -179,32 +168,22 @@ const SubmissionViewPage: FC = (): ReactElement => {
)} )}
{/* Submission Info */} {/* Submission Info */}
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500"> <div className="bg-gray-50 rounded-lg p-4 border-t-4 border-blue-600">
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3> <h3 className="text-sm font-bold text-gray-900 mb-2">Submission Information</h3>
<div className="grid gap-2 text-sm"> <div className="grid gap-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Status:</span> <span className="text-gray-600">Status:</span>
<span className={`font-medium ${ <span className="font-medium text-green-600">
submission.status === 'submitted' {submission.status === 'submitted' ? ' Submitted' : 'Draft'}
? 'text-green-600 dark:text-green-400'
: submission.status === 'pending_verification'
? 'text-yellow-600 dark:text-yellow-400'
: 'text-orange-600 dark:text-orange-400'
}`}>
{submission.status === 'submitted'
? ' Submitted'
: submission.status === 'pending_verification'
? ' Pending Verification'
: '📝 Draft'}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Submitted:</span> <span className="text-gray-600">Submitted:</span>
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span> <span className="font-medium text-gray-900">{submittedDate}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Submission ID:</span> <span className="text-gray-600">Submission ID:</span>
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs"> <span className="font-medium text-gray-900 font-mono text-xs">
{submission.id} {submission.id}
</span> </span>
</div> </div>
@@ -212,8 +191,8 @@ const SubmissionViewPage: FC = (): ReactElement => {
</div> </div>
{/* Read-only Notice */} {/* Read-only Notice */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200"> <p className="text-sm text-yellow-800">
<strong>Note:</strong> This submission is now locked and cannot be edited or deleted. <strong>Note:</strong> This submission is now locked and cannot be edited or deleted.
If you need to make changes, please contact the hackathon organizers. If you need to make changes, please contact the hackathon organizers.
</p> </p>
@@ -13,31 +13,23 @@ import {
useAuthStore, useAuthStore,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const SubmitProjectPage: FC = (): ReactElement => { const SubmitProjectPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { session } = useAuthStore(); const { session } = useAuthStore();
const [showConfirmModal, setShowConfirmModal] = useState(false); const [showConfirmModal, setShowConfirmModal] = useState(false);
const [confirmText, setConfirmText] = useState('');
const [screenshots, setScreenshots] = useState<string[]>([]); const [screenshots, setScreenshots] = useState<string[]>([]);
const { data: teamData } = useTeamById(teamId || ''); const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId); const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
const { mutateAsync: submitProject, isPending: isSubmitting } = const { mutateAsync: submitProject, isPending: isSubmitting } = useSubmitProject(teamId || '');
useSubmitProject(teamId || '');
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data; const team = teamData?.data;
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id; const isLeader = currentUserId === team?.leader_id;
const hasSubmission = !!submissionData?.data; const hasSubmission = !!submissionData?.data;
const memberCount = team?.members?.length || 0;
const hasEnoughMembers = memberCount >= MIN_TEAM_MEMBERS;
const form = useForm<TProjectSubmissionForm>({ const form = useForm<TProjectSubmissionForm>({
resolver: zodResolver(projectSubmissionSchema), resolver: zodResolver(projectSubmissionSchema),
@@ -46,38 +38,25 @@ const SubmitProjectPage: FC = (): ReactElement => {
if (!isLeader) { if (!isLeader) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
Access Denied <p className="text-gray-600 mb-4">Only the team leader can submit projects</p>
</h2> <Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can submit projects
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div> </div>
); );
} }
if (hasSubmission) { if (hasSubmission) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex flex-col items-center justify-center min-h-screen">
<div className="text-6xl mb-4"></div> <div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">Project Already Submitted</h2>
Project Already Submitted <p className="text-gray-600 mb-4">Your team has already submitted a project</p>
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Your team has already submitted a project
</p>
<div className="flex space-x-3"> <div className="flex space-x-3">
<Button onClick={() => navigate(`/teams/${teamId}/submission`)}> <Button onClick={() => navigate(`/teams/${teamId}/submission`)}>
View Submission View Submission
</Button> </Button>
<Button <Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team Back to Team
</Button> </Button>
</div> </div>
@@ -85,28 +64,17 @@ const SubmitProjectPage: FC = (): ReactElement => {
); );
} }
const handleScreenshotUpload = async ( const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
e: React.ChangeEvent<HTMLInputElement>
) => {
const files = e.target.files; const files = e.target.files;
if (!files) return; if (!files) return;
// Validate file sizes
const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE);
if (oversizedFiles.length > 0) {
toast.error(`${oversizedFiles.length} file(s) are too large. Maximum size is 2MB per file.`);
e.target.value = '';
return;
}
try { try {
const uploadPromises = Array.from(files).map((file) => uploadFile(file)); const uploadPromises = Array.from(files).map(file => uploadFile(file));
const results = await Promise.all(uploadPromises); const results = await Promise.all(uploadPromises);
const urls = results.map((r) => r.data.url); const urls = results.map(r => r.data.url);
setScreenshots([...screenshots, ...urls]); setScreenshots([...screenshots, ...urls]);
} catch (error) { } catch (error) {
console.error('Failed to upload screenshots:', error); console.error('Failed to upload screenshots:', error);
toast.error('Failed to upload screenshots. Please try again.');
} }
}; };
@@ -127,77 +95,47 @@ const SubmitProjectPage: FC = (): ReactElement => {
}); });
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen bg-gray-50">
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700"> <div className="bg-white border-b">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"> <h1 className="text-3xl font-bold text-gray-900">Submit Project</h1>
Submit Project <p className="text-gray-600 mt-1">{team?.name}</p>
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
</div> </div>
</div> </div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Minimum Members Warning */}
{!hasEnoughMembers && (
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
<div className="flex items-start space-x-3">
<span className="text-3xl">👥</span>
<div>
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
Team Members Required
</h3>
<p className="text-amber-800 dark:text-amber-200 mt-2 text-sm font-sans">
Your team needs at least <strong>{MIN_TEAM_MEMBERS} members</strong> to submit a project.
Currently you have <strong>{memberCount} member{memberCount !== 1 ? 's' : ''}</strong>.
Please invite more members to your team before submitting.
</p>
</div>
</div>
</div>
)}
{/* Warning Banner */} {/* Warning Banner */}
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-lg p-6 mb-6"> <div className="bg-red-50 border-2 border-red-500 rounded-lg p-6 mb-6">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<span className="text-3xl"></span> <span className="text-3xl"></span>
<div> <div>
<h3 className="font-bold text-red-900 dark:text-red-100 text-lg"> <h3 className="font-bold text-red-900 text-lg">IMPORTANT WARNING</h3>
IMPORTANT WARNING <ul className="text-red-800 mt-2 space-y-1 text-sm">
</h3>
<ul className="text-red-800 dark:text-red-200 mt-2 space-y-1 text-sm font-sans">
<li> You can only submit your project ONCE</li> <li> You can only submit your project ONCE</li>
<li> After submission, you CANNOT edit or change anything</li> <li> After submission, you CANNOT edit or change anything</li>
<li> <li> Make sure all information is correct before submitting</li>
Make sure all information is correct before submitting
</li>
<li> Review your project details carefully</li> <li> Review your project details carefully</li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8 border dark:border-gray-800"> <div className="bg-white rounded-lg shadow-md p-8">
<form <form onSubmit={(e) => {
onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
setShowConfirmModal(true); setShowConfirmModal(true);
}} }} className="space-y-6">
className="space-y-6"
>
{/* Project Name */} {/* Project Name */}
<ControlledInputField <ControlledInputField
control={form.control} control={form.control}
label="Project Name" label="Project Name"
placeholder="Enter your project name" placeholder="Enter your project name"
name="project_name" name="project_name"
size="lg"
isRequired={true}
/> />
{/* Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
Project Description <span className="text-red-500">*</span> Project Description <span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
@@ -210,12 +148,9 @@ const SubmitProjectPage: FC = (): ReactElement => {
placeholder="Describe your project, its features, and what problem it solves..." placeholder="Describe your project, its features, and what problem it solves..."
rows={6} rows={6}
className="w-full" className="w-full"
size="lg"
/> />
{fieldState.error && ( {fieldState.error && (
<p className="text-sm text-red-500 mt-1"> <p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
{fieldState.error.message}
</p>
)} )}
</div> </div>
)} )}
@@ -229,8 +164,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
placeholder="https://github.com/username/project" placeholder="https://github.com/username/project"
name="repository_url" name="repository_url"
type="url" type="url"
size="lg"
isRequired={true}
/> />
{/* Demo URL */} {/* Demo URL */}
@@ -240,13 +173,21 @@ const SubmitProjectPage: FC = (): ReactElement => {
placeholder="https://your-project-demo.com" placeholder="https://your-project-demo.com"
name="demo_url" name="demo_url"
type="url" type="url"
size="lg" />
{/* Presentation URL */}
<ControlledInputField
control={form.control}
label="Presentation URL (Optional)"
placeholder="https://slides.com/your-presentation or Google Drive link"
name="presentation_url"
type="url"
/> />
{/* Screenshots */} {/* Screenshots */}
<div> <div className="space-y-2">
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
Project Screenshots (Optional) Project Screenshots <span className="text-gray-400">(Optional)</span>
</label> </label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4"> <div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
{screenshots.map((url, index) => ( {screenshots.map((url, index) => (
@@ -254,7 +195,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
<img <img
src={url} src={url}
alt={`Screenshot ${index + 1}`} alt={`Screenshot ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700" className="w-full h-32 object-cover rounded-lg border-2 border-gray-200"
/> />
<button <button
type="button" type="button"
@@ -266,14 +207,10 @@ const SubmitProjectPage: FC = (): ReactElement => {
</div> </div>
))} ))}
</div> </div>
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800"> <label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<div className="text-center"> <div className="text-center">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500">Click to upload screenshots</p>
Click to upload screenshots <p className="text-xs text-gray-400 mt-1">PNG, JPG up to 5MB each</p>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 font-sans">
PNG, JPG. Max 2MB each
</p>
</div> </div>
<input <input
type="file" type="file"
@@ -299,73 +236,52 @@ const SubmitProjectPage: FC = (): ReactElement => {
<Button <Button
type="submit" type="submit"
className="flex-1" className="flex-1"
disabled={!form.formState.isValid || isUploading || !hasEnoughMembers} disabled={!form.formState.isValid || isUploading}
> >
Review & Submit Review & Submit
</Button> </Button>
</div> </div>
{!hasEnoughMembers && (
<p className="text-center text-sm text-amber-600 dark:text-amber-400 mt-2">
You need at least {MIN_TEAM_MEMBERS} team members to submit
</p>
)}
</form> </form>
</div> </div>
</div> </div>
{/* Confirmation Modal */} {/* Confirmation Modal */}
{showConfirmModal && ( {showConfirmModal && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6"> <div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<div className="text-center mb-6"> <div className="text-center mb-6">
<div className="text-5xl mb-4"></div> <div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-2xl font-bold text-gray-900 mb-2">
Final Confirmation Final Confirmation
</h2> </h2>
<p className="text-red-600 dark:text-red-400 font-medium"> <p className="text-red-600 font-medium">
This action is IRREVERSIBLE! This action is IRREVERSIBLE!
</p> </p>
</div> </div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-6"> <div className="bg-gray-50 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3"> <p className="text-sm text-gray-700 mb-3">
By submitting, you confirm that: By clicking "Submit Project", you confirm that:
</p> </p>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2"> <ul className="text-sm text-gray-600 space-y-2">
<li> All information is correct and complete</li> <li> All information is correct and complete</li>
<li> You understand this can only be done once</li> <li> You understand this can only be done once</li>
<li> You cannot edit after submission</li> <li> You cannot edit after submission</li>
<li> Your team agrees with this submission</li> <li> Your team agrees with this submission</li>
</ul> </ul>
</div> </div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Type <span className="font-bold text-red-600 dark:text-red-400">SUBMIT</span> to confirm:
</label>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="Type SUBMIT here"
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
autoComplete="off"
/>
</div>
<div className="flex space-x-3"> <div className="flex space-x-3">
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
className="flex-1" className="flex-1"
onClick={() => { onClick={() => setShowConfirmModal(false)}
setShowConfirmModal(false);
setConfirmText('');
}}
> >
Go Back Go Back
</Button> </Button>
<Button <Button
onClick={onSubmit} onClick={onSubmit}
className="flex-1 bg-red-600 hover:bg-red-700" className="flex-1 bg-red-600 hover:bg-red-700"
disabled={isSubmitting || confirmText !== 'SUBMIT'} disabled={isSubmitting}
> >
{isSubmitting ? 'Submitting...' : 'Submit Project'} {isSubmitting ? 'Submitting...' : 'Submit Project'}
</Button> </Button>
+57 -469
View File
@@ -1,206 +1,24 @@
import { FC, ReactElement, useState, useEffect, useCallback } from 'react'; import { FC, ReactElement, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { Link, useNavigate, useSearchParams } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { import { useTeams, useJoinTeam, useMyTeams, ETeamVisibility, joinTeamSchema, TJoinTeamForm } from '@imphnen-frontend-service/service';
useTeams,
useJoinTeam,
useMyTeams,
ETeamVisibility,
joinTeamSchema,
TJoinTeamForm,
} from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { CitySelect } from '../../../components/city-select'; import INDONESIAN_CITIES_DATA from '../../../constants/cities';
import { Icon } from '@iconify/react';
const DEFAULT_PER_PAGE = 12; const INDONESIAN_CITIES = ['All Cities', ...INDONESIAN_CITIES_DATA];
const PER_PAGE_OPTIONS = [6, 12, 24, 48];
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
// Member filter options
const MEMBER_FILTER_OPTIONS = [
{ label: 'All Teams', value: '', minMembers: undefined, maxMembers: undefined },
{ label: 'Looking for Members (1-4)', value: 'looking', minMembers: 1, maxMembers: 4 },
{ label: '1 Member', value: '1', minMembers: 1, maxMembers: 1 },
{ label: '2 Members', value: '2', minMembers: 2, maxMembers: 2 },
{ label: '3 Members', value: '3', minMembers: 3, maxMembers: 3 },
{ label: '4 Members', value: '4', minMembers: 4, maxMembers: 4 },
{ label: '5 Members (Full)', value: '5', minMembers: 5, maxMembers: 5 },
];
// Submission status filter options
const SUBMISSION_FILTER_OPTIONS = [
{ label: 'All Teams', value: '' },
{ label: 'Submitted', value: 'true' },
{ label: 'Not Submitted', value: 'false' },
];
// Skeleton card component for loading state
const TeamCardSkeleton: FC = () => (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden flex flex-col border dark:border-gray-800 animate-pulse">
<div className="w-full aspect-3/1 bg-gray-200 dark:bg-gray-700" />
<div className="p-6 flex flex-col flex-1">
<div className="flex items-center space-x-3 mb-3">
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="flex-1">
<div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
</div>
<div className="space-y-2 mb-4 flex-1">
<div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 w-5/6 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 w-4/6 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
<div className="space-y-3 mt-auto">
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
</div>
</div>
</div>
);
const BrowseTeamsPage: FC = (): ReactElement => { const BrowseTeamsPage: FC = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [search, setSearch] = useState('');
const [selectedCity, setSelectedCity] = useState('All Cities');
// Check if team features are closed
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
// Initialize state from URL params
const initialPage = parseInt(searchParams.get('page') || '1', 10);
const initialPerPage = parseInt(
searchParams.get('per_page') || String(DEFAULT_PER_PAGE),
10
);
const initialSearch = searchParams.get('search') || '';
const initialCity = searchParams.get('city') || '';
const initialMembers = searchParams.get('members') || '';
const initialSubmission = searchParams.get('submission') || '';
const [searchTerm, setSearchTerm] = useState(initialSearch);
const [debouncedSearch, setDebouncedSearch] = useState(initialSearch);
const [selectedCity, setSelectedCity] = useState(initialCity);
const [selectedMembers, setSelectedMembers] = useState(initialMembers);
const [selectedSubmission, setSelectedSubmission] = useState(initialSubmission);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null); const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [showJoinModal, setShowJoinModal] = useState(false); const [showJoinModal, setShowJoinModal] = useState(false);
const [currentPage, setCurrentPage] = useState(
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPage : 1
);
const [perPage, setPerPage] = useState(
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPerPage : DEFAULT_PER_PAGE
);
// Update URL when pagination state changes const { data: teamsData, isLoading } = useTeams({
const updateUrlParams = useCallback( search,
(params: { city: selectedCity === 'All Cities' ? undefined : selectedCity,
page?: number;
per_page?: number;
search?: string;
city?: string;
members?: string;
submission?: string;
}) => {
const newParams = new URLSearchParams(searchParams);
if (params.page !== undefined) {
if (params.page === 1) {
newParams.delete('page');
} else {
newParams.set('page', String(params.page));
}
}
if (params.per_page !== undefined) {
if (params.per_page === DEFAULT_PER_PAGE) {
newParams.delete('per_page');
} else {
newParams.set('per_page', String(params.per_page));
}
}
if (params.search !== undefined) {
if (params.search === '') {
newParams.delete('search');
} else {
newParams.set('search', params.search);
}
}
if (params.city !== undefined) {
if (params.city === '') {
newParams.delete('city');
} else {
newParams.set('city', params.city);
}
}
if (params.members !== undefined) {
if (params.members === '') {
newParams.delete('members');
} else {
newParams.set('members', params.members);
}
}
if (params.submission !== undefined) {
if (params.submission === '') {
newParams.delete('submission');
} else {
newParams.set('submission', params.submission);
}
}
setSearchParams(newParams, { replace: true });
},
[searchParams, setSearchParams]
);
// Debounce search term and reset page
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchTerm);
if (searchTerm !== debouncedSearch) {
setCurrentPage(1);
updateUrlParams({ search: searchTerm, page: 1 });
}
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
// Update URL when city filter changes
useEffect(() => {
if (selectedCity !== initialCity) {
setCurrentPage(1);
updateUrlParams({ city: selectedCity, page: 1 });
}
}, [selectedCity]);
// Get member filter values
const memberFilter = MEMBER_FILTER_OPTIONS.find(
(opt) => opt.value === selectedMembers
);
// Convert submission filter value to boolean
const hasSubmissionFilter = selectedSubmission === 'true' ? true : selectedSubmission === 'false' ? false : undefined;
const {
data: teamsData,
isLoading,
isFetching,
} = useTeams({
page: currentPage,
limit: perPage,
search: debouncedSearch,
city: selectedCity || undefined,
visibility: ETeamVisibility.PUBLIC, visibility: ETeamVisibility.PUBLIC,
minMembers: memberFilter?.minMembers,
maxMembers: memberFilter?.maxMembers,
hasSubmission: hasSubmissionFilter,
}); });
const { data: myTeamsData } = useMyTeams(); const { data: myTeamsData } = useMyTeams();
@@ -211,9 +29,7 @@ const BrowseTeamsPage: FC = (): ReactElement => {
mode: 'all', mode: 'all',
}); });
const teams = teamsData?.teams || []; const teams = teamsData?.data || [];
const totalPages = teamsData?.totalPages || 1;
const total = teamsData?.total || 0;
const myTeams = myTeamsData?.data || []; const myTeams = myTeamsData?.data || [];
// Helper function to check if user is a member of a team // Helper function to check if user is a member of a team
@@ -239,58 +55,17 @@ const BrowseTeamsPage: FC = (): ReactElement => {
} }
}); });
// Generate page numbers to display
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisible = 5;
if (totalPages <= maxVisible + 2) {
// Show all pages if total is small
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
if (currentPage > 3) {
pages.push('...');
}
// Show pages around current
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push('...');
}
// Always show last page
pages.push(totalPages);
}
return pages;
};
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen bg-gray-50">
{/* Header */} {/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-800"> <div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"> <h1 className="text-3xl font-bold text-gray-900">Browse Teams</h1>
Browse Teams <p className="text-gray-600 mt-1">Find and join teams looking for members</p>
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Find and join teams looking for members
</p>
</div> </div>
<Link to="/dashboard" className="hidden md:block"> <Link to="/dashboard">
<Button variant="secondary">Back to Dashboard</Button> <Button variant="secondary">Back to Dashboard</Button>
</Link> </Link>
</div> </div>
@@ -299,66 +74,31 @@ const BrowseTeamsPage: FC = (): ReactElement => {
{/* Filters */} {/* Filters */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-sm mb-6 border dark:border-gray-800"> <div className="bg-white p-6 rounded-lg shadow-sm mb-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Search Teams Search Teams
</label> </label>
<input <Input
type="text" type="text"
placeholder="Search by team name..." placeholder="Search by team name..."
value={searchTerm} value={search}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Filter by City Filter by City
</label> </label>
<CitySelect <select
value={selectedCity} value={selectedCity}
onChange={setSelectedCity} onChange={(e) => setSelectedCity(e.target.value)}
placeholder="All Cities (search to filter...)" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Filter by Members
</label>
<select
value={selectedMembers}
onChange={(e) => {
setSelectedMembers(e.target.value);
setCurrentPage(1);
updateUrlParams({ members: e.target.value, page: 1 });
}}
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
> >
{MEMBER_FILTER_OPTIONS.map((option) => ( {INDONESIAN_CITIES.map((city) => (
<option key={option.value} value={option.value}> <option key={city} value={city}>
{option.label} {city}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Submission Status
</label>
<select
value={selectedSubmission}
onChange={(e) => {
setSelectedSubmission(e.target.value);
setCurrentPage(1);
updateUrlParams({ submission: e.target.value, page: 1 });
}}
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
{SUBMISSION_FILTER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option> </option>
))} ))}
</select> </select>
@@ -366,44 +106,28 @@ const BrowseTeamsPage: FC = (): ReactElement => {
</div> </div>
</div> </div>
{/* Teams Count */}
{!isLoading && total > 0 && (
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Showing {(currentPage - 1) * perPage + 1}-
{Math.min(currentPage * perPage, total)} of {total} teams
</div>
)}
{/* Teams List */} {/* Teams List */}
{isLoading || isFetching ? ( {isLoading ? (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3"> <div className="text-center py-12">
{Array.from({ length: perPage }).map((_, index) => ( <p className="text-gray-600">Loading teams...</p>
<TeamCardSkeleton key={index} />
))}
</div> </div>
) : teams.length === 0 ? ( ) : teams.length === 0 ? (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm p-12 text-center"> <div className="bg-white rounded-lg shadow-sm p-12 text-center">
<p className="text-gray-600 dark:text-gray-400 text-lg"> <p className="text-gray-600 text-lg">No teams found</p>
No teams found <p className="text-gray-500 mt-2">Try adjusting your filters</p>
</p>
<p className="text-gray-500 dark:text-gray-500 mt-2">
Try adjusting your filters
</p>
</div> </div>
) : ( ) : (
<> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3"> {teams.map((team) => (
{teams.map((team: any) => ( <div key={team.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
<div {team.banner && (
key={team.id}
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
>
<img <img
src={team.banner || '/images/banner-imphnen.webp'} src={team.banner}
alt={team.name} alt={team.name}
className="w-full aspect-3/1 object-cover" className="w-full h-32 object-cover"
/> />
<div className="p-6 flex flex-col flex-1"> )}
<div className="p-6">
<div className="flex items-center space-x-3 mb-3"> <div className="flex items-center space-x-3 mb-3">
{team.logo ? ( {team.logo ? (
<img <img
@@ -412,46 +136,18 @@ const BrowseTeamsPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" className="w-12 h-12 rounded-full object-cover"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"> <div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-300 text-xl"> <span className="text-gray-500 text-xl">👥</span>
<Icon icon="mdi:account-group" />
</span>
</div> </div>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1">
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-1 leading-tight"> <h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
{team.name} <p className="text-sm text-gray-600">📍 {team.city}</p>
</h3>
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex items-center gap-2 mt-1">
{team.has_submission && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
<Icon icon="mdi:check-circle" className="text-sm" />
Submitted
</span>
)}
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
<Icon icon="mdi:map-marker" />{' '}
<span>{team.city}</span>
</p>
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
<Icon icon="mdi:account-group" />{' '}
{team.member_count || 0} members
</p>
</div> </div>
</div> </div>
</div> <p className="text-gray-600 text-sm mb-4 line-clamp-3">
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans flex-1">
{team.description} {team.description}
</p> </p>
<div className="space-y-3 mt-auto">
{(team.member_count || 0) === 1 && !team.has_submission && (
<div className="p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p className="text-xs text-amber-700 dark:text-amber-400 flex items-center gap-1">
<Icon icon="mdi:alert" className="text-sm shrink-0" />
<span>This team needs at least 2 members to submit</span>
</p>
</div>
)}
{isMyTeam(team.id) ? ( {isMyTeam(team.id) ? (
<Button <Button
className="w-full" className="w-full"
@@ -461,11 +157,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
Your Team Your Team
</Button> </Button>
) : ( ) : (
<>
{myTeams.length === 0 &&
(team.member_count || 0) < 5 &&
!team.has_submission &&
!isTeamFeaturesClosed && (
<Button <Button
className="w-full" className="w-full"
onClick={() => handleJoinRequest(team.id)} onClick={() => handleJoinRequest(team.id)}
@@ -473,139 +164,36 @@ const BrowseTeamsPage: FC = (): ReactElement => {
Request to Join Request to Join
</Button> </Button>
)} )}
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
View Team
</Button>
</>
)}
</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
{/* Pagination */}
{(totalPages > 1 || total > 6) && (
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
{totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => {
const newPage = Math.max(1, currentPage - 1);
setCurrentPage(newPage);
updateUrlParams({ page: newPage });
}}
disabled={currentPage === 1 || isFetching}
className="px-3"
>
<Icon icon="mdi:chevron-left" className="text-xl" />
</Button>
<div className="flex items-center gap-1">
{getPageNumbers().map((page, index) =>
typeof page === 'string' ? (
<span
key={`ellipsis-${index}`}
className="px-2 text-gray-400 dark:text-gray-500"
>
...
</span>
) : (
<button
key={page}
onClick={() => {
setCurrentPage(page);
updateUrlParams({ page });
}}
disabled={isFetching}
className={`min-w-10 h-10 px-3 rounded-md text-sm font-medium transition-colors ${
currentPage === page
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600'
}`}
>
{page}
</button>
)
)}
</div>
<Button
variant="secondary"
onClick={() => {
const newPage = Math.min(totalPages, currentPage + 1);
setCurrentPage(newPage);
updateUrlParams({ page: newPage });
}}
disabled={currentPage === totalPages || isFetching}
className="px-3"
>
<Icon icon="mdi:chevron-right" className="text-xl" />
</Button>
</div>
)}
<div className="flex items-center gap-3">
{totalPages > 1 && (
<span className="text-sm text-gray-500 dark:text-gray-400">
Page {currentPage} of {totalPages}
</span>
)}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Show:</span>
<select
value={perPage}
onChange={(e) => {
const newPerPage = Number(e.target.value);
setPerPage(newPerPage);
setCurrentPage(1);
updateUrlParams({ per_page: newPerPage, page: 1 });
}}
className="h-10 px-3 pr-8 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{PER_PAGE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
</div>
</div>
)}
</>
)} )}
</div> </div>
{/* Join Request Modal */} {/* Join Request Modal */}
{showJoinModal && ( {showJoinModal && (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6"> <div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">
Request to Join Team Request to Join Team
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 font-sans"> <p className="text-gray-600 mb-6">
Send a message to the team leader explaining why you want to join Send a message to the team leader explaining why you want to join
</p> </p>
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Your Message Your Message
</label> </label>
<textarea <textarea
{...form.register('message')} {...form.register('message')}
rows={4} rows={4}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Tell the team leader why you want to join their team..." placeholder="Tell the team leader why you want to join their team..."
/> />
{form.formState.errors.message && ( {form.formState.errors.message && (
<p className="text-sm text-red-500 mt-1 font-sans"> <p className="text-sm text-red-500 mt-1">
{form.formState.errors.message.message} {form.formState.errors.message.message}
</p> </p>
)} )}
+45 -115
View File
@@ -5,22 +5,10 @@ import { useNavigate } from 'react-router';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { teamCreateSchema, TTeamCreateForm, useCreateTeam, ETeamVisibility, useUploadFile } from '@imphnen-frontend-service/service'; import { teamCreateSchema, TTeamCreateForm, useCreateTeam, ETeamVisibility, useUploadFile } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner'; import INDONESIAN_CITIES from '../../../constants/cities';
import { Icon } from '@iconify/react';
import { CitySelect } from '../../../components/city-select';
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
const CreateTeamPage: FC = (): ReactElement => { const CreateTeamPage: FC = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
// Check if team features are closed
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string>(''); const [logoPreview, setLogoPreview] = useState<string>('');
const [bannerFile, setBannerFile] = useState<File | null>(null); const [bannerFile, setBannerFile] = useState<File | null>(null);
@@ -42,11 +30,6 @@ const CreateTeamPage: FC = (): ReactElement => {
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
if (file.size > MAX_FILE_SIZE) {
toast.error('Logo image is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
setLogoFile(file); setLogoFile(file);
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
@@ -59,11 +42,6 @@ const CreateTeamPage: FC = (): ReactElement => {
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
if (file.size > MAX_FILE_SIZE) {
toast.error('Banner image is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
setBannerFile(file); setBannerFile(file);
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
@@ -94,91 +72,36 @@ const CreateTeamPage: FC = (): ReactElement => {
banner: bannerUrl, banner: bannerUrl,
}); });
toast.success('Team created successfully!');
navigate(`/teams/${result.data.id}`); navigate(`/teams/${result.data.id}`);
} catch (error: any) { } catch (error) {
console.error('Failed to create team:', error); console.error('Failed to create team:', error);
// Handle specific error messages
const message = error?.message || '';
if (message.includes('413') || message.includes('length limit') || message.includes('too large')) {
toast.error('Image file is too large. Please use smaller images (max 2MB each).');
} else if (message.includes('already a member')) {
toast.error(message);
} else {
toast.error(message || 'Failed to create team. Please try again.');
}
} }
}); });
// Show closed screen if team features are closed
if (isTeamFeaturesClosed) {
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4"> <div className="min-h-screen bg-gray-50">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
<Icon
icon="mdi:clock-alert"
className="text-3xl text-red-600 dark:text-red-400"
/>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Team Features Closed
</h2>
<p className="text-gray-600 dark:text-gray-400">
Team creation is no longer available.
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
The deadline for team features was November 30, 2025 at 23:59 WIB.
</p>
<button
onClick={() => navigate('/dashboard')}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
>
Back to Dashboard
</button>
<button
onClick={() => navigate('/teams/browse')}
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
>
Browse Teams
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
{/* Header */} {/* Header */}
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700"> <div className="bg-white border-b">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Create Your Team</h1> <h1 className="text-3xl font-bold text-gray-900">Create Your Team</h1>
<p className="text-gray-600 dark:text-neutral-400 mt-1">Build your hackathon dream team</p> <p className="text-gray-600 mt-1">Build your hackathon dream team</p>
</div> </div>
</div> </div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-900/50 p-8"> <div className="bg-white rounded-lg shadow-md p-8">
<form onSubmit={onSubmit} className="space-y-6"> <form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */} {/* Banner Upload */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Team Banner <span className="text-gray-400 dark:text-neutral-500">(Optional)</span> Team Banner <span className="text-gray-400">(Optional)</span>
</label> </label>
{bannerPreview ? ( {bannerPreview ? (
<div className="relative"> <div className="relative">
<img <img
src={bannerPreview} src={bannerPreview}
alt="Banner preview" alt="Banner preview"
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700" className="w-full h-48 object-cover rounded-lg border-2 border-gray-200"
/> />
<button <button
type="button" type="button"
@@ -192,10 +115,10 @@ const CreateTeamPage: FC = (): ReactElement => {
</button> </button>
</div> </div>
) : ( ) : (
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-neutral-800"> <label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<div className="text-center"> <div className="text-center">
<p className="text-gray-500 dark:text-neutral-400">Click to upload banner</p> <p className="text-gray-500">Click to upload banner</p>
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended. Max 2MB</p> <p className="text-xs text-gray-400 mt-1">1200x400 recommended</p>
</div> </div>
<input <input
type="file" type="file"
@@ -209,22 +132,21 @@ const CreateTeamPage: FC = (): ReactElement => {
{/* Logo Upload */} {/* Logo Upload */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Team Logo <span className="text-gray-400 dark:text-neutral-500">(Optional, but highly recommended)</span> Team Logo <span className="text-gray-400">(Optional, but highly recommended)</span>
</label> </label>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
{logoPreview ? ( {logoPreview ? (
<img <img
src={logoPreview} src={logoPreview}
alt="Logo preview" alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-neutral-700" className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
/> />
) : ( ) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center"> <div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 dark:text-neutral-500 text-3xl">👥</span> <span className="text-gray-400 text-3xl">👥</span>
</div> </div>
)} )}
<div>
<div> <div>
<label htmlFor="logo" className="cursor-pointer"> <label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block"> <span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
@@ -251,8 +173,6 @@ const CreateTeamPage: FC = (): ReactElement => {
</button> </button>
)} )}
</div> </div>
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-2">Max 2MB</p>
</div>
</div> </div>
</div> </div>
@@ -266,26 +186,36 @@ const CreateTeamPage: FC = (): ReactElement => {
{/* City */} {/* City */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-sm font-medium text-gray-700">
City <span className="text-red-500">*</span> City <span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
control={form.control} control={form.control}
name="city" name="city"
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<CitySelect <div>
value={field.value} <select
onChange={field.onChange} {...field}
error={fieldState.error?.message} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Search your city..." >
/> <option value="">Select city</option>
{INDONESIAN_CITIES.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)} )}
/> />
</div> </div>
{/* Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-sm font-medium text-gray-700">
Description <span className="text-red-500">*</span> Description <span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
@@ -309,7 +239,7 @@ const CreateTeamPage: FC = (): ReactElement => {
{/* Visibility */} {/* Visibility */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-sm font-medium text-gray-700">
Team Visibility <span className="text-red-500">*</span> Team Visibility <span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
@@ -317,7 +247,7 @@ const CreateTeamPage: FC = (): ReactElement => {
name="visibility" name="visibility"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-3"> <div className="space-y-3">
<label className="flex items-start space-x-3 cursor-pointer border dark:border-neutral-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800"> <label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input <input
type="radio" type="radio"
{...field} {...field}
@@ -326,13 +256,13 @@ const CreateTeamPage: FC = (): ReactElement => {
className="mt-1" className="mt-1"
/> />
<div> <div>
<p className="font-medium text-gray-900 dark:text-white">Public</p> <p className="font-medium text-gray-900">Public</p>
<p className="text-sm text-gray-600 dark:text-neutral-400"> <p className="text-sm text-gray-600">
Team will be visible in Browse Teams. Anyone can request to join. Team will be visible in Browse Teams. Anyone can request to join.
</p> </p>
</div> </div>
</label> </label>
<label className="flex items-start space-x-3 cursor-pointer border dark:border-neutral-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800"> <label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input <input
type="radio" type="radio"
{...field} {...field}
@@ -341,8 +271,8 @@ const CreateTeamPage: FC = (): ReactElement => {
className="mt-1" className="mt-1"
/> />
<div> <div>
<p className="font-medium text-gray-900 dark:text-white">Private</p> <p className="font-medium text-gray-900">Private</p>
<p className="text-sm text-gray-600 dark:text-neutral-400"> <p className="text-sm text-gray-600">
Team is hidden from Browse Teams. Members can only join via invitation. Team is hidden from Browse Teams. Members can only join via invitation.
</p> </p>
</div> </div>
@@ -353,8 +283,8 @@ const CreateTeamPage: FC = (): ReactElement => {
</div> </div>
{/* Warning */} {/* Warning */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200"> <p className="text-sm text-yellow-800">
<strong>Note:</strong> As team leader, you cannot leave or join another team after creating this team. <strong>Note:</strong> As team leader, you cannot leave or join another team after creating this team.
</p> </p>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More