chore: upgrade dependencies, restructure shared libs, and fix UI
- Upgrade Nx 22.1.1 → 22.6.3 and all patch/minor dependencies - Restructure shared libs: move business logic from utils to service - Consolidate shadcn-ui into ui lib with atomic design pattern - Fix container centering for landing app (Tailwind v4 compatibility) - Fix button styling by updating @source directive in globals.css - Fix SiCss3 → SiCss rename in react-icons 5.6 - Fix duplicate useSession export conflict - Remove dead code, comments, and unused files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f68d97188c
commit
3f4461c65c
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(npx nx run-many:*)",
|
|
||||||
"Bash(npm install:*)",
|
|
||||||
"Bash(npm view:*)",
|
|
||||||
"Bash(npx nx build landing)",
|
|
||||||
"Bash(npm uninstall:*)",
|
|
||||||
"Bash(dir:*)",
|
|
||||||
"Bash(ren page.tsx page-original.tsx)",
|
|
||||||
"Bash(ren:*)",
|
|
||||||
"Bash(npx supabase:*)",
|
|
||||||
"Bash(libs/service/src/types/supabase.ts)",
|
|
||||||
"Bash(npx nx build:*)",
|
|
||||||
"Bash(git add:*)",
|
|
||||||
"Bash(git commit:*)",
|
|
||||||
"Bash(git push)",
|
|
||||||
"Bash(findstr:*)",
|
|
||||||
"Bash(ls:*)"
|
|
||||||
],
|
|
||||||
"deny": [],
|
|
||||||
"ask": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -70,3 +70,6 @@ out
|
|||||||
# Nix
|
# Nix
|
||||||
.direnv
|
.direnv
|
||||||
result
|
result
|
||||||
|
|
||||||
|
.claude/worktrees
|
||||||
|
.claude/settings.local.json
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"nx-mcp": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["nx", "mcp"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
-8
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"nrwl.angular-console",
|
|
||||||
"esbenp.prettier-vscode",
|
|
||||||
"dbaeumer.vscode-eslint",
|
|
||||||
"ms-playwright.playwright"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!-- nx configuration start-->
|
|
||||||
<!-- Leave the start & end comments to automatically receive updates. -->
|
|
||||||
|
|
||||||
# General Guidelines for working with Nx
|
|
||||||
|
|
||||||
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
|
|
||||||
- You have access to the Nx MCP server and its tools, use them to help the user
|
|
||||||
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
|
|
||||||
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
|
|
||||||
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
|
|
||||||
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
|
|
||||||
|
|
||||||
<!-- nx configuration end-->
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Tue, Nov 25, 2025 4:21:27 PM
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# Deployment trigger
|
|
||||||
# Updated to deploy circular dependency fixes and auth hooks
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ interface Account {
|
|||||||
address: string;
|
address: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock data for demonstration
|
|
||||||
const mockData: Account[] = Array.from({ length: 90 }, (_, i) => ({
|
const mockData: Account[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||||
@@ -136,13 +135,10 @@ export const Components: FC = (): ReactElement => {
|
|||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
<h1 className="text-p2 font-semibold">Data Akun</h1>
|
<h1 className="text-p2 font-semibold">Data Akun</h1>
|
||||||
</header>
|
</header>
|
||||||
{/* Account Table Section */}
|
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
{/* Search and Filter */}
|
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="flex justify-between items-center gap-8 mb-2">
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Input
|
<Input
|
||||||
@@ -171,12 +167,10 @@ export const Components: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Table */}
|
|
||||||
<DataTable data={mockData} columns={columns} table={table} />
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Modal Edit Account */}
|
|
||||||
<ModalEditAccount
|
<ModalEditAccount
|
||||||
currentStep={currentStep}
|
currentStep={currentStep}
|
||||||
isOpen={showModalEditAccount}
|
isOpen={showModalEditAccount}
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
// const result = await actionFunction?.();
|
|
||||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ export const Components: FC = (): ReactElement => {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right-side illustration */}
|
|
||||||
<img
|
<img
|
||||||
src="gacha.webp"
|
src="gacha.webp"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -156,7 +155,6 @@ export const Components: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Modal Add Item */}
|
|
||||||
<ModalAddItem
|
<ModalAddItem
|
||||||
currentStep={currentStep}
|
currentStep={currentStep}
|
||||||
isOpen={showModalAddItem}
|
isOpen={showModalAddItem}
|
||||||
@@ -166,7 +164,6 @@ export const Components: FC = (): ReactElement => {
|
|||||||
resetStep={resetStep}
|
resetStep={resetStep}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal Edit Item */}
|
|
||||||
<ModalEditItem
|
<ModalEditItem
|
||||||
currentStep={currentStep}
|
currentStep={currentStep}
|
||||||
isOpen={showModalEditItem}
|
isOpen={showModalEditItem}
|
||||||
@@ -176,7 +173,6 @@ export const Components: FC = (): ReactElement => {
|
|||||||
resetStep={resetStep}
|
resetStep={resetStep}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal Delete Item */}
|
|
||||||
<ModalDeleteItem
|
<ModalDeleteItem
|
||||||
isOpen={showModalDeleteItem}
|
isOpen={showModalDeleteItem}
|
||||||
onClose={() => setShowModalDeleteItem(false)}
|
onClose={() => setShowModalDeleteItem(false)}
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
// const result = await actionFunction?.();
|
|
||||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
|
|||||||
@@ -8,19 +8,16 @@ import {
|
|||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
export const HackathonDashboardPage: FC = (): ReactElement => {
|
export const HackathonDashboardPage: FC = (): ReactElement => {
|
||||||
// Fetch total participants
|
|
||||||
const { data: usersData } = useQuery({
|
const { data: usersData } = useQuery({
|
||||||
queryKey: ['admin-users-count'],
|
queryKey: ['admin-users-count'],
|
||||||
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch total teams
|
|
||||||
const { data: teamsData } = useQuery({
|
const { data: teamsData } = useQuery({
|
||||||
queryKey: ['admin-teams-count'],
|
queryKey: ['admin-teams-count'],
|
||||||
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch total submissions
|
|
||||||
const { data: submissionsData } = useQuery({
|
const { data: submissionsData } = useQuery({
|
||||||
queryKey: ['admin-submissions-count'],
|
queryKey: ['admin-submissions-count'],
|
||||||
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
|
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
|
||||||
@@ -35,21 +32,18 @@ export const HackathonDashboardPage: FC = (): ReactElement => {
|
|||||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
||||||
|
|
||||||
<section className="grid grid-cols-5 gap-5">
|
<section className="grid grid-cols-5 gap-5">
|
||||||
{/* Participant */}
|
|
||||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||||
{totalParticipants}
|
{totalParticipants}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-neutral-400 text-p3">Total Participants</p>
|
<p className="text-neutral-400 text-p3">Total Participants</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Team */}
|
|
||||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||||
{totalTeams}
|
{totalTeams}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-neutral-400 text-p3">Total Teams</p>
|
<p className="text-neutral-400 text-p3">Total Teams</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Project Submitted */}
|
|
||||||
<div className="bg-white px-6 py-4 rounded-md shadow">
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||||
{totalSubmissions}
|
{totalSubmissions}
|
||||||
|
|||||||
+3
-19
@@ -40,7 +40,7 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
|
||||||
@@ -68,9 +68,8 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Submission Status */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 p-4 border rounded-lg',
|
'flex items-center gap-3 p-4 border rounded-lg',
|
||||||
@@ -96,7 +95,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project Description */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-neutral-700 mb-2">
|
<h3 className="text-sm font-medium text-neutral-700 mb-2">
|
||||||
Project Description
|
Project Description
|
||||||
@@ -106,13 +104,11 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project Links */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium text-neutral-700">
|
<h3 className="text-sm font-medium text-neutral-700">
|
||||||
Project Links
|
Project Links
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Repository URL */}
|
|
||||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
<LinkOutlined className="text-primary-500 mt-1" />
|
<LinkOutlined className="text-primary-500 mt-1" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -130,7 +126,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Demo URL */}
|
|
||||||
{submission.demo_url && (
|
{submission.demo_url && (
|
||||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
<LinkOutlined className="text-primary-500 mt-1" />
|
<LinkOutlined className="text-primary-500 mt-1" />
|
||||||
@@ -150,7 +145,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Presentation URL */}
|
|
||||||
{submission.presentation_url && (
|
{submission.presentation_url && (
|
||||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||||
<LinkOutlined className="text-primary-500 mt-1" />
|
<LinkOutlined className="text-primary-500 mt-1" />
|
||||||
@@ -171,7 +165,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Screenshots */}
|
|
||||||
{submission.screenshots && submission.screenshots.length > 0 && (
|
{submission.screenshots && submission.screenshots.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
|
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
|
||||||
@@ -198,7 +191,6 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-neutral-500 mb-1">Created</p>
|
<p className="text-xs text-neutral-500 mb-1">Created</p>
|
||||||
@@ -227,19 +219,11 @@ const SubmissionModal: FC<SubmissionModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
|
||||||
<Button variant="secondary" onClick={onClose}>
|
<Button variant="secondary" onClick={onClose}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
{/* <Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => {
|
|
||||||
console.log('Edit submission:', submission.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit Status
|
|
||||||
</Button> */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
|||||||
useState<SubmissionType | null>(null);
|
useState<SubmissionType | null>(null);
|
||||||
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||||
|
|
||||||
// Fetch submissions from API
|
|
||||||
const {
|
const {
|
||||||
data: submissionsResponse,
|
data: submissionsResponse,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -64,14 +63,13 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
|||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
}),
|
}),
|
||||||
staleTime: 30000, // 30 seconds cache
|
staleTime: 30000,
|
||||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
gcTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalData = submissionsResponse?.meta?.total_data || 0;
|
const totalData = submissionsResponse?.meta?.total_data || 0;
|
||||||
const totalPages = submissionsResponse?.meta?.total_page || 1;
|
const totalPages = submissionsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
// Handle page change
|
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = useCallback(
|
||||||
(newPage: number) => {
|
(newPage: number) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -85,19 +83,16 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
|||||||
[setSearchParams, perPage, searchQuery, statusFilter]
|
[setSearchParams, perPage, searchQuery, statusFilter]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate page number
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
setSearchParams({ page: totalPages.toString() });
|
setSearchParams({ page: totalPages.toString() });
|
||||||
}
|
}
|
||||||
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||||
|
|
||||||
// Sync globalFilter with URL
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGlobalFilter(searchQuery);
|
setGlobalFilter(searchQuery);
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
// Handle search
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('page', '1');
|
params.set('page', '1');
|
||||||
@@ -118,7 +113,6 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
|||||||
[handleSearch]
|
[handleSearch]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle per page change
|
|
||||||
const handlePerPageChange = useCallback(
|
const handlePerPageChange = useCallback(
|
||||||
(newPerPage: number) => {
|
(newPerPage: number) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -131,20 +125,6 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
|||||||
[setSearchParams, searchQuery, statusFilter]
|
[setSearchParams, searchQuery, statusFilter]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle status filter change
|
|
||||||
// const handleStatusFilterChange = useCallback(
|
|
||||||
// (newStatus: string) => {
|
|
||||||
// const params = new URLSearchParams();
|
|
||||||
// params.set('page', '1');
|
|
||||||
// if (perPage !== 10) params.set('per_page', perPage.toString());
|
|
||||||
// if (searchQuery) params.set('search', searchQuery);
|
|
||||||
// if (newStatus !== 'all') params.set('status', newStatus);
|
|
||||||
// setSearchParams(params);
|
|
||||||
// },
|
|
||||||
// [setSearchParams, perPage, searchQuery]
|
|
||||||
// );
|
|
||||||
|
|
||||||
// Handle modal
|
|
||||||
const handleShowSubmissionModal = useCallback(
|
const handleShowSubmissionModal = useCallback(
|
||||||
(submission: SubmissionType) => {
|
(submission: SubmissionType) => {
|
||||||
setSelectedSubmission(submission);
|
setSelectedSubmission(submission);
|
||||||
@@ -158,12 +138,10 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
|||||||
setSelectedSubmission(null);
|
setSelectedSubmission(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Get submissions data
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
return submissionsResponse?.data || [];
|
return submissionsResponse?.data || [];
|
||||||
}, [submissionsResponse]);
|
}, [submissionsResponse]);
|
||||||
|
|
||||||
// Memoize columns
|
|
||||||
const columns: ColumnDef<SubmissionType>[] = useMemo(
|
const columns: ColumnDef<SubmissionType>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -252,7 +230,6 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
|||||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
<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">
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
{/* Search bar */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
<input
|
<input
|
||||||
@@ -265,7 +242,6 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Per Page Dropdown */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
@@ -281,52 +257,16 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => handleStatusFilterChange(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="all">All Status</option>
|
|
||||||
<option value="submitted">Submitted</option>
|
|
||||||
<option value="pending">Pending</option>
|
|
||||||
<option value="approved">Approved</option>
|
|
||||||
<option value="rejected">Rejected</option>
|
|
||||||
</select>
|
|
||||||
</div> */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active filters */}
|
{}
|
||||||
{/* {statusFilter !== 'all' && (
|
{
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
}
|
||||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
|
||||||
<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={() => handleStatusFilterChange('all')}
|
|
||||||
className="text-info-600 hover:text-info-800 cursor-pointer"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
handleStatusFilterChange('all');
|
|
||||||
setGlobalFilter('');
|
|
||||||
}}
|
|
||||||
className="text-sm text-neutral-600"
|
|
||||||
>
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
{/* Loading & results */}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||||
@@ -360,7 +300,6 @@ export const HackathonSubmissionsPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Submission Modal */}
|
|
||||||
{selectedSubmission && (
|
{selectedSubmission && (
|
||||||
<SubmissionModal
|
<SubmissionModal
|
||||||
isOpen={showSubmissionModal}
|
isOpen={showSubmissionModal}
|
||||||
|
|||||||
-13
@@ -33,7 +33,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Initialize form data when modal opens
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (team) {
|
if (team) {
|
||||||
@@ -55,7 +54,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
}
|
}
|
||||||
}, [isOpen, team]);
|
}, [isOpen, team]);
|
||||||
|
|
||||||
// Check if form has changes
|
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
if (!formData || !team) return !!formData;
|
if (!formData || !team) return !!formData;
|
||||||
return (
|
return (
|
||||||
@@ -68,7 +66,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
);
|
);
|
||||||
}, [formData, team]);
|
}, [formData, team]);
|
||||||
|
|
||||||
// Check if required fields are filled
|
|
||||||
const isFormValid = useMemo(() => {
|
const isFormValid = useMemo(() => {
|
||||||
if (!formData) return false;
|
if (!formData) return false;
|
||||||
return (
|
return (
|
||||||
@@ -144,7 +141,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
@@ -172,7 +168,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
|
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -189,7 +184,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Banner Section */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-neutral-700">
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
Team Banner{' '}
|
Team Banner{' '}
|
||||||
@@ -234,7 +228,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logo & Name */}
|
|
||||||
<div className="grid grid-cols-12 gap-4 items-start">
|
<div className="grid grid-cols-12 gap-4 items-start">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
@@ -305,7 +298,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-neutral-700">
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
Description <span className="text-danger-500">*</span>
|
Description <span className="text-danger-500">*</span>
|
||||||
@@ -319,7 +311,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* City */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-neutral-700">
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
City <span className="text-danger-500">*</span>
|
City <span className="text-danger-500">*</span>
|
||||||
@@ -336,7 +327,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Visibility */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-neutral-700">
|
<label className="block text-sm font-medium text-neutral-700">
|
||||||
Team Visibility
|
Team Visibility
|
||||||
@@ -373,7 +363,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team Details */}
|
|
||||||
{team && (
|
{team && (
|
||||||
<div className="space-y-4 border-t border-neutral-200 pt-4">
|
<div className="space-y-4 border-t border-neutral-200 pt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -429,7 +418,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
|
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
|
||||||
<div>
|
<div>
|
||||||
{team && (
|
{team && (
|
||||||
@@ -463,7 +451,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
|
||||||
{showDeleteConfirm && (
|
{showDeleteConfirm && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||||
|
|||||||
+2
-5
@@ -15,7 +15,7 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
|||||||
className = '',
|
className = '',
|
||||||
showPlaceholder = true,
|
showPlaceholder = true,
|
||||||
}) => {
|
}) => {
|
||||||
const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
|
const aspectRatioClass = 'aspect-[3/1]';
|
||||||
|
|
||||||
if (!banner && !showPlaceholder) {
|
if (!banner && !showPlaceholder) {
|
||||||
return null;
|
return null;
|
||||||
@@ -35,7 +35,6 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
|||||||
alt={`${teamName} banner`}
|
alt={`${teamName} banner`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// Fallback to placeholder if image fails to load
|
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
target.style.display = 'none';
|
target.style.display = 'none';
|
||||||
const placeholder = target.nextElementSibling as HTMLElement;
|
const placeholder = target.nextElementSibling as HTMLElement;
|
||||||
@@ -44,11 +43,10 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Fallback placeholder (hidden by default, shown on image error) */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
||||||
'hidden' // Hidden by default
|
'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -61,7 +59,6 @@ const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No banner - show placeholder
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -46,11 +46,9 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
useState<TeamType | null>(null);
|
useState<TeamType | null>(null);
|
||||||
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||||
|
|
||||||
// Advanced filtering states
|
|
||||||
const [visibilityFilter, setVisibilityFilter] = useState('all');
|
const [visibilityFilter, setVisibilityFilter] = useState('all');
|
||||||
const [cityFilter, setCityFilter] = useState('all');
|
const [cityFilter, setCityFilter] = useState('all');
|
||||||
|
|
||||||
// Fetch teams from API
|
|
||||||
const {
|
const {
|
||||||
data: teamsResponse,
|
data: teamsResponse,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -70,14 +68,13 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
per_page: perPage,
|
per_page: perPage,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
}),
|
}),
|
||||||
staleTime: 30000, // 30 seconds cache
|
staleTime: 30000,
|
||||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
gcTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalData = teamsResponse?.meta?.total_data || 0;
|
const totalData = teamsResponse?.meta?.total_data || 0;
|
||||||
const totalPages = teamsResponse?.meta?.total_page || 1;
|
const totalPages = teamsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
// Handle page change - update URL query params
|
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = useCallback(
|
||||||
(newPage: number) => {
|
(newPage: number) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -90,19 +87,16 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
[setSearchParams, perPage, searchQuery]
|
[setSearchParams, perPage, searchQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate page number doesn't exceed total pages
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
setSearchParams({ page: totalPages.toString() });
|
setSearchParams({ page: totalPages.toString() });
|
||||||
}
|
}
|
||||||
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||||
|
|
||||||
// Sync globalFilter with URL search param on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGlobalFilter(searchQuery);
|
setGlobalFilter(searchQuery);
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
// Handle search teams
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('page', '1');
|
params.set('page', '1');
|
||||||
@@ -113,7 +107,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
setSearchParams(params);
|
setSearchParams(params);
|
||||||
}, [globalFilter, setSearchParams, perPage]);
|
}, [globalFilter, setSearchParams, perPage]);
|
||||||
|
|
||||||
// Handle Enter key press in search input
|
|
||||||
const handleSearchKeyPress = useCallback(
|
const handleSearchKeyPress = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -123,7 +116,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
[handleSearch]
|
[handleSearch]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle per page change
|
|
||||||
const handlePerPageChange = useCallback(
|
const handlePerPageChange = useCallback(
|
||||||
(newPerPage: number) => {
|
(newPerPage: number) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -135,7 +127,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
[setSearchParams, searchQuery]
|
[setSearchParams, searchQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize the callback to prevent recreation
|
|
||||||
const handleShowDetailModal = useCallback((team: TeamType) => {
|
const handleShowDetailModal = useCallback((team: TeamType) => {
|
||||||
setSelectedTeam(team);
|
setSelectedTeam(team);
|
||||||
setShowDetailModal(true);
|
setShowDetailModal(true);
|
||||||
@@ -154,12 +145,10 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
setShowNewTeamModal(false);
|
setShowNewTeamModal(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Get teams data from API response
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
return teamsResponse?.data || [];
|
return teamsResponse?.data || [];
|
||||||
}, [teamsResponse]);
|
}, [teamsResponse]);
|
||||||
|
|
||||||
// Memoize columns to prevent recreation on every render
|
|
||||||
const columns: ColumnDef<TeamType>[] = useMemo(
|
const columns: ColumnDef<TeamType>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -169,7 +158,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
const team = row.original;
|
const team = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Team Logo */}
|
|
||||||
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
|
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
{team.logo ? (
|
{team.logo ? (
|
||||||
<img
|
<img
|
||||||
@@ -181,7 +169,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
<TeamOutlined className="text-neutral-400 text-lg" />
|
<TeamOutlined className="text-neutral-400 text-lg" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Team Name */}
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p
|
<p
|
||||||
className="font-medium text-neutral-900 truncate max-w-sm"
|
className="font-medium text-neutral-900 truncate max-w-sm"
|
||||||
@@ -276,12 +263,9 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
Team Management
|
Team Management
|
||||||
</h1>
|
</h1>
|
||||||
{/* Filters and actions */}
|
|
||||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
<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">
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
{/* Left side - Search & filters */}
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
{/* Search bar */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
<input
|
<input
|
||||||
@@ -294,7 +278,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Per Page Dropdown */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
@@ -310,31 +293,15 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Visibility 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-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
|
||||||
value={visibilityFilter}
|
|
||||||
onChange={(e) => setVisibilityFilter(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="all">All Visibility</option>
|
|
||||||
<option value="public">Public</option>
|
|
||||||
<option value="private">Private</option>
|
|
||||||
</select>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
{/* City Filter */}
|
{}
|
||||||
{/* <CityFilterSelect
|
{
|
||||||
value={cityFilter}
|
}
|
||||||
onChange={setCityFilter}
|
|
||||||
className="w-full sm:w-44"
|
|
||||||
placeholder="Search cities..."
|
|
||||||
allOptionLabel="All Cities"
|
|
||||||
/> */}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Add Team Button */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -348,12 +315,10 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active filters display */}
|
|
||||||
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
|
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
|
||||||
{/* Visibility filter badge */}
|
|
||||||
{visibilityFilter !== 'all' && (
|
{visibilityFilter !== 'all' && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||||
Visibility: {visibilityFilter}
|
Visibility: {visibilityFilter}
|
||||||
@@ -366,7 +331,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* City filter badge */}
|
|
||||||
{cityFilter !== 'all' && (
|
{cityFilter !== 'all' && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
City: {cityFilter}
|
City: {cityFilter}
|
||||||
@@ -379,7 +343,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Clear all filters */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -395,7 +358,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading & results display */}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||||
@@ -427,18 +389,16 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Modals component */}
|
|
||||||
<ModalTeamDetail
|
<ModalTeamDetail
|
||||||
isOpen={showDetailModal}
|
isOpen={showDetailModal}
|
||||||
onClose={handleCloseDetailModal}
|
onClose={handleCloseDetailModal}
|
||||||
team={selectedTeam}
|
team={selectedTeam}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* New Team Modal */}
|
|
||||||
<ModalTeamDetail
|
<ModalTeamDetail
|
||||||
isOpen={showNewTeamModal}
|
isOpen={showNewTeamModal}
|
||||||
onClose={handleCloseNewTeamModal}
|
onClose={handleCloseNewTeamModal}
|
||||||
team={null} // null indicates creating new team
|
team={null}
|
||||||
/>
|
/>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
+4
-33
@@ -37,16 +37,13 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Initialize form data when modal opens
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (user) {
|
if (user) {
|
||||||
// Edit existing user
|
|
||||||
setFormData({ ...user });
|
setFormData({ ...user });
|
||||||
} else {
|
} else {
|
||||||
// Create new user
|
|
||||||
setFormData({
|
setFormData({
|
||||||
id: '', // Will be generated by backend
|
id: '',
|
||||||
fullname: '',
|
fullname: '',
|
||||||
bio: '',
|
bio: '',
|
||||||
location: '',
|
location: '',
|
||||||
@@ -60,10 +57,9 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
}
|
}
|
||||||
}, [isOpen, user]);
|
}, [isOpen, user]);
|
||||||
|
|
||||||
// Check if form has changes
|
|
||||||
const hasChanges = useMemo(() => {
|
const hasChanges = useMemo(() => {
|
||||||
if (!formData) return false;
|
if (!formData) return false;
|
||||||
if (!user) return true; // New user always has changes
|
if (!user) return true;
|
||||||
return (
|
return (
|
||||||
formData.fullname !== user.fullname ||
|
formData.fullname !== user.fullname ||
|
||||||
formData.location !== user.location ||
|
formData.location !== user.location ||
|
||||||
@@ -74,7 +70,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
);
|
);
|
||||||
}, [formData, user]);
|
}, [formData, user]);
|
||||||
|
|
||||||
// Check if required fields are filled
|
|
||||||
const isFormValid = useMemo(() => {
|
const isFormValid = useMemo(() => {
|
||||||
if (!formData) return false;
|
if (!formData) return false;
|
||||||
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
|
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
|
||||||
@@ -99,25 +94,22 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
if (!formData) return;
|
if (!formData) return;
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
// Update existing user
|
|
||||||
console.log('Update user data:', formData);
|
console.log('Update user data:', formData);
|
||||||
} else {
|
} else {
|
||||||
// Create new user
|
|
||||||
console.log('Create new user:', formData);
|
console.log('Create new user:', formData);
|
||||||
}
|
}
|
||||||
// Here you would typically make an API call to save the data
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setFormData({ ...user }); // Reset to original for edit mode
|
setFormData({ ...user });
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAccount = () => {
|
const handleDeleteAccount = () => {
|
||||||
if (!user) return; // Can't delete new user
|
if (!user) return;
|
||||||
console.log('Delete user:', user.id);
|
console.log('Delete user:', user.id);
|
||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -126,19 +118,16 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
// Validate file type
|
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
alert('Please select an image file');
|
alert('Please select an image file');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file size (max 5MB)
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
alert('Image size must be less than 5MB');
|
alert('Image size must be less than 5MB');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create preview URL
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
const avatarUrl = e.target?.result as string;
|
const avatarUrl = e.target?.result as string;
|
||||||
@@ -183,7 +172,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Hidden File Input */}
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -191,10 +179,8 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
{/* Header */}
|
|
||||||
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Interactive User Avatar */}
|
|
||||||
<div className="relative group ">
|
<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">
|
<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 ? (
|
{formData.avatar ? (
|
||||||
@@ -208,7 +194,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Avatar Hover Overlay */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
|
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"
|
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||||
@@ -216,7 +201,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
<CameraOutlined className="text-white text-lg" />
|
<CameraOutlined className="text-white text-lg" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Avatar Menu Dropdown */}
|
|
||||||
{showAvatarMenu && (
|
{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">
|
<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
|
<button
|
||||||
@@ -269,17 +253,14 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
{/* Left Column - Basic Info */}
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
Basic Information
|
Basic Information
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Full Name - Required */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<UserOutlined className="text-neutral-400" />
|
<UserOutlined className="text-neutral-400" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -310,7 +291,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Location - Required */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<EnvironmentOutlined className="text-neutral-400" />
|
<EnvironmentOutlined className="text-neutral-400" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -346,7 +326,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Joined Date - Read Only - Only show for existing users */}
|
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CalendarOutlined className="text-neutral-400" />
|
<CalendarOutlined className="text-neutral-400" />
|
||||||
@@ -370,7 +349,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bio Section - Optional */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
||||||
Bio{' '}
|
Bio{' '}
|
||||||
@@ -390,9 +368,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Skills & Status */}
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Account Status - Enhanced Tab Design */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
Account Status
|
Account Status
|
||||||
@@ -448,7 +424,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skills Section - Optional */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
Skills & Expertise{' '}
|
Skills & Expertise{' '}
|
||||||
@@ -510,7 +485,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Account Details - Read Only - Only show for existing users */}
|
|
||||||
{user && (
|
{user && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
@@ -547,7 +521,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Actions */}
|
|
||||||
<div className="border-t border-neutral-200 px-8 py-6">
|
<div className="border-t border-neutral-200 px-8 py-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -558,7 +531,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
? 'Please fill required fields'
|
? 'Please fill required fields'
|
||||||
: 'No changes made'}
|
: 'No changes made'}
|
||||||
</div>
|
</div>
|
||||||
{/* Delete Account Button - Only show for existing users */}
|
|
||||||
{user && (
|
{user && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
@@ -595,7 +567,6 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
|
||||||
{showDeleteConfirm && (
|
{showDeleteConfirm && (
|
||||||
<div className="fixed inset-0 z-60">
|
<div className="fixed inset-0 z-60">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
|
|
||||||
type UserType = TAdminUserItem;
|
type UserType = TAdminUserItem;
|
||||||
|
|
||||||
// Skills options for filter
|
|
||||||
const skillsOptions = [
|
const skillsOptions = [
|
||||||
'Frontend Developer',
|
'Frontend Developer',
|
||||||
'Backend Developer',
|
'Backend Developer',
|
||||||
@@ -57,12 +56,10 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||||
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||||
|
|
||||||
// Advanced filtering states
|
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
const [cityFilter, setCityFilter] = useState('all');
|
const [cityFilter, setCityFilter] = useState('all');
|
||||||
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||||
|
|
||||||
// Fetch users from API
|
|
||||||
const {
|
const {
|
||||||
data: usersResponse,
|
data: usersResponse,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -82,14 +79,13 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
per_page: perPage,
|
per_page: perPage,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
}),
|
}),
|
||||||
staleTime: 30000, // 30 seconds cache
|
staleTime: 30000,
|
||||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
gcTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalData = usersResponse?.meta?.total_data || 0;
|
const totalData = usersResponse?.meta?.total_data || 0;
|
||||||
const totalPages = usersResponse?.meta?.total_page || 1;
|
const totalPages = usersResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
// Handle page change - update URL query params
|
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = useCallback(
|
||||||
(newPage: number) => {
|
(newPage: number) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -102,19 +98,16 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
[setSearchParams, perPage, searchQuery]
|
[setSearchParams, perPage, searchQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate page number doesn't exceed total pages
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
setSearchParams({ page: totalPages.toString() });
|
setSearchParams({ page: totalPages.toString() });
|
||||||
}
|
}
|
||||||
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||||
|
|
||||||
// Sync globalFilter with URL search param on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGlobalFilter(searchQuery);
|
setGlobalFilter(searchQuery);
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
// Handle search users
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('page', '1');
|
params.set('page', '1');
|
||||||
@@ -125,7 +118,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
setSearchParams(params);
|
setSearchParams(params);
|
||||||
}, [globalFilter, setSearchParams, perPage]);
|
}, [globalFilter, setSearchParams, perPage]);
|
||||||
|
|
||||||
// Handle Enter key press in search input
|
|
||||||
const handleSearchKeyPress = useCallback(
|
const handleSearchKeyPress = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -135,7 +127,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
[handleSearch]
|
[handleSearch]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle per page change
|
|
||||||
const handlePerPageChange = useCallback(
|
const handlePerPageChange = useCallback(
|
||||||
(newPerPage: number) => {
|
(newPerPage: number) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -147,7 +138,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
[setSearchParams, searchQuery]
|
[setSearchParams, searchQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize the callback to prevent recreation
|
|
||||||
const handleShowDetailModal = useCallback((user: UserType) => {
|
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||||
setSelectedUser(user);
|
setSelectedUser(user);
|
||||||
setShowDetailModal(true);
|
setShowDetailModal(true);
|
||||||
@@ -166,17 +156,14 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
setShowNewUserModal(false);
|
setShowNewUserModal(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Filter data based on current filter states
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const usersData = usersResponse?.data || [];
|
const usersData = usersResponse?.data || [];
|
||||||
return usersData.filter((user: UserType) => {
|
return usersData.filter((user: UserType) => {
|
||||||
// Status filter
|
|
||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
const isActive = statusFilter === 'active';
|
const isActive = statusFilter === 'active';
|
||||||
if (user.is_active !== isActive) return false;
|
if (user.is_active !== isActive) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skills filter
|
|
||||||
if (skillsFilter.length > 0) {
|
if (skillsFilter.length > 0) {
|
||||||
const userSkills = user.skills || [];
|
const userSkills = user.skills || [];
|
||||||
const hasMatchingSkill = skillsFilter.some((skill) =>
|
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||||
@@ -189,7 +176,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
});
|
});
|
||||||
}, [usersResponse, statusFilter, skillsFilter]);
|
}, [usersResponse, statusFilter, skillsFilter]);
|
||||||
|
|
||||||
// Memoize columns to prevent recreation on every render
|
|
||||||
const columns: ColumnDef<UserType>[] = useMemo(
|
const columns: ColumnDef<UserType>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -197,7 +183,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
header: 'User',
|
header: 'User',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||||
{row.original.avatar ? (
|
{row.original.avatar ? (
|
||||||
<img
|
<img
|
||||||
@@ -209,7 +194,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
<UserOutlined className="text-neutral-500 text-lg" />
|
<UserOutlined className="text-neutral-500 text-lg" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Name only */}
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium text-neutral-900 truncate">
|
<p className="font-medium text-neutral-900 truncate">
|
||||||
{row.original.fullname}
|
{row.original.fullname}
|
||||||
@@ -318,17 +302,8 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
<EditOutlined className="text-sm" />
|
<EditOutlined className="text-sm" />
|
||||||
Manage
|
Manage
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@@ -342,12 +317,9 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
User Management
|
User Management
|
||||||
</h1>
|
</h1>
|
||||||
{/* Filters and actions */}
|
|
||||||
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
<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">
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
{/* Left side - Search & filters */}
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
{/* Search bar */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
<input
|
<input
|
||||||
@@ -360,7 +332,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Per Page Dropdown */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
@@ -376,70 +347,19 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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> */}
|
|
||||||
|
|
||||||
{/* City Filter */}
|
{}
|
||||||
{/* <CityFilterSelect
|
{
|
||||||
value={cityFilter}
|
}
|
||||||
onChange={setCityFilter}
|
|
||||||
className="w-full sm:w-44"
|
|
||||||
placeholder="Search cities..."
|
|
||||||
allOptionLabel="All Cities"
|
|
||||||
/>
|
|
||||||
{cityFilter !== 'all' && (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
|
||||||
Location: {cityFilter}
|
|
||||||
<button
|
|
||||||
onClick={() => setCityFilter('all')}
|
|
||||||
className="text-green-600 hover:text-green-800 cursor-pointer"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
{/* 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>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Add User Button */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -453,14 +373,12 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active filters display */}
|
|
||||||
{(skillsFilter.length > 0 ||
|
{(skillsFilter.length > 0 ||
|
||||||
statusFilter !== 'all' ||
|
statusFilter !== 'all' ||
|
||||||
cityFilter !== 'all') && (
|
cityFilter !== 'all') && (
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<span className="text-sm text-neutral-600">Active filters:</span>
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
|
||||||
{/* Status filter badge */}
|
|
||||||
{statusFilter !== 'all' && (
|
{statusFilter !== 'all' && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||||
Status: {statusFilter}
|
Status: {statusFilter}
|
||||||
@@ -473,7 +391,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Location filter badge */}
|
|
||||||
{cityFilter !== 'all' && (
|
{cityFilter !== 'all' && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
City: {cityFilter}
|
City: {cityFilter}
|
||||||
@@ -486,7 +403,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Skills filter badges */}
|
|
||||||
{skillsFilter.map((skill) => (
|
{skillsFilter.map((skill) => (
|
||||||
<span
|
<span
|
||||||
key={skill}
|
key={skill}
|
||||||
@@ -504,7 +420,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Clear all filters */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -521,7 +436,6 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading & results display */}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||||
@@ -553,18 +467,16 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Modals component */}
|
|
||||||
<ModalUserDetail
|
<ModalUserDetail
|
||||||
isOpen={showDetailModal}
|
isOpen={showDetailModal}
|
||||||
onClose={handleCloseDetailModal}
|
onClose={handleCloseDetailModal}
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* New User Modal */}
|
|
||||||
<ModalUserDetail
|
<ModalUserDetail
|
||||||
isOpen={showNewUserModal}
|
isOpen={showNewUserModal}
|
||||||
onClose={handleCloseNewUserModal}
|
onClose={handleCloseNewUserModal}
|
||||||
user={null} // null indicates creating new user
|
user={null}
|
||||||
/>
|
/>
|
||||||
</BackofficeWrapper>
|
</BackofficeWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ export const AppLayout: FC = (): ReactElement => {
|
|||||||
onClose={() => setMobileSidebarOpen(false)}
|
onClose={() => setMobileSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{/* Sticky top header */}
|
|
||||||
<header
|
<header
|
||||||
className={
|
className={
|
||||||
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
'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')
|
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Mobile menu button (shown on small screens) */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
|
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
// import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
// import {
|
|
||||||
// gachaRollItemSchema,
|
|
||||||
// TGachaRollItem
|
|
||||||
// } from '@imphnen-frontend-service/service';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export const useItem = (
|
export const useItem = (
|
||||||
@@ -11,7 +6,6 @@ export const useItem = (
|
|||||||
initialValues?: any
|
initialValues?: any
|
||||||
) => {
|
) => {
|
||||||
const form = useForm<any>({
|
const form = useForm<any>({
|
||||||
// resolver: zodResolver(),
|
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
});
|
});
|
||||||
@@ -38,8 +32,6 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
// const result = await actionFunction?.();
|
|
||||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const items = [
|
|||||||
'Gelang Karet',
|
'Gelang Karet',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mock data for transactions
|
|
||||||
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
const mockData: Prize[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
name: 'Nama Lengkap',
|
name: 'Nama Lengkap',
|
||||||
@@ -190,13 +189,10 @@ export const Components: FC = (): ReactElement => {
|
|||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
<h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1>
|
<h1 className="text-p2 font-semibold">Data Pengiriman Hadiah</h1>
|
||||||
</header>
|
</header>
|
||||||
{/* Account Table Section */}
|
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
{/* Search and Filter */}
|
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="flex justify-between items-center gap-8 mb-2">
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Input
|
<Input
|
||||||
@@ -224,19 +220,16 @@ export const Components: FC = (): ReactElement => {
|
|||||||
onClose={() => setShowFilter(false)}
|
onClose={() => setShowFilter(false)}
|
||||||
onFilterChange={(value) => {
|
onFilterChange={(value) => {
|
||||||
console.log('Selected filter:', value);
|
console.log('Selected filter:', value);
|
||||||
// Filter logic di sini
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Table */}
|
|
||||||
<DataTable data={mockData} columns={columns} table={table} />
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Modal Process Delivery */}
|
|
||||||
<ModalProcessDelivery
|
<ModalProcessDelivery
|
||||||
isOpen={showModalProcessDelivery}
|
isOpen={showModalProcessDelivery}
|
||||||
onClose={() => setShowModalProcessDelivery(false)}
|
onClose={() => setShowModalProcessDelivery(false)}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
// import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
// import {
|
|
||||||
// gachaRollItemSchema,
|
|
||||||
// TGachaRollItem
|
|
||||||
// } from '@imphnen-frontend-service/service';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export const useItem = (
|
export const useItem = (
|
||||||
@@ -11,7 +6,6 @@ export const useItem = (
|
|||||||
initialValues?: any
|
initialValues?: any
|
||||||
) => {
|
) => {
|
||||||
const form = useForm<any>({
|
const form = useForm<any>({
|
||||||
// resolver: zodResolver(),
|
|
||||||
mode: 'all',
|
mode: 'all',
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
});
|
});
|
||||||
@@ -38,8 +32,6 @@ export const useConfirmItem = (
|
|||||||
) => {
|
) => {
|
||||||
const onConfirm = async () => {
|
const onConfirm = async () => {
|
||||||
try {
|
try {
|
||||||
// const result = await actionFunction?.();
|
|
||||||
// result ? toast.success(messages?.success) : toast.error(messages?.error);
|
|
||||||
toast.success(messages?.success);
|
toast.success(messages?.success);
|
||||||
onClose();
|
onClose();
|
||||||
resetStep();
|
resetStep();
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ interface Transaction {
|
|||||||
status: TransactionStatus;
|
status: TransactionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock data for transactions
|
|
||||||
const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
|
const mockTransactions: Transaction[] = Array.from({ length: 20 }, (_, i) => ({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
name: i === 0 ? 'Ahmad Wijuana' : 'Nama Lengkap',
|
||||||
@@ -149,14 +148,11 @@ export const Components: FC = (): ReactElement => {
|
|||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
<main className="w-full px-[48px] py-[40px] flex flex-col gap-8">
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
<header className="bg-white py-4 px-8 rounded-lg shadow p-4">
|
||||||
<h1 className="text-p2 font-semibold">Validasi Transaksi</h1>
|
<h1 className="text-p2 font-semibold">Validasi Transaksi</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Account Table Section */}
|
|
||||||
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
{/* Search and Filter */}
|
|
||||||
<div className="flex justify-between items-center gap-8 mb-2">
|
<div className="flex justify-between items-center gap-8 mb-2">
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Input
|
<Input
|
||||||
@@ -185,7 +181,6 @@ export const Components: FC = (): ReactElement => {
|
|||||||
onClose={() => setShowFilter(false)}
|
onClose={() => setShowFilter(false)}
|
||||||
onFilterChange={(value) => {
|
onFilterChange={(value) => {
|
||||||
console.log('Selected filter:', value);
|
console.log('Selected filter:', value);
|
||||||
// Filter logic di sini
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +188,6 @@ export const Components: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<DataTable data={mockTransactions} columns={columns} table={table} />
|
<DataTable data={mockTransactions} columns={columns} table={table} />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -24,12 +24,10 @@ export const CityFilterSelect: FC<CityFilterSelectProps> = ({
|
|||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Filter cities based on search query
|
|
||||||
const filteredCities = INDONESIAN_CITIES.filter((city) =>
|
const filteredCities = INDONESIAN_CITIES.filter((city) =>
|
||||||
city.toLowerCase().includes(searchQuery.toLowerCase())
|
city.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
@@ -102,7 +100,6 @@ export const CityFilterSelect: FC<CityFilterSelectProps> = ({
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute z-50 w-full mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
<div className="absolute z-50 w-full mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
{/* All Cities Option */}
|
|
||||||
<div
|
<div
|
||||||
onClick={() => handleSelectCity('all')}
|
onClick={() => handleSelectCity('all')}
|
||||||
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${
|
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${
|
||||||
@@ -114,7 +111,6 @@ export const CityFilterSelect: FC<CityFilterSelectProps> = ({
|
|||||||
{allOptionLabel}
|
{allOptionLabel}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtered Cities */}
|
|
||||||
{filteredCities.length > 0 ? (
|
{filteredCities.length > 0 ? (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{filteredCities.slice(0, 100).map((city) => (
|
{filteredCities.slice(0, 100).map((city) => (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
PERMISSIONS,
|
PERMISSIONS,
|
||||||
SessionToken,
|
SessionToken,
|
||||||
SessionUser,
|
SessionUser,
|
||||||
} from '@imphnen-frontend-service/utils';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
|
|
||||||
const mappingPublicRoutes = [
|
const mappingPublicRoutes = [
|
||||||
@@ -66,14 +66,6 @@ const mappingRoutePermissions = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
//TODO : Fix this later
|
|
||||||
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
|
|
||||||
// const fallback = mappingRoutePermissions.find((route) =>
|
|
||||||
// route.permissions.some((perm) => userPermissions.includes(perm))
|
|
||||||
// );
|
|
||||||
// return redirect(fallback?.path ?? '/auth/login');
|
|
||||||
// };
|
|
||||||
|
|
||||||
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ export default defineConfig(() => ({
|
|||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
},
|
},
|
||||||
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
// Uncomment this if you are using workers.
|
|
||||||
// worker: {
|
|
||||||
// plugins: [ nxViteTsPaths() ],
|
|
||||||
// },
|
|
||||||
build: {
|
build: {
|
||||||
outDir: '../../dist/apps/backoffice',
|
outDir: '../../dist/apps/backoffice',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Tue, Nov 25, 2025 4:21:27 PM
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# Deployment trigger
|
|
||||||
# Updated to deploy circular dependency fixes and auth hooks
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const CTASection: FC = () => {
|
|||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate={isInView ? "visible" : "hidden"}
|
animate={isInView ? "visible" : "hidden"}
|
||||||
>
|
>
|
||||||
{/* Background gradients and shapes */}
|
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<motion.div
|
<motion.div
|
||||||
className={cn("absolute top-8 -left-14 size-28 bg-gradient-to-tl from-primary-400 to-primary-300 rounded-full",
|
className={cn("absolute top-8 -left-14 size-28 bg-gradient-to-tl from-primary-400 to-primary-300 rounded-full",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const FAQSection: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section ref={ref} className="relative mx-auto py-4 px-8 overflow-x-clip md:py-8 md:px-[60px] lg:px-20 md:pb-8">
|
<section ref={ref} className="relative mx-auto py-4 px-8 overflow-x-clip md:py-8 md:px-[60px] lg:px-20 md:pb-8">
|
||||||
{/* Background shapes */}
|
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { motion } from 'framer-motion'
|
|||||||
export function HeroSection() {
|
export function HeroSection() {
|
||||||
return (
|
return (
|
||||||
<section className="relative w-full z-0 py-20 md:py-28 lg:py-32">
|
<section className="relative w-full z-0 py-20 md:py-28 lg:py-32">
|
||||||
{/* Background gradients and shapes */}
|
|
||||||
<div className="absolute inset-0 -z-10 overflow-x-clip hidden md:block">
|
<div className="absolute inset-0 -z-10 overflow-x-clip hidden md:block">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const Footer: FC = () => {
|
|||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate={isInView ? "visible" : "hidden"}
|
animate={isInView ? "visible" : "hidden"}
|
||||||
>
|
>
|
||||||
{/* Mascot */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="hidden absolute bottom-0 right-0 size-40 md:block lg:size-72"
|
className="hidden absolute bottom-0 right-0 size-40 md:block lg:size-72"
|
||||||
variants={mascotVariants}
|
variants={mascotVariants}
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ const ProfileByIdContent: FC = (): ReactElement => {
|
|||||||
setIsEditProfileModalOpen(false);
|
setIsEditProfileModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set isViewOnly to true for this page
|
|
||||||
const isViewOnly = true;
|
const isViewOnly = true;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -151,7 +150,6 @@ const ProfileByIdContent: FC = (): ReactElement => {
|
|||||||
message={notification.message}
|
message={notification.message}
|
||||||
header="Profile"
|
header="Profile"
|
||||||
/>
|
/>
|
||||||
{/* Only render EditProfileModal if not in view-only mode */}
|
|
||||||
{!isViewOnly && (
|
{!isViewOnly && (
|
||||||
<EditProfileModal
|
<EditProfileModal
|
||||||
isOpen={isEditProfileModalOpen}
|
isOpen={isEditProfileModalOpen}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Guard } from '@imphnen-frontend-service/utils';
|
|
||||||
|
|
||||||
interface MentorGuardProps {
|
interface MentorGuardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -10,18 +9,6 @@ interface MentorGuardProps {
|
|||||||
|
|
||||||
export const MentorGuard: React.FC<MentorGuardProps> = ({
|
export const MentorGuard: React.FC<MentorGuardProps> = ({
|
||||||
children,
|
children,
|
||||||
fallback = (
|
|
||||||
<div className="p-4 text-center text-red-500">
|
|
||||||
<p>Akses ditolak: Anda tidak memiliki izin untuk mengakses fitur mentor.</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return <>{children}</>;
|
||||||
<Guard
|
|
||||||
permissions={['mentor', 'admin']}
|
|
||||||
fallback={fallback}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Guard>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
|||||||
const fullname = profileData.fullname ||
|
const fullname = profileData.fullname ||
|
||||||
(profileType === 'mentor' && 'legal_name' in profileData ? profileData.legal_name : '') || '';
|
(profileType === 'mentor' && 'legal_name' in profileData ? profileData.legal_name : '') || '';
|
||||||
|
|
||||||
|
|
||||||
const avatar = (profileType === 'user' && 'avatar' in profileData)
|
const avatar = (profileType === 'user' && 'avatar' in profileData)
|
||||||
? profileData.avatar || '/image/testimonial.webp'
|
? profileData.avatar || '/image/testimonial.webp'
|
||||||
: '/image/testimonial.webp';
|
: '/image/testimonial.webp';
|
||||||
@@ -39,7 +38,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
|||||||
avatar: (profileType === 'user' && 'avatar' in profileData) ? profileData.avatar || '' : ''
|
avatar: (profileType === 'user' && 'avatar' in profileData) ? profileData.avatar || '' : ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
setPreviewUrl(avatar);
|
setPreviewUrl(avatar);
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
@@ -60,7 +58,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
|||||||
try {
|
try {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const result = reader.result as string;
|
const result = reader.result as string;
|
||||||
@@ -68,22 +65,18 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
|||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
|
||||||
const uploadResult = await uploadAvatarMutation.mutateAsync(file);
|
const uploadResult = await uploadAvatarMutation.mutateAsync(file);
|
||||||
|
|
||||||
console.log('Avatar upload response:', uploadResult);
|
console.log('Avatar upload response:', uploadResult);
|
||||||
|
|
||||||
|
|
||||||
interface UploadData {
|
interface UploadData {
|
||||||
url?: string;
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData);
|
const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData);
|
||||||
|
|
||||||
|
|
||||||
setFormData(prev => ({ ...prev, avatar: uploadData.url || '' }));
|
setFormData(prev => ({ ...prev, avatar: uploadData.url || '' }));
|
||||||
|
|
||||||
|
|
||||||
setPreviewUrl(uploadData.url || '/image/testimonial.webp');
|
setPreviewUrl(uploadData.url || '/image/testimonial.webp');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -101,7 +94,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
|||||||
try {
|
try {
|
||||||
const updates: Record<string, string> = {};
|
const updates: Record<string, string> = {};
|
||||||
|
|
||||||
|
|
||||||
if (formData.fullname.trim() !== '') {
|
if (formData.fullname.trim() !== '') {
|
||||||
if (profileType === 'user') {
|
if (profileType === 'user') {
|
||||||
updates.fullname = formData.fullname;
|
updates.fullname = formData.fullname;
|
||||||
@@ -110,7 +102,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (formData.avatar && formData.avatar !== (profileData && 'avatar' in profileData ? profileData.avatar : '')) {
|
if (formData.avatar && formData.avatar !== (profileData && 'avatar' in profileData ? profileData.avatar : '')) {
|
||||||
updates.avatar = formData.avatar;
|
updates.avatar = formData.avatar;
|
||||||
}
|
}
|
||||||
@@ -141,7 +132,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
|||||||
msg = parsed.message;
|
msg = parsed.message;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parse errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +175,6 @@ export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, s
|
|||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hover overlay */}
|
|
||||||
<div className="absolute inset-0 rounded-full bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
|
<div className="absolute inset-0 rounded-full bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
|
||||||
<span className="text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
<span className="text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
Change Photo
|
Change Photo
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface Education {
|
|||||||
|
|
||||||
interface ProfileFormProps {
|
interface ProfileFormProps {
|
||||||
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||||
isViewOnly?: boolean; // Add isViewOnly prop
|
isViewOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly = false }) => {
|
export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly = false }) => {
|
||||||
@@ -161,7 +161,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||||
if (isViewOnly) { // Prevent updates if in view-only mode
|
if (isViewOnly) {
|
||||||
showNotification('error', 'Akses Ditolak', 'Anda tidak memiliki izin untuk mengedit profil ini.');
|
showNotification('error', 'Akses Ditolak', 'Anda tidak memiliki izin untuk mengedit profil ini.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly
|
|||||||
}}
|
}}
|
||||||
showNotification={showNotification}
|
showNotification={showNotification}
|
||||||
isLoading={isUpdating}
|
isLoading={isUpdating}
|
||||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
isViewOnly={isViewOnly}
|
||||||
/>
|
/>
|
||||||
{}
|
{}
|
||||||
<CvResumeSection
|
<CvResumeSection
|
||||||
@@ -202,7 +202,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly
|
|||||||
}}
|
}}
|
||||||
showNotification={showNotification}
|
showNotification={showNotification}
|
||||||
isLoading={isUpdating}
|
isLoading={isUpdating}
|
||||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
isViewOnly={isViewOnly}
|
||||||
/>
|
/>
|
||||||
{}
|
{}
|
||||||
<ExperiencesSection
|
<ExperiencesSection
|
||||||
@@ -218,7 +218,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly
|
|||||||
}}
|
}}
|
||||||
showNotification={showNotification}
|
showNotification={showNotification}
|
||||||
isLoading={isUpdating}
|
isLoading={isUpdating}
|
||||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
isViewOnly={isViewOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{}
|
{}
|
||||||
@@ -235,7 +235,7 @@ export const ProfileForm: FC<ProfileFormProps> = ({ showNotification, isViewOnly
|
|||||||
}}
|
}}
|
||||||
showNotification={showNotification}
|
showNotification={showNotification}
|
||||||
isLoading={isUpdating}
|
isLoading={isUpdating}
|
||||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
isViewOnly={isViewOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NotificationModal
|
<NotificationModal
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useProfile } from '../contexts/profile-context';
|
|||||||
|
|
||||||
interface ProfileHeaderProps {
|
interface ProfileHeaderProps {
|
||||||
onEditProfileClick: () => void;
|
onEditProfileClick: () => void;
|
||||||
isViewOnly?: boolean; // Add isViewOnly prop
|
isViewOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isViewOnly = false }) => {
|
export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isViewOnly = false }) => {
|
||||||
@@ -17,7 +17,6 @@ export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isVi
|
|||||||
? profileData.avatar || "/image/testimonial.webp"
|
? profileData.avatar || "/image/testimonial.webp"
|
||||||
: "/image/testimonial.webp";
|
: "/image/testimonial.webp";
|
||||||
|
|
||||||
|
|
||||||
const displayFullname = profileData?.fullname ||
|
const displayFullname = profileData?.fullname ||
|
||||||
(profileType === 'mentor' && profileData && 'legal_name' in profileData
|
(profileType === 'mentor' && profileData && 'legal_name' in profileData
|
||||||
? profileData.legal_name
|
? profileData.legal_name
|
||||||
@@ -34,7 +33,6 @@ export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isVi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const joinDate = profileData && 'created_at' in profileData
|
const joinDate = profileData && 'created_at' in profileData
|
||||||
? new Date(profileData.created_at).toLocaleDateString('id-ID', {
|
? new Date(profileData.created_at).toLocaleDateString('id-ID', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -73,7 +71,7 @@ export const ProfileHeader: FC<ProfileHeaderProps> = ({ onEditProfileClick, isVi
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isViewOnly && ( // Conditionally render the button
|
{!isViewOnly && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ import { useProfile } from '../contexts/profile-context';
|
|||||||
|
|
||||||
interface ProfileSidebarProps {
|
interface ProfileSidebarProps {
|
||||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
isViewOnly?: boolean; // Add isViewOnly prop
|
isViewOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isViewOnly = false }) => {
|
export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isViewOnly = false }) => {
|
||||||
const { profileData, updateProfile, profileType, isLoading, isUpdating } = useProfile();
|
const { profileData, updateProfile, profileType, isLoading, isUpdating } = useProfile();
|
||||||
|
|
||||||
|
|
||||||
const getCareerStatus = useCallback(() => {
|
const getCareerStatus = useCallback(() => {
|
||||||
if (!profileData) {
|
if (!profileData) {
|
||||||
return 'Career Status';
|
return 'Career Status';
|
||||||
@@ -114,7 +113,6 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
|||||||
}
|
}
|
||||||
}, [profileData, profileType, isLoading, isInitialized, getCareerStatus, getEmail, getPhone, getLocation, getSkills, isUpdatingCareerStatus]);
|
}, [profileData, profileType, isLoading, isInitialized, getCareerStatus, getEmail, getPhone, getLocation, getSkills, isUpdatingCareerStatus]);
|
||||||
|
|
||||||
|
|
||||||
const tryParseJsonMessage = (msg: string): string => {
|
const tryParseJsonMessage = (msg: string): string => {
|
||||||
if (msg.trim().startsWith('{') && msg.trim().endsWith('}')) {
|
if (msg.trim().startsWith('{') && msg.trim().endsWith('}')) {
|
||||||
try {
|
try {
|
||||||
@@ -129,7 +127,6 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
|||||||
return msg;
|
return msg;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const extractApiMessage = (err: unknown): string => {
|
const extractApiMessage = (err: unknown): string => {
|
||||||
if (typeof err !== 'object' || err === null) return '';
|
if (typeof err !== 'object' || err === null) return '';
|
||||||
|
|
||||||
@@ -147,7 +144,7 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
const handleProfileUpdate = async (updates: Partial<MentorUpdateRequestDto | UserUpdateRequestDto>) => {
|
||||||
if (isViewOnly) { // Prevent updates if in view-only mode
|
if (isViewOnly) {
|
||||||
showNotification('error', 'Akses Ditolak', 'Anda tidak memiliki izin untuk mengedit profil ini.');
|
showNotification('error', 'Akses Ditolak', 'Anda tidak memiliki izin untuk mengedit profil ini.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -167,7 +164,7 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
|||||||
<Select
|
<Select
|
||||||
value={careerStatus}
|
value={careerStatus}
|
||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
if (isUpdatingCareerStatus) return; // Prevent multiple clicks
|
if (isUpdatingCareerStatus) return;
|
||||||
const newStatus = e.target.value;
|
const newStatus = e.target.value;
|
||||||
console.log('ProfileSidebar: User selected career status:', newStatus);
|
console.log('ProfileSidebar: User selected career status:', newStatus);
|
||||||
setCareerStatus(newStatus);
|
setCareerStatus(newStatus);
|
||||||
@@ -190,7 +187,7 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-full min-w-[200px]"
|
className="w-full min-w-[200px]"
|
||||||
disabled={isUpdatingCareerStatus || isViewOnly} // Disable if in view-only mode
|
disabled={isUpdatingCareerStatus || isViewOnly}
|
||||||
>
|
>
|
||||||
<option value="Career Status">Career Status</option>
|
<option value="Career Status">Career Status</option>
|
||||||
<option value="Student">Student</option>
|
<option value="Student">Student</option>
|
||||||
@@ -214,7 +211,7 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
|||||||
}}
|
}}
|
||||||
showNotification={showNotification}
|
showNotification={showNotification}
|
||||||
isLoading={isUpdating}
|
isLoading={isUpdating}
|
||||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
isViewOnly={isViewOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SkillsSection
|
<SkillsSection
|
||||||
@@ -232,7 +229,7 @@ export const ProfileSidebar: FC<ProfileSidebarProps> = ({ showNotification, isVi
|
|||||||
}}
|
}}
|
||||||
showNotification={showNotification}
|
showNotification={showNotification}
|
||||||
isLoading={isUpdating}
|
isLoading={isUpdating}
|
||||||
isViewOnly={isViewOnly} // Pass isViewOnly
|
isViewOnly={isViewOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ import { SectionWrapper } from '../shared/section-wrapper';
|
|||||||
import { NotificationType } from '../modals/notification-modal';
|
import { NotificationType } from '../modals/notification-modal';
|
||||||
import { EditSectionButton } from '../buttons/edit-section-button';
|
import { EditSectionButton } from '../buttons/edit-section-button';
|
||||||
|
|
||||||
|
|
||||||
interface CvResumeSectionProps {
|
interface CvResumeSectionProps {
|
||||||
initialFileName: string;
|
initialFileName: string;
|
||||||
fullname: string;
|
fullname: string;
|
||||||
onSave: (cvData: { fileName: string; fileUrl?: string }) => Promise<void>;
|
onSave: (cvData: { fileName: string; fileUrl?: string }) => Promise<void>;
|
||||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isViewOnly?: boolean; // Add isViewOnly prop
|
isViewOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
||||||
@@ -22,7 +21,7 @@ export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
showNotification,
|
showNotification,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isViewOnly = false, // Default to false
|
isViewOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isCVModalOpen, setIsCVModalOpen] = useState(false);
|
const [isCVModalOpen, setIsCVModalOpen] = useState(false);
|
||||||
const [fileName, setFileName] = useState(initialFileName);
|
const [fileName, setFileName] = useState(initialFileName);
|
||||||
@@ -33,11 +32,10 @@ export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
|||||||
}, [initialFileName]);
|
}, [initialFileName]);
|
||||||
|
|
||||||
const handleSave = async (cvData: { fileName: string; fileUrl?: string }) => {
|
const handleSave = async (cvData: { fileName: string; fileUrl?: string }) => {
|
||||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
if (isViewOnly) return;
|
||||||
await onSave(cvData);
|
await onSave(cvData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
let displayFileName = 'Belum ada CV';
|
let displayFileName = 'Belum ada CV';
|
||||||
let fileUrl = '';
|
let fileUrl = '';
|
||||||
if (fileName) {
|
if (fileName) {
|
||||||
@@ -65,7 +63,7 @@ export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
|||||||
<SectionWrapper
|
<SectionWrapper
|
||||||
title="CV/Resume"
|
title="CV/Resume"
|
||||||
editButton={
|
editButton={
|
||||||
!isViewOnly ? ( // Conditionally render the edit button
|
!isViewOnly ? (
|
||||||
<EditSectionButton
|
<EditSectionButton
|
||||||
onClick={() => setIsCVModalOpen(true)}
|
onClick={() => setIsCVModalOpen(true)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -94,7 +92,7 @@ export const CvResumeSection: FC<CvResumeSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CVModal
|
<CVModal
|
||||||
isOpen={isCVModalOpen && !isViewOnly} // Only open if not in view-only mode
|
isOpen={isCVModalOpen && !isViewOnly}
|
||||||
onClose={() => setIsCVModalOpen(false)}
|
onClose={() => setIsCVModalOpen(false)}
|
||||||
initialValue={{ fileName, fileUrl: fileName }}
|
initialValue={{ fileName, fileUrl: fileName }}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
|||||||
+5
-5
@@ -9,7 +9,7 @@ interface DescriptionSectionProps {
|
|||||||
onSave: (newDescription: string) => Promise<void>;
|
onSave: (newDescription: string) => Promise<void>;
|
||||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isViewOnly?: boolean; // Add isViewOnly prop
|
isViewOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
||||||
@@ -17,7 +17,7 @@ export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
showNotification,
|
showNotification,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isViewOnly = false, // Default to false
|
isViewOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
|
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
|
||||||
const [description, setDescription] = useState(initialDescription);
|
const [description, setDescription] = useState(initialDescription);
|
||||||
@@ -28,7 +28,7 @@ export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
|||||||
}, [initialDescription]);
|
}, [initialDescription]);
|
||||||
|
|
||||||
const handleSave = async (newDescription: string) => {
|
const handleSave = async (newDescription: string) => {
|
||||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
if (isViewOnly) return;
|
||||||
await onSave(newDescription);
|
await onSave(newDescription);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
|||||||
<SectionWrapper
|
<SectionWrapper
|
||||||
title="Description"
|
title="Description"
|
||||||
editButton={
|
editButton={
|
||||||
!isViewOnly ? ( // Conditionally render the edit button
|
!isViewOnly ? (
|
||||||
<EditSectionButton
|
<EditSectionButton
|
||||||
onClick={() => setIsDescriptionModalOpen(true)}
|
onClick={() => setIsDescriptionModalOpen(true)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -50,7 +50,7 @@ export const DescriptionSection: FC<DescriptionSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DescriptionModal
|
<DescriptionModal
|
||||||
isOpen={isDescriptionModalOpen && !isViewOnly} // Only open if not in view-only mode
|
isOpen={isDescriptionModalOpen && !isViewOnly}
|
||||||
onClose={() => setIsDescriptionModalOpen(false)}
|
onClose={() => setIsDescriptionModalOpen(false)}
|
||||||
initialValue={description}
|
initialValue={description}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface EducationSectionProps {
|
|||||||
onSave: (newEducation: Education[]) => Promise<void>;
|
onSave: (newEducation: Education[]) => Promise<void>;
|
||||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isViewOnly?: boolean; // Add isViewOnly prop
|
isViewOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EducationSection: FC<EducationSectionProps> = ({
|
export const EducationSection: FC<EducationSectionProps> = ({
|
||||||
@@ -25,7 +25,7 @@ export const EducationSection: FC<EducationSectionProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
showNotification,
|
showNotification,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isViewOnly = false, // Default to false
|
isViewOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isEducationModalOpen, setIsEducationModalOpen] = useState(false);
|
const [isEducationModalOpen, setIsEducationModalOpen] = useState(false);
|
||||||
const [education, setEducation] = useState<Education[]>(initialEducation);
|
const [education, setEducation] = useState<Education[]>(initialEducation);
|
||||||
@@ -36,7 +36,7 @@ export const EducationSection: FC<EducationSectionProps> = ({
|
|||||||
}, [initialEducation]);
|
}, [initialEducation]);
|
||||||
|
|
||||||
const handleSave = async (newEducation: Education[]) => {
|
const handleSave = async (newEducation: Education[]) => {
|
||||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
if (isViewOnly) return;
|
||||||
await onSave(newEducation);
|
await onSave(newEducation);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export const EducationSection: FC<EducationSectionProps> = ({
|
|||||||
<SectionWrapper
|
<SectionWrapper
|
||||||
title="Education"
|
title="Education"
|
||||||
editButton={
|
editButton={
|
||||||
!isViewOnly ? ( // Conditionally render the edit button
|
!isViewOnly ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<EditSectionButton
|
<EditSectionButton
|
||||||
onClick={() => setIsEducationModalOpen(true)}
|
onClick={() => setIsEducationModalOpen(true)}
|
||||||
@@ -73,7 +73,7 @@ export const EducationSection: FC<EducationSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EducationModal
|
<EducationModal
|
||||||
isOpen={isEducationModalOpen && !isViewOnly} // Only open if not in view-only mode
|
isOpen={isEducationModalOpen && !isViewOnly}
|
||||||
onClose={() => setIsEducationModalOpen(false)}
|
onClose={() => setIsEducationModalOpen(false)}
|
||||||
initialValue={education}
|
initialValue={education}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
|||||||
+5
-5
@@ -17,7 +17,7 @@ interface ExperiencesSectionProps {
|
|||||||
onSave: (newExperiences: Experience[]) => Promise<void>;
|
onSave: (newExperiences: Experience[]) => Promise<void>;
|
||||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isViewOnly?: boolean; // Add isViewOnly prop
|
isViewOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
||||||
@@ -25,7 +25,7 @@ export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
showNotification,
|
showNotification,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isViewOnly = false, // Default to false
|
isViewOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isExperienceModalOpen, setIsExperienceModalOpen] = useState(false);
|
const [isExperienceModalOpen, setIsExperienceModalOpen] = useState(false);
|
||||||
const [experiences, setExperiences] = useState<Experience[]>(initialExperiences);
|
const [experiences, setExperiences] = useState<Experience[]>(initialExperiences);
|
||||||
@@ -36,7 +36,7 @@ export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
|||||||
}, [initialExperiences]);
|
}, [initialExperiences]);
|
||||||
|
|
||||||
const handleSave = async (newExperiences: Experience[]) => {
|
const handleSave = async (newExperiences: Experience[]) => {
|
||||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
if (isViewOnly) return;
|
||||||
await onSave(newExperiences);
|
await onSave(newExperiences);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
|||||||
<SectionWrapper
|
<SectionWrapper
|
||||||
title="Experiences"
|
title="Experiences"
|
||||||
editButton={
|
editButton={
|
||||||
!isViewOnly ? ( // Conditionally render the edit button
|
!isViewOnly ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<EditSectionButton
|
<EditSectionButton
|
||||||
onClick={() => setIsExperienceModalOpen(true)}
|
onClick={() => setIsExperienceModalOpen(true)}
|
||||||
@@ -73,7 +73,7 @@ export const ExperiencesSection: FC<ExperiencesSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ExperienceModal
|
<ExperienceModal
|
||||||
isOpen={isExperienceModalOpen && !isViewOnly} // Only open if not in view-only mode
|
isOpen={isExperienceModalOpen && !isViewOnly}
|
||||||
onClose={() => setIsExperienceModalOpen(false)}
|
onClose={() => setIsExperienceModalOpen(false)}
|
||||||
initialValue={experiences}
|
initialValue={experiences}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
|||||||
+5
-6
@@ -16,7 +16,7 @@ interface PersonalInfoSectionProps {
|
|||||||
onSave: (newContactInfo: PersonalInfo) => Promise<void>;
|
onSave: (newContactInfo: PersonalInfo) => Promise<void>;
|
||||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isViewOnly?: boolean; // Add isViewOnly prop
|
isViewOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
||||||
@@ -24,18 +24,17 @@ export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
showNotification,
|
showNotification,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isViewOnly = false, // Default to false
|
isViewOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isPersonalInfoModalOpen, setIsPersonalInfoModalOpen] = useState(false);
|
const [isPersonalInfoModalOpen, setIsPersonalInfoModalOpen] = useState(false);
|
||||||
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>(initialContactInfo);
|
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>(initialContactInfo);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPersonalInfo(initialContactInfo);
|
setPersonalInfo(initialContactInfo);
|
||||||
}, [initialContactInfo]);
|
}, [initialContactInfo]);
|
||||||
|
|
||||||
const handleSave = async (newInfo: { email: string; phone: string; location: string }) => {
|
const handleSave = async (newInfo: { email: string; phone: string; location: string }) => {
|
||||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
if (isViewOnly) return;
|
||||||
const newPersonalInfo = { email: newInfo.email, phone: newInfo.phone, location: newInfo.location };
|
const newPersonalInfo = { email: newInfo.email, phone: newInfo.phone, location: newInfo.location };
|
||||||
|
|
||||||
await onSave(newPersonalInfo);
|
await onSave(newPersonalInfo);
|
||||||
@@ -45,7 +44,7 @@ export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
|||||||
<SectionWrapper
|
<SectionWrapper
|
||||||
title="Personal Informations"
|
title="Personal Informations"
|
||||||
editButton={
|
editButton={
|
||||||
!isViewOnly ? ( // Conditionally render the edit button
|
!isViewOnly ? (
|
||||||
<EditSectionButton
|
<EditSectionButton
|
||||||
onClick={() => setIsPersonalInfoModalOpen(true)}
|
onClick={() => setIsPersonalInfoModalOpen(true)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -87,7 +86,7 @@ export const PersonalInfoSection: FC<PersonalInfoSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PersonalInfoModal
|
<PersonalInfoModal
|
||||||
isOpen={isPersonalInfoModalOpen && !isViewOnly} // Only open if not in view-only mode
|
isOpen={isPersonalInfoModalOpen && !isViewOnly}
|
||||||
onClose={() => setIsPersonalInfoModalOpen(false)}
|
onClose={() => setIsPersonalInfoModalOpen(false)}
|
||||||
initialValue={personalInfo}
|
initialValue={personalInfo}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface SkillsSectionProps {
|
|||||||
onSave: (newSkills: string[]) => Promise<void>;
|
onSave: (newSkills: string[]) => Promise<void>;
|
||||||
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
showNotification: (type: NotificationType['type'], title: string, message?: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isViewOnly?: boolean; // Add isViewOnly prop
|
isViewOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SkillsSection: FC<SkillsSectionProps> = ({
|
export const SkillsSection: FC<SkillsSectionProps> = ({
|
||||||
@@ -22,18 +22,17 @@ export const SkillsSection: FC<SkillsSectionProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
showNotification,
|
showNotification,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isViewOnly = false, // Default to false
|
isViewOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [isSkillsModalOpen, setIsSkillsModalOpen] = useState(false);
|
const [isSkillsModalOpen, setIsSkillsModalOpen] = useState(false);
|
||||||
const [skills, setSkills] = useState<string[]>(initialSkills);
|
const [skills, setSkills] = useState<string[]>(initialSkills);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSkills(initialSkills);
|
setSkills(initialSkills);
|
||||||
}, [initialSkills]);
|
}, [initialSkills]);
|
||||||
|
|
||||||
const handleSave = async (newSkills: Skill[]) => {
|
const handleSave = async (newSkills: Skill[]) => {
|
||||||
if (isViewOnly) return; // Prevent save if in view-only mode
|
if (isViewOnly) return;
|
||||||
const stringSkills = newSkills.map(skill => skill.name);
|
const stringSkills = newSkills.map(skill => skill.name);
|
||||||
|
|
||||||
await onSave(stringSkills);
|
await onSave(stringSkills);
|
||||||
@@ -43,7 +42,7 @@ export const SkillsSection: FC<SkillsSectionProps> = ({
|
|||||||
<SectionWrapper
|
<SectionWrapper
|
||||||
title="Skills"
|
title="Skills"
|
||||||
editButton={
|
editButton={
|
||||||
!isViewOnly ? ( // Conditionally render the edit button
|
!isViewOnly ? (
|
||||||
<EditSectionButton
|
<EditSectionButton
|
||||||
onClick={() => setIsSkillsModalOpen(true)}
|
onClick={() => setIsSkillsModalOpen(true)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -64,7 +63,7 @@ export const SkillsSection: FC<SkillsSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SkillsModal
|
<SkillsModal
|
||||||
isOpen={isSkillsModalOpen && !isViewOnly} // Only open if not in view-only mode
|
isOpen={isSkillsModalOpen && !isViewOnly}
|
||||||
onClose={() => setIsSkillsModalOpen(false)}
|
onClose={() => setIsSkillsModalOpen(false)}
|
||||||
initialValue={skills.map(skill => ({ id: skill, name: skill }))}
|
initialValue={skills.map(skill => ({ id: skill, name: skill }))}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ export const useGoogleLogin = () => {
|
|||||||
const user = payload.user;
|
const user = payload.user;
|
||||||
|
|
||||||
if (accessToken && refreshToken && user && typeof accessToken === 'string' && typeof refreshToken === 'string') {
|
if (accessToken && refreshToken && user && typeof accessToken === 'string' && typeof refreshToken === 'string') {
|
||||||
// Convert Google user data to match TUserItem structure
|
|
||||||
const convertedUser = {
|
const convertedUser = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
avatar: user.avatar || '',
|
avatar: user.avatar || '',
|
||||||
@@ -97,7 +96,6 @@ export const useGoogleLogin = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use setSession like credential login does
|
|
||||||
setSession({
|
setSession({
|
||||||
token: {
|
token: {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
@@ -107,7 +105,7 @@ export const useGoogleLogin = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast.success('Login berhasil!');
|
toast.success('Login berhasil!');
|
||||||
navigate(0); // Same as credential login
|
navigate(0);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Data login tidak lengkap');
|
toast.error('Data login tidak lengkap');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { useForm } from 'react-hook-form';
|
|||||||
import {
|
import {
|
||||||
authLoginSchema,
|
authLoginSchema,
|
||||||
TLoginRequest,
|
TLoginRequest,
|
||||||
|
usePostLogin,
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useSession } from '@imphnen-frontend-service/utils';
|
|
||||||
|
|
||||||
export const useLogin = () => {
|
export const useLogin = () => {
|
||||||
const form = useForm<TLoginRequest>({
|
const form = useForm<TLoginRequest>({
|
||||||
@@ -12,13 +12,13 @@ export const useLogin = () => {
|
|||||||
mode: 'all',
|
mode: 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { signIn, isLoading } = useSession();
|
const loginMutation = usePostLogin();
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => signIn(data));
|
const onSubmit = form.handleSubmit((data) => loginMutation.mutate(data));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
form,
|
form,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isLoading
|
isLoading: loginMutation.isPending,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { useForm } from 'react-hook-form';
|
|||||||
import {
|
import {
|
||||||
TVerifyOtpRequest,
|
TVerifyOtpRequest,
|
||||||
verifyEmailSchema,
|
verifyEmailSchema,
|
||||||
|
usePostVerifyEmail,
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useOtp } from '@imphnen-frontend-service/utils';
|
|
||||||
|
|
||||||
export const useOtpHook = () => {
|
export const useOtpHook = () => {
|
||||||
const form = useForm<TVerifyOtpRequest>({
|
const form = useForm<TVerifyOtpRequest>({
|
||||||
@@ -12,9 +12,9 @@ export const useOtpHook = () => {
|
|||||||
mode: 'all',
|
mode: 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { otp, isLoading } = useOtp();
|
const { mutate, isPending: isLoading } = usePostVerifyEmail();
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => otp(data));
|
const onSubmit = form.handleSubmit(() => mutate());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
form,
|
form,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { useForm } from 'react-hook-form';
|
|||||||
import {
|
import {
|
||||||
authRegisterSchema,
|
authRegisterSchema,
|
||||||
TRegisterRequest,
|
TRegisterRequest,
|
||||||
|
usePostRegister,
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useRegister } from '@imphnen-frontend-service/utils';
|
|
||||||
|
|
||||||
export const useRegisterHook = () => {
|
export const useRegisterHook = () => {
|
||||||
const form = useForm<TRegisterRequest>({
|
const form = useForm<TRegisterRequest>({
|
||||||
@@ -12,9 +12,15 @@ export const useRegisterHook = () => {
|
|||||||
mode: 'all',
|
mode: 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register, isLoading } = useRegister();
|
const { mutate: register, isPending: isLoading } = usePostRegister();
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((data) => register(data));
|
const onSubmit = form.handleSubmit((data) =>
|
||||||
|
register({
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
fullname: data.fullname,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
form,
|
form,
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useSendOTP } from '@imphnen-frontend-service/utils';
|
import { usePostSendOtp } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
export const useResendOtpHook = () => {
|
export const useResendOtpHook = () => {
|
||||||
|
|
||||||
const { resendOTP, isLoading } = useSendOTP();
|
const { mutate, isPending: isLoading } = usePostSendOtp();
|
||||||
|
|
||||||
|
const resendOTP = (_data?: { email: string }) => {
|
||||||
|
mutate();
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resendOTP
|
resendOTP,
|
||||||
|
isLoading
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -179,7 +179,6 @@ export const GoogleOAuthPopupPage: FC = (): ReactElement => {
|
|||||||
setTimeout(checkForJson, 1000);
|
setTimeout(checkForJson, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Small delay to ensure DOM is ready
|
|
||||||
setTimeout(handleOAuthResponse, 100);
|
setTimeout(handleOAuthResponse, 100);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
PERMISSIONS,
|
PERMISSIONS,
|
||||||
SessionToken,
|
SessionToken,
|
||||||
SessionUser,
|
SessionUser,
|
||||||
} from '@imphnen-frontend-service/utils';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
|
|
||||||
const mappingPublicRoutes = [
|
const mappingPublicRoutes = [
|
||||||
@@ -80,14 +80,6 @@ const mappingPublicPrefixRoutes = [
|
|||||||
'/articles',
|
'/articles',
|
||||||
]
|
]
|
||||||
|
|
||||||
//TODO : Fix this later
|
|
||||||
// const redirectToFirstAccessibleRoute = (userPermissions: string[]) => {
|
|
||||||
// const fallback = mappingRoutePermissions.find((route) =>
|
|
||||||
// route.permissions.some((perm) => userPermissions.includes(perm))
|
|
||||||
// );
|
|
||||||
// return redirect(fallback?.path ?? '/auth/login');
|
|
||||||
// };
|
|
||||||
|
|
||||||
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
@@ -97,9 +89,6 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
const userPermissions =
|
const userPermissions =
|
||||||
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||||
|
|
||||||
// Allow to access the landing page without authentication
|
|
||||||
// So, if the route prefix is in the mappingPublicPrefixRoutes, we return null
|
|
||||||
// to indicate that we don't need to authenticate the user
|
|
||||||
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
|
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ export default defineConfig(() => ({
|
|||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
},
|
},
|
||||||
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
// Uncomment this if you are using workers.
|
|
||||||
// worker: {
|
|
||||||
// plugins: [ nxViteTsPaths() ],
|
|
||||||
// },
|
|
||||||
build: {
|
build: {
|
||||||
outDir: '../../dist/apps/dimentorin',
|
outDir: '../../dist/apps/dimentorin',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Tue, Nov 25, 2025 4:21:27 PM
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# Deployment trigger
|
|
||||||
# Updated to deploy circular dependency fixes and auth hooks
|
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
// import { SessionToken, SessionUser } from '@imphnen-frontend-service/utils';
|
|
||||||
import { LoaderFunctionArgs } from 'react-router';
|
import { LoaderFunctionArgs } from 'react-router';
|
||||||
|
|
||||||
// const mappingPublicRoutes = ['/'];
|
|
||||||
|
|
||||||
// const mappingRoutePermissions = [
|
|
||||||
// {
|
|
||||||
// path: '/',
|
|
||||||
// permissions: [],
|
|
||||||
// },
|
|
||||||
// ];
|
|
||||||
|
|
||||||
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ export default defineConfig(() => ({
|
|||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
},
|
},
|
||||||
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
// Uncomment this if you are using workers.
|
|
||||||
// worker: {
|
|
||||||
// plugins: [ nxViteTsPaths() ],
|
|
||||||
// },
|
|
||||||
build: {
|
build: {
|
||||||
outDir: '../../dist/apps/gacha',
|
outDir: '../../dist/apps/gacha',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Tue, Nov 25, 2025 16:42:27 PM
|
|
||||||
|
|||||||
@@ -18,56 +18,45 @@ const CallbackPage: FC = (): ReactElement => {
|
|||||||
hasRunRef.current = true;
|
hasRunRef.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check URL hash for Supabase email confirmation callback
|
|
||||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||||
const urlParams = new URLSearchParams(globalThis.location.search);
|
const urlParams = new URLSearchParams(globalThis.location.search);
|
||||||
|
|
||||||
const type = hashParams.get('type') || urlParams.get('type');
|
const type = hashParams.get('type') || urlParams.get('type');
|
||||||
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
||||||
|
|
||||||
// Debug: log what we received
|
|
||||||
console.log('[Callback] Params:', { type, accessToken: !!accessToken, hash: globalThis.location.hash, search: globalThis.location.search });
|
console.log('[Callback] Params:', { type, accessToken: !!accessToken, hash: globalThis.location.hash, search: globalThis.location.search });
|
||||||
|
|
||||||
// Handle Supabase email callbacks (has access_token in hash or query)
|
|
||||||
// This includes: signup confirmation, email confirmation, password recovery
|
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
|
||||||
// Password recovery - type is 'recovery' or we have access_token from reset email
|
|
||||||
if (type === 'recovery' || type === 'magiclink') {
|
if (type === 'recovery' || type === 'magiclink') {
|
||||||
toast.success('Email verified! Please set your new password.');
|
toast.success('Email verified! Please set your new password.');
|
||||||
navigate('/auth/reset-password?access_token=' + accessToken);
|
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signup/Email confirmation
|
|
||||||
if (type === 'signup' || type === 'email_confirmation') {
|
if (type === 'signup' || type === 'email_confirmation') {
|
||||||
toast.success('Email verified successfully! Please log in to continue.');
|
toast.success('Email verified successfully! Please log in to continue.');
|
||||||
navigate('/auth/login');
|
navigate('/auth/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have access_token but unknown type, assume it's password recovery
|
|
||||||
// (Supabase sometimes sends without explicit type)
|
|
||||||
toast.success('Email verified! Please set your new password.');
|
toast.success('Email verified! Please set your new password.');
|
||||||
navigate('/auth/reset-password?access_token=' + accessToken);
|
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the code from URL query params (GitHub OAuth)
|
|
||||||
const code = urlParams.get('code');
|
const code = urlParams.get('code');
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
throw new Error('No authorization code received');
|
throw new Error('No authorization code received');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange the code for tokens using backend API (GitHub OAuth)
|
|
||||||
const result = await exchangeGitHubCode({ code });
|
const result = await exchangeGitHubCode({ code });
|
||||||
|
|
||||||
toast.success('Login successful!');
|
toast.success('Login successful!');
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
|
||||||
// Check if user has completed onboarding (has location)
|
|
||||||
if (result.user.location) {
|
if (result.user.location) {
|
||||||
globalThis.location.replace('/dashboard');
|
globalThis.location.replace('/dashboard');
|
||||||
} else {
|
} else {
|
||||||
@@ -90,7 +79,6 @@ const CallbackPage: FC = (): ReactElement => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
// Check if error is related to private email
|
|
||||||
const isPrivateEmailError =
|
const isPrivateEmailError =
|
||||||
error.toLowerCase().includes('failed to create user') ||
|
error.toLowerCase().includes('failed to create user') ||
|
||||||
error.toLowerCase().includes('email') ||
|
error.toLowerCase().includes('email') ||
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export default function LoginPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
// Check for password reset tokens in URL and redirect to reset-password page
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||||
const urlParams = new URLSearchParams(globalThis.location.search);
|
const urlParams = new URLSearchParams(globalThis.location.search);
|
||||||
@@ -27,11 +26,9 @@ export default function LoginPage() {
|
|||||||
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
||||||
const type = hashParams.get('type') || urlParams.get('type');
|
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) {
|
if (accessToken) {
|
||||||
console.log('[Login] Detected access_token, redirecting to reset-password page');
|
console.log('[Login] Detected access_token, redirecting to reset-password page');
|
||||||
|
|
||||||
// Check if it's a password recovery
|
|
||||||
if (type === 'recovery' || type === 'magiclink' || !type) {
|
if (type === 'recovery' || type === 'magiclink' || !type) {
|
||||||
toast.info('Redirecting to password reset...');
|
toast.info('Redirecting to password reset...');
|
||||||
navigate('/auth/reset-password?access_token=' + accessToken);
|
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||||
@@ -53,7 +50,6 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
toast.success('Login successful!');
|
toast.success('Login successful!');
|
||||||
|
|
||||||
// Redirect based on onboarding status
|
|
||||||
if (result.user.location) {
|
if (result.user.location) {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
} else {
|
} else {
|
||||||
@@ -71,7 +67,6 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const result = await signInWithGitHub();
|
const result = await signInWithGitHub();
|
||||||
|
|
||||||
// Check if we got a redirect URL
|
|
||||||
if (result?.url) {
|
if (result?.url) {
|
||||||
globalThis.location.href = result.url;
|
globalThis.location.href = result.url;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ export default function ResetPasswordPage() {
|
|||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get the access_token from URL hash (Supabase sends it as hash fragment)
|
|
||||||
// or from query params (when redirected from callback page)
|
|
||||||
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||||
const queryParams = new URLSearchParams(globalThis.location.search);
|
const queryParams = new URLSearchParams(globalThis.location.search);
|
||||||
const token = hashParams.get('access_token') || queryParams.get('access_token');
|
const token = hashParams.get('access_token') || queryParams.get('access_token');
|
||||||
@@ -55,7 +53,6 @@ export default function ResetPasswordPage() {
|
|||||||
|
|
||||||
toast.success('Password updated successfully!');
|
toast.success('Password updated successfully!');
|
||||||
|
|
||||||
// Clear session and redirect to login
|
|
||||||
clearSession();
|
clearSession();
|
||||||
navigate('/auth/login');
|
navigate('/auth/login');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -32,13 +32,11 @@ const signupSchema = z
|
|||||||
|
|
||||||
type SignupFormData = z.infer<typeof signupSchema>;
|
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');
|
const REGISTRATION_DEADLINE = new Date('2025-11-30T16:29:00Z');
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Check if registration is closed
|
|
||||||
const isRegistrationClosed = new Date() >= REGISTRATION_DEADLINE;
|
const isRegistrationClosed = new Date() >= REGISTRATION_DEADLINE;
|
||||||
const { signInWithGitHub } = useGitHubAuth();
|
const { signInWithGitHub } = useGitHubAuth();
|
||||||
const signupMutation = useSignup();
|
const signupMutation = useSignup();
|
||||||
@@ -76,7 +74,6 @@ export default function SignupPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show closed registration screen
|
|
||||||
if (isRegistrationClosed) {
|
if (isRegistrationClosed) {
|
||||||
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 dark:bg-gray-950 p-4">
|
||||||
@@ -119,7 +116,6 @@ export default function SignupPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show success screen after registration
|
|
||||||
if (registrationSuccess) {
|
if (registrationSuccess) {
|
||||||
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 dark:bg-gray-950 p-4">
|
||||||
|
|||||||
@@ -41,14 +41,11 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
}, [certId]);
|
}, [certId]);
|
||||||
|
|
||||||
// Fetch certificate data using the new endpoint
|
|
||||||
const { data: certificateData, isLoading: isLoadingCertificate } =
|
const { data: certificateData, isLoading: isLoadingCertificate } =
|
||||||
useCertificatePublicData(decodedInfo?.userId || '', !!decodedInfo?.userId);
|
useCertificatePublicData(decodedInfo?.userId || '', !!decodedInfo?.userId);
|
||||||
|
|
||||||
// Generate QR Code
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (certId) {
|
if (certId) {
|
||||||
// Use encodeURIComponent to properly encode the certId for the URL
|
|
||||||
const encodedCertId = encodeURIComponent(certId);
|
const encodedCertId = encodeURIComponent(certId);
|
||||||
const certificateUrl = `${window.location.origin}/certificate/${encodedCertId}`;
|
const certificateUrl = `${window.location.origin}/certificate/${encodedCertId}`;
|
||||||
QRCode.toDataURL(certificateUrl, {
|
QRCode.toDataURL(certificateUrl, {
|
||||||
@@ -71,13 +68,10 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
const isLoading = (!decodedInfo && !error) || isLoadingCertificate;
|
const isLoading = (!decodedInfo && !error) || isLoadingCertificate;
|
||||||
|
|
||||||
// Certificate name from the user data
|
|
||||||
const certificateName = certificateUser?.fullname;
|
const certificateName = certificateUser?.fullname;
|
||||||
|
|
||||||
// Check if current user is viewing their own certificate (team member)
|
|
||||||
const isTeamMember = session?.user?.id === decodedInfo?.userId;
|
const isTeamMember = session?.user?.id === decodedInfo?.userId;
|
||||||
|
|
||||||
// Dynamic font sizing: shrink by 2px if height exceeds 80px
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const adjustFontSize = (
|
const adjustFontSize = (
|
||||||
element: HTMLElement | null,
|
element: HTMLElement | null,
|
||||||
@@ -106,14 +100,12 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [team?.name, certificateName]);
|
}, [team?.name, certificateName]);
|
||||||
|
|
||||||
// Generate certificate canvas screenshot
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generateCertificate = async () => {
|
const generateCertificate = async () => {
|
||||||
if (!certificateRef.current || !team || !submission || !qrCodeUrl) return;
|
if (!certificateRef.current || !team || !submission || !qrCodeUrl) return;
|
||||||
|
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
try {
|
try {
|
||||||
// Wait longer for fonts and images to load properly
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
const canvas = await html2canvas(certificateRef.current, {
|
const canvas = await html2canvas(certificateRef.current, {
|
||||||
@@ -141,7 +133,6 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
generateCertificate();
|
generateCertificate();
|
||||||
}, [team, submission, qrCodeUrl]);
|
}, [team, submission, qrCodeUrl]);
|
||||||
|
|
||||||
// Download certificate
|
|
||||||
const handleDownloadCertificate = () => {
|
const handleDownloadCertificate = () => {
|
||||||
if (!certificateImage) return;
|
if (!certificateImage) return;
|
||||||
|
|
||||||
@@ -151,7 +142,6 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
link.click();
|
link.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Print certificate
|
|
||||||
const handlePrintCertificate = () => {
|
const handlePrintCertificate = () => {
|
||||||
if (!certificateImage) return;
|
if (!certificateImage) return;
|
||||||
|
|
||||||
@@ -228,7 +218,6 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
{/* Print Styles */}
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@media print {
|
@media print {
|
||||||
@page {
|
@page {
|
||||||
@@ -267,7 +256,6 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile responsive - zoom out to fit */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#certificate-container {
|
#certificate-container {
|
||||||
transform-origin: top center;
|
transform-origin: top center;
|
||||||
@@ -275,7 +263,6 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="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">
|
||||||
@@ -299,12 +286,10 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Certificate Content */}
|
|
||||||
<div
|
<div
|
||||||
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||||
id="certificate-wrapper"
|
id="certificate-wrapper"
|
||||||
>
|
>
|
||||||
{/* Hidden Template for Canvas Generation */}
|
|
||||||
<div
|
<div
|
||||||
className={showTemplate ? 'block' : 'hidden'}
|
className={showTemplate ? 'block' : 'hidden'}
|
||||||
style={{ position: 'absolute', left: '-9999px' }}
|
style={{ position: 'absolute', left: '-9999px' }}
|
||||||
@@ -321,7 +306,6 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
height: `${(1000 * 2480) / 3508}px`,
|
height: `${(1000 * 2480) / 3508}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Team Name - positioned in middle between "Diberikan Kepada" and "Telah Berpartisipasi" */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -347,7 +331,6 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Name - positioned below team name */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -373,7 +356,6 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* QR Code - positioned in the white box area */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -397,7 +379,6 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display Certificate Image */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
||||||
{isGenerating && (
|
{isGenerating && (
|
||||||
<div className="flex items-center justify-center p-12">
|
<div className="flex items-center justify-center p-12">
|
||||||
@@ -419,7 +400,6 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{isTeamMember && (
|
{isTeamMember && (
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||||
<Button
|
<Button
|
||||||
@@ -451,7 +431,6 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Box */}
|
|
||||||
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||||
Certificate Information
|
Certificate Information
|
||||||
|
|||||||
@@ -38,17 +38,11 @@ const formatOrdinalRank = (rank: number): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep the template size aligned with the SVG native size (842x595) using an integer multiplier.
|
|
||||||
// This reduces sub-pixel scaling artifacts (blur) on thin lines when rasterizing with html2canvas.
|
|
||||||
const CERT_WIDTH = 842 * 2;
|
const CERT_WIDTH = 842 * 2;
|
||||||
const CERT_HEIGHT = 595 * 2;
|
const CERT_HEIGHT = 595 * 2;
|
||||||
|
|
||||||
// Balance between output sharpness and file size.
|
|
||||||
// Output resolution will be (CERT_WIDTH * EXPORT_SCALE) x (CERT_HEIGHT * EXPORT_SCALE).
|
|
||||||
const EXPORT_SCALE = 2;
|
const EXPORT_SCALE = 2;
|
||||||
|
|
||||||
// The original layout was tuned around a ~1000px-wide template.
|
|
||||||
// We keep the same visual proportions by scaling fixed pixel values.
|
|
||||||
const LAYOUT_BASE_WIDTH = 1000;
|
const LAYOUT_BASE_WIDTH = 1000;
|
||||||
const LAYOUT_SCALE = CERT_WIDTH / LAYOUT_BASE_WIDTH;
|
const LAYOUT_SCALE = CERT_WIDTH / LAYOUT_BASE_WIDTH;
|
||||||
const s = (px: number) => Math.round(px * LAYOUT_SCALE);
|
const s = (px: number) => Math.round(px * LAYOUT_SCALE);
|
||||||
@@ -122,7 +116,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
(t) => (t as { id?: string } | null | undefined)?.id === decodedTeamId
|
(t) => (t as { id?: string } | null | undefined)?.id === decodedTeamId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate QR Code (public link)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!certId) return;
|
if (!certId) return;
|
||||||
|
|
||||||
@@ -141,7 +134,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
.catch((err) => console.error('QR Code generation failed:', err));
|
.catch((err) => console.error('QR Code generation failed:', err));
|
||||||
}, [certId]);
|
}, [certId]);
|
||||||
|
|
||||||
// Generate certificate canvas screenshot
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generateCertificate = async () => {
|
const generateCertificate = async () => {
|
||||||
if (!certificateRef.current) return;
|
if (!certificateRef.current) return;
|
||||||
@@ -311,7 +303,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="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">
|
||||||
@@ -339,7 +330,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||||
id="certificate-wrapper"
|
id="certificate-wrapper"
|
||||||
>
|
>
|
||||||
{/* Hidden Template for Canvas Generation */}
|
|
||||||
<div
|
<div
|
||||||
className={showTemplate ? 'block' : 'hidden'}
|
className={showTemplate ? 'block' : 'hidden'}
|
||||||
style={{ position: 'absolute', left: '-9999px' }}
|
style={{ position: 'absolute', left: '-9999px' }}
|
||||||
@@ -363,7 +353,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* Team Name */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -388,7 +377,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Members list */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -416,7 +404,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Award text */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -445,7 +432,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rank badge */}
|
|
||||||
{!!rankLabel && (
|
{!!rankLabel && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -476,7 +462,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* QR Code (same placement as existing certificate page) */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -504,7 +489,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display Certificate Image */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
||||||
{isGenerating && (
|
{isGenerating && (
|
||||||
<div className="flex items-center justify-center p-12">
|
<div className="flex items-center justify-center p-12">
|
||||||
@@ -526,7 +510,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{isTeamMember && (
|
{isTeamMember && (
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||||
<Button
|
<Button
|
||||||
@@ -588,7 +571,6 @@ const CertificateWinnerPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Box */}
|
|
||||||
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||||
Certificate Information
|
Certificate Information
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const DashboardLayout: FC = (): ReactElement => {
|
|||||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">
|
<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">
|
<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
|
<button
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ import { Icon } from '@iconify/react';
|
|||||||
import ProfilePage from '../profile/page';
|
import ProfilePage from '../profile/page';
|
||||||
import { encodeWinnerCertificateId } from '../../utils/certificate';
|
import { encodeWinnerCertificateId } from '../../utils/certificate';
|
||||||
|
|
||||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
|
||||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||||
|
|
||||||
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
|
|
||||||
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
||||||
|
|
||||||
type Invitation = {
|
type Invitation = {
|
||||||
@@ -50,13 +48,10 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
seconds: number;
|
seconds: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Check if team features are closed
|
|
||||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||||
|
|
||||||
// Check if submission deadline passed
|
|
||||||
const isSubmissionDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
const isSubmissionDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||||
|
|
||||||
// Countdown timer
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSubmissionDeadlinePassed) return;
|
if (isSubmissionDeadlinePassed) return;
|
||||||
|
|
||||||
@@ -83,7 +78,6 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [isSubmissionDeadlinePassed]);
|
}, [isSubmissionDeadlinePassed]);
|
||||||
|
|
||||||
// Lock background scroll when profile modal is open
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showProfileModal) {
|
if (showProfileModal) {
|
||||||
const originalOverflow = document.body.style.overflow;
|
const originalOverflow = document.body.style.overflow;
|
||||||
@@ -145,7 +139,6 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Winner Banner */}
|
|
||||||
{winnerEntry && myTeams.length > 0 && (
|
{winnerEntry && myTeams.length > 0 && (
|
||||||
<div className="mb-8 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6">
|
<div className="mb-8 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
@@ -176,7 +169,6 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Countdown Timer */}
|
|
||||||
{timeLeft && !isSubmissionDeadlinePassed && (
|
{timeLeft && !isSubmissionDeadlinePassed && (
|
||||||
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6">
|
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAuthStore, useUserMe } from '@imphnen-frontend-service/service';
|
import { useAuthStore, useUserMe } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
// Define onboarding routes
|
|
||||||
const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
|
const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
@@ -21,56 +20,46 @@ export default function RootLayout() {
|
|||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
const pathname = location.pathname;
|
const pathname = location.pathname;
|
||||||
|
|
||||||
// Allow hackathon pages without checks
|
|
||||||
if (pathname.startsWith('/hackathons')) {
|
if (pathname.startsWith('/hackathons')) {
|
||||||
setIsChecking(false);
|
setIsChecking(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow auth callback without checks
|
|
||||||
if (pathname === '/auth/callback') {
|
if (pathname === '/auth/callback') {
|
||||||
setIsChecking(false);
|
setIsChecking(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public auth pages - allow unauthenticated access
|
|
||||||
if (pathname.startsWith('/auth')) {
|
if (pathname.startsWith('/auth')) {
|
||||||
// If already authenticated and not on password reset pages, redirect to dashboard
|
|
||||||
if (session && pathname !== '/auth/reset-password') {
|
if (session && pathname !== '/auth/reset-password') {
|
||||||
navigate('/dashboard', { replace: true });
|
navigate('/dashboard', { replace: true });
|
||||||
setIsChecking(false);
|
setIsChecking(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Allow unauthenticated access to auth pages
|
|
||||||
setIsChecking(false);
|
setIsChecking(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Home page - allow everyone to view the landing page
|
|
||||||
if (pathname === '/') {
|
if (pathname === '/') {
|
||||||
setIsChecking(false);
|
setIsChecking(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Certificate page - allow public access
|
|
||||||
if (pathname.startsWith('/certificate/')) {
|
if (pathname.startsWith('/certificate/')) {
|
||||||
setIsChecking(false);
|
setIsChecking(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Require authentication for all other routes
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
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
|
|
||||||
if (isUserLoading) {
|
if (isUserLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has completed onboarding (skip for onboarding routes)
|
|
||||||
if (!ONBOARDING_ROUTES.has(pathname)) {
|
if (!ONBOARDING_ROUTES.has(pathname)) {
|
||||||
const hasLocation = !!userData?.data?.location || !!session?.user?.location;
|
const hasLocation = !!userData?.data?.location || !!session?.user?.location;
|
||||||
|
|
||||||
@@ -87,7 +76,6 @@ export default function RootLayout() {
|
|||||||
checkAuth();
|
checkAuth();
|
||||||
}, [location.pathname, navigate, session, userData, isUserLoading]);
|
}, [location.pathname, navigate, session, userData, isUserLoading]);
|
||||||
|
|
||||||
// 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 dark:bg-neutral-950">
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set initial avatar preview from GitHub avatar if available
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.user?.avatar && !avatarPreview) {
|
if (session?.user?.avatar && !avatarPreview) {
|
||||||
setAvatarPreview(session.user.avatar);
|
setAvatarPreview(session.user.avatar);
|
||||||
@@ -60,14 +59,12 @@ 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) {
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
toast.error('The file is too large. Maximum size is 2MB.');
|
toast.error('The file is too large. Maximum size is 2MB.');
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
toast.error('The file must be an image');
|
toast.error('The file must be an image');
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
@@ -87,13 +84,11 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
|||||||
try {
|
try {
|
||||||
let avatarUrl = session?.user?.avatar || null;
|
let avatarUrl = session?.user?.avatar || null;
|
||||||
|
|
||||||
// Upload avatar if a new file was selected
|
|
||||||
if (avatarFile) {
|
if (avatarFile) {
|
||||||
const uploadResult = await uploadAvatar(avatarFile);
|
const uploadResult = await uploadAvatar(avatarFile);
|
||||||
avatarUrl = uploadResult.data.url;
|
avatarUrl = uploadResult.data.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user in Supabase
|
|
||||||
await updateUser({
|
await updateUser({
|
||||||
fullname: data.fullname,
|
fullname: data.fullname,
|
||||||
avatar: avatarUrl,
|
avatar: avatarUrl,
|
||||||
@@ -102,11 +97,8 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
|||||||
skills: data.skills,
|
skills: data.skills,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait a bit for the onSuccess handler to update localStorage
|
|
||||||
// The updateUser mutation's onSuccess handler updates the Zustand store and localStorage
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// 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(
|
toast.error(
|
||||||
@@ -130,7 +122,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="space-y-6">
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
{/* Avatar Upload */}
|
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{avatarPreview ? (
|
{avatarPreview ? (
|
||||||
@@ -169,7 +160,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full Name */}
|
|
||||||
<ControlledInputField
|
<ControlledInputField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
label="Full Name"
|
label="Full Name"
|
||||||
@@ -180,7 +170,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
|||||||
isRequired={true}
|
isRequired={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 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-base font-medium text-gray-700 dark:text-gray-300">
|
||||||
City <span className="text-red-500">*</span>
|
City <span className="text-red-500">*</span>
|
||||||
@@ -199,7 +188,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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-base font-medium text-gray-700 dark:text-gray-300">
|
||||||
Role / Skills
|
Role / Skills
|
||||||
@@ -237,7 +225,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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-base font-medium text-gray-700 dark:text-gray-300">
|
||||||
Bio{' '}
|
Bio{' '}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-white dark:bg-gray-950">
|
<main className="min-h-screen bg-white dark:bg-gray-950">
|
||||||
{/* Navigation */}
|
|
||||||
<div id="#top" className="hidden"></div>
|
<div id="#top" className="hidden"></div>
|
||||||
<nav className="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 sticky top-0 z-50">
|
<nav className="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 sticky top-0 z-50">
|
||||||
<div className="flex items-center justify-between max-w-7xl mx-auto px-4 md:px-8 py-4">
|
<div className="flex items-center justify-between max-w-7xl mx-auto px-4 md:px-8 py-4">
|
||||||
@@ -82,7 +82,7 @@ export default function HomePage() {
|
|||||||
Hackathon
|
Hackathon
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{/* Desktop Menu */}
|
|
||||||
<div className="hidden md:flex text-label1 items-center gap-4 lg:gap-8">
|
<div className="hidden md:flex text-label1 items-center gap-4 lg:gap-8">
|
||||||
<a
|
<a
|
||||||
href="#timeline"
|
href="#timeline"
|
||||||
@@ -131,7 +131,7 @@ export default function HomePage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Mobile Menu Button */}
|
|
||||||
<div className="flex items-center gap-2 md:hidden">
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button
|
<button
|
||||||
@@ -156,7 +156,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
|
||||||
{mobileMenuOpen && (
|
{mobileMenuOpen && (
|
||||||
<nav className="md:hidden sticky top-18 border-b border-gray-200 dark:border-gray-800 bg-white z-40 dark:bg-gray-900">
|
<nav className="md:hidden sticky top-18 border-b border-gray-200 dark:border-gray-800 bg-white z-40 dark:bg-gray-900">
|
||||||
<div className="flex flex-col items-start gap-4 px-4 py-4">
|
<div className="flex flex-col items-start gap-4 px-4 py-4">
|
||||||
@@ -205,7 +204,6 @@ export default function HomePage() {
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<section className="relative w-full overflow-hidden py-20">
|
<section className="relative w-full overflow-hidden py-20">
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -219,7 +217,7 @@ export default function HomePage() {
|
|||||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] dark:bg-[linear-gradient(rgba(59,130,246,0.1)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.1)_1px,transparent_1px)] bg-size-[40px_40px]"></div>
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] dark:bg-[linear-gradient(rgba(59,130,246,0.1)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.1)_1px,transparent_1px)] bg-size-[40px_40px]"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto container px-4 relative flex flex-col items-center">
|
<div className="mx-auto container px-4 relative flex flex-col items-center">
|
||||||
{/* Logos */}
|
|
||||||
<div className="flex items-center gap-4 md:gap-8 lg:gap-12 mb-8 md:mb-12 lg:mb-16 flex-wrap justify-center">
|
<div className="flex items-center gap-4 md:gap-8 lg:gap-12 mb-8 md:mb-12 lg:mb-16 flex-wrap justify-center">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<img
|
<img
|
||||||
@@ -239,20 +237,20 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Title */}
|
|
||||||
<h1 className="text-h1 font-bold text-gray-900 dark:text-white mb-4 text-center">
|
<h1 className="text-h1 font-bold text-gray-900 dark:text-white mb-4 text-center">
|
||||||
Hackathon
|
Hackathon
|
||||||
</h1>
|
</h1>
|
||||||
{/* Subtitle */}
|
|
||||||
<p className="text-p1 text-primary-500 font-semibold mb-6 md:mb-8 text-center px-4">
|
<p className="text-p1 text-primary-500 font-semibold mb-6 md:mb-8 text-center px-4">
|
||||||
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
|
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
|
||||||
</p>
|
</p>
|
||||||
{/* Description */}
|
|
||||||
<p className="text-p3 text-gray-600 dark:text-gray-200 max-w-lg md:max-w-xl text-center mb-8 md:mb-12 px-4 font-sans">
|
<p className="text-p3 text-gray-600 dark:text-gray-200 max-w-lg md:max-w-xl text-center mb-8 md:mb-12 px-4 font-sans">
|
||||||
Kompetisi pengembangan teknologi untuk menciptakan solusi inovatif
|
Kompetisi pengembangan teknologi untuk menciptakan solusi inovatif
|
||||||
yang menghadirkan dampak nyata
|
yang menghadirkan dampak nyata
|
||||||
</p>
|
</p>
|
||||||
{/* Status */}
|
|
||||||
<div className="flex flex-col md:flex-row items-center gap-4 md:gap-8 mb-10 md:mb-16 text-base text-gray-600 dark:text-gray-200">
|
<div className="flex flex-col md:flex-row items-center gap-4 md:gap-8 mb-10 md:mb-16 text-base text-gray-600 dark:text-gray-200">
|
||||||
<div className="flex items-center gap-2 md:gap-3">
|
<div className="flex items-center gap-2 md:gap-3">
|
||||||
<Icon icon="streamline-plump:web" className="w-4 h-4" />
|
<Icon icon="streamline-plump:web" className="w-4 h-4" />
|
||||||
@@ -263,7 +261,7 @@ export default function HomePage() {
|
|||||||
<span>Pendaftaran hingga 30 November 2025</span>
|
<span>Pendaftaran hingga 30 November 2025</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* CTA Buttons */}
|
|
||||||
<div className="flex flex-col md:flex-row items-center gap-4 px-4">
|
<div className="flex flex-col md:flex-row items-center gap-4 px-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate('/auth/signup')}
|
onClick={() => navigate('/auth/signup')}
|
||||||
@@ -280,7 +278,7 @@ export default function HomePage() {
|
|||||||
Gabung Grup WA Hackathon
|
Gabung Grup WA Hackathon
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{/* Scroll indicator */}
|
|
||||||
<div className="mt-12 md:mt-20">
|
<div className="mt-12 md:mt-20">
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6 text-gray-400 dark:text-gray-500 animate-bounce"
|
className="w-6 h-6 text-gray-400 dark:text-gray-500 animate-bounce"
|
||||||
@@ -299,7 +297,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* About Section */}
|
|
||||||
<section className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950">
|
<section className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950">
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
<div className="max-w-4xl mx-auto text-center">
|
||||||
<h2 className="text-3xl md:text-5xl font-bold mb-10 dark:text-white">
|
<h2 className="text-3xl md:text-5xl font-bold mb-10 dark:text-white">
|
||||||
@@ -322,7 +319,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Prizes Section */}
|
|
||||||
<section
|
<section
|
||||||
id="hadiah"
|
id="hadiah"
|
||||||
className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-linear-to-b dark:from-gray-950 dark:to-gray-900"
|
className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-linear-to-b dark:from-gray-950 dark:to-gray-900"
|
||||||
@@ -338,7 +334,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{/* Prize 1 */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-primary-500 dark:hover:border-primary-500 transition-colors">
|
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-primary-500 dark:hover:border-primary-500 transition-colors">
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<div className="w-16 h-16 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
|
<div className="w-16 h-16 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
|
||||||
@@ -356,7 +352,6 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prize 2 */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-500 transition-colors">
|
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-500 transition-colors">
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
|
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
|
||||||
@@ -374,7 +369,6 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prize 3 */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-orange-300 dark:hover:border-orange-500 transition-colors">
|
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-orange-300 dark:hover:border-orange-500 transition-colors">
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<div className="w-16 h-16 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center">
|
<div className="w-16 h-16 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center">
|
||||||
@@ -392,7 +386,6 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Special Prize */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-purple-300 dark:hover:border-purple-500 transition-colors">
|
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-purple-300 dark:hover:border-purple-500 transition-colors">
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<div className="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
|
<div className="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
|
||||||
@@ -413,7 +406,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Timeline Section */}
|
|
||||||
<section
|
<section
|
||||||
id="timeline"
|
id="timeline"
|
||||||
className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950"
|
className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950"
|
||||||
@@ -429,7 +421,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{/* Timeline Item 1 */}
|
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
||||||
@@ -448,7 +440,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline Item 2 */}
|
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
||||||
@@ -468,7 +459,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline Item 3 */}
|
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
||||||
@@ -487,7 +477,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline Item 4 */}
|
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
||||||
@@ -506,7 +495,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline Item 5 */}
|
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
|
||||||
@@ -527,7 +515,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Judges Section */}
|
|
||||||
<section className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-gray-900 font-sans">
|
<section className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-gray-900 font-sans">
|
||||||
<div className="md:max-w-6xl mx-auto">
|
<div className="md:max-w-6xl mx-auto">
|
||||||
<div className="text-center mb-12 font-bai-jamjuree">
|
<div className="text-center mb-12 font-bai-jamjuree">
|
||||||
@@ -540,7 +527,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-md md:max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="w-full max-w-md md:max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
{/* Judge 1 */}
|
|
||||||
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
||||||
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
||||||
Alifais Farrel Ramdhani
|
Alifais Farrel Ramdhani
|
||||||
@@ -551,7 +538,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Judge 2 */}
|
|
||||||
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
||||||
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
||||||
Muhammad Alif Ramadhan
|
Muhammad Alif Ramadhan
|
||||||
@@ -562,7 +548,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Judge 3 */}
|
|
||||||
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
||||||
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
||||||
Hafid Nur
|
Hafid Nur
|
||||||
@@ -576,7 +561,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ Section */}
|
|
||||||
<section
|
<section
|
||||||
id="faq"
|
id="faq"
|
||||||
className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950"
|
className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950"
|
||||||
@@ -631,7 +615,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Sponsors Section */}
|
|
||||||
<section className="py-20 md:py-28 px-4 md:px-8 bg-gray-50 dark:bg-gray-900">
|
<section className="py-20 md:py-28 px-4 md:px-8 bg-gray-50 dark:bg-gray-900">
|
||||||
<div className="max-w-6xl mx-auto text-center">
|
<div className="max-w-6xl mx-auto text-center">
|
||||||
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
|
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
|
||||||
@@ -658,7 +641,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA Section */}
|
|
||||||
<section
|
<section
|
||||||
id="masuk"
|
id="masuk"
|
||||||
className="py-20 md:py-28 px-4 md:px-8 bg-linear-to-b from-white to-blue-50 dark:from-gray-950 dark:to-gray-900"
|
className="py-20 md:py-28 px-4 md:px-8 bg-linear-to-b from-white to-blue-50 dark:from-gray-950 dark:to-gray-900"
|
||||||
@@ -700,11 +682,10 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="bg-gray-950 text-white py-12 px-4 md:px-8 font-sans">
|
<footer className="bg-gray-950 text-white py-12 px-4 md:px-8 font-sans">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
|
||||||
{/* Brand */}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-4 font-bai-jamjuree">
|
<div className="flex items-center gap-2 mb-4 font-bai-jamjuree">
|
||||||
<span className="text-2xl font-bold">IMPHNEN</span>
|
<span className="text-2xl font-bold">IMPHNEN</span>
|
||||||
@@ -768,7 +749,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Links */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">
|
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">
|
||||||
Quick Links
|
Quick Links
|
||||||
@@ -806,7 +786,6 @@ export default function HomePage() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">
|
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">
|
||||||
Contact
|
Contact
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set initial avatar preview from current user avatar
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.user?.avatar && !avatarPreview) {
|
if (session?.user?.avatar && !avatarPreview) {
|
||||||
setAvatarPreview(session.user.avatar);
|
setAvatarPreview(session.user.avatar);
|
||||||
@@ -84,14 +83,12 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
|||||||
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) {
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
toast.error('The file is too large. Maximum size is 2MB.');
|
toast.error('The file is too large. Maximum size is 2MB.');
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
toast.error('The file must be an image');
|
toast.error('The file must be an image');
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
@@ -111,13 +108,11 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
|||||||
try {
|
try {
|
||||||
let avatarUrl = session?.user?.avatar || null;
|
let avatarUrl = session?.user?.avatar || null;
|
||||||
|
|
||||||
// Upload avatar if a new file was selected
|
|
||||||
if (avatarFile) {
|
if (avatarFile) {
|
||||||
const uploadResult = await uploadAvatar(avatarFile);
|
const uploadResult = await uploadAvatar(avatarFile);
|
||||||
avatarUrl = uploadResult.data.url;
|
avatarUrl = uploadResult.data.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user profile
|
|
||||||
await updateUser({
|
await updateUser({
|
||||||
fullname: data.fullname,
|
fullname: data.fullname,
|
||||||
avatar: avatarUrl,
|
avatar: avatarUrl,
|
||||||
@@ -128,10 +123,8 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
|||||||
|
|
||||||
toast.success('Profile updated successfully!');
|
toast.success('Profile updated successfully!');
|
||||||
|
|
||||||
// Wait a bit for the onSuccess handler to update localStorage
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Close modal
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Profile update failed:', error);
|
console.error('Profile update failed:', error);
|
||||||
@@ -181,7 +174,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="space-y-6">
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
{/* Avatar Upload */}
|
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
{avatarPreview ? (
|
{avatarPreview ? (
|
||||||
@@ -200,7 +192,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Camera overlay */}
|
|
||||||
<label
|
<label
|
||||||
htmlFor="avatar"
|
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"
|
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"
|
||||||
@@ -241,7 +232,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full Name */}
|
|
||||||
<ControlledInputField
|
<ControlledInputField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
label="Full Name"
|
label="Full Name"
|
||||||
@@ -250,7 +240,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
|||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* City */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-label1 font-medium text-neutral-800 dark:text-neutral-300">
|
<label className="block text-label1 font-medium text-neutral-800 dark:text-neutral-300">
|
||||||
City
|
City
|
||||||
@@ -269,7 +258,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Role/Skills */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
|
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
|
||||||
Role / Skills
|
Role / Skills
|
||||||
@@ -307,7 +295,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bio */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
|
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
|
||||||
Bio{' '}
|
Bio{' '}
|
||||||
@@ -337,7 +324,6 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex space-x-3 pt-4">
|
<div className="flex space-x-3 pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
|||||||
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 {
|
interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -40,10 +39,8 @@ const TeamChatPage: FC = (): ReactElement => {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline delete confirmation UI state
|
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Prepare messages: de-duplicate by id and sort by created_at
|
|
||||||
const displayMessages = useMemo(() => {
|
const displayMessages = useMemo(() => {
|
||||||
const raw: ChatMessage[] = Array.isArray(messages)
|
const raw: ChatMessage[] = Array.isArray(messages)
|
||||||
? (messages as ChatMessage[])
|
? (messages as ChatMessage[])
|
||||||
@@ -64,7 +61,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
|||||||
return dedup;
|
return dedup;
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// Auto-scroll to bottom when new messages arrive
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [displayMessages]);
|
}, [displayMessages]);
|
||||||
@@ -108,7 +104,6 @@ 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 dark:bg-gray-950">
|
||||||
{/* 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 dark:bg-gray-900 border-b dark:border-gray-700 shadow-sm dark:shadow-gray-950/50">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div className="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">
|
||||||
@@ -130,7 +125,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages Container */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<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 ? (
|
||||||
@@ -156,7 +150,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
|||||||
isOwnMessage ? 'flex-row-reverse' : 'flex-row'
|
isOwnMessage ? 'flex-row-reverse' : 'flex-row'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Avatar */}
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
{msg.user?.avatar ? (
|
{msg.user?.avatar ? (
|
||||||
<img
|
<img
|
||||||
@@ -171,7 +164,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message Bubble */}
|
|
||||||
<div className={`flex-1 text-left`}>
|
<div className={`flex-1 text-left`}>
|
||||||
<div
|
<div
|
||||||
className={`inline-block ${
|
className={`inline-block ${
|
||||||
@@ -197,7 +189,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
|||||||
{msg.message}
|
{msg.message}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Delete button */}
|
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -214,7 +205,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
|||||||
}`}
|
}`}
|
||||||
title="Delete message"
|
title="Delete message"
|
||||||
>
|
>
|
||||||
{/* Icon trash */}
|
|
||||||
<Icon
|
<Icon
|
||||||
icon="mdi:trash-can-outline"
|
icon="mdi:trash-can-outline"
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
@@ -267,7 +257,6 @@ const TeamChatPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 dark:bg-gray-900 border-t dark:border-gray-700 shadow-lg dark:shadow-gray-950/50">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<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">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { CitySelect } from '../../../../components/city-select';
|
|||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
const MAX_FILE_SIZE = 2 * 1024 * 1024;
|
||||||
|
|
||||||
const EditTeamPage: FC = (): ReactElement => {
|
const EditTeamPage: FC = (): ReactElement => {
|
||||||
const { teamId } = useParams<{ teamId: string }>();
|
const { teamId } = useParams<{ teamId: string }>();
|
||||||
@@ -162,7 +162,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
|||||||
<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 dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8">
|
||||||
<form onSubmit={onSubmit} className="space-y-6">
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
{/* Banner Upload */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Team Banner
|
Team Banner
|
||||||
@@ -209,7 +208,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logo Upload */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Team Logo
|
Team Logo
|
||||||
@@ -261,7 +259,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team Name */}
|
|
||||||
<ControlledInputField
|
<ControlledInputField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
label="Team Name"
|
label="Team Name"
|
||||||
@@ -271,7 +268,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
|||||||
isRequired={true}
|
isRequired={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 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 dark:text-gray-300">
|
||||||
City
|
City
|
||||||
@@ -290,7 +286,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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-label1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
Description
|
Description
|
||||||
@@ -318,7 +313,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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-label1 font-medium text-gray-700 dark:text-gray-300">
|
||||||
Team Visibility
|
Team Visibility
|
||||||
@@ -367,7 +361,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div className="flex space-x-3 pt-4">
|
<div className="flex space-x-3 pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const TeamDetailLayout: FC = (): ReactElement => {
|
|||||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">
|
<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">
|
<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
|
<button
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
|||||||
mode: 'all',
|
mode: 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show loading state while fetching team data
|
|
||||||
if (isLoadingTeam) {
|
if (isLoadingTeam) {
|
||||||
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 bg-gray-50 dark:bg-gray-950">
|
||||||
@@ -153,7 +152,6 @@ 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 && (
|
{hasSubmission && (
|
||||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-4">
|
<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">
|
<div className="flex items-start space-x-3">
|
||||||
@@ -170,7 +168,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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 dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
@@ -239,7 +236,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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 dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
Current Members ({members.length})
|
Current Members ({members.length})
|
||||||
@@ -311,7 +307,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Warning */}
|
|
||||||
{!hasSubmission && (
|
{!hasSubmission && (
|
||||||
<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 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
@@ -322,7 +317,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ 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');
|
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||||
|
|
||||||
// Image component with loading state
|
|
||||||
const ImageWithLoader: FC<{
|
const ImageWithLoader: FC<{
|
||||||
src: string;
|
src: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
@@ -56,7 +54,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
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 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);
|
||||||
@@ -67,8 +64,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
const [imagesLoaded, setImagesLoaded] = useState(false);
|
const [imagesLoaded, setImagesLoaded] = useState(false);
|
||||||
const [imageLoadCount, setImageLoadCount] = useState(0);
|
const [imageLoadCount, setImageLoadCount] = useState(0);
|
||||||
|
|
||||||
// Calculate total images to load (banner + logo + member avatars)
|
const totalImagesToLoad = 0;
|
||||||
const totalImagesToLoad = 0; // Simplified - disable image loading overlay
|
|
||||||
|
|
||||||
const handleImageLoad = () => {
|
const handleImageLoad = () => {
|
||||||
setImageLoadCount((prev) => {
|
setImageLoadCount((prev) => {
|
||||||
@@ -81,11 +77,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImageError = () => {
|
const handleImageError = () => {
|
||||||
// Treat error as loaded to not block the UI
|
|
||||||
handleImageLoad();
|
handleImageLoad();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set images as loaded immediately since we disabled the loading overlay
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImagesLoaded(true);
|
setImagesLoaded(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -102,7 +96,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
const currentUserId = session?.user?.id;
|
const currentUserId = session?.user?.id;
|
||||||
const isLeader = currentUserId === team?.leader_id;
|
const isLeader = currentUserId === team?.leader_id;
|
||||||
|
|
||||||
// Only fetch join requests if user is the team leader
|
|
||||||
const { data: joinRequestsData } = useTeamJoinRequests(
|
const { data: joinRequestsData } = useTeamJoinRequests(
|
||||||
teamId || '',
|
teamId || '',
|
||||||
!!teamId && isLeader
|
!!teamId && isLeader
|
||||||
@@ -183,7 +176,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
if (isLoadingTeam || isLoadingMembers) {
|
if (isLoadingTeam || isLoadingMembers) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
{/* Skeleton Header */}
|
|
||||||
<div className="bg-white dark:bg-gray-950 border-b">
|
<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="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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
@@ -202,7 +194,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{/* Main Content Skeleton */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<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="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="h-6 w-32 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
|
||||||
@@ -214,7 +205,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar Skeleton */}
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-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="h-6 w-40 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
|
||||||
@@ -234,7 +224,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</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="fixed inset-0 bg-white/60 dark:bg-black/50 flex items-center justify-center z-50">
|
||||||
<div className="text-center">
|
<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>
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
|
||||||
@@ -262,7 +251,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 relative dark:bg-gray-950">
|
<div className="min-h-screen bg-gray-50 relative dark:bg-gray-950">
|
||||||
{/* Loading overlay while images are loading */}
|
|
||||||
{!imagesLoaded && totalImagesToLoad > 0 && (
|
{!imagesLoaded && totalImagesToLoad > 0 && (
|
||||||
<div className="fixed inset-0 bg-white/80 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-white/80 flex items-center justify-center z-50">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -275,7 +263,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header with Banner */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||||
{team.banner && (
|
{team.banner && (
|
||||||
<div className="relative w-full aspect-3/1 overflow-hidden">
|
<div className="relative w-full aspect-3/1 overflow-hidden">
|
||||||
@@ -356,9 +343,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
<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-4 md:py-8">
|
||||||
<div className="grid gap-4 md:gap-6 xl:grid-cols-3">
|
<div className="grid gap-4 md:gap-6 xl:grid-cols-3">
|
||||||
{/* Main Content */}
|
|
||||||
<div className="lg:col-span-2 space-y-4 md:space-y-6">
|
<div className="lg:col-span-2 space-y-4 md:space-y-6">
|
||||||
{/* Team Description */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||||
About Team
|
About Team
|
||||||
@@ -368,14 +353,12 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||||
Team Management
|
Team Management
|
||||||
</h2>
|
</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 && (
|
{!team.has_submission && !isTeamFeaturesClosed && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -447,7 +430,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
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 && (
|
{members.length === 1 && !team.has_submission && !isTeamFeaturesClosed && (
|
||||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<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">
|
<h3 className="text-sm font-medium text-red-600 dark:text-red-400 mb-3">
|
||||||
@@ -465,7 +447,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick Actions for Members (non-leaders) */}
|
|
||||||
{!isLeader && isMember && (
|
{!isLeader && isMember && (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||||
@@ -499,7 +480,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Warning for single member teams */}
|
|
||||||
{members.length === 1 && !team.has_submission && isMember && (
|
{members.length === 1 && !team.has_submission && isMember && (
|
||||||
<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-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 md:p-6">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
@@ -516,7 +496,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Submission Status - Only show to members */}
|
|
||||||
{team.has_submission && isMember && (
|
{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="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">
|
||||||
@@ -539,9 +518,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
{/* Team Leader */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||||
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||||
Team Leader
|
Team Leader
|
||||||
@@ -576,7 +553,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team Members */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||||
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||||
Members ({members.length})
|
Members ({members.length})
|
||||||
@@ -621,7 +597,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 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="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||||
@@ -684,7 +659,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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 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-2xl w-full p-6 max-h-[80vh] overflow-y-auto">
|
<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">
|
||||||
@@ -797,7 +771,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Leave Team Confirmation Modal */}
|
|
||||||
{showLeaveModal && (
|
{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="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="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||||
@@ -841,7 +814,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Team Confirmation Modal */}
|
|
||||||
{showDeleteModal && (
|
{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="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="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 */}
|
|
||||||
{submission.status === 'submitted' ? (
|
{submission.status === 'submitted' ? (
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6">
|
<div className="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">
|
||||||
@@ -103,7 +103,6 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Certificate Banner - Only show when submission is submitted */}
|
|
||||||
{submission.status === 'submitted' && (
|
{submission.status === 'submitted' && (
|
||||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6 mb-6">
|
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6 mb-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
@@ -136,15 +135,14 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden">
|
||||||
{/* Project Header */}
|
|
||||||
<div className="bg-linear-to-r from-blue-600 to-blue-800 text-white p-8">
|
<div className="bg-linear-to-r from-blue-600 to-blue-800 text-white p-8">
|
||||||
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
|
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
|
||||||
<p className="text-blue-100">Team: {team?.name}</p>
|
<p className="text-blue-100">Team: {team?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project Details */}
|
|
||||||
<div className="p-8 space-y-6">
|
<div className="p-8 space-y-6">
|
||||||
{/* 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 dark:text-white mb-3">Project Description</h3>
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
@@ -152,9 +150,8 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links */}
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
{/* 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 dark:text-white mb-3">Repository</h3>
|
||||||
<a
|
<a
|
||||||
@@ -168,7 +165,6 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 dark:text-white mb-3">Live Demo</h3>
|
||||||
@@ -186,7 +182,6 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 dark:text-white mb-3">
|
||||||
@@ -212,7 +207,6 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Submission Info */}
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
||||||
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
|
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
|
||||||
<div className="grid gap-2 text-sm">
|
<div className="grid gap-2 text-sm">
|
||||||
@@ -245,7 +239,6 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
<strong>Note:</strong> This submission is now locked and cannot be edited or deleted.
|
<strong>Note:</strong> This submission is now locked and cannot be edited or deleted.
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
|
|
||||||
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
|
const MIN_TEAM_MEMBERS = 2;
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
const MAX_FILE_SIZE = 2 * 1024 * 1024;
|
||||||
|
|
||||||
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
|
|
||||||
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
||||||
|
|
||||||
const SubmitProjectPage: FC = (): ReactElement => {
|
const SubmitProjectPage: FC = (): ReactElement => {
|
||||||
@@ -36,10 +35,8 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
seconds: number;
|
seconds: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Check if deadline passed
|
|
||||||
const isDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
const isDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||||
|
|
||||||
// Countdown timer
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDeadlinePassed) return;
|
if (isDeadlinePassed) return;
|
||||||
|
|
||||||
@@ -125,7 +122,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show deadline passed screen
|
|
||||||
if (isDeadlinePassed) {
|
if (isDeadlinePassed) {
|
||||||
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 dark:bg-gray-950 p-4">
|
||||||
@@ -175,7 +171,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
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);
|
const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE);
|
||||||
if (oversizedFiles.length > 0) {
|
if (oversizedFiles.length > 0) {
|
||||||
toast.error(`${oversizedFiles.length} file(s) are too large. Maximum size is 2MB per file.`);
|
toast.error(`${oversizedFiles.length} file(s) are too large. Maximum size is 2MB per file.`);
|
||||||
@@ -222,7 +217,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
</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">
|
||||||
{/* Countdown Timer */}
|
|
||||||
{timeLeft && (
|
{timeLeft && (
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6 mb-6">
|
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6 mb-6">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
@@ -273,7 +267,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Minimum Members Warning */}
|
|
||||||
{!hasEnoughMembers && (
|
{!hasEnoughMembers && (
|
||||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
|
<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">
|
<div className="flex items-start space-x-3">
|
||||||
@@ -292,7 +285,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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 dark:bg-red-900/20 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>
|
||||||
@@ -320,7 +312,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
}}
|
}}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
{/* Project Name */}
|
|
||||||
<ControlledInputField
|
<ControlledInputField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
label="Project Name"
|
label="Project Name"
|
||||||
@@ -330,7 +321,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
isRequired={true}
|
isRequired={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 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-[15px] font-medium text-gray-700 dark:text-gray-300">
|
||||||
Project Description <span className="text-red-500">*</span>
|
Project Description <span className="text-red-500">*</span>
|
||||||
@@ -360,7 +350,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Repository URL */}
|
|
||||||
<ControlledInputField
|
<ControlledInputField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
label="Repository URL (GitHub, GitLab, etc.)"
|
label="Repository URL (GitHub, GitLab, etc.)"
|
||||||
@@ -371,7 +360,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
isRequired={true}
|
isRequired={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Demo URL */}
|
|
||||||
<ControlledInputField
|
<ControlledInputField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
label="Demo URL (Optional)"
|
label="Demo URL (Optional)"
|
||||||
@@ -381,7 +369,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Screenshots */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300">
|
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300">
|
||||||
Project Screenshots (Optional)
|
Project Screenshots (Optional)
|
||||||
@@ -424,7 +411,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div className="flex space-x-3 pt-4">
|
<div className="flex space-x-3 pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -451,7 +437,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ import { Icon } from '@iconify/react';
|
|||||||
const DEFAULT_PER_PAGE = 12;
|
const DEFAULT_PER_PAGE = 12;
|
||||||
const PER_PAGE_OPTIONS = [6, 12, 24, 48];
|
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');
|
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||||
|
|
||||||
// Member filter options
|
|
||||||
const MEMBER_FILTER_OPTIONS = [
|
const MEMBER_FILTER_OPTIONS = [
|
||||||
{ label: 'All Teams', value: '', minMembers: undefined, maxMembers: undefined },
|
{ label: 'All Teams', value: '', minMembers: undefined, maxMembers: undefined },
|
||||||
{ label: 'Looking for Members (1-4)', value: 'looking', minMembers: 1, maxMembers: 4 },
|
{ label: 'Looking for Members (1-4)', value: 'looking', minMembers: 1, maxMembers: 4 },
|
||||||
@@ -31,14 +29,12 @@ const MEMBER_FILTER_OPTIONS = [
|
|||||||
{ label: '5 Members (Full)', value: '5', minMembers: 5, maxMembers: 5 },
|
{ label: '5 Members (Full)', value: '5', minMembers: 5, maxMembers: 5 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Submission status filter options
|
|
||||||
const SUBMISSION_FILTER_OPTIONS = [
|
const SUBMISSION_FILTER_OPTIONS = [
|
||||||
{ label: 'All Teams', value: '' },
|
{ label: 'All Teams', value: '' },
|
||||||
{ label: 'Submitted', value: 'true' },
|
{ label: 'Submitted', value: 'true' },
|
||||||
{ label: 'Not Submitted', value: 'false' },
|
{ label: 'Not Submitted', value: 'false' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Skeleton card component for loading state
|
|
||||||
const TeamCardSkeleton: FC = () => (
|
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="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="w-full aspect-3/1 bg-gray-200 dark:bg-gray-700" />
|
||||||
@@ -67,10 +63,8 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
// Check if team features are closed
|
|
||||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||||
|
|
||||||
// Initialize state from URL params
|
|
||||||
const initialPage = parseInt(searchParams.get('page') || '1', 10);
|
const initialPage = parseInt(searchParams.get('page') || '1', 10);
|
||||||
const initialPerPage = parseInt(
|
const initialPerPage = parseInt(
|
||||||
searchParams.get('per_page') || String(DEFAULT_PER_PAGE),
|
searchParams.get('per_page') || String(DEFAULT_PER_PAGE),
|
||||||
@@ -95,7 +89,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPerPage : DEFAULT_PER_PAGE
|
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPerPage : DEFAULT_PER_PAGE
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update URL when pagination state changes
|
|
||||||
const updateUrlParams = useCallback(
|
const updateUrlParams = useCallback(
|
||||||
(params: {
|
(params: {
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -160,7 +153,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
[searchParams, setSearchParams]
|
[searchParams, setSearchParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Debounce search term and reset page
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedSearch(searchTerm);
|
setDebouncedSearch(searchTerm);
|
||||||
@@ -172,7 +164,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
// Update URL when city filter changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCity !== initialCity) {
|
if (selectedCity !== initialCity) {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
@@ -180,12 +171,10 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
}, [selectedCity]);
|
}, [selectedCity]);
|
||||||
|
|
||||||
// Get member filter values
|
|
||||||
const memberFilter = MEMBER_FILTER_OPTIONS.find(
|
const memberFilter = MEMBER_FILTER_OPTIONS.find(
|
||||||
(opt) => opt.value === selectedMembers
|
(opt) => opt.value === selectedMembers
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert submission filter value to boolean
|
|
||||||
const hasSubmissionFilter = selectedSubmission === 'true' ? true : selectedSubmission === 'false' ? false : undefined;
|
const hasSubmissionFilter = selectedSubmission === 'true' ? true : selectedSubmission === 'false' ? false : undefined;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -216,7 +205,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
const total = teamsData?.total || 0;
|
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
|
|
||||||
const isMyTeam = (teamId: string) => {
|
const isMyTeam = (teamId: string) => {
|
||||||
return myTeams.some((team: any) => team.id === teamId);
|
return myTeams.some((team: any) => team.id === teamId);
|
||||||
};
|
};
|
||||||
@@ -239,25 +227,21 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate page numbers to display
|
|
||||||
const getPageNumbers = () => {
|
const getPageNumbers = () => {
|
||||||
const pages: (number | string)[] = [];
|
const pages: (number | string)[] = [];
|
||||||
const maxVisible = 5;
|
const maxVisible = 5;
|
||||||
|
|
||||||
if (totalPages <= maxVisible + 2) {
|
if (totalPages <= maxVisible + 2) {
|
||||||
// Show all pages if total is small
|
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
pages.push(i);
|
pages.push(i);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Always show first page
|
|
||||||
pages.push(1);
|
pages.push(1);
|
||||||
|
|
||||||
if (currentPage > 3) {
|
if (currentPage > 3) {
|
||||||
pages.push('...');
|
pages.push('...');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show pages around current
|
|
||||||
const start = Math.max(2, currentPage - 1);
|
const start = Math.max(2, currentPage - 1);
|
||||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||||
|
|
||||||
@@ -269,7 +253,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
pages.push('...');
|
pages.push('...');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always show last page
|
|
||||||
pages.push(totalPages);
|
pages.push(totalPages);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +261,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-800">
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-800">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="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">
|
||||||
@@ -297,7 +279,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 dark:bg-gray-900 p-6 rounded-lg shadow-sm mb-6 border dark:border-gray-800">
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
@@ -366,7 +347,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Teams Count */}
|
|
||||||
{!isLoading && total > 0 && (
|
{!isLoading && total > 0 && (
|
||||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Showing {(currentPage - 1) * perPage + 1}-
|
Showing {(currentPage - 1) * perPage + 1}-
|
||||||
@@ -374,7 +354,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Teams List */}
|
|
||||||
{isLoading || isFetching ? (
|
{isLoading || isFetching ? (
|
||||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{Array.from({ length: perPage }).map((_, index) => (
|
{Array.from({ length: perPage }).map((_, index) => (
|
||||||
@@ -488,7 +467,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{(totalPages > 1 || total > 6) && (
|
{(totalPages > 1 || total > 6) && (
|
||||||
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
@@ -583,7 +561,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 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="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||||
|
|||||||
@@ -10,15 +10,13 @@ import { Icon } from '@iconify/react';
|
|||||||
|
|
||||||
import { CitySelect } from '../../../components/city-select';
|
import { CitySelect } from '../../../components/city-select';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
const MAX_FILE_SIZE = 2 * 1024 * 1024;
|
||||||
|
|
||||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
|
||||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
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 isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||||
|
|
||||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||||
@@ -99,7 +97,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to create team:', error);
|
console.error('Failed to create team:', error);
|
||||||
|
|
||||||
// Handle specific error messages
|
|
||||||
const message = error?.message || '';
|
const message = error?.message || '';
|
||||||
if (message.includes('413') || message.includes('length limit') || message.includes('too large')) {
|
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).');
|
toast.error('Image file is too large. Please use smaller images (max 2MB each).');
|
||||||
@@ -111,7 +108,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show closed screen if team features are closed
|
|
||||||
if (isTeamFeaturesClosed) {
|
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="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
@@ -157,7 +153,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
|
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
|
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<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 dark:text-white">Create Your Team</h1>
|
||||||
@@ -168,7 +163,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
|||||||
<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 dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-900/50 p-8">
|
||||||
<form onSubmit={onSubmit} className="space-y-6">
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
{/* 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 dark:text-neutral-300 mb-2">
|
||||||
Team Banner <span className="text-gray-400 dark:text-neutral-500">(Optional)</span>
|
Team Banner <span className="text-gray-400 dark:text-neutral-500">(Optional)</span>
|
||||||
@@ -207,7 +201,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 dark:text-neutral-300 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 dark:text-neutral-500">(Optional, but highly recommended)</span>
|
||||||
@@ -256,7 +249,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team Name */}
|
|
||||||
<ControlledInputField
|
<ControlledInputField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
label="Team Name"
|
label="Team Name"
|
||||||
@@ -264,7 +256,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
|||||||
name="name"
|
name="name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 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 dark:text-neutral-300">
|
||||||
City <span className="text-red-500">*</span>
|
City <span className="text-red-500">*</span>
|
||||||
@@ -283,7 +274,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 dark:text-neutral-300">
|
||||||
Description <span className="text-red-500">*</span>
|
Description <span className="text-red-500">*</span>
|
||||||
@@ -307,7 +297,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 dark:text-neutral-300">
|
||||||
Team Visibility <span className="text-red-500">*</span>
|
Team Visibility <span className="text-red-500">*</span>
|
||||||
@@ -352,14 +341,12 @@ const CreateTeamPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
<strong>Note:</strong> As team leader, you cannot leave or join another team after creating this team.
|
<strong>Note:</strong> As team leader, you cannot leave or join another team after creating this team.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div className="flex space-x-3 pt-4">
|
<div className="flex space-x-3 pt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const TeamsLayout: FC = (): ReactElement => {
|
|||||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{/* Mobile Header with Hamburger */}
|
|
||||||
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 z-10">
|
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 z-10">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const UserDetailLayout: FC = (): ReactElement => {
|
|||||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-auto">
|
<div className="flex-1 flex flex-col overflow-auto">
|
||||||
{/* Mobile Header with Hamburger */}
|
|
||||||
<div className="lg:hidden bg-white dark:bg-gray-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center">
|
<div className="lg:hidden bg-white dark:bg-gray-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const UserProfilePage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
@@ -91,9 +91,9 @@ const UserProfilePage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
<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-4 md:py-8">
|
||||||
<div className="grid gap-4 md:gap-6 lg:grid-cols-3">
|
<div className="grid gap-4 md:gap-6 lg:grid-cols-3">
|
||||||
{/* Main Content */}
|
|
||||||
<div className="lg:col-span-2 space-y-4 md:space-y-6">
|
<div className="lg:col-span-2 space-y-4 md:space-y-6">
|
||||||
{/* About Section */}
|
|
||||||
{user.bio && (
|
{user.bio && (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6 overflow-hidden">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6 overflow-hidden">
|
||||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||||
@@ -105,7 +105,6 @@ const UserProfilePage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Skills Section */}
|
|
||||||
{user.skills && user.skills.length > 0 && (
|
{user.skills && user.skills.length > 0 && (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||||
@@ -124,7 +123,6 @@ const UserProfilePage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Team Section */}
|
|
||||||
{userTeams.length > 0 ? (
|
{userTeams.length > 0 ? (
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||||
@@ -202,9 +200,8 @@ const UserProfilePage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
{/* Contact Info */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||||
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||||
Contact Information
|
Contact Information
|
||||||
|
|||||||
@@ -35,10 +35,8 @@ const WinnerPage: FC = (): ReactElement => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort winners by rank
|
|
||||||
const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank);
|
const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank);
|
||||||
|
|
||||||
// Medal emojis for top 3
|
|
||||||
const getMedalEmoji = (rank: number) => {
|
const getMedalEmoji = (rank: number) => {
|
||||||
switch (rank) {
|
switch (rank) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -52,7 +50,6 @@ const WinnerPage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get rank color
|
|
||||||
const getRankColor = (rank: number) => {
|
const getRankColor = (rank: number) => {
|
||||||
switch (rank) {
|
switch (rank) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -68,7 +65,6 @@ const WinnerPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -83,7 +79,6 @@ const WinnerPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Winners List */}
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{winners.length === 0 ? (
|
{winners.length === 0 ? (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
@@ -97,7 +92,6 @@ const WinnerPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Top 3 Winners - Mobile View */}
|
|
||||||
<div className="md:hidden space-y-6">
|
<div className="md:hidden space-y-6">
|
||||||
{[1, 2, 3].map((position) => {
|
{[1, 2, 3].map((position) => {
|
||||||
const winner = sortedWinners[position - 1];
|
const winner = sortedWinners[position - 1];
|
||||||
@@ -148,7 +142,6 @@ const WinnerPage: FC = (): ReactElement => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top 3 Winners - Tablet/Desktop Podium View */}
|
|
||||||
<div className="hidden md:flex gap-8 items-end justify-center w-full">
|
<div className="hidden md:flex gap-8 items-end justify-center w-full">
|
||||||
{[2, 1, 3].map((position) => {
|
{[2, 1, 3].map((position) => {
|
||||||
const winner = sortedWinners[position - 1];
|
const winner = sortedWinners[position - 1];
|
||||||
@@ -160,7 +153,6 @@ const WinnerPage: FC = (): ReactElement => {
|
|||||||
>
|
>
|
||||||
<div className="text-5xl">{getMedalEmoji(winner.rank)}</div>
|
<div className="text-5xl">{getMedalEmoji(winner.rank)}</div>
|
||||||
|
|
||||||
{/* Team Logo */}
|
|
||||||
{winner.team.logo && (
|
{winner.team.logo && (
|
||||||
<img
|
<img
|
||||||
src={winner.team.logo}
|
src={winner.team.logo}
|
||||||
@@ -178,7 +170,6 @@ const WinnerPage: FC = (): ReactElement => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Podium */}
|
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
winner.rank === 1
|
winner.rank === 1
|
||||||
@@ -197,7 +188,6 @@ const WinnerPage: FC = (): ReactElement => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ranks 4-23: Prize Winners */}
|
|
||||||
{sortedWinners.filter((w) => w.rank >= 4 && w.rank <= 23).length >
|
{sortedWinners.filter((w) => w.rank >= 4 && w.rank <= 23).length >
|
||||||
0 && (
|
0 && (
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
@@ -247,7 +237,6 @@ const WinnerPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rank 24+: Remaining Participants */}
|
|
||||||
{sortedWinners.filter((w) => w.rank >= 24).length > 0 && (
|
{sortedWinners.filter((w) => w.rank >= 24).length > 0 && (
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||||
@@ -296,7 +285,6 @@ const WinnerPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Info */}
|
|
||||||
{winners.length > 0 && (
|
{winners.length > 0 && (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
||||||
|
|||||||
@@ -19,12 +19,10 @@ export const CitySelect: FC<CitySelectProps> = ({
|
|||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Filter cities based on search query
|
|
||||||
const filteredCities = INDONESIAN_CITIES.filter((city) =>
|
const filteredCities = INDONESIAN_CITIES.filter((city) =>
|
||||||
city.toLowerCase().includes(searchQuery.toLowerCase())
|
city.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
import { useUserMe, useMyTeams } from '@imphnen-frontend-service/service';
|
import { useUserMe, useMyTeams } from '@imphnen-frontend-service/service';
|
||||||
import { useSession } from '@imphnen-frontend-service/utils';
|
import { useSession } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
export const Navigation: FC = () => {
|
export const Navigation: FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -23,13 +23,12 @@ export const Navigation: FC = () => {
|
|||||||
<nav className="bg-white border-b shadow-sm">
|
<nav className="bg-white border-b shadow-sm">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between items-center h-16">
|
<div className="flex justify-between items-center h-16">
|
||||||
{/* Logo */}
|
|
||||||
<Link to="/dashboard" className="flex items-center space-x-2">
|
<Link to="/dashboard" className="flex items-center space-x-2">
|
||||||
<span className="text-2xl">🏆</span>
|
<span className="text-2xl">🏆</span>
|
||||||
<span className="text-xl font-bold text-gray-900">Hackathon</span>
|
<span className="text-xl font-bold text-gray-900">Hackathon</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation Links */}
|
|
||||||
<div className="hidden md:flex items-center space-x-6">
|
<div className="hidden md:flex items-center space-x-6">
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
@@ -53,7 +52,6 @@ export const Navigation: FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Menu */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||||
@@ -88,7 +86,6 @@ export const Navigation: FC = () => {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
|
||||||
{showUserMenu && (
|
{showUserMenu && (
|
||||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
|
||||||
<div className="px-4 py-2 border-b border-gray-200">
|
<div className="px-4 py-2 border-b border-gray-200">
|
||||||
@@ -132,7 +129,6 @@ export const Navigation: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Close dropdown when clicking outside */}
|
|
||||||
{showUserMenu && (
|
{showUserMenu && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40"
|
className="fixed inset-0 z-40"
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
|||||||
const myTeams = teamsData?.data || [];
|
const myTeams = teamsData?.data || [];
|
||||||
const hasTeam = myTeams.length > 0;
|
const hasTeam = myTeams.length > 0;
|
||||||
|
|
||||||
// Close sidebar on route change (mobile)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onClose) {
|
if (onClose) {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -159,7 +158,6 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
|||||||
|
|
||||||
const sidebarContent = (
|
const sidebarContent = (
|
||||||
<div className="w-64 bg-white dark:bg-gray-900 border-r dark:border-gray-800 min-h-screen flex flex-col">
|
<div className="w-64 bg-white dark:bg-gray-900 border-r dark:border-gray-800 min-h-screen flex flex-col">
|
||||||
{/* Logo / Brand with Close Button */}
|
|
||||||
<div className="p-6 flex items-center justify-between border-b dark:border-gray-800">
|
<div className="p-6 flex items-center justify-between border-b dark:border-gray-800">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
Hackathon
|
Hackathon
|
||||||
@@ -186,7 +184,6 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Info */}
|
|
||||||
<div className="p-4 border-b dark:border-gray-800">
|
<div className="p-4 border-b dark:border-gray-800">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{user?.avatar ? (
|
{user?.avatar ? (
|
||||||
@@ -216,7 +213,6 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="flex-1 p-4">
|
<nav className="flex-1 p-4">
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{navItems
|
{navItems
|
||||||
@@ -242,7 +238,6 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Theme Toggle & Logout */}
|
|
||||||
<div className="p-4 border-t dark:border-gray-800 space-y-2">
|
<div className="p-4 border-t dark:border-gray-800 space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={cycleTheme}
|
onClick={cycleTheme}
|
||||||
@@ -267,20 +262,16 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop Sidebar - Always visible on lg+, sticky position */}
|
|
||||||
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto">
|
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto">
|
||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Sidebar - Overlay */}
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="lg:hidden fixed inset-0 z-50">
|
<div className="lg:hidden fixed inset-0 z-50">
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
|
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
|
||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,11 +48,9 @@ export const ThemeProvider: FC<ThemeProviderProps> = ({ children, defaultTheme =
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
// Calculate the resolved theme
|
|
||||||
const resolved = theme === 'system' ? getSystemTheme() : theme;
|
const resolved = theme === 'system' ? getSystemTheme() : theme;
|
||||||
setResolvedTheme(resolved);
|
setResolvedTheme(resolved);
|
||||||
|
|
||||||
// Apply dark class to root
|
|
||||||
if (resolved === 'dark') {
|
if (resolved === 'dark') {
|
||||||
root.classList.add('dark');
|
root.classList.add('dark');
|
||||||
} else {
|
} else {
|
||||||
@@ -60,7 +58,6 @@ export const ThemeProvider: FC<ThemeProviderProps> = ({ children, defaultTheme =
|
|||||||
}
|
}
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme !== 'system') return;
|
if (theme !== 'system') return;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ interface ThemeToggleProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sun icon for light mode
|
|
||||||
const SunIcon = () => (
|
const SunIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
@@ -22,7 +21,6 @@ const SunIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Moon icon for dark mode
|
|
||||||
const MoonIcon = () => (
|
const MoonIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
@@ -39,7 +37,6 @@ const MoonIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// System icon (monitor)
|
|
||||||
const SystemIcon = () => (
|
const SystemIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { useTeamById, useTeamMembers, useUserMe, useTeamSubmission } from '@imphnen-frontend-service/service';
|
import { useTeamById, useTeamMembers, useUserMe, useTeamSubmission } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to check if current user is a member of the team
|
|
||||||
*/
|
|
||||||
export const useTeamMembership = (teamId: string) => {
|
export const useTeamMembership = (teamId: string) => {
|
||||||
const { data: userData } = useUserMe();
|
const { data: userData } = useUserMe();
|
||||||
const { data: membersData } = useTeamMembers(teamId);
|
const { data: membersData } = useTeamMembers(teamId);
|
||||||
@@ -21,9 +18,6 @@ export const useTeamMembership = (teamId: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to check if current user is the team leader
|
|
||||||
*/
|
|
||||||
export const useIsTeamLeader = (teamId: string) => {
|
export const useIsTeamLeader = (teamId: string) => {
|
||||||
const { data: teamData } = useTeamById(teamId);
|
const { data: teamData } = useTeamById(teamId);
|
||||||
const { data: userData } = useUserMe();
|
const { data: userData } = useUserMe();
|
||||||
@@ -39,9 +33,6 @@ export const useIsTeamLeader = (teamId: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get team submission status
|
|
||||||
*/
|
|
||||||
export const useTeamSubmissionStatus = (teamId: string) => {
|
export const useTeamSubmissionStatus = (teamId: string) => {
|
||||||
const { data: submissionData, isLoading } = useTeamSubmission(teamId, !!teamId);
|
const { data: submissionData, isLoading } = useTeamSubmission(teamId, !!teamId);
|
||||||
|
|
||||||
@@ -57,9 +48,6 @@ export const useTeamSubmissionStatus = (teamId: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to check if current user can perform team actions
|
|
||||||
*/
|
|
||||||
export const useTeamPermissions = (teamId: string) => {
|
export const useTeamPermissions = (teamId: string) => {
|
||||||
const { isLeader } = useIsTeamLeader(teamId);
|
const { isLeader } = useIsTeamLeader(teamId);
|
||||||
const { isMember } = useTeamMembership(teamId);
|
const { isMember } = useTeamMembership(teamId);
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ add404PageToRoutesChildren(notFoundFiles, routes);
|
|||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
...routes,
|
...routes,
|
||||||
// MIDDLEWARE TEMPORARILY DISABLED - causing infinite loops with React Router v7
|
|
||||||
// TODO: Implement auth checks at component level or use different pattern
|
|
||||||
// loader: middleware,
|
|
||||||
// shouldRevalidate: () => false,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SessionUser } from '@imphnen-frontend-service/utils';
|
import { SessionUser } from '@imphnen-frontend-service/service';
|
||||||
import { hackathonApi, SessionToken } from '@imphnen-frontend-service/service';
|
import { hackathonApi, SessionToken } from '@imphnen-frontend-service/service';
|
||||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
|
|
||||||
@@ -29,47 +29,38 @@ const mappingPublicPrefixRoutes = [
|
|||||||
'/hackathons',
|
'/hackathons',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Cache to prevent redundant checks (cache for 5 seconds)
|
|
||||||
const onboardingCache = new Map<string, { hasLocation: boolean; timestamp: number }>();
|
const onboardingCache = new Map<string, { hasLocation: boolean; timestamp: number }>();
|
||||||
const CACHE_DURATION = 5000; // 5 seconds
|
const CACHE_DURATION = 5000;
|
||||||
|
|
||||||
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
|
|
||||||
// Get token from cookies and user from local storage
|
|
||||||
const tokenData = SessionToken.get();
|
const tokenData = SessionToken.get();
|
||||||
const user = SessionUser.get();
|
const user = SessionUser.get();
|
||||||
const isAuthenticated = !!tokenData?.token?.access_token;
|
const isAuthenticated = !!tokenData?.token?.access_token;
|
||||||
|
|
||||||
// Allow to access the hackathon pages without authentication
|
|
||||||
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
|
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public routes - allow everyone to view the landing page
|
|
||||||
if (mappingPublicRoutes.includes(pathname)) {
|
if (mappingPublicRoutes.includes(pathname)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth callback - allow without authentication check (for OAuth callback)
|
|
||||||
if (pathname === '/auth/callback') {
|
if (pathname === '/auth/callback') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth routes (all /auth/* paths) - redirect to dashboard if already authenticated
|
|
||||||
if (pathname.startsWith('/auth')) {
|
if (pathname.startsWith('/auth')) {
|
||||||
if (isAuthenticated) return redirect('/dashboard');
|
if (isAuthenticated) return redirect('/dashboard');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Require authentication for all other routes
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return redirect('/auth/login');
|
return redirect('/auth/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has completed onboarding
|
|
||||||
// Skip onboarding check for onboarding routes themselves
|
|
||||||
if (!mappingOnboardingRoutes.includes(pathname)) {
|
if (!mappingOnboardingRoutes.includes(pathname)) {
|
||||||
try {
|
try {
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
@@ -79,28 +70,23 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = onboardingCache.get(userId);
|
const cached = onboardingCache.get(userId);
|
||||||
let hasLocation = false;
|
let hasLocation = false;
|
||||||
|
|
||||||
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
||||||
hasLocation = cached.hasLocation;
|
hasLocation = cached.hasLocation;
|
||||||
} else {
|
} else {
|
||||||
// First check user data (faster)
|
|
||||||
if (user?.location) {
|
if (user?.location) {
|
||||||
hasLocation = true;
|
hasLocation = true;
|
||||||
} else {
|
} else {
|
||||||
// Fetch from backend API
|
|
||||||
try {
|
try {
|
||||||
const response = await hackathonApi.get('/users/me');
|
const response = await hackathonApi.get('/users/me');
|
||||||
hasLocation = !!response.data?.data?.location;
|
hasLocation = !!response.data?.data?.location;
|
||||||
} catch {
|
} catch {
|
||||||
// If API fails, check user data as fallback
|
|
||||||
hasLocation = !!user?.location;
|
hasLocation = !!user?.location;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cache
|
|
||||||
onboardingCache.set(userId, { hasLocation, timestamp: now });
|
onboardingCache.set(userId, { hasLocation, timestamp: now });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +95,10 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Middleware] Unexpected error checking onboarding:', error);
|
console.error('[Middleware] Unexpected error checking onboarding:', error);
|
||||||
// On error, allow access (fail open)
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check route permissions using user data
|
|
||||||
const userPermissions =
|
const userPermissions =
|
||||||
user?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
user?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,9 @@ async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, ite
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypts plaintext with passphrase -> returns base64(salt||iv||ciphertext)
|
|
||||||
*/
|
|
||||||
export async function encryptText(plaintext: string, passphrase: string) {
|
export async function encryptText(plaintext: string, passphrase: string) {
|
||||||
const salt = randBytes(16); // 128-bit salt
|
const salt = randBytes(16);
|
||||||
const iv = randBytes(12); // 96-bit IV recommended for GCM
|
const iv = randBytes(12);
|
||||||
const key = await deriveKeyFromPassphrase(passphrase, salt);
|
const key = await deriveKeyFromPassphrase(passphrase, salt);
|
||||||
|
|
||||||
const cipher = await crypto.subtle.encrypt(
|
const cipher = await crypto.subtle.encrypt(
|
||||||
@@ -58,7 +55,6 @@ export async function encryptText(plaintext: string, passphrase: string) {
|
|||||||
enc.encode(plaintext)
|
enc.encode(plaintext)
|
||||||
);
|
);
|
||||||
|
|
||||||
// concat salt + iv + ciphertext
|
|
||||||
const out = new Uint8Array(salt.length + iv.length + cipher.byteLength);
|
const out = new Uint8Array(salt.length + iv.length + cipher.byteLength);
|
||||||
out.set(salt, 0);
|
out.set(salt, 0);
|
||||||
out.set(iv, salt.length);
|
out.set(iv, salt.length);
|
||||||
@@ -67,9 +63,6 @@ export async function encryptText(plaintext: string, passphrase: string) {
|
|||||||
return bufToBase64(out.buffer);
|
return bufToBase64(out.buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypts base64(salt||iv||ciphertext) with passphrase -> plaintext
|
|
||||||
*/
|
|
||||||
export async function decryptText(b64combined: string, passphrase: string) {
|
export async function decryptText(b64combined: string, passphrase: string) {
|
||||||
const combined = new Uint8Array(base64ToBuf(b64combined));
|
const combined = new Uint8Array(base64ToBuf(b64combined));
|
||||||
const salt = combined.slice(0, 16);
|
const salt = combined.slice(0, 16);
|
||||||
|
|||||||
@@ -2,40 +2,21 @@ import { decryptText, encryptText } from "./aesclient";
|
|||||||
|
|
||||||
const SECRET_KEY = 'imphnen-hackathon-2025';
|
const SECRET_KEY = 'imphnen-hackathon-2025';
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode teamId, submissionId, and userId into a certificate ID
|
|
||||||
* Uses AES encryption for secure encoding
|
|
||||||
* @param teamId - The team ID
|
|
||||||
* @param submissionId - The submission ID
|
|
||||||
* @param userId - The user ID (team member)
|
|
||||||
* @returns Encoded certificate ID
|
|
||||||
*/
|
|
||||||
export const encodeCertificateId = async (teamId: string, submissionId: string, userId: string): Promise<string> => {
|
export const encodeCertificateId = async (teamId: string, submissionId: string, userId: string): Promise<string> => {
|
||||||
const combined = `${teamId}::${submissionId}::${userId}`;
|
const combined = `${teamId}::${submissionId}::${userId}`;
|
||||||
return encryptText(combined, SECRET_KEY);
|
return encryptText(combined, SECRET_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode winner certificate ID (team-based)
|
|
||||||
* @param teamId - Winner team ID
|
|
||||||
* @returns Encoded winner certificate ID
|
|
||||||
*/
|
|
||||||
export const encodeWinnerCertificateId = async (teamId: string): Promise<string> => {
|
export const encodeWinnerCertificateId = async (teamId: string): Promise<string> => {
|
||||||
const combined = `winner::${teamId}`;
|
const combined = `winner::${teamId}`;
|
||||||
return encryptText(combined, SECRET_KEY);
|
return encryptText(combined, SECRET_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode certificate ID back to teamId, submissionId, and userId
|
|
||||||
* @param certId - The encoded certificate ID
|
|
||||||
* @returns Object containing teamId, submissionId, and userId
|
|
||||||
*/
|
|
||||||
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string; userId: string }> => {
|
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string; userId: string }> => {
|
||||||
try {
|
try {
|
||||||
const decoded = await decryptText(certId, SECRET_KEY);
|
const decoded = await decryptText(certId, SECRET_KEY);
|
||||||
const parts = decoded.split('::');
|
const parts = decoded.split('::');
|
||||||
|
|
||||||
// Handle both old format (teamId::submissionId) and new format (teamId::submissionId::userId)
|
|
||||||
if (parts.length === 2) {
|
if (parts.length === 2) {
|
||||||
const [teamId, submissionId] = parts;
|
const [teamId, submissionId] = parts;
|
||||||
return { teamId, submissionId, userId: '' };
|
return { teamId, submissionId, userId: '' };
|
||||||
@@ -50,17 +31,11 @@ export const decodeCertificateId = async (certId: string): Promise<{ teamId: str
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode winner certificate ID back to teamId
|
|
||||||
* @param certId - The encoded winner certificate ID
|
|
||||||
* @returns Object containing teamId
|
|
||||||
*/
|
|
||||||
export const decodeWinnerCertificateId = async (certId: string): Promise<{ teamId: string }> => {
|
export const decodeWinnerCertificateId = async (certId: string): Promise<{ teamId: string }> => {
|
||||||
try {
|
try {
|
||||||
const decoded = await decryptText(certId, SECRET_KEY);
|
const decoded = await decryptText(certId, SECRET_KEY);
|
||||||
const parts = decoded.split('::');
|
const parts = decoded.split('::');
|
||||||
|
|
||||||
// winner::teamId
|
|
||||||
if (parts.length === 2 && parts[0] === 'winner') {
|
if (parts.length === 2 && parts[0] === 'winner') {
|
||||||
return { teamId: parts[1] };
|
return { teamId: parts[1] };
|
||||||
}
|
}
|
||||||
@@ -71,12 +46,6 @@ export const decodeWinnerCertificateId = async (certId: string): Promise<{ teamI
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* For development: Create a certId using created_at timestamp
|
|
||||||
* @param teamId - The team ID
|
|
||||||
* @param createdAt - The creation timestamp
|
|
||||||
* @returns Encoded certificate ID
|
|
||||||
*/
|
|
||||||
export const encodeCertificateIdWithTimestamp = (teamId: string, createdAt: string): string => {
|
export const encodeCertificateIdWithTimestamp = (teamId: string, createdAt: string): string => {
|
||||||
const combined = `${teamId}::${createdAt}`;
|
const combined = `${teamId}::${createdAt}`;
|
||||||
return Buffer.from(combined).toString('base64');
|
return Buffer.from(combined).toString('base64');
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ export default defineConfig(() => ({
|
|||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
},
|
},
|
||||||
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
// Uncomment this if you are using workers.
|
|
||||||
// worker: {
|
|
||||||
// plugins: [ nxViteTsPaths() ],
|
|
||||||
// },
|
|
||||||
build: {
|
build: {
|
||||||
outDir: '../../dist/apps/hackathon',
|
outDir: '../../dist/apps/hackathon',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const techStack = [
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a]">
|
<div className="min-h-screen bg-[#0a0a0a]">
|
||||||
{/* Header */}
|
|
||||||
<header className="border-b border-zinc-800 bg-[#0a0a0a]/80 backdrop-blur-sm sticky top-0 z-50">
|
<header className="border-b border-zinc-800 bg-[#0a0a0a]/80 backdrop-blur-sm sticky top-0 z-50">
|
||||||
<div className="container mx-auto px-6 py-4">
|
<div className="container mx-auto px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -47,7 +47,7 @@ export default function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container mx-auto px-6 py-12">
|
<main className="container mx-auto px-6 py-12">
|
||||||
{/* Hero */}
|
|
||||||
<section className="text-center mb-16">
|
<section className="text-center mb-16">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
Infrastructure Overview
|
Infrastructure Overview
|
||||||
@@ -58,33 +58,29 @@ export default function App() {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Architecture Diagram */}
|
|
||||||
<section className="mb-16">
|
<section className="mb-16">
|
||||||
<h2 className="text-2xl font-semibold mb-6">Architecture</h2>
|
<h2 className="text-2xl font-semibold mb-6">Architecture</h2>
|
||||||
<div className="card bg-[#141414] border-zinc-800 p-8">
|
<div className="card bg-[#141414] border-zinc-800 p-8">
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
{/* Internet */}
|
|
||||||
<div className="flex items-center gap-2 text-zinc-400">
|
<div className="flex items-center gap-2 text-zinc-400">
|
||||||
<span className="text-2xl">🌐</span>
|
<span className="text-2xl">🌐</span>
|
||||||
<span>Internet</span>
|
<span>Internet</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-8 bg-zinc-700" />
|
<div className="w-px h-8 bg-zinc-700" />
|
||||||
|
|
||||||
{/* Cloudflare */}
|
|
||||||
<div className="card bg-orange-500/10 border-orange-500/30 px-6 py-3">
|
<div className="card bg-orange-500/10 border-orange-500/30 px-6 py-3">
|
||||||
<span className="badge badge-orange">Cloudflare</span>
|
<span className="badge badge-orange">Cloudflare</span>
|
||||||
<p className="text-sm text-zinc-400 mt-1">DNS + CDN + DDoS Protection</p>
|
<p className="text-sm text-zinc-400 mt-1">DNS + CDN + DDoS Protection</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-8 bg-zinc-700" />
|
<div className="w-px h-8 bg-zinc-700" />
|
||||||
|
|
||||||
{/* Hetzner VPS */}
|
|
||||||
<div className="card bg-[#1a1a1a] border-zinc-700 w-full max-w-3xl">
|
<div className="card bg-[#1a1a1a] border-zinc-700 w-full max-w-3xl">
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
<span className="badge badge-purple">Hetzner Cloud VPS</span>
|
<span className="badge badge-purple">Hetzner Cloud VPS</span>
|
||||||
<p className="text-sm text-zinc-400 mt-1">NixOS | 167.235.70.37</p>
|
<p className="text-sm text-zinc-400 mt-1">NixOS | 167.235.70.37</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nginx */}
|
|
||||||
<div className="card bg-green-500/10 border-green-500/30 mb-4">
|
<div className="card bg-green-500/10 border-green-500/30 mb-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<span className="badge badge-green">Nginx</span>
|
<span className="badge badge-green">Nginx</span>
|
||||||
@@ -92,7 +88,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Apps Grid */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
{apps.map((app) => (
|
{apps.map((app) => (
|
||||||
<div
|
<div
|
||||||
@@ -112,7 +107,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Deployed Apps */}
|
|
||||||
<section className="mb-16">
|
<section className="mb-16">
|
||||||
<h2 className="text-2xl font-semibold mb-6">Deployed Applications</h2>
|
<h2 className="text-2xl font-semibold mb-6">Deployed Applications</h2>
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
@@ -138,7 +132,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Tech Stack */}
|
|
||||||
<section className="mb-16">
|
<section className="mb-16">
|
||||||
<h2 className="text-2xl font-semibold mb-6">Technology Stack</h2>
|
<h2 className="text-2xl font-semibold mb-6">Technology Stack</h2>
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
@@ -159,7 +152,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Deployment Flow */}
|
|
||||||
<section className="mb-16">
|
<section className="mb-16">
|
||||||
<h2 className="text-2xl font-semibold mb-6">Deployment Flow</h2>
|
<h2 className="text-2xl font-semibold mb-6">Deployment Flow</h2>
|
||||||
<div className="card bg-[#141414] border-zinc-800">
|
<div className="card bg-[#141414] border-zinc-800">
|
||||||
@@ -207,7 +199,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Repositories */}
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-2xl font-semibold mb-6">Repositories</h2>
|
<h2 className="text-2xl font-semibold mb-6">Repositories</h2>
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
@@ -237,7 +228,6 @@ export default function App() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="border-t border-zinc-800 mt-16">
|
<footer className="border-t border-zinc-800 mt-16">
|
||||||
<div className="container mx-auto px-6 py-8 text-center text-zinc-500 text-sm">
|
<div className="container mx-auto px-6 py-8 text-center text-zinc-500 text-sm">
|
||||||
<p>IMPHNEN Infrastructure © {new Date().getFullYear()}</p>
|
<p>IMPHNEN Infrastructure © {new Date().getFullYear()}</p>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Tue, Nov 25, 2025 4:21:27 PM
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# Deployment trigger
|
|
||||||
# Updated to deploy circular dependency fixes and auth hooks
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
SiCplusplus,
|
SiCplusplus,
|
||||||
SiCss3,
|
SiCss,
|
||||||
SiGo,
|
SiGo,
|
||||||
SiHtml5,
|
SiHtml5,
|
||||||
SiJavascript,
|
SiJavascript,
|
||||||
@@ -35,7 +35,7 @@ export function AnimatedBackground() {
|
|||||||
{ Icon: SiGo, color: '#00ADD8' },
|
{ Icon: SiGo, color: '#00ADD8' },
|
||||||
{ Icon: SiPhp, color: '#777BB4' },
|
{ Icon: SiPhp, color: '#777BB4' },
|
||||||
{ Icon: SiHtml5, color: '#E34F26' },
|
{ Icon: SiHtml5, color: '#E34F26' },
|
||||||
{ Icon: SiCss3, color: '#1572B6' },
|
{ Icon: SiCss, color: '#1572B6' },
|
||||||
],
|
],
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function CTASection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="w-full py-20 md:py-32 bg-gradient-to-br from-primary-500 to-primary-600 relative overflow-hidden">
|
<section className="w-full py-20 md:py-32 bg-gradient-to-br from-primary-500 to-primary-600 relative overflow-hidden">
|
||||||
{/* Background pattern */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-10"
|
className="absolute inset-0 opacity-10"
|
||||||
style={{
|
style={{
|
||||||
@@ -21,7 +21,7 @@ export function CTASection() {
|
|||||||
|
|
||||||
<div className="container relative" ref={ref}>
|
<div className="container relative" ref={ref}>
|
||||||
<div className="flex flex-col lg:flex-row items-center justify-between gap-12">
|
<div className="flex flex-col lg:flex-row items-center justify-between gap-12">
|
||||||
{/* Text Content */}
|
|
||||||
<div className="flex-1 text-center lg:text-left space-y-8">
|
<div className="flex-1 text-center lg:text-left space-y-8">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
@@ -65,7 +65,6 @@ export function CTASection() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Illustration */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 50 }}
|
initial={{ opacity: 0, x: 50 }}
|
||||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function HeroSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
|
<section className="relative w-full py-20 md:py-32 lg:py-40 overflow-hidden">
|
||||||
{/* Background gradients and motion */}
|
|
||||||
<div className="absolute inset-0 -z-10 overflow-hidden">
|
<div className="absolute inset-0 -z-10 overflow-hidden">
|
||||||
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
|
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-b from-background to-background/50" />
|
||||||
<div
|
<div
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user