Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38ec9a28f7 | ||
|
|
97a3ac90eb | ||
|
|
8f8c720632 | ||
|
|
ac22bd6cab | ||
|
|
27ed5605ce | ||
|
|
2c6389a67a | ||
|
|
a4c75770ae | ||
|
|
d923237969 | ||
|
|
b2a28c6fd3 | ||
|
|
417ff3390b | ||
|
|
0c646ceb85 | ||
|
|
6bbc87479a | ||
|
|
23b6677323 | ||
|
|
45c75b3283 | ||
|
|
764eb34e47 | ||
|
|
fe4e9291fa | ||
|
|
ec9a81d7b5 | ||
|
|
e652c6ed0e | ||
|
|
d431564aac | ||
|
|
350d08a2b4 | ||
|
|
eb5e043357 | ||
|
|
d67e3e7244 | ||
|
|
a7196dd453 | ||
|
|
c83e54fae8 | ||
|
|
35a5761a7c | ||
|
|
b472fbdbbf | ||
|
|
4fcc96ac15 | ||
|
|
2dfcd3adad | ||
|
|
d79788258b | ||
|
|
427d2b69c7 | ||
|
|
7e5e4cc29b | ||
|
|
a1e89b882a | ||
|
|
a74f0181f1 | ||
|
|
a9a1e5450b | ||
|
|
5e65942060 | ||
|
|
d044d0ef50 | ||
|
|
d40a913ccc | ||
|
|
66178ccc18 | ||
|
|
8280ba2a64 | ||
|
|
8259020450 | ||
|
|
692a7a7258 | ||
|
|
1ee04ada14 | ||
|
|
193cb61b45 | ||
|
|
4777a34f23 | ||
|
|
4d3677ebd2 | ||
|
|
f0eb11010d | ||
|
|
e1f13b3872 | ||
|
|
fa30849d03 | ||
|
|
f8d592d811 | ||
|
|
c385aa1996 | ||
|
|
9a97eb3e8e | ||
|
|
70ee5300f7 | ||
|
|
35c9539c08 | ||
|
|
bc5b072ad8 | ||
|
|
66b8b064ba | ||
|
|
63643cc286 | ||
|
|
19bd44a7ca | ||
|
|
b3bdf25b5f | ||
|
|
cdd4fd4703 | ||
|
|
7d1b86acdb | ||
|
|
6e6c361eb8 | ||
|
|
62780c8889 | ||
|
|
1839f4cab0 | ||
|
|
c9f5b9e4a5 | ||
|
|
33365affa2 | ||
|
|
63db7ff6ad | ||
|
|
7f8b53edd4 | ||
|
|
eb0628ac3e | ||
|
|
cbb880a053 | ||
|
|
083a912e91 | ||
|
|
ab744b411d | ||
|
|
8947b4d05e | ||
|
|
7e3eaa6584 | ||
|
|
98a459c7d7 | ||
|
|
9d24a11c05 | ||
|
|
ee60787100 | ||
|
|
301a349776 | ||
|
|
81488ceb6b | ||
|
|
9e99fd61e7 | ||
|
|
d36c693615 | ||
|
|
20125a6f4a | ||
|
|
4bc5006c91 | ||
|
|
71769b71aa | ||
|
|
a2cf67d210 | ||
|
|
d9c4217fd3 | ||
|
|
719e36e46c | ||
|
|
bd052f4866 | ||
|
|
734e8540f2 | ||
|
|
1289555749 | ||
|
|
971fe7160f | ||
|
|
722caeec9d | ||
|
|
7404afe466 | ||
|
|
400daea60f | ||
|
|
40cd47c6be | ||
|
|
529d065a75 | ||
|
|
69d6d86342 | ||
|
|
31736cc403 | ||
|
|
1cb2443b58 | ||
|
|
9e9bfc2793 | ||
|
|
5a99f216cb | ||
|
|
efdbc65be6 | ||
|
|
f5905c0acc | ||
|
|
6348a4998b | ||
|
|
e067450fcb | ||
|
|
a281f279f8 | ||
|
|
a157e11e13 | ||
|
|
b38afd2b44 | ||
|
|
3c301ec2c4 | ||
|
|
65a6bb686c | ||
|
|
f3a19fa460 | ||
|
|
228f92e9ed | ||
|
|
dcd4ef88d6 |
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Nx
|
||||||
|
.nx
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.editorconfig
|
||||||
|
.prettierrc
|
||||||
|
.eslintrc
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
VITE_SUPABASE_URL=https://your-project-id.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Deploy Backoffice
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
|
||||||
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
|
||||||
|
else
|
||||||
|
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
|
fi
|
||||||
|
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << EOF
|
||||||
|
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||||
|
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||||
|
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||||
|
EOF
|
||||||
|
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
|
||||||
|
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build --progress=plain
|
||||||
|
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
|
||||||
|
docker image prune -af
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Deploy Dimentorin
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
|
||||||
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
|
||||||
|
else
|
||||||
|
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
|
fi
|
||||||
|
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << EOF
|
||||||
|
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||||
|
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||||
|
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||||
|
EOF
|
||||||
|
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
|
||||||
|
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build --progress=plain
|
||||||
|
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
|
||||||
|
docker image prune -af
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Deploy Gacha
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
|
||||||
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
|
||||||
|
else
|
||||||
|
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
|
fi
|
||||||
|
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << EOF
|
||||||
|
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||||
|
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||||
|
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||||
|
EOF
|
||||||
|
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
|
||||||
|
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build --progress=plain
|
||||||
|
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
|
||||||
|
docker image prune -af
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Deploy Hackathon
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
|
||||||
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
|
||||||
|
else
|
||||||
|
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
|
fi
|
||||||
|
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF
|
||||||
|
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||||
|
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||||
|
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||||
|
EOF
|
||||||
|
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
|
||||||
|
docker builder prune -af || true
|
||||||
|
DOCKER_BUILDKIT=1 docker compose --env-file ~/imphnen-frontend-service-hackathon/.env.local -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build --progress=plain
|
||||||
|
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
|
||||||
|
docker image prune -af
|
||||||
@@ -2,9 +2,15 @@ name: Deploy Landing
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
paths:
|
paths:
|
||||||
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-vps
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -19,15 +25,20 @@ jobs:
|
|||||||
username: ${{ secrets.SSH_USER }}
|
username: ${{ secrets.SSH_USER }}
|
||||||
key: ${{ secrets.SSH_KEY }}
|
key: ${{ secrets.SSH_KEY }}
|
||||||
port: ${{ secrets.SSH_PORT }}
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
command_timeout: 30m
|
||||||
script: |
|
script: |
|
||||||
set -e
|
set -e
|
||||||
if [ ! -d ~/imphnen-frontend-service ]; then
|
if [ ! -d ~/imphnen-frontend-service ]; then
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
||||||
else
|
else
|
||||||
cd ~/imphnen-frontend-service && git pull
|
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
|
||||||
fi
|
fi
|
||||||
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
|
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
|
||||||
|
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
|
EOF
|
||||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
||||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
|
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain
|
||||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
||||||
docker image prune -af
|
docker image prune -af
|
||||||
|
|||||||
@@ -62,3 +62,7 @@ storybook-static
|
|||||||
# Next.js
|
# Next.js
|
||||||
.next
|
.next
|
||||||
out
|
out
|
||||||
|
.cursor/rules/nx-rules.mdc
|
||||||
|
.github/instructions/nx.instructions.md
|
||||||
|
|
||||||
|
.env.local
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"nx-mcp": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["nx", "mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!-- nx configuration start-->
|
||||||
|
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||||
|
|
||||||
|
# General Guidelines for working with Nx
|
||||||
|
|
||||||
|
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
|
||||||
|
- You have access to the Nx MCP server and its tools, use them to help the user
|
||||||
|
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
|
||||||
|
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
|
||||||
|
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
|
||||||
|
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
|
||||||
|
|
||||||
|
<!-- nx configuration end-->
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Tue, Nov 25, 2025 4:21:27 PM
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Deployment trigger
|
||||||
|
# Updated to deploy circular dependency fixes and auth hooks
|
||||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"
|
||||||
|
import { PieLabelProps } from "recharts/types/polar/Pie"
|
||||||
|
|
||||||
|
type ChartProps = {
|
||||||
|
name: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData: ChartProps[] = [
|
||||||
|
{ name: "Active", value: 49, color: "#23A1EB" },
|
||||||
|
{ name: "Done", value: 24, color: "#81CBF8" },
|
||||||
|
{ name: "Canceled", value: 27, color: "#BCE1FB" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const RADIAN = Math.PI / 180;
|
||||||
|
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }: PieLabelProps) => {
|
||||||
|
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||||
|
const x = cx + radius * Math.cos(-(midAngle ?? 0) * RADIAN);
|
||||||
|
const y = cy + radius * Math.sin(-(midAngle ?? 0) * RADIAN);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<text x={x} y={y} fill="white" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline="central">
|
||||||
|
{`${((percent ?? 1) * 100).toFixed(0)}%`}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SessionStatusChart = () => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<PieChart width={500} height={320}>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={40}
|
||||||
|
outerRadius={100}
|
||||||
|
labelLine={false}
|
||||||
|
label={renderCustomizedLabel}
|
||||||
|
>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
|
||||||
|
|
||||||
|
type ChartProps = {
|
||||||
|
name: string
|
||||||
|
activeUser: number
|
||||||
|
activeSession: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData: ChartProps[] = [
|
||||||
|
{ name: "2014", activeUser: 0, activeSession: 0 },
|
||||||
|
{ name: "2015", activeUser: 15, activeSession: 25 },
|
||||||
|
{ name: "2016", activeUser: 30, activeSession: 40 },
|
||||||
|
{ name: "2017", activeUser: 45, activeSession: 55 },
|
||||||
|
{ name: "2018", activeUser: 60, activeSession: 70 },
|
||||||
|
{ name: "2019", activeUser: 75, activeSession: 85 },
|
||||||
|
{ name: "2020", activeUser: 90, activeSession: 95 },
|
||||||
|
{ name: "2021", activeUser: 85, activeSession: 80 },
|
||||||
|
{ name: "2022", activeUser: 95, activeSession: 90 },
|
||||||
|
{ name: "2023", activeUser: 100, activeSession: 100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const UserGrowthChart = () => {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart data={chartData} width={500} height={320} margin={{ left: -32 }}>
|
||||||
|
<CartesianGrid />
|
||||||
|
<XAxis dataKey="name" />
|
||||||
|
<YAxis tickCount={10} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line dataKey="activeUser" stroke="#23A1EB" />
|
||||||
|
<Line dataKey="activeSession" stroke="#0877C1" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms";
|
||||||
|
import { For } from "@imphnen-frontend-service/utils";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import { UserGrowthChart } from "./_components/chart/user-growth";
|
||||||
|
import { SessionStatusChart } from "./_components/chart/session-status";
|
||||||
|
|
||||||
|
const Overview = () => {
|
||||||
|
return (
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Users</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Components(): ReactElement {
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
|
<h1 className="text-p1 font-semibold text-neutral-700 mb-5">Overview</h1>
|
||||||
|
|
||||||
|
<div className="space-y-14">
|
||||||
|
<div>
|
||||||
|
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
||||||
|
Overview
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-5">
|
||||||
|
<For data={Array.from({ length: 5 })}>
|
||||||
|
{(_, index) => <Overview key={index} />}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button type="button" size="sm" variant="bordered" className="bg-white text-md text-neutral-900 mb-5 border-primary-200">
|
||||||
|
Trends & Analytics
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-x-5">
|
||||||
|
<div className="bg-white px-6 py-4 rounded-lg col-span-5">
|
||||||
|
<div className="flex items-center justify-between mb-7">
|
||||||
|
<h2 className="font-semibold text-p3 text-neutral-700">User Growth</h2>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<UserGrowthChart />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white px-6 py-4 rounded-lg col-span-2">
|
||||||
|
<h2 className="font-semibold text-p3 text-neutral-700 mb-7">Session Status</h2>
|
||||||
|
<SessionStatusChart />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-5">
|
||||||
|
<div className="bg-white px-7 py-4 rounded-lg">
|
||||||
|
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top 5 Mentors</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-label1 bg-primary-50 text-left rounded-full">
|
||||||
|
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
||||||
|
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
|
||||||
|
<th className="font-medium py-4 px-5 rounded-r-lg">Avg Rating</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<For data={Array.from({ length: 5 })}>
|
||||||
|
{(_, index) => (
|
||||||
|
<tr key={index} className="shadow rounded-lg">
|
||||||
|
<td className="py-4 px-5">{index + 1}</td>
|
||||||
|
<td className="py-4 px-5">Mursid Al-Catraz</td>
|
||||||
|
<td className="py-4 px-5">4.9</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white px-6 py-4 rounded-lg">
|
||||||
|
<h2 className="font-semibold text-p3 text-neutral-700 mb-5">Top Booked Mentoring Topics</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-label1 bg-primary-50 text-left font-medium">
|
||||||
|
<th className="font-medium py-4 px-5 w-[10%] rounded-l-lg">No.</th>
|
||||||
|
<th className="font-medium py-4 px-5 w-3/5">Nama Lengkap</th>
|
||||||
|
<th className="font-medium py-4 px-5 rounded-r-lg">Total Sesi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<For data={Array.from({ length: 5 })}>
|
||||||
|
{(_, index) => (
|
||||||
|
<tr key={index} className="shadow rounded-lg">
|
||||||
|
<td className="py-4 px-5">{index + 1}</td>
|
||||||
|
<td className="py-4 px-5">Mursid Al-Catraz</td>
|
||||||
|
<td className="py-4 px-5">1000</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
|
||||||
|
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||||
|
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
||||||
|
import { ReactElement, useState } from "react"
|
||||||
|
|
||||||
|
const TABS = {
|
||||||
|
MENTORING: 'Mentoring',
|
||||||
|
PLATFORM: 'Platform'
|
||||||
|
} as const
|
||||||
|
type Tabs = typeof TABS[keyof typeof TABS]
|
||||||
|
|
||||||
|
type FeedbackStatus = 'done' | 'todo';
|
||||||
|
|
||||||
|
interface FeedbackType {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
rating: number
|
||||||
|
status: FeedbackStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: FeedbackType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
|
||||||
|
email: 'fullname23@gmail.com',
|
||||||
|
rating: 4.5,
|
||||||
|
status: i % 2 === 0 ? 'done' : 'todo',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default function Components(): ReactElement {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tabs>(TABS.MENTORING)
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<FeedbackType>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
meta: { cellClassName: cn("w-20") },
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
header: 'Email',
|
||||||
|
accessorKey: 'email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rating',
|
||||||
|
header: 'Rating',
|
||||||
|
accessorKey: 'rating',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const statusColors: Record<FeedbackStatus, string> = {
|
||||||
|
done: 'bg-success-200 text-success-500',
|
||||||
|
todo: 'bg-primary-200 text-primary-500',
|
||||||
|
};
|
||||||
|
const statusText: Record<FeedbackStatus, string> = {
|
||||||
|
done: 'Done',
|
||||||
|
todo: 'To Do',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||||
|
>
|
||||||
|
{statusText[status]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn("w-72") },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
>
|
||||||
|
<SearchOutlined className="text-[16px]" /> Lihat Feedback
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
|
<div className="mb-8 flex justify-between items-center">
|
||||||
|
<h1 className="text-p1 font-semibold text-neutral-700">Feedback</h1>
|
||||||
|
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||||
|
<For data={Object.values(TABS)}>
|
||||||
|
{(tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab}
|
||||||
|
variant="text"
|
||||||
|
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
<div className="flex justify-between items-center gap-5 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama mentor/mentee"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select>
|
||||||
|
<option selected disabled>Rating</option>
|
||||||
|
<option value="4.5">4.5</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
</Select>
|
||||||
|
<Select>
|
||||||
|
<option selected disabled>Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
|
||||||
|
export const HackathonDashboardPage: FC = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-5 gap-5">
|
||||||
|
{/* Participant */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||||
|
1261
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Participants</p>
|
||||||
|
</div>
|
||||||
|
{/* Team */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">206</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Teams</p>
|
||||||
|
</div>
|
||||||
|
{/* Project Submitted */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Project Submitted</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonDashboardPage;
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { FC, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockData: any[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
project_name: `Project ${i + 1}`,
|
||||||
|
repository_url: `https://github.com/user/repo${i + 1}`,
|
||||||
|
demo_url: `https://demo.example.com/project${i + 1}`,
|
||||||
|
presentation_url: `https://slides.example.com/project${i + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type UserStatus = 'active' | 'inactive';
|
||||||
|
|
||||||
|
interface SubmissionType {
|
||||||
|
id: number;
|
||||||
|
project_name: string;
|
||||||
|
repository_url: string;
|
||||||
|
demo_url: string;
|
||||||
|
presentation_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<SubmissionType>[] = [
|
||||||
|
{
|
||||||
|
header: 'Project Name',
|
||||||
|
accessorKey: 'project_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Repository URL',
|
||||||
|
accessorKey: 'repository_url',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Demo URL',
|
||||||
|
accessorKey: 'demo_url',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Presentation URL',
|
||||||
|
accessorKey: 'presentation_url',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn('w-72') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
onClick={() => {
|
||||||
|
// View detail logic
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-base" /> View & Manage
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
Project Submission
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
|
||||||
|
placeholder="Search name or email"
|
||||||
|
/>
|
||||||
|
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||||
|
<option value="all">All City</option>
|
||||||
|
<option value="jakarta">Jakarta</option>
|
||||||
|
<option value="bandung">Bandung</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Modals extracted into shared backoffice components */}
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonUsersPage;
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { FC, ReactElement, useState } from 'react';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { useTeams } from '@imphnen-frontend-service/service';
|
||||||
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export const HackathonTeamsPage: FC = (): ReactElement => {
|
||||||
|
const { data: teamsData } = useTeams();
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockData: TeamType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: `team-${i + 1}`,
|
||||||
|
name: `Team ${i + 1} - ${i % 3 === 0 ? 'Innovators' : 'Hackers'}`,
|
||||||
|
city: i % 2 === 0 ? 'Jakarta' : 'Bandung',
|
||||||
|
visibility: i % 4 === 0 ? 'private' : 'public',
|
||||||
|
member_count: Math.floor(Math.random() * 4) + 1,
|
||||||
|
has_submission: i % 3 !== 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
leader: {
|
||||||
|
user: {
|
||||||
|
fullname: `Leader User ${i}`,
|
||||||
|
email: `leader${i}@example.com`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface TeamType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
city: string;
|
||||||
|
visibility: 'public' | 'private';
|
||||||
|
member_count: number;
|
||||||
|
has_submission: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
leader?: {
|
||||||
|
user: {
|
||||||
|
fullname: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<TeamType>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'id',
|
||||||
|
header: 'ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Team Name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'city',
|
||||||
|
header: 'City',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'visibility',
|
||||||
|
header: 'Visibility',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isPublic = row.original.visibility === 'public';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'py-2 px-4 text-sm rounded-2xl text-center',
|
||||||
|
isPublic
|
||||||
|
? 'bg-info-200 text-info-700'
|
||||||
|
: 'bg-gray-200 text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPublic ? 'Public' : 'Private'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'member_count',
|
||||||
|
header: 'Members',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'leader',
|
||||||
|
header: 'Leader',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const leader = row.original.leader?.user;
|
||||||
|
return leader ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{leader.fullname}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">{leader.email}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">-</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'has_submission',
|
||||||
|
header: 'Submitted',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hasSubmission = row.original.has_submission;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'py-2 px-4 text-sm rounded-2xl text-center',
|
||||||
|
hasSubmission
|
||||||
|
? 'bg-success-200 text-success-700'
|
||||||
|
: 'bg-danger-200 text-danger-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasSubmission ? 'Yes' : 'No'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'updated_at',
|
||||||
|
header: 'Last Updated',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return new Date(row.original.updated_at).toLocaleDateString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn('w-48') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
onClick={() => {
|
||||||
|
// View detail logic
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-base" /> View & Manage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
Team Management
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64"
|
||||||
|
placeholder="Search name or email"
|
||||||
|
/>
|
||||||
|
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40">
|
||||||
|
<option value="all">All City</option>
|
||||||
|
<option value="jakarta">Jakarta</option>
|
||||||
|
<option value="bandung">Bandung</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
{/* Modals extracted into shared backoffice components */}
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonTeamsPage;
|
||||||
@@ -0,0 +1,651 @@
|
|||||||
|
import { FC, useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
ExclamationOutlined,
|
||||||
|
CameraOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface UserType {
|
||||||
|
id: string;
|
||||||
|
avatar?: string;
|
||||||
|
fullname: string;
|
||||||
|
bio?: string;
|
||||||
|
location: string;
|
||||||
|
is_active: boolean;
|
||||||
|
skills: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
user: UserType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||||
|
const [formData, setFormData] = useState<UserType | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Initialize form data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (user) {
|
||||||
|
// Edit existing user
|
||||||
|
setFormData({ ...user });
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
setFormData({
|
||||||
|
id: '', // Will be generated by backend
|
||||||
|
fullname: '',
|
||||||
|
bio: '',
|
||||||
|
location: '',
|
||||||
|
is_active: true,
|
||||||
|
skills: [],
|
||||||
|
avatar: undefined,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, user]);
|
||||||
|
|
||||||
|
// Check if form has changes
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
if (!user) return true; // New user always has changes
|
||||||
|
return (
|
||||||
|
formData.fullname !== user.fullname ||
|
||||||
|
formData.location !== user.location ||
|
||||||
|
formData.is_active !== user.is_active ||
|
||||||
|
formData.avatar !== user.avatar ||
|
||||||
|
JSON.stringify(formData.skills) !== JSON.stringify(user.skills) ||
|
||||||
|
formData.bio !== user.bio
|
||||||
|
);
|
||||||
|
}, [formData, user]);
|
||||||
|
|
||||||
|
// Check if required fields are filled
|
||||||
|
const isFormValid = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
return formData.fullname.trim() !== '' && formData.location.trim() !== '';
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
const canSave = hasChanges && isFormValid;
|
||||||
|
|
||||||
|
if (!isOpen || !formData) return null;
|
||||||
|
|
||||||
|
const handleInputChange = (
|
||||||
|
field: keyof UserType,
|
||||||
|
value: string | boolean | string[] | undefined
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkillsChange = (skills: string[]) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, skills } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Update existing user
|
||||||
|
console.log('Update user data:', formData);
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
console.log('Create new user:', formData);
|
||||||
|
}
|
||||||
|
// Here you would typically make an API call to save the data
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (user) {
|
||||||
|
setFormData({ ...user }); // Reset to original for edit mode
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = () => {
|
||||||
|
if (!user) return; // Can't delete new user
|
||||||
|
console.log('Delete user:', user.id);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create preview URL
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const avatarUrl = e.target?.result as string;
|
||||||
|
handleInputChange('avatar', avatarUrl);
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAvatar = () => {
|
||||||
|
handleInputChange('avatar', undefined);
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerFileUpload = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableSkills = [
|
||||||
|
'Frontend Developer',
|
||||||
|
'Backend Developer',
|
||||||
|
'Full Stack Developer',
|
||||||
|
'DevOps Engineer',
|
||||||
|
'UI/UX Designer',
|
||||||
|
'Product Manager',
|
||||||
|
'Data Scientist',
|
||||||
|
'Mobile Developer',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
onClick={(e) => {
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Hidden File Input */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Interactive User Avatar */}
|
||||||
|
<div className="relative group ">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
|
||||||
|
{formData.avatar ? (
|
||||||
|
<img
|
||||||
|
src={formData.avatar}
|
||||||
|
alt={formData.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOutlined className="text-neutral-500 text-2xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar Hover Overlay */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
|
||||||
|
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CameraOutlined className="text-white text-lg" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Avatar Menu Dropdown */}
|
||||||
|
{showAvatarMenu && (
|
||||||
|
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
|
||||||
|
<button
|
||||||
|
onClick={triggerFileUpload}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<UploadOutlined className="text-sm" />
|
||||||
|
{formData.avatar ? 'Change Photo' : 'Upload Photo'}
|
||||||
|
</button>
|
||||||
|
{formData.avatar && (
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-sm" />
|
||||||
|
Remove Photo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h2 className="text-2xl font-bold text-neutral-900">
|
||||||
|
{user ? 'Edit User Profile' : 'Create New User'}
|
||||||
|
</h2>
|
||||||
|
{user && (
|
||||||
|
<span className="px-3 py-1 bg-info-100 text-info-700 text-xs font-medium rounded-2xl">
|
||||||
|
Hover avatar to change
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-500">
|
||||||
|
{user
|
||||||
|
? `Make changes to ${
|
||||||
|
formData.fullname || 'this user'
|
||||||
|
}'s profile information`
|
||||||
|
: 'Fill in the information below to create a new user account'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
handleCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-neutral-400 text-lg cursor-pointer" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Left Column - Basic Info */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Basic Information
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Full Name - Required */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserOutlined className="text-neutral-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm text-neutral-500 block mb-1">
|
||||||
|
Full Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.fullname}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('fullname', e.target.value)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
|
||||||
|
formData.fullname.trim() === ''
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-neutral-300'
|
||||||
|
)}
|
||||||
|
placeholder="Enter full name"
|
||||||
|
/>
|
||||||
|
{formData.fullname.trim() === '' && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
Full name is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location - Required */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<EnvironmentOutlined className="text-neutral-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm text-neutral-500 block mb-1">
|
||||||
|
Location <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.location}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('location', e.target.value)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
|
||||||
|
formData.location.trim() === ''
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-neutral-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">Select location</option>
|
||||||
|
<option value="Jakarta">Jakarta</option>
|
||||||
|
<option value="Bandung">Bandung</option>
|
||||||
|
<option value="Surabaya">Surabaya</option>
|
||||||
|
<option value="Medan">Medan</option>
|
||||||
|
<option value="Yogyakarta">Yogyakarta</option>
|
||||||
|
</select>
|
||||||
|
{formData.location.trim() === '' && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
Location is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Joined Date - Read Only - Only show for existing users */}
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CalendarOutlined className="text-neutral-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Joined Date
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(formData.created_at).toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bio Section - Optional */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
||||||
|
Bio{' '}
|
||||||
|
<span className="text-neutral-400 text-sm font-normal">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<textarea
|
||||||
|
value={formData.bio || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('bio', e.target.value || undefined)
|
||||||
|
}
|
||||||
|
placeholder="Tell us about yourself..."
|
||||||
|
rows={4}
|
||||||
|
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Skills & Status */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Account Status - Enhanced Tab Design */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Account Status
|
||||||
|
</h3>
|
||||||
|
<div className="flex bg-neutral-100 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => handleInputChange('is_active', true)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||||
|
formData.is_active
|
||||||
|
? 'bg-white text-success-700 shadow-sm ring-1 ring-success-200'
|
||||||
|
: 'text-neutral-600 hover:text-neutral-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
formData.is_active
|
||||||
|
? 'bg-success-500'
|
||||||
|
: 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleInputChange('is_active', false)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||||
|
!formData.is_active
|
||||||
|
? 'bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-200'
|
||||||
|
: 'text-neutral-600 hover:text-neutral-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
!formData.is_active
|
||||||
|
? 'bg-neutral-500'
|
||||||
|
: 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Inactive
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500 mt-2">
|
||||||
|
{formData.is_active
|
||||||
|
? 'User can access their account and participate in activities'
|
||||||
|
: 'User account is suspended and cannot access services'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills Section - Optional */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Skills & Expertise{' '}
|
||||||
|
<span className="text-neutral-400 text-sm font-normal">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 min-h-10 p-3 border border-neutral-300 rounded-lg bg-neutral-50">
|
||||||
|
{formData.skills.length > 0 ? (
|
||||||
|
formData.skills.map((skill, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-2xl text-sm font-medium bg-blue-100 text-blue-800"
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleSkillsChange(
|
||||||
|
formData.skills.filter((_, i) => i !== index)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-blue-600 hover:text-blue-800 ml-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400 text-sm">
|
||||||
|
No skills added yet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value &&
|
||||||
|
!formData.skills.includes(e.target.value)
|
||||||
|
) {
|
||||||
|
handleSkillsChange([
|
||||||
|
...formData.skills,
|
||||||
|
e.target.value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white"
|
||||||
|
>
|
||||||
|
<option value="">Add a skill...</option>
|
||||||
|
{availableSkills
|
||||||
|
.filter((skill) => !formData.skills.includes(skill))
|
||||||
|
.map((skill) => (
|
||||||
|
<option key={skill} value={skill}>
|
||||||
|
{skill}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Details - Read Only - Only show for existing users */}
|
||||||
|
{user && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Account Details
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 bg-neutral-50 p-4 rounded-lg">
|
||||||
|
<div className="flex justify-between items-center py-1">
|
||||||
|
<span className="text-neutral-600 text-sm">
|
||||||
|
User ID
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm text-neutral-800">
|
||||||
|
{formData.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1">
|
||||||
|
<span className="text-neutral-600 text-sm">
|
||||||
|
Last Updated
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-neutral-800">
|
||||||
|
{new Date(formData.updated_at).toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
{
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="border-t border-neutral-200 px-8 py-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-sm text-neutral-500">
|
||||||
|
{canSave
|
||||||
|
? 'Ready to save changes'
|
||||||
|
: hasChanges
|
||||||
|
? 'Please fill required fields'
|
||||||
|
: 'No changes made'}
|
||||||
|
</div>
|
||||||
|
{/* Delete Account Button - Only show for existing users */}
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="text-red-600 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-6"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-6',
|
||||||
|
!canSave && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SaveOutlined className="text-sm" />
|
||||||
|
{user ? 'Save Changes' : 'Create User'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 z-60">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<ExclamationOutlined className="text-red-600 text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900">
|
||||||
|
Delete Account
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
This action cannot be undone
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-neutral-700 mb-6">
|
||||||
|
Are you sure you want to permanently delete{' '}
|
||||||
|
<strong>{formData.fullname}</strong>'s account? This will
|
||||||
|
remove all their data and cannot be reversed.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
className="px-4 bg-red-600 hover:bg-red-700 border-red-600"
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalUserDetail;
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
|
||||||
|
import ModalUserDetail from './_components/modal-user-detail';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
// Removed unused SearchOutlined icon after schema revision
|
||||||
|
|
||||||
|
// Define interface outside component
|
||||||
|
interface UserType {
|
||||||
|
id: string; // UUID
|
||||||
|
avatar?: string;
|
||||||
|
fullname: string;
|
||||||
|
bio?: string;
|
||||||
|
location: string;
|
||||||
|
is_active: boolean; // admin can deactivate
|
||||||
|
skills: string[]; // Frontend Developer, Backend Developer, etc.
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move mock data outside component to prevent recreation
|
||||||
|
const skillsOptions = [
|
||||||
|
'Frontend Developer',
|
||||||
|
'Backend Developer',
|
||||||
|
'Full Stack Developer',
|
||||||
|
'DevOps Engineer',
|
||||||
|
'UI/UX Designer',
|
||||||
|
'Product Manager',
|
||||||
|
'Data Scientist',
|
||||||
|
'Mobile Developer',
|
||||||
|
];
|
||||||
|
|
||||||
|
const locations = ['Jakarta', 'Bandung', 'Surabaya', 'Medan', 'Yogyakarta'];
|
||||||
|
const bios = [
|
||||||
|
'Passionate developer with 5+ years experience',
|
||||||
|
'Tech enthusiast and problem solver',
|
||||||
|
'Building scalable solutions for modern problems',
|
||||||
|
'Creative designer with technical background',
|
||||||
|
'Data-driven decision maker',
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockData: UserType[] = Array.from({ length: 50 }, (_, i) => {
|
||||||
|
const randomSkillsCount = Math.floor(Math.random() * 3) + 1; // 1-3 skills
|
||||||
|
const randomSkills = skillsOptions
|
||||||
|
.sort(() => 0.5 - Math.random())
|
||||||
|
.slice(0, randomSkillsCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `24db9e4d-ca4c-46aa-ac36-8ef04bbe01${String(i).padStart(2, '0')}`,
|
||||||
|
avatar:
|
||||||
|
i % 4 === 0
|
||||||
|
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||||
|
i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana'
|
||||||
|
)}&background=random`
|
||||||
|
: undefined,
|
||||||
|
fullname:
|
||||||
|
i % 3 === 0
|
||||||
|
? 'Ahmad Wijuana'
|
||||||
|
: i % 3 === 1
|
||||||
|
? 'Sofia Wijuana'
|
||||||
|
: 'Budi Santoso',
|
||||||
|
bio: i % 4 === 0 ? bios[i % bios.length] : undefined,
|
||||||
|
location: locations[i % locations.length],
|
||||||
|
is_active: i % 7 !== 0, // More realistic distribution
|
||||||
|
skills: randomSkills,
|
||||||
|
created_at: new Date(
|
||||||
|
Date.now() - i * 86400000 * (Math.random() * 30 + 1)
|
||||||
|
).toISOString(), // Random within last 30-60 days
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
|
const [showNewUserModal, setShowNewUserModal] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState('');
|
||||||
|
|
||||||
|
// Advanced filtering states
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [locationFilter, setLocationFilter] = useState('all');
|
||||||
|
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
// Memoize the callback to prevent recreation
|
||||||
|
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseDetailModal = useCallback(() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowNewUserModal = useCallback(() => {
|
||||||
|
setShowNewUserModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseNewUserModal = useCallback(() => {
|
||||||
|
setShowNewUserModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter data based on current filter states
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return mockData.filter((user) => {
|
||||||
|
// Status filter
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
const isActive = statusFilter === 'active';
|
||||||
|
if (user.is_active !== isActive) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location filter
|
||||||
|
if (locationFilter !== 'all' && user.location !== locationFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills filter
|
||||||
|
if (skillsFilter.length > 0) {
|
||||||
|
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||||
|
user.skills.includes(skill)
|
||||||
|
);
|
||||||
|
if (!hasMatchingSkill) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [statusFilter, locationFilter, skillsFilter]);
|
||||||
|
|
||||||
|
// Memoize columns to prevent recreation on every render
|
||||||
|
const columns: ColumnDef<UserType>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'fullname',
|
||||||
|
header: 'User',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||||
|
{row.original.avatar ? (
|
||||||
|
<img
|
||||||
|
src={row.original.avatar}
|
||||||
|
alt={row.original.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOutlined className="text-neutral-500 text-lg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Name only */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-neutral-900 truncate">
|
||||||
|
{row.original.fullname}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'skills',
|
||||||
|
header: 'Skills',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||||
|
{row.original.skills.slice(0, 2).map((skill, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
||||||
|
>
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{row.original.skills.length > 2 && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
|
||||||
|
+{row.original.skills.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'location',
|
||||||
|
header: 'Location',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-700">{row.original.location}</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'is_active',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const aActive = rowA.original.is_active;
|
||||||
|
const bActive = rowB.original.is_active;
|
||||||
|
if (aActive && !bActive) return -1;
|
||||||
|
if (!aActive && bActive) return 1;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Joined',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-900 text-sm">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
meta: { cellClassName: cn('w-48') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2 text-sm px-4 py-2"
|
||||||
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-sm" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm px-4 py-2"
|
||||||
|
onClick={() => {
|
||||||
|
// Toggle user status - implement later
|
||||||
|
console.log(`Toggle status for ${row.original.fullname}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Deactivate' : 'Activate'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowDetailModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
{/* Left side - Search & filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Search users by name or location..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={locationFilter}
|
||||||
|
onChange={(e) => setLocationFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Locations</option>
|
||||||
|
{locations.map((location) => (
|
||||||
|
<option key={location} value={location}>
|
||||||
|
{location}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills Filter with Icon */}
|
||||||
|
<div className="relative">
|
||||||
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
|
<select
|
||||||
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value &&
|
||||||
|
!skillsFilter.includes(e.target.value)
|
||||||
|
) {
|
||||||
|
setSkillsFilter((prev) => [...prev, e.target.value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Add Skill Filter</option>
|
||||||
|
{skillsOptions.map((skill) => (
|
||||||
|
<option
|
||||||
|
key={skill}
|
||||||
|
value={skill}
|
||||||
|
disabled={skillsFilter.includes(skill)}
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Add User Button */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-2 px-4 py-2"
|
||||||
|
onClick={handleShowNewUserModal}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-sm" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters display */}
|
||||||
|
{(skillsFilter.length > 0 ||
|
||||||
|
statusFilter !== 'all' ||
|
||||||
|
locationFilter !== 'all') && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
|
||||||
|
{/* Status filter badge */}
|
||||||
|
{statusFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
||||||
|
Status: {statusFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location filter badge */}
|
||||||
|
{locationFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
Location: {locationFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setLocationFilter('all')}
|
||||||
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills filter badges */}
|
||||||
|
{skillsFilter.map((skill) => (
|
||||||
|
<span
|
||||||
|
key={skill}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
||||||
|
>
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||||
|
}
|
||||||
|
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Clear all filters */}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter('all');
|
||||||
|
setLocationFilter('all');
|
||||||
|
setSkillsFilter([]);
|
||||||
|
setGlobalFilter('');
|
||||||
|
}}
|
||||||
|
className="text-sm text-neutral-600"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination-aware results display */}
|
||||||
|
{filteredData.length > 0 && (
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
Showing {Math.min(pageSize, filteredData.length)} of{' '}
|
||||||
|
{filteredData.length} users
|
||||||
|
{filteredData.length > pageSize}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<DataTable data={filteredData} columns={columns} pageSize={10} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Modals component */}
|
||||||
|
<ModalUserDetail
|
||||||
|
isOpen={showDetailModal}
|
||||||
|
onClose={handleCloseDetailModal}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* New User Modal */}
|
||||||
|
<ModalUserDetail
|
||||||
|
isOpen={showNewUserModal}
|
||||||
|
onClose={handleCloseNewUserModal}
|
||||||
|
user={null} // null indicates creating new user
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonUsersPage;
|
||||||
@@ -1,13 +1,51 @@
|
|||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement, useState } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
export const AppLayout: FC = (): ReactElement => {
|
export const AppLayout: FC = (): ReactElement => {
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||||
<BackofficeSidebar />
|
<BackofficeSidebar
|
||||||
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
|
isOpen={mobileSidebarOpen}
|
||||||
|
onClose={() => setMobileSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* Sticky top header */}
|
||||||
|
<header
|
||||||
|
className={
|
||||||
|
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
||||||
|
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Mobile menu button (shown on small screens) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700"
|
||||||
|
onClick={() => setMobileSidebarOpen(true)}
|
||||||
|
aria-label="Open sidebar"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 className="text-p3 font-semibold text-primary-700">
|
||||||
|
IMPHNEN Backoffice
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { ArrowRightOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { Accordion, Modal, ModalProps } from "@imphnen-frontend-service/ui/molecules";
|
||||||
|
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||||
|
import { FC } from "react";
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
|
||||||
|
|
||||||
|
export const ModalCreateRoadmap: FC<Omit<ModalProps, 'children'>> = ({ isOpen, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
className="xl:max-w-[64rem] bg-white px-10 py-9"
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<div className="overflow-y-auto max-h-[80vh]">
|
||||||
|
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
|
||||||
|
Create Roadmaps
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-6 mb-12">
|
||||||
|
<div className="col-span-3">
|
||||||
|
<label className={labelClass}>Prompt</label>
|
||||||
|
<Textarea
|
||||||
|
className="min-w-full w-full h-[calc(100%-2rem)]"
|
||||||
|
placeholder="Create a learning roadmap for (subject) at the (level) level. Include topics, estimated duration, and logical progression."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-8">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Roadmap Name</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="Nama Roadmap" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Roadmap Name</label>
|
||||||
|
<Select className="w-full">
|
||||||
|
<option value="pemula">Pemula</option>
|
||||||
|
<option value="menengah">Menengah</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Model</label>
|
||||||
|
<Select className="w-full">
|
||||||
|
<option value="gpt-3.5-turbo">GPT 3.5 Turbo</option>
|
||||||
|
<option value="gpt-4">GPT 4</option>
|
||||||
|
<option value="gpt-4-32k">GPT 4 32k</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-full">
|
||||||
|
<Button>
|
||||||
|
Generate Roadmap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-neutral-600 text-p2 font-semibold mb-8">
|
||||||
|
Roadmap Preview
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<For data={Array.from({ length: 3 })}>
|
||||||
|
{(_, index) => (
|
||||||
|
<Accordion
|
||||||
|
key={index}
|
||||||
|
title={`Day ${index + 1}`}
|
||||||
|
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit. In convallis tincidunt nisl, id consequat mi malesuada vel. Nulla facilisi. Nam in turpis ligula."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-12">
|
||||||
|
<Button type="button" className="flex items-center gap-5">
|
||||||
|
Submit Roadmap
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { DeleteOutlined, EditOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils";
|
||||||
|
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ModalCreateRoadmap } from "./_components/modal/create-roadmap";
|
||||||
|
|
||||||
|
type LearningStatus = 'active' | 'inactive'
|
||||||
|
|
||||||
|
type RoadmapType = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
learningLevel: string
|
||||||
|
status: LearningStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: RoadmapType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: i === 0 ? 'Ahmad Wijuana' : 'Anna Wiguana',
|
||||||
|
learningLevel: ['Pemula', 'Menengah'][Math.floor(Math.random() * 2)],
|
||||||
|
status: i % 2 === 0 ? 'active' : 'inactive',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default function Components(): React.ReactElement {
|
||||||
|
const [openCreateModal, setOpenCreateModal] = useState(false)
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<RoadmapType>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
meta: { cellClassName: cn("w-20") },
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'id',
|
||||||
|
header: 'No',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Nama Roadmap',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'learningLevel',
|
||||||
|
header: 'Tingkat Belajar',
|
||||||
|
accessorKey: 'learningLevel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const statusColors: Record<LearningStatus, string> = {
|
||||||
|
inactive: 'bg-danger-200 text-danger-700',
|
||||||
|
active: 'bg-success-200 text-success-500',
|
||||||
|
};
|
||||||
|
const statusText: Record<LearningStatus, string> = {
|
||||||
|
inactive: 'Inactive',
|
||||||
|
active: 'Active',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||||
|
>
|
||||||
|
{statusText[status]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn("w-72") },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-[8px]">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<EditOutlined /> Action
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<DeleteOutlined /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
|
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Content & Roadmap</h1>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
<div className="flex items-center justify-between mb-9">
|
||||||
|
<h2 className="text-p2 font-semibold text-neutral-600">AI Roadmaps</h2>
|
||||||
|
<Button type="button" variant="primary" className="flex items-center gap-2" onClick={() => setOpenCreateModal(true)}>
|
||||||
|
<PlusOutlined /> Buat Roadmap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center gap-5 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama roadmap"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select>
|
||||||
|
<option selected disabled>Tingkat Belajar</option>
|
||||||
|
<option value="pemula">Pemula</option>
|
||||||
|
<option value="menengah">Menengah</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ModalCreateRoadmap isOpen={openCreateModal} onClose={() => setOpenCreateModal(false)} />
|
||||||
|
</BackofficeWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { Modal } from "@imphnen-frontend-service/ui/molecules";
|
||||||
|
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||||
|
import { FC } from "react";
|
||||||
|
|
||||||
|
const TOPICS = [
|
||||||
|
{ id: 2, icon: '🏢', name: 'Industry Insight' },
|
||||||
|
{ id: 4, icon: '🖥️', name: 'Basic IT' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
|
||||||
|
|
||||||
|
Saat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.
|
||||||
|
|
||||||
|
Saya ingin tahu secara khusus tentang ___.
|
||||||
|
1.Pertanyaan Anda
|
||||||
|
2. ...
|
||||||
|
3. ...`
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModalDetailSession: FC<ModalProps> = ({ open, setOpen }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
className="xl:max-w-[64rem] bg-white px-10 py-9"
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
|
||||||
|
Detail Sesi
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-9 px-9 py-7 border rounded-md gap-12 mb-10">
|
||||||
|
<div className="col-span-4 flex items-center gap-x-12">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-p2 font-semibold text-primary-500 mb-4">Mentor</h2>
|
||||||
|
<div>
|
||||||
|
<p className="text-p3 font-semibold mb-2.5">Muhammad Firdaus Oi Oi Oi, S.H., M.H.</p>
|
||||||
|
<p className="text-neutral-600">UI Designer at Oray orayan Studios</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Icon icon="ph:arrow-right" className="text-9xl text-primary-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-5 flex items-center gap-12">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-p2 font-semibold text-primary-500 mb-4">Mentee</h2>
|
||||||
|
<div>
|
||||||
|
<p className="text-p3 font-semibold mb-2.5">Muhammad Firdaus Oi Oi Oi, S.H., M.H.</p>
|
||||||
|
<p className="text-neutral-600">UI Designer at Oray orayan Studios</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-p2 font-semibold text-neutral-700 mb-4">Status</h2>
|
||||||
|
<div className="py-2 px-6 rounded-md text-center bg-success-200 text-success-500 font-semibold">
|
||||||
|
Finished
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-p3 font-medium mb-2.5">Topics</h1>
|
||||||
|
<div className="p-5 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-2.5 mb-8">
|
||||||
|
<For data={TOPICS}>
|
||||||
|
{(item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow font-medium"
|
||||||
|
>
|
||||||
|
<span>{item.icon} </span>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2.5 md:grid-cols-2 md:gap-5">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Tanggal</label>
|
||||||
|
<Input type="date" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Waktu</label>
|
||||||
|
<Input type="time" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="relative md:col-span-full">
|
||||||
|
<label className={labelClass}>Lokasi</label>
|
||||||
|
<Select className="min-w-full w-full">
|
||||||
|
<option value="online">Online</option>
|
||||||
|
<option value="offline">Offline</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-full">
|
||||||
|
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
|
||||||
|
<Textarea
|
||||||
|
className="min-w-full w-full h-40"
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils";
|
||||||
|
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
||||||
|
import { ReactElement, useState } from "react";
|
||||||
|
import { ModalDetailSession } from "./_components/modal/detail";
|
||||||
|
|
||||||
|
type SessionStatus = 'ongoing' | 'finished';
|
||||||
|
|
||||||
|
interface SessionType {
|
||||||
|
id: string
|
||||||
|
mentorName: string
|
||||||
|
menteeName: string
|
||||||
|
datetime: number
|
||||||
|
status: SessionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: SessionType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: `DS-${i + 1}`,
|
||||||
|
mentorName: 'Ahmad Wijuana',
|
||||||
|
menteeName: 'Sofia Wijuana',
|
||||||
|
datetime: new Date().getTime(),
|
||||||
|
status: i % 2 === 0 ? 'ongoing' : 'finished',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default function Components(): ReactElement {
|
||||||
|
const [openDetail, setOpenDetail] = useState(false);
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<SessionType>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
meta: { cellClassName: cn("w-20") },
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'id',
|
||||||
|
header: 'ID Sesi',
|
||||||
|
accessorKey: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mentorName',
|
||||||
|
header: 'Nama Mentor',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menteeName',
|
||||||
|
header: 'Nama Mentee',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'datetime',
|
||||||
|
header: 'Waktu',
|
||||||
|
accessorKey: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const statusColors: Record<SessionStatus, string> = {
|
||||||
|
ongoing: 'bg-warning-200 text-warning-700',
|
||||||
|
finished: 'bg-success-200 text-success-500',
|
||||||
|
};
|
||||||
|
const statusText: Record<SessionStatus, string> = {
|
||||||
|
ongoing: 'On Going',
|
||||||
|
finished: 'Finished',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||||
|
>
|
||||||
|
{statusText[status]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn("w-52") },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenDetail(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
>
|
||||||
|
<SearchOutlined className="text-[16px]" /> Cek Detail
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
|
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Session Management</h1>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
<div className="flex justify-between items-center gap-5 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama lengkap"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select>
|
||||||
|
<option selected disabled>Rating</option>
|
||||||
|
<option value="4.5">4.5</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
</Select>
|
||||||
|
<Select>
|
||||||
|
<option selected disabled>Status</option>
|
||||||
|
<option value="finished">Finished</option>
|
||||||
|
<option value="ongoing">On Going</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ModalDetailSession open={openDetail} setOpen={setOpenDetail} />
|
||||||
|
</BackofficeWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Button, Input, Select, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils"
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
|
||||||
|
|
||||||
|
export const GeneralSettings: FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">General Settings</h1>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ToggleInput label="Mode Maintenance" />
|
||||||
|
|
||||||
|
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Platform Settings</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-5 mb-8">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Nama Platform</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Bahasa</label>
|
||||||
|
<Select defaultValue="id" className="w-full">
|
||||||
|
<option value="id">Indonesia</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Logo Platform</label>
|
||||||
|
<Input type="file" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Favicon</label>
|
||||||
|
<Input type="file" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Legal Settings</h2>
|
||||||
|
<div className="grid gap-x-8 gap-y-5 mb-20">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>URL Syarat & Ketentuan</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>URL Kebijakan Privasi</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-5">
|
||||||
|
<Button type="button" variant="bordered">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="button">
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Button, Textarea, ToggleInput } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
export const NotificationSettings: FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Notification Settings</h1>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-8 flex-wrap">
|
||||||
|
<ToggleInput label="Nyalakan Notifikasi Email" />
|
||||||
|
<ToggleInput label="Beritahu mentor ketika ada request" />
|
||||||
|
<ToggleInput label="Beritahu mentee untuk update sesi " />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-20">
|
||||||
|
<label className="text-neutral-800 font-medium inline-block mb-2 text-p3">
|
||||||
|
API Integrasi Notifikasi (URL)
|
||||||
|
</label>
|
||||||
|
<Textarea placeholder="Masukkan url API notifikasi yang akan digunakan" className="w-full h-40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-5">
|
||||||
|
<Button type="button" variant="bordered">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="button">
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Button, Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils"
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
|
||||||
|
|
||||||
|
export const PaymentSettings: FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Payment</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-5 mb-8">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Integrasi Payment Gateway</label>
|
||||||
|
<Textarea className="min-w-full w-full h-20" placeholder="Durasi dalam menit" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Mata Uang</label>
|
||||||
|
<Select defaultValue="idr">
|
||||||
|
<option value="idr">Rupiah (IDR)</option>
|
||||||
|
<option value="usd">Dollar (USD)</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Harga sesi mentoring <span className="text-neutral-600">(default)</span></label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="Masukkan harga sesi mentoring" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Tarif Komisi untuk Platform</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="Masukkan persentase komisi" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-p3 font-semibold text-neutral-700 mb-5">Invoice</h2>
|
||||||
|
<div className="mb-32">
|
||||||
|
<label className={labelClass}>Masukkan Format Invoice</label>
|
||||||
|
<Input type="file" className="min-w-full w-full" placeholder="InvoiceDimentorin.png" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-5">
|
||||||
|
<Button type="button" variant="bordered">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="button">
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Button, Input } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils";
|
||||||
|
import { FC } from "react";
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-medium mb-1.5 inline-block md:text-xs md:mb-2 xl:text-p3')
|
||||||
|
|
||||||
|
export const SecuritySettings: FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-700 mb-8">Security Settings</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-8 mb-20">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Durasi Session Timeout</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="Durasi dalam menit" />
|
||||||
|
<p className="text-[10px] text-neutral-800">Auto logout setelah X menit tidak aktif</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Blokir Setelah Upaya Gagal</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="x kali perobaan login" />
|
||||||
|
<p className="text-[10px] text-neutral-800">Misal: 5 kali salah login = lock akun</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-5">
|
||||||
|
<Button type="button" variant="bordered">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="button">
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { DeleteOutlined, UserSwitchOutlined } from "@ant-design/icons";
|
||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { DataTable } from "@imphnen-frontend-service/ui/organisms";
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils";
|
||||||
|
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
||||||
|
import { FC, useState } from "react";
|
||||||
|
|
||||||
|
type UserRolesPermissionType = {
|
||||||
|
id: number
|
||||||
|
role: string
|
||||||
|
totalUser: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: UserRolesPermissionType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
role: ['Admin', 'Super Admin', 'Mentee', 'Mentor'][Math.floor(Math.random() * 4)],
|
||||||
|
totalUser: 10
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const UserRolesPermission: FC = () => {
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<UserRolesPermissionType>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
meta: { cellClassName: cn("w-20") },
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'role',
|
||||||
|
header: 'Role',
|
||||||
|
accessorKey: 'role',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'totalUser',
|
||||||
|
header: 'Total User',
|
||||||
|
accessorKey: 'totalUser',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn("w-96") },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
>
|
||||||
|
<UserSwitchOutlined className="text-[16px]" /> Manage Permissions
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
>
|
||||||
|
<DeleteOutlined className="text-[16px]" /> Delete Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-700">User Roles & Permissions</h1>
|
||||||
|
<Button type="button">
|
||||||
|
Add Rols
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white shadow p-8 rounded-lg">
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { BackofficeWrapper } from "@imphnen-frontend-service/ui/organisms"
|
||||||
|
import { cn, For } from "@imphnen-frontend-service/utils"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { GeneralSettings } from "./_components/general"
|
||||||
|
import { UserRolesPermission } from "./_components/user-roles-permission"
|
||||||
|
import { NotificationSettings } from "./_components/notification"
|
||||||
|
import { SecuritySettings } from "./_components/security"
|
||||||
|
import { PaymentSettings } from "./_components/payment"
|
||||||
|
|
||||||
|
const TABS = {
|
||||||
|
general: "General Settings",
|
||||||
|
userRolePermissions: "User Roles & Permissions",
|
||||||
|
notification: "Notification Settings",
|
||||||
|
security: "Security",
|
||||||
|
payment: "Payment",
|
||||||
|
} as const
|
||||||
|
type Tabs = typeof TABS[keyof typeof TABS]
|
||||||
|
|
||||||
|
export default function Components(): React.ReactElement {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tabs>(TABS.general)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
|
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">Settings</h1>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-x-8">
|
||||||
|
<div className="w-64 bg-white p-2.5 shadow space-y-2 rounded-md">
|
||||||
|
<For data={Object.values(TABS)}>
|
||||||
|
{(tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-3 w-full text-left font-medium rounded-md text-neutral-400 cursor-pointer select-none hover:bg-primary-100",
|
||||||
|
activeTab === tab && "bg-primary-500 text-white hover:bg-primary-600"
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white px-8 py-6 shadow space-y-2 rounded-md flex-1">
|
||||||
|
{activeTab === TABS.general && <GeneralSettings />}
|
||||||
|
{activeTab === TABS.userRolePermissions && <UserRolesPermission />}
|
||||||
|
{activeTab === TABS.notification && <NotificationSettings />}
|
||||||
|
{activeTab === TABS.security && <SecuritySettings />}
|
||||||
|
{activeTab === TABS.payment && <PaymentSettings />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { Modal } from "@imphnen-frontend-service/ui/molecules";
|
||||||
|
import { FC } from "react";
|
||||||
|
|
||||||
|
type ModalDeleteProps = {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
hadnleDelete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModalDelete: FC<ModalDeleteProps> = ({ open, setOpen, hadnleDelete }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className="min-w-[400px] bg-primary-50 rounded-lg p-[40px] flex flex-col gap-8 text-center"
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<Modal.Header className="gap-8">
|
||||||
|
<img
|
||||||
|
src="/chibi-delete.webp"
|
||||||
|
alt="Delete item?"
|
||||||
|
width={148}
|
||||||
|
className="self-center"
|
||||||
|
/>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-p1 font-semibold text-danger-500 mb-3">
|
||||||
|
Hapus Akun
|
||||||
|
</h2>
|
||||||
|
<p className="text-p3 text-neutral-400">
|
||||||
|
Apakah kamu yakin untuk menghapus akun ini?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="lg"
|
||||||
|
className="w-full border-danger-500 text-danger-500 hover:bg-danger-50 hover:text-danger-600 hover:border-danger-600"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => hadnleDelete?.()}
|
||||||
|
>
|
||||||
|
Hapus Akun
|
||||||
|
</Button>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Button, Input } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils";
|
||||||
|
import { FC, useState } from "react";
|
||||||
|
import { ModalDelete } from "../delete";
|
||||||
|
import { ModalProps } from "../type";
|
||||||
|
import { ModalSuspendOrBan } from "../suspend-or-ban";
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
|
||||||
|
|
||||||
|
export const AccountProfile: FC<ModalProps> = ({ setOpen }) => {
|
||||||
|
const [openDelete, setOpenDelete] = useState(false)
|
||||||
|
const [openSuspendOrBan, setOpenSuspendOrBan] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-x-8 mb-8">
|
||||||
|
<div className="size-[100px] rounded-full overflow-hidden">
|
||||||
|
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button type="button" size="sm" variant="bordered" className="mb-4">
|
||||||
|
Upload Foto
|
||||||
|
</Button>
|
||||||
|
<p className="text-neutral-400 font-medium">
|
||||||
|
Setidaknya rekomendasi ukuran 240x240 px. <br />
|
||||||
|
.jpg, .jpeg, .png diperbolehkan
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="mb-7 text-p2 font-semibold">Informasi Pribadi</h1>
|
||||||
|
<div className="grid grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Nama Depan</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="Nama Depan" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Nama Belakang</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="Nama Belakang" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Email</label>
|
||||||
|
<Input type="email" className="min-w-full w-full" placeholder="Email" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Nomor Telepon</label>
|
||||||
|
<Input type="text" className="min-w-full w-full" placeholder="+62 81234567890" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-7 text-p2 font-semibold">Status Akun</h1>
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="bordered"
|
||||||
|
className="border-danger-500 text-danger-500"
|
||||||
|
onClick={() => setOpenSuspendOrBan(true)}
|
||||||
|
>
|
||||||
|
Suspend/Ban
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="danger" onClick={() => setOpenDelete(true)}>
|
||||||
|
Delete Akun
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-x-5">
|
||||||
|
<Button type="button" variant="bordered" onClick={() => setOpen(false)}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled>
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalDelete open={openDelete} setOpen={setOpenDelete} />
|
||||||
|
<ModalSuspendOrBan open={openSuspendOrBan} setOpen={setOpenSuspendOrBan} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { SearchOutlined } from "@ant-design/icons"
|
||||||
|
import { Input, Select } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { DataTable } from "@imphnen-frontend-service/ui/organisms"
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils"
|
||||||
|
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table"
|
||||||
|
import { FC, useState } from "react"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
|
interface ActivityLogType {
|
||||||
|
id: number
|
||||||
|
date: string
|
||||||
|
menu: string
|
||||||
|
activity: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: ActivityLogType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
menu: 'Mentoring',
|
||||||
|
activity: 'Mentoring Session',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const ActivityLog: FC = () => {
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ColumnDef<ActivityLogType>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
meta: { cellClassName: cn("w-16") },
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'date',
|
||||||
|
header: 'Waktu',
|
||||||
|
accessorKey: 'date',
|
||||||
|
cell: (info) => dayjs(info.row.original.date).format('DD MMMM YYYY, HH:mm WIB'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'menu',
|
||||||
|
header: 'Menu',
|
||||||
|
accessorKey: 'menu',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'activity',
|
||||||
|
header: 'Activity',
|
||||||
|
accessorKey: 'activity',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 shadow rounded-lg">
|
||||||
|
<div className="flex justify-between items-center gap-5 mb-9">
|
||||||
|
<div className="relative w-1/2">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari aktivitas"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/4">
|
||||||
|
<Input type="date" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<Select>
|
||||||
|
<option selected disabled>Menu</option>
|
||||||
|
<option value="profle">Profile</option>
|
||||||
|
<option value="profle-2">Profile</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Icon } from "@iconify/react"
|
||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { For } from "@imphnen-frontend-service/utils"
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
const SOCIAL_LINKS = [
|
||||||
|
{ icon: <Icon icon="mdi:linkedin" className="text-2xl" />, url: "https://linkedin.com", label: "LinkedIn" },
|
||||||
|
{ icon: <Icon icon="mdi:github" className="text-2xl" />, url: "https://github.com", label: "Github" },
|
||||||
|
{ icon: <Icon icon="mingcute:meta-line" className="text-2xl" />, url: "https://facebook.com", label: "Facebook (Meta)" },
|
||||||
|
{ icon: <Icon icon="mdi:stack-overflow" className="text-2xl" />, url: "https://stackoverflow.com", label: "Stack Overflow" }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DetailProfile: FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="shadow rounded-lg p-8 mb-7">
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div className="flex items-center gap-x-6">
|
||||||
|
<div className="size-[54px] rounded-full overflow-hidden">
|
||||||
|
<img src="/images/asd687hwq6nds4dfjj2983.webp" alt="Profile" className="w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-800">Muhammad Firdaus Oiwobo</h1>
|
||||||
|
<p className="text-p3 font-medium text-neutral-600">Mentor</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="text" className="bg-primary-100">
|
||||||
|
Actively Seeking Job
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<For data={SOCIAL_LINKS}>
|
||||||
|
{({ icon, url, label }) => (
|
||||||
|
<a key={url} href={url} target="_blank" rel="noreferrer">
|
||||||
|
<Button type="button" size="sm" className="flex items-center gap-x-2">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-7">
|
||||||
|
<div className="text-pretty px-8 py-10 rounded-lg shadow h-max">
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-800 mb-6">Description</h1>
|
||||||
|
<p className="text-p3 font-medium text-neutral-600">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-8 py-10 rounded-lg shadow h-max">
|
||||||
|
<h1 className="text-p2 font-semibold text-neutral-800 mb-6">Personal Informations</h1>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="flex items-center gap-x-5">
|
||||||
|
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
|
||||||
|
<Icon icon="ic:outline-mail" className="text-3xl" />
|
||||||
|
</div>
|
||||||
|
<div className="text-p3">
|
||||||
|
<p className="text-neutral-800 font-semibold">rzalaxib23@gmail.com</p>
|
||||||
|
<p className="text-neutral-600 font-medium">Email Address</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-5">
|
||||||
|
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
|
||||||
|
<Icon icon="cil:phone" className="text-3xl" />
|
||||||
|
</div>
|
||||||
|
<div className="text-p3">
|
||||||
|
<p className="text-neutral-800 font-semibold">+62 888 8888 8888</p>
|
||||||
|
<p className="text-neutral-600 font-medium">Phone Number</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-5">
|
||||||
|
<div className="bg-primary-50 rounded-full p-2.5 flex items-center justify-center text-primary-500">
|
||||||
|
<Icon icon="ion:location-outline" className="text-3xl" />
|
||||||
|
</div>
|
||||||
|
<div className="text-p3">
|
||||||
|
<p className="text-neutral-800 font-semibold">Jl. Mergosari, Kec. Suryakencana, Banjaran</p>
|
||||||
|
<p className="text-neutral-600 font-medium">Location</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { Modal } from "@imphnen-frontend-service/ui/molecules"
|
||||||
|
import { cn, For, Show } from "@imphnen-frontend-service/utils"
|
||||||
|
import { FC, useEffect, useState } from "react"
|
||||||
|
import { AccountProfile } from "./account-profile"
|
||||||
|
import { DetailProfile } from "./detail-profile"
|
||||||
|
import { ActivityLog } from "./activity-log"
|
||||||
|
import { ModalDetailUserProps } from "./type"
|
||||||
|
|
||||||
|
const TABS = {
|
||||||
|
account: 'account profile',
|
||||||
|
detail: 'detail profile',
|
||||||
|
activity: 'activity logs',
|
||||||
|
} as const
|
||||||
|
type TabType = typeof TABS[keyof typeof TABS]
|
||||||
|
|
||||||
|
export const ModalDetailUser: FC<ModalDetailUserProps> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>(TABS.account)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setActiveTab(TABS.account)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
className="xl:max-w-[84rem] bg-white px-10 py-9"
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
|
||||||
|
Detail - Muhammad Firdaus Oiwobo
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md w-max mb-8">
|
||||||
|
<For data={Object.values(TABS)}>
|
||||||
|
{(tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab}
|
||||||
|
variant="text"
|
||||||
|
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Show condition={activeTab === TABS.account}>
|
||||||
|
<AccountProfile open={open} setOpen={setOpen} />
|
||||||
|
</Show>
|
||||||
|
<Show condition={activeTab === TABS.detail}>
|
||||||
|
<DetailProfile />
|
||||||
|
</Show>
|
||||||
|
<Show condition={activeTab === TABS.activity}>
|
||||||
|
<ActivityLog />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ModalProps } from "../type";
|
||||||
|
|
||||||
|
export interface ModalDetailUserProps extends ModalProps {
|
||||||
|
userId?: number | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { ModalProps } from "../type";
|
||||||
|
import { Modal } from "@imphnen-frontend-service/ui/molecules";
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils";
|
||||||
|
import { Button, Select, Textarea } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
|
||||||
|
|
||||||
|
export const ModalSuspendOrBan: FC<ModalProps> = ({ open, setOpen }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
className="bg-white px-10 py-9 xl:max-w-[32rem]"
|
||||||
|
closeButtonClassName="hidden"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="bg-primary-50 px-6 py-3 text-neutral-800 text-p2 font-semibold mb-8">
|
||||||
|
Suspend/Ban
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Suspend/Ban</label>
|
||||||
|
<Select defaultValue="suspend" className="w-full">
|
||||||
|
<option value="suspend">Suspend</option>
|
||||||
|
<option value="banned">Banned</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Alasan</label>
|
||||||
|
<Textarea placeholder="Masukkan alasan suspend/ban" className="w-full h-40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" className="w-full">
|
||||||
|
Selesai
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface ModalProps {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { cn, For } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { ReactElement, useState } from 'react';
|
||||||
|
import { ModalDetailUser } from './_components/modal/detail';
|
||||||
|
|
||||||
|
type UserStatus = 'active' | 'inactive';
|
||||||
|
|
||||||
|
interface UserType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
rating: number;
|
||||||
|
status: UserStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
name: i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana',
|
||||||
|
email: 'fullname23@gmail.com',
|
||||||
|
rating: 4.5,
|
||||||
|
status: i % 2 === 0 ? 'active' : 'inactive',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function Components(): ReactElement {
|
||||||
|
const TABS = ['mentor', 'mentee'] as const;
|
||||||
|
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
|
||||||
|
const [showDetail, setShowDetail] = useState(false);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<UserType>[] = [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
meta: { cellClassName: cn('w-20') },
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
header: 'Email',
|
||||||
|
accessorKey: 'email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rating',
|
||||||
|
header: 'Rating',
|
||||||
|
accessorKey: 'rating',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status;
|
||||||
|
const statusColors: Record<UserStatus, string> = {
|
||||||
|
active: 'bg-success-200 text-success-500',
|
||||||
|
inactive: 'bg-danger-200 text-danger-500',
|
||||||
|
};
|
||||||
|
const statusText: Record<UserStatus, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`py-2 px-4 rounded-md text-center ${statusColors[status]}`}
|
||||||
|
>
|
||||||
|
{statusText[status]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
meta: { cellClassName: cn('w-72') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedUserId(row.original.id);
|
||||||
|
setShowDetail(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-max"
|
||||||
|
>
|
||||||
|
<SearchOutlined className="text-[16px]" /> Lihat Detail & Action
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: mockData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
pageCount: Math.ceil(mockData.length / pagination.pageSize),
|
||||||
|
manualPagination: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
|
<div className="mb-8 flex justify-between items-center">
|
||||||
|
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||||
|
<For data={TABS}>
|
||||||
|
{(tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab}
|
||||||
|
variant="text"
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2 capitalize',
|
||||||
|
activeTab === tab && 'bg-white'
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-6 p-8 bg-white rounded-md">
|
||||||
|
<div className="flex justify-between items-center gap-5 mb-2">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama lengkap"
|
||||||
|
className="pl-12 w-full max-h-full"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[16px]">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select>
|
||||||
|
<option selected disabled>
|
||||||
|
Rating
|
||||||
|
</option>
|
||||||
|
<option value="4.5">4.5</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
</Select>
|
||||||
|
<Select>
|
||||||
|
<option selected disabled>
|
||||||
|
Status
|
||||||
|
</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable data={mockData} columns={columns} table={table} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ModalDetailUser
|
||||||
|
open={showDetail}
|
||||||
|
setOpen={setShowDetail}
|
||||||
|
userId={selectedUserId}
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
html {
|
html {
|
||||||
font-family: 'Bai Jamjuree', sans-serif;
|
font-family: 'Bai Jamjuree', sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) return redirect('/auth/login');
|
// if (!session) return redirect('/auth/login');
|
||||||
|
|
||||||
const matchedRoute = mappingRoutePermissions.find(
|
const matchedRoute = mappingRoutePermissions.find(
|
||||||
(route) => route.path === pathname
|
(route) => route.path === pathname
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Tue, Nov 25, 2025 4:21:27 PM
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Deployment trigger
|
||||||
|
# Updated to deploy circular dependency fixes and auth hooks
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -3,7 +3,7 @@ import { motion } from 'framer-motion'
|
|||||||
|
|
||||||
export function HeroSection() {
|
export function HeroSection() {
|
||||||
return (
|
return (
|
||||||
<section className="relative w-full 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 */}
|
{/* 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
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { CloseOutlined, MenuOutlined } from "@ant-design/icons";
|
|||||||
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||||
import { cn, For, Show } from "@imphnen-frontend-service/utils";
|
import { cn, For, Show } from "@imphnen-frontend-service/utils";
|
||||||
import { motion, useMotionValueEvent, useScroll, Variants } from "framer-motion";
|
import { motion, useMotionValueEvent, useScroll, Variants } from "framer-motion";
|
||||||
import { FC, useState } from "react";
|
import { FC, useMemo, useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
const MENUS: { label: string; href: string }[] = [
|
const MENUS: { label: string; href: string }[] = [
|
||||||
{ label: "Home", href: "/" },
|
{ label: "Home", href: "/" },
|
||||||
@@ -30,8 +30,15 @@ export const Header: FC = () => {
|
|||||||
else setShow(true)
|
else setShow(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isActive = useMemo(() => {
|
||||||
|
return (path: string) => {
|
||||||
|
if (path === '/' && location.pathname !== '/') return false
|
||||||
|
return location.pathname.includes(path)
|
||||||
|
}
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-8 pt-8 top-0 z-50 md:px-[60px] md:pt-[60px] lg:px-20 sticky overflow-hidden">
|
<div className={cn("w-full px-8 pt-8 top-0 z-50 md:px-[60px] md:pt-[60px] lg:px-20 sticky", !show && "overflow-hidden")}>
|
||||||
<motion.header
|
<motion.header
|
||||||
className="bg-white shadow-lg rounded-lg h-12 flex justify-between w-full max-w-7xl xl:mx-auto md:h-[60px] lg:h-[71px]"
|
className="bg-white shadow-lg rounded-lg h-12 flex justify-between w-full max-w-7xl xl:mx-auto md:h-[60px] lg:h-[71px]"
|
||||||
aria-roledescription="nav"
|
aria-roledescription="nav"
|
||||||
@@ -63,18 +70,22 @@ export const Header: FC = () => {
|
|||||||
variant="text"
|
variant="text"
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-10 py-1.5 text-neutral-300 hover:text-neutral-400 lg:px-2.5 lg:py-2",
|
"px-10 py-1.5 text-neutral-300 hover:text-neutral-400 lg:px-2.5 lg:py-2",
|
||||||
location.pathname === menu.href && "text-primary-500 hover:text-primary-500"
|
isActive(menu.href) && "text-primary-500 hover:text-primary-500"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Link to={menu.href}>{menu.label}</Link>
|
<NavLink to={menu.href}>{menu.label}</NavLink>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<Button type="button" className="px-12 py-1 md:hidden">Login</Button>
|
<Link to="/auth/login">
|
||||||
|
<Button type="button" className="px-12 py-1 lg:hidden">Login</Button>
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button type="button" className="px-5 py-2 hidden lg:block">Login</Button>
|
<Link to="/auth/login">
|
||||||
|
<Button type="button" className="px-5 py-2 hidden lg:block">Login</Button>
|
||||||
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="text"
|
variant="text"
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export default function DetailArticle() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<motion.section
|
||||||
|
className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-[900px] mx-auto mb-8 md:mb-[60px]">
|
||||||
|
<div className="mb-10 flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
|
||||||
|
>
|
||||||
|
<Icon icon="material-symbols:arrow-back-rounded" />
|
||||||
|
<span>Back</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
className="flex gap-x-2 text-[10px] px-2.5 py-2 md:text-[15px]"
|
||||||
|
>
|
||||||
|
<span>Read Next</span>
|
||||||
|
<Icon icon="material-symbols:arrow-forward-rounded" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center leading-tight">
|
||||||
|
<h1
|
||||||
|
className="text-[23px] text-neutral-800 font-semibold mb-6 md:text-[37px] md:mb-10 xl:text-[46px]"
|
||||||
|
>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="text-[15px] font-semibold text-neutral-800 mb-2 md:text-[19px] md:mb-1 xl:text-[23px] xl:mb-2"
|
||||||
|
>
|
||||||
|
Writer’s Name
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-600 mb-6 md:text-[15px] xl:text-[19px]">
|
||||||
|
14 March 2025
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="text-[10px] text-primary-600 font-medium bg-primary-200 px-3 py-1 rounded-4xl w-max mx-auto md:text-xs xl:text-[15px]"
|
||||||
|
>
|
||||||
|
5 min read
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-[900px] mx-auto">
|
||||||
|
<div className="mb-8 w-full h-auto aspect-16/10 overflow-hidden md:aspect-31/16 md:mb-[60px] xl:aspect-45/26">
|
||||||
|
<img src="/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp" alt="Cover Article" className="size-full object-cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-balance text-[15px] font-medium text-neutral-600 space-y-8",
|
||||||
|
"md:space-y-12 md:text-[19px] xl:text-[23px] xl:max-w-[780px] xl:mx-auto",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<h2 className="text-[19px] text-neutral-800 font-semibold leading-tight md:text-[23px] xl:text-[29px]">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus.
|
||||||
|
</h2>
|
||||||
|
<p className="leading-tight">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<img src="/image/code-article.webp" alt="Code" className="w-full h-auto mb-12" />
|
||||||
|
|
||||||
|
<p className="leading-tight">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
|
||||||
|
</p>
|
||||||
|
<p className="leading-tight">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { cn, Show } from "@imphnen-frontend-service/utils"
|
||||||
|
import { FC } from "react"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
|
||||||
|
export type ArticleCardProps = {
|
||||||
|
variant?: "default" | "recent" | "featured"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ArticleCard: FC<ArticleCardProps> = ({ variant = 'default' }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
variant === "recent" && "md:grid md:grid-cols-2 md:gap-7 xl:flex xl:items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full h-auto aspect-16/10 overflow-hidden mb-8 md:h-60 md:w-full xl:aspect-auto",
|
||||||
|
variant === "featured" && "md:h-[300px]",
|
||||||
|
variant === "recent" && "md:h-auto md:w-full md:aspect-15/7 md:mb-0 xl:aspect-square xl:w-[162px] xl:h-auto",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/image/59de8a682dfbe42455613ea07477ff3e30532c1f.webp"
|
||||||
|
alt="Cover's Article"
|
||||||
|
className={cn("w-full object-cover", variant === "recent" && "h-full")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn("leading-tight", variant === "recent" && "xl:flex-1")}>
|
||||||
|
<div className="w-max bg-primary-200 text-primary-600 px-3 py-1 text-xs font-medium rounded-4xl mb-4 xl:mb-[15px]">
|
||||||
|
5 min read
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/articles/detail"
|
||||||
|
className={cn(
|
||||||
|
"text-[15px] text-neutral-800 font-semibold mb-4 line-clamp-2 md:text-[19px] xl:mb-[23px]",
|
||||||
|
variant === "featured" && "text-[19px] md:text-[23px]",
|
||||||
|
variant === "recent" && "mb-7 xl:text-[27px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||||
|
</Link>
|
||||||
|
<Show condition={variant !== "recent"}>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xs text-neutral-600 mb-6 md:text-[15px] md:mb-8 xl:text-[19px]",
|
||||||
|
variant === "featured" && "text-[15px] md:text-[19px] md:mb-6",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris.
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex justify-between items-center text-xs text-neutral-600 md:text-[15px] xl:text-[19px]",
|
||||||
|
variant === "default" && "mb-4",
|
||||||
|
variant === "featured" && "text-[15px] md:text-[19px] xl:text-base",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p>Writer’s Name</p>
|
||||||
|
<p>14 March 2025</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show condition={variant === "default"}>
|
||||||
|
<Button type="button" size="sm" className="px-2.5 py-2">
|
||||||
|
Baca Artikel
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { ReactElement, useRef, useState } from "react";
|
||||||
|
import { ArticleCard } from "./_components/card/article";
|
||||||
|
import { cn, For } from "@imphnen-frontend-service/utils";
|
||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||||
|
import { motion, useInView, Variants } from "framer-motion";
|
||||||
|
|
||||||
|
const CATEGORIES = ['UI/UX Design', 'Software/Web Dev', 'Data & AI', 'Cloud & DevOps', 'Cybersecurity', 'IT & Network', 'Project Management', 'QA & Testing'] as const
|
||||||
|
type Category = typeof CATEGORIES[number]
|
||||||
|
|
||||||
|
export default function Components(): ReactElement {
|
||||||
|
const [activeTab, setActiveTab] = useState<Category>('UI/UX Design')
|
||||||
|
|
||||||
|
const ref = useRef(null)
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const childVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main ref={ref}>
|
||||||
|
<motion.section
|
||||||
|
className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? "visible" : "hidden"}
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto mb-8 md:mb-20">
|
||||||
|
<motion.h1
|
||||||
|
className="text-[23px] font-semibold text-neutral-800 mb-9 md:text-[29px] xl:text-[46px] xl:font-bold"
|
||||||
|
variants={childVariants}
|
||||||
|
>
|
||||||
|
Featuring Articles
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<div className="grid gap-12 xl:grid-cols-2">
|
||||||
|
<motion.div variants={childVariants}>
|
||||||
|
<ArticleCard variant="featured" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<motion.h1
|
||||||
|
className="text-[19px] text-neutral-800 font-semibold mb-10 md:text-[29px] xl:text-[37px] xl:mb-12"
|
||||||
|
variants={childVariants}
|
||||||
|
>
|
||||||
|
Recent Articles
|
||||||
|
</motion.h1>
|
||||||
|
<div className="grid gap-10">
|
||||||
|
<For data={Array.from({ length: 3 })}>
|
||||||
|
{(_, index) => (
|
||||||
|
<motion.div key={index} variants={childVariants}>
|
||||||
|
<ArticleCard variant="recent" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<motion.h1
|
||||||
|
className="text-[19px] text-neutral-800 font-semibold mb-4 md:text-[23px] xl:text-[29px]"
|
||||||
|
variants={childVariants}
|
||||||
|
>
|
||||||
|
Category
|
||||||
|
</motion.h1>
|
||||||
|
<div className="scrollbar-hide mb-8 w-full overflow-auto mx-auto">
|
||||||
|
<motion.div className="min-w-max w-auto flex gap-x-3" variants={childVariants}>
|
||||||
|
<For data={CATEGORIES}>
|
||||||
|
{(category) => (
|
||||||
|
<Button
|
||||||
|
key={category}
|
||||||
|
type="button"
|
||||||
|
variant="text"
|
||||||
|
onClick={() => setActiveTab(category)}
|
||||||
|
className={cn(
|
||||||
|
"relative text-[10px] text-neutral-400 font-medium px-3 py-2 rounded-4xl md:text-xs md:font-semibold xl:text-[15px]",
|
||||||
|
"before:w-0 before:absolute before:h-0.5 before:mx-auto before:inset-x-0 before:bg-primary-500 hover:before:w-full before:bottom-0 before:left-0 before:transition-all before:duration-300",
|
||||||
|
activeTab === category && "text-primary-500 before:w-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8 xl:grid-cols-3">
|
||||||
|
<For data={Array.from({ length: 3 })}>
|
||||||
|
{(_, index) => (
|
||||||
|
<motion.div key={index} variants={childVariants}>
|
||||||
|
<ArticleCard />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export default function Layout() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-primary-50">
|
<div className="flex min-h-screen flex-col bg-primary-50">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1 z-0">
|
<main className="flex-1">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { CloseOutlined } from "@ant-design/icons"
|
||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
|
import { FC, useCallback, useEffect, useState } from "react"
|
||||||
|
import { TopicStep } from "./steps/topic"
|
||||||
|
import { cn, Show } from "@imphnen-frontend-service/utils"
|
||||||
|
import { ScheduleStep } from "./steps/schedule"
|
||||||
|
import { ProfileStep } from "./steps/profile"
|
||||||
|
import { QrisPaymentStep } from "./steps/qris-payement"
|
||||||
|
import { VAPaymentStep } from "./steps/va-payment"
|
||||||
|
import { SuccessStep } from "./steps/success"
|
||||||
|
import { PaymentStep } from "./steps/payment"
|
||||||
|
|
||||||
|
const STEPS = ['topic', 'schedule', 'profile', 'payment', 'qr-payment', 'va-payment', 'success'] as const
|
||||||
|
type Step = typeof STEPS[number]
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppointmentModal: FC<Props> = ({ open, setOpen }) => {
|
||||||
|
const [step, setStep] = useState<Step>('topic')
|
||||||
|
const [selectedTopics, setSelectedTopics] = useState<number[]>([])
|
||||||
|
|
||||||
|
const handleStep = (action: 'next' | 'prev') => {
|
||||||
|
if (action === 'next' && step === 'success') {
|
||||||
|
setOpen(false)
|
||||||
|
} else if (action === 'next') {
|
||||||
|
setStep(STEPS[STEPS.indexOf(step) + 1])
|
||||||
|
} else if (action === 'prev' && step !== 'topic') {
|
||||||
|
setStep(STEPS[STEPS.indexOf(step) - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEscapeKey = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && open) setOpen(false)
|
||||||
|
},
|
||||||
|
[open, setOpen]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = "hidden"
|
||||||
|
window.addEventListener("keydown", handleEscapeKey)
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = ""
|
||||||
|
window.removeEventListener("keydown", handleEscapeKey)
|
||||||
|
}
|
||||||
|
}, [handleEscapeKey, open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, top: "50%" }}
|
||||||
|
animate={{ opacity: 1, top: 0 }}
|
||||||
|
exit={{ opacity: 0, top: "50%" }}
|
||||||
|
className="fixed inset-0 z-100 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-primary-900/30" onClick={() => setOpen(false)} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative bg-primary-50 px-5 py-6 w-full max-w-[280px] mx-4 rounded-lg md:max-w-[627px] md:py-10 md:px-[60px] md:rounded-xl xl:max-w-[668px]",
|
||||||
|
step === 'success' && 'md:max-w-[400px] md:p-10 xl:max-w-[400px]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Show condition={step !== 'success'}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="text"
|
||||||
|
className="absolute top-3 right-3 bg-primary-200 p-1 shadow md:bg-white md:p-2 md:top-8 md:right-10"
|
||||||
|
>
|
||||||
|
<CloseOutlined className="md:text-lg" />
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{step !== 'success' && (
|
||||||
|
<img
|
||||||
|
src="/logos/simple.svg"
|
||||||
|
alt="IMPHNEN Logo"
|
||||||
|
className="mb-6 h-8 w-auto mx-auto md:h-12 xl:h-[60px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="scrollbar-hide max-h-[50dvh] overflow-y-auto">
|
||||||
|
{step !== 'success' && (
|
||||||
|
<div className="relative mb-7 bg-primary-500 p-4 overflow-hidden rounded-md md:px-7 md:py-5 md:flex md:justify-between md:gap-x-6">
|
||||||
|
<p className="text-xs font-semibold text-primary-50 md:text-[19px] md:w-[290px] xl:text-[23px]">
|
||||||
|
<Show
|
||||||
|
condition={step !== 'qr-payment' && step !== 'va-payment'}
|
||||||
|
fallback="Yosha~! Saatnya Membayar :)"
|
||||||
|
>
|
||||||
|
Yosha~! Saatnya Level Up dengan Sesi Mentoring bersama Senpai!
|
||||||
|
</Show>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src="/image/mascot-character.webp"
|
||||||
|
alt="IMPHNEN Mascot"
|
||||||
|
className="hidden w-[166px] h-auto absolute right-0 -bottom-3 md:block xl:w-[207px] xl:-bottom-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{step === 'topic' && <TopicStep selectedTopics={selectedTopics} setSelectedTopics={setSelectedTopics} />}
|
||||||
|
{step === 'schedule' && <ScheduleStep />}
|
||||||
|
{step === 'profile' && <ProfileStep />}
|
||||||
|
{step === 'payment' && <PaymentStep selectedTopics={selectedTopics} />}
|
||||||
|
{step === 'qr-payment' && <QrisPaymentStep />}
|
||||||
|
{step === 'va-payment' && <VAPaymentStep />}
|
||||||
|
{step === 'success' && <SuccessStep />}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show condition={step !== 'success'}>
|
||||||
|
<hr className="my-6 border-primary-300" />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="text"
|
||||||
|
className={cn((step === 'topic' || step === 'success') && 'hidden')}
|
||||||
|
onClick={() => handleStep('prev')}
|
||||||
|
>
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
className={cn((step === 'topic' || step === 'success') && 'w-full')}
|
||||||
|
disabled={selectedTopics.length === 0 && step === 'topic'}
|
||||||
|
onClick={() => handleStep('next')}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
condition={step !== 'success'}
|
||||||
|
fallback="Halman Booking"
|
||||||
|
>
|
||||||
|
<Show condition={step !== 'payment'} fallback="Bayar Sekarang">
|
||||||
|
Selanjutnya
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { For, Show } from "@imphnen-frontend-service/utils"
|
||||||
|
import { FC } from "react"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { TOPICS } from "../../../sections/topics"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedTopics: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaymentStep: FC<Props> = ({ selectedTopics }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="bg-white px-6 py-5 rounded-md grid gap-6 md:grid-cols-2 md:gap-8 xl:grid-cols-9"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
>
|
||||||
|
<div className="space-y-6 xl:col-span-4">
|
||||||
|
<div>
|
||||||
|
<p className="w-max mx-auto text-[10px] text-neutral-400 font-medium mb-2 md:text-xs md:ms-0">
|
||||||
|
Senpai Kamu
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<div className="aspect-square mb-2 size-10 mx-auto rounded-full overflow-hidden md:size-20 md:ms-0">
|
||||||
|
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="w-44 mx-auto text-center md:ms-0 md:text-left md:mb-2 md:text-[15px] md:w-auto">
|
||||||
|
<h3 className="text-xs font-semibold mb-1 xl:text-[15px]">
|
||||||
|
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-neutral-600 md:text-xs">
|
||||||
|
UI Designer at Oray orayan Studios
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-neutral-400 font-medium mb-2 md:text-xs">
|
||||||
|
Topics :
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<For data={TOPICS}>
|
||||||
|
{(topic) => (
|
||||||
|
<Show key={topic.id} condition={selectedTopics.includes(topic.id)}>
|
||||||
|
<div
|
||||||
|
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow text-[10px] font-medium"
|
||||||
|
>
|
||||||
|
<span>{topic.icon} </span>
|
||||||
|
<span>{topic.name}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6 xl:col-span-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-neutral-400 font-medium mb-2 md:text-xs">
|
||||||
|
Metode Pembayaran
|
||||||
|
</p>
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-[8px] text-neutral-400 mb-2 md:text-[10px]">
|
||||||
|
Virtual Account
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2 md:grid-cols-4">
|
||||||
|
<For data={['BCA', 'BRI', 'BNI', 'MANDIRI', 'BCA-Virtual', 'BNI-Virtual', 'MANDIRI-Virtual']}>
|
||||||
|
{(bank, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1"
|
||||||
|
>
|
||||||
|
<input type="radio" name="payment" id={bank} className="size-2" />
|
||||||
|
<label htmlFor={bank} className="block">
|
||||||
|
<img src="/image/payment/bca.webp" alt={bank} className="object-scale-down" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[8px] text-neutral-400 mb-2 md:text-[10px]">
|
||||||
|
QRIS
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2 md:grid-cols-4">
|
||||||
|
<div
|
||||||
|
className="p-1 bg-white rounded-xs border border-primary-50 flex items-center gap-x-1"
|
||||||
|
>
|
||||||
|
<input type="radio" name="payment" id="QRIS" className="size-2" />
|
||||||
|
<label htmlFor="QRIS" className="block">
|
||||||
|
<img src="/image/payment/qris.webp" alt="QRIS" className="object-scale-down" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-neutral-400 font-medium mb-2 md:text-xs">
|
||||||
|
Detail Pembayaran
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 space-y-2.5 text-[10px] text-neutral-400 font-medium md:text-xs md:space-y-3">
|
||||||
|
<p className="font-normal">Subtotal :</p>
|
||||||
|
<p className="text-right">Rp. 50.000</p>
|
||||||
|
<p className="font-normal">Service Fee :</p>
|
||||||
|
<p className="text-right">Rp. 2.000</p>
|
||||||
|
|
||||||
|
<hr className="border-neutral-200 col-span-full mt-1" />
|
||||||
|
|
||||||
|
<p className="text-neutral-700">Total :</p>
|
||||||
|
<p className="text-right text-neutral-700">Rp. 52.000</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Input } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
|
||||||
|
|
||||||
|
export const ProfileStep = () => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="bg-white px-6 py-5 rounded-md md:px-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold mb-1.5 md:text-[15px] md:mb-2 xl:text-[19px]">
|
||||||
|
Langkah 3 dari 3
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-neutral-600 mb-5 md:text-xs xl:text-[15px]">
|
||||||
|
Lengkapi profil kamu agar senpai dapat mengenal kamu lebih baik.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2.5 md:space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Nomor WhatsApp</label>
|
||||||
|
<Input className="min-w-full w-full" placeholder="080000000" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>CV (Opsional)</label>
|
||||||
|
<Input type="file" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Portoflio (Opsional)</label>
|
||||||
|
<Input className="min-w-full w-full" placeholder="Link Url" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { For } from "@imphnen-frontend-service/utils"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
const PAYMENT_STEP = [
|
||||||
|
'Buka aplikasi e-wallet atau m-banking kamu',
|
||||||
|
'Cari fitur bayar menggunakan QRIS',
|
||||||
|
'Scan kode QR di samping',
|
||||||
|
'Konfimasi pembayaran, dan proses selesai.'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const QrisPaymentStep = () => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="bg-white px-6 py-5 rounded-md md:flex md:justify-between md:gap-8"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
>
|
||||||
|
<div className="mb-7 md:mb-0">
|
||||||
|
<h3 className="text-xs font-semibold mb-4 text-neutral-600 text-center md:text-start">
|
||||||
|
Silahkan ikuti instruksi pembayaran di bawah ini
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-neutral-400 mb-1.5">
|
||||||
|
Cara melakukan pembayaran
|
||||||
|
</p>
|
||||||
|
<ul className="list-decimal pl-2.5 mb-4 md:mb-7">
|
||||||
|
<For data={PAYMENT_STEP}>
|
||||||
|
{(step, index) => (
|
||||||
|
<li key={index} className="text-[10px] text-neutral-400 leading-tight font-medium">
|
||||||
|
{step}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="text-center md:text-start">
|
||||||
|
<p className="text-[10px] text-neutral-400 mb-1.5 font-medium md:text-xs">
|
||||||
|
Biaya yang harus dibayarkan
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-semibold text-neutral-700 md:text-[15px]">Rp. 52.000</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-neutral-400 mb-2 font-medium text-center md:text-xs">
|
||||||
|
QR Code Pembayaran
|
||||||
|
</p>
|
||||||
|
<div className="bg-primary-50 p-2.5 rounded-lg size-[120px] mx-auto md:size-[142px] md:me-0">
|
||||||
|
<img src="/image/sample-qrcode.webp" alt="QR Code" className="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Input, Select, Textarea } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
const placeholder = `Hi [Nama Mentor], Saya [Nama Kamu] & saya berharap dapat memiliki sesi mentoring dengan Anda.
|
||||||
|
|
||||||
|
Saat ini, saya tertarik untuk mengejar __. Tujuan saya untuk sesi ini adalah __.
|
||||||
|
|
||||||
|
Saya ingin tahu secara khusus tentang ___.
|
||||||
|
1.Pertanyaan Anda
|
||||||
|
2. ...
|
||||||
|
3. ...`
|
||||||
|
|
||||||
|
const labelClass = cn('text-neutral-800 text-[10px] font-semibold mb-1.5 inline-block md:text-xs md:mb-2 xl:text-[15px]')
|
||||||
|
|
||||||
|
export const ScheduleStep = () => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="bg-white px-6 py-5 rounded-md md:px-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold mb-1.5 md:text-[15px] md:mb-2 xl:text-[19px]">
|
||||||
|
Langkah 2 dari 3
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-neutral-600 mb-5 md:text-xs xl:text-[15px]">
|
||||||
|
Pilih jadwal sesuai preferensi kamu & siapkan pertanyaanmu
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-2.5 md:grid-cols-2 md:gap-5">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Tanggal</label>
|
||||||
|
<Input type="date" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Waktu</label>
|
||||||
|
<Input type="time" className="min-w-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="relative md:col-span-full">
|
||||||
|
<label className={labelClass}>Lokasi</label>
|
||||||
|
<Select className="min-w-full w-full">
|
||||||
|
<option value="online">Online</option>
|
||||||
|
<option value="offline">Offline</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-full">
|
||||||
|
<label className={labelClass}>Pertanyaan Untuk Senpai</label>
|
||||||
|
<Textarea
|
||||||
|
className="min-w-full w-full h-40"
|
||||||
|
placeholder={placeholder} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { CheckOutlined } from "@ant-design/icons"
|
||||||
|
import { cn } from "@imphnen-frontend-service/utils"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
export const SuccessStep = () => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="pb-6 rounded-md md:pb-8"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
>
|
||||||
|
<h2 className="text-[15px] font-semibold mb-3 text-primary-500 text-center md:text-[19px] md:mb-7 xl:text-[23px] xl:mb-8">
|
||||||
|
Booking Senpai Berhasil
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mb-3 flex justify-center items-center mx-auto size-[60px] bg-green-200 rounded-full",
|
||||||
|
"md:size-[100px] md:mb-7 xl:size-[120px] xl:mb-8"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-green-500 rounded-full size-10 flex justify-center items-center text-white md:size-16 xl:size-20">
|
||||||
|
<CheckOutlined className="text-xl md:text-3xl xl:text-5xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-neutral-400 text-center md:text-xs md:max-w-[220px] md:mx-auto xl:text-[15px]">
|
||||||
|
Untuk detail dan instruksi berikutnya dapat dilihat di dashboard pada halaman booking
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { CheckOutlined } from "@ant-design/icons"
|
||||||
|
import { cn, For, Show } from "@imphnen-frontend-service/utils"
|
||||||
|
import { FC } from "react"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { TOPICS } from "../../../sections/topics"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selectedTopics: number[]
|
||||||
|
setSelectedTopics: (topics: number[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopicStep: FC<Props> = ({ selectedTopics, setSelectedTopics }) => {
|
||||||
|
const handleSelectTopic = (topicId: number) => {
|
||||||
|
if (selectedTopics.includes(topicId)) {
|
||||||
|
setSelectedTopics(selectedTopics.filter((id) => id !== topicId))
|
||||||
|
} else {
|
||||||
|
setSelectedTopics([...selectedTopics, topicId])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
className="mb-6 flex items-center gap-x-4 bg-white rounded-md overflow-hidden shadow/5 md:gap-x-10"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
>
|
||||||
|
<div className="w-16 aspect-4/5 overflow-hidden md:w-[134px] xl:w-[140px]">
|
||||||
|
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 pe-4">
|
||||||
|
<h3 className="text-xs font-semibold mb-1 line-clamp-2 md:text-[19px] md:mb-2 xl:text-[23px]">
|
||||||
|
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] line-clamp-2 text-neutral-600 md:text-[15px] xl:text-[19px]">
|
||||||
|
UI Designer at Oray orayan Studios
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="bg-white p-5 rounded-md shadow/5 md:px-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold mb-1.5 md:text-[15px] md:mb-2 xl:text-[19px]">
|
||||||
|
Langkah 1 dari 3
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-neutral-600 mb-5 md:text-xs xl:text-[15px]">
|
||||||
|
Pilih topik yang ingin kamu diskusikan dengan Senpai kamu~
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 md:content-center">
|
||||||
|
<For data={TOPICS}>
|
||||||
|
{(topic) => (
|
||||||
|
<div
|
||||||
|
key={topic.id}
|
||||||
|
className={cn(
|
||||||
|
"relative px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow text-[10px] font-medium cursor-pointer",
|
||||||
|
"md:text-xs xl:text-xs xl:font-semibold",
|
||||||
|
selectedTopics.includes(topic.id) && "bg-primary-50 border-primary-200"
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelectTopic(topic.id)}
|
||||||
|
>
|
||||||
|
<span>{topic.icon} </span>
|
||||||
|
<span>{topic.name}</span>
|
||||||
|
|
||||||
|
<Show condition={selectedTopics.includes(topic.id)}>
|
||||||
|
<div className="absolute top-2 right-2 bg-primary-500 size-[14px] rounded-sm flex justify-center items-center text-white">
|
||||||
|
<CheckOutlined />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { For } from "@imphnen-frontend-service/utils"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
|
const PAYMENT_STEP = [
|
||||||
|
'Buka aplikasi e-wallet atau m-banking kamu',
|
||||||
|
'Pergi ke fitur transfer via Virtual Account',
|
||||||
|
'Pilih bank dan kode VA sesuai dengan yang ada pada halaman ini',
|
||||||
|
'Konfimasi pembayaran, dan proses selesai.'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const VAPaymentStep = () => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="bg-white px-6 py-5 rounded-md md:flex md:items-center md:gap-11"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
>
|
||||||
|
<div className="mb-6 md:mb-0">
|
||||||
|
<h3 className="text-xs font-semibold mb-4 text-neutral-600 text-center md:text-start">
|
||||||
|
Silahkan ikuti instruksi pembayaran di bawah ini
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-neutral-400 mb-1.5">
|
||||||
|
Cara melakukan pembayaran
|
||||||
|
</p>
|
||||||
|
<ul className="list-decimal pl-2.5">
|
||||||
|
<For data={PAYMENT_STEP}>
|
||||||
|
{(step, index) => (
|
||||||
|
<li key={index} className="text-[10px] text-neutral-400 leading-tight font-medium">
|
||||||
|
{step}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center md:text-start">
|
||||||
|
<div className="w-max mx-auto mb-3 md:ms-0 md:mb-4">
|
||||||
|
<img src="/image/payment/bca.webp" alt="VA BCA" className="h-5 w-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-neutral-400 mb-1 font-medium md:text-xs">
|
||||||
|
Kode Virtual Account
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-semibold text-neutral-700 mb-1.5 md:text-[15px]">
|
||||||
|
8091239861969812
|
||||||
|
</p>
|
||||||
|
<p className="text-[8px] text-neutral-400 md:text-[10px]">
|
||||||
|
A/N Unknown
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { cn, For } from "@imphnen-frontend-service/utils"
|
||||||
|
|
||||||
|
const EDUCATION = [
|
||||||
|
{ name: 'Universitas Widyabakti', major: 'Intern Front End', duration: '2 Months', range: 'Oct 2024 - Present' },
|
||||||
|
{ name: 'SMKN 99 Banjaran', major: 'Rekayasa Perangkat Lunak', duration: '6 Months', range: 'May 2024 - Oct 2024' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const EducationSection = () => {
|
||||||
|
return (
|
||||||
|
<div className="px-7 py-8 rounded-md shadow-md">
|
||||||
|
<h2 className="text-xs font-semibold mb-5 md:text-[15px] xl:text-[19px]">Education</h2>
|
||||||
|
<div className="space-y-4 divide-y">
|
||||||
|
<For data={EDUCATION}>
|
||||||
|
{(item, index) => (
|
||||||
|
<div key={index} className={cn("flex items-center gap-x-4", index !== EDUCATION.length - 1 && "pb-4")}>
|
||||||
|
<div className="rounded-full size-6 bg-neutral-200 md:size-7 xl:size-8"></div>
|
||||||
|
<div className='flex-1 text-[10px] font-medium'>
|
||||||
|
<p className="text-neutral-800 md:text-xs xl:text-[15px]">{item.name}</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-neutral-600 xl:text-xs">{item.major}</span>
|
||||||
|
<span className="text-neutral-300"> · </span>
|
||||||
|
<span className="text-neutral-400 font-normal">{item.duration}</span>
|
||||||
|
<span className="text-neutral-300"> · </span>
|
||||||
|
<span className="text-neutral-400 font-normal xl:font-medium">{item.range}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { cn, For } from "@imphnen-frontend-service/utils"
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
const EXPERIENCE = [
|
||||||
|
{ name: 'Sunday.com', position: 'Intern Front End', duration: '2 Months', range: 'Oct 2024 - Present' },
|
||||||
|
{ name: 'CodeX Digital', position: 'Intern Front End', duration: '6 Months', range: 'May 2024 - Oct 2024' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ExperienceSection: FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="px-7 py-8 rounded-md shadow-md">
|
||||||
|
<h2 className="text-xs font-semibold mb-5 md:text-[15px] xl:text-[19px]">Experience</h2>
|
||||||
|
<div className="space-y-4 divide-y">
|
||||||
|
<For data={EXPERIENCE}>
|
||||||
|
{(item, index) => (
|
||||||
|
<div key={index} className={cn("flex items-center gap-x-4", index !== EXPERIENCE.length - 1 && "pb-4")}>
|
||||||
|
<div className="rounded-full size-6 bg-neutral-200 md:size-7 xl:size-8"></div>
|
||||||
|
<div className='flex-1 text-[10px] font-medium'>
|
||||||
|
<p className="text-neutral-800 md:text-xs xl:text-[15px]">{item.name}</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-neutral-600 xl:text-xs">{item.position}</span>
|
||||||
|
<span className="text-neutral-300"> · </span>
|
||||||
|
<span className="text-neutral-400 font-normal">{item.duration}</span>
|
||||||
|
<span className="text-neutral-300"> · </span>
|
||||||
|
<span className="text-neutral-400 font-normal xl:font-medium">{item.range}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { StarFilled } from "@ant-design/icons"
|
||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { cn, For } from "@imphnen-frontend-service/utils"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onBook: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileSection: React.FC<Props> = ({ onBook }) => {
|
||||||
|
return (
|
||||||
|
<div className="bg-white px-4 py-5 space-y-6 md:px-8 md:pt-6 md:pb-0 xl:space-y-0 xl:py-[30px] xl:flex xl:gap-x-9 xl:justify-between">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center",
|
||||||
|
"md:flex-row md:justify-start md:gap-x-8 md:items-start",
|
||||||
|
"xl:items-center xl:gap-x-10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mb-5 h-20 w-auto aspect-4/5 rounded-sm overflow-hidden",
|
||||||
|
"md:aspect-33/40 md:h-40 md:rounded-md md:border md:border-primary-50 md:mb-0",
|
||||||
|
"xl:aspect-5/6 md:h-60 md:rounded-lg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:max-w-[242px] xl:max-w-[306px]">
|
||||||
|
<h1
|
||||||
|
className={cn(
|
||||||
|
"mb-1 text-[15px] font-semibold text-center leading-snug",
|
||||||
|
"md:text-[19px] md:mb-2 md:text-start xl:text-[23px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Muhammad Firdaus Oi Oi Oi, S.H., M.H.
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs mb-4 text-neutral-600 md:mb-5 md:text-[15px] xl:text-[19px] xl:mb-5">
|
||||||
|
UI Designer at Oray orayan Studios
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-x-2.5 w-full max-w-max border border-primary-50 p-1.5 rounded-md mx-auto md:ms-0">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-tr from-primary-500 to-primary-200 rounded-sm text-white size-5 flex justify-center items-center xl:size-[29.4px]"
|
||||||
|
>
|
||||||
|
<StarFilled className="text-xs xl:text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-medium mb-1 text-neutral-800 xl:text-xs">Excelent Sensei</p>
|
||||||
|
<p className="text-[8px] text-neutral-600 xl:text-[10px]">4.8/5.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-y-3 xl:gap-y-4 xl:max-w-[402px]">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Expertise</h2>
|
||||||
|
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-3">
|
||||||
|
<For data={['UI Design', 'UX Reseacrh']}>
|
||||||
|
{(item) => (
|
||||||
|
<div key={item} className="bg-primary-300 text-primary-600 px-3 py-2 rounded-md text-[10px] md:font-medium xl:text-xs xl:font-semibold">
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Soft Skills</h2>
|
||||||
|
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-3">
|
||||||
|
<For data={['Design Thinking', 'Communication', 'Problem Solving', '19:00 WIB']}>
|
||||||
|
{(item) => (
|
||||||
|
<div key={item} className="bg-primary-300 text-primary-600 px-3 py-2 rounded-md text-[10px] md:font-medium xl:text-xs xl:font-semibold">
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:flex xl:hidden justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-primary-500 text-[15px] font-semibold">Jum, 4 April 2025</p>
|
||||||
|
<p className="text-neutral-500 text-xs font-medium">Jum, 4 April 2025</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" onClick={onBook}>
|
||||||
|
Book Your Senpai!
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Button } from "@imphnen-frontend-service/ui/atoms"
|
||||||
|
import { cn, For } from "@imphnen-frontend-service/utils"
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
const SCHEDULES = [
|
||||||
|
{
|
||||||
|
date: "Kamis, 20 Maret 2025",
|
||||||
|
availableCount: 10,
|
||||||
|
schedule: [
|
||||||
|
{ time: "19:00 WIB", available: true },
|
||||||
|
{ time: "20:00 WIB", available: true },
|
||||||
|
{ time: "21:00 WIB", available: true },
|
||||||
|
{ time: "22:00 WIB", available: true },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "Jumat, 21 Maret 2025",
|
||||||
|
availableCount: 1,
|
||||||
|
schedule: [
|
||||||
|
{ time: "19:00 WIB", available: true },
|
||||||
|
{ time: "20:00 WIB", available: true },
|
||||||
|
{ time: "21:00 WIB", available: false },
|
||||||
|
{ time: "22:00 WIB", available: true },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onBook: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SenpaiScheduleSection: FC<Props> = ({ onBook }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Schedule</h2>
|
||||||
|
<div className="p-4 bg-primary-50 border border-primary-100 rounded-md space-y-5">
|
||||||
|
<For data={SCHEDULES}>
|
||||||
|
{(item) => (
|
||||||
|
<div key={item.date}>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<p className="text-[15px] font-medium">{item.date}</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"rounded-4xl py-1 px-2.5 text-xs font-medium",
|
||||||
|
item.availableCount > 5 ? "bg-primary-200 text-primary-500" : "bg-danger-100 text-danger-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.availableCount} slot tersisa
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-x-3">
|
||||||
|
<For data={item.schedule}>
|
||||||
|
{(schedule, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bg-primary-300 text-primary-600 px-3 py-2 text-xs rounded-md font-semibold"
|
||||||
|
>
|
||||||
|
{schedule.time}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<hr className="border-primary-200" />
|
||||||
|
|
||||||
|
<Button type="button" size="sm" className="w-full" onClick={onBook}>
|
||||||
|
Book Your Senpai!
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { StarFilled } from "@ant-design/icons";
|
||||||
|
import { For } from "@imphnen-frontend-service/utils";
|
||||||
|
import { FC } from "react";
|
||||||
|
|
||||||
|
const SENPAI_STATISTIC = [
|
||||||
|
{ name: 'Total Sessions', count: 8 },
|
||||||
|
{ name: 'Mentee Impact', count: 1000 },
|
||||||
|
{ name: 'Response Time', count: '30 Minute' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const StatisticsSection: FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="md:mb-10">
|
||||||
|
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Senpai Statistics</h2>
|
||||||
|
<div className="grid gap-4 xl:flex">
|
||||||
|
<For data={SENPAI_STATISTIC}>
|
||||||
|
{(item, index) => (
|
||||||
|
<div key={index} className="px-2.5 py-2 border border-primary-50 rounded-md shadow flex items-center gap-x-2.5">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-tr from-primary-500 to-primary-200 rounded-sm text-white size-7 flex justify-center items-center xl:size-[29.4px]"
|
||||||
|
>
|
||||||
|
<StarFilled className="text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-neutral-600 font-medium md:text-xs xl:font-semibold">{item.count}</p>
|
||||||
|
<p className="text-[8px] text-neutral-800 md:text-[10px]">{item.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { For } from "@imphnen-frontend-service/utils"
|
||||||
|
import { FC } from "react"
|
||||||
|
|
||||||
|
export const TOPICS = [
|
||||||
|
{ id: 1, icon: '💼', name: 'Career & Self Development' },
|
||||||
|
{ id: 2, icon: '🏢', name: 'Industry Insight' },
|
||||||
|
{ id: 3, icon: '🗂️', name: 'Project Management & IT Tools' },
|
||||||
|
{ id: 4, icon: '🖥️', name: 'Basic IT' },
|
||||||
|
{ id: 5, icon: '💻', name: 'Programming/Software Dev' },
|
||||||
|
{ id: 6, icon: '🗃️', name: 'Data & Database' },
|
||||||
|
{ id: 7, icon: <span className="font-bold text-primary-500">AI</span>, name: 'AI Tips' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TopicsSection: FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold mb-3 md:text-[15px] xl:text-[19px]">Topics</h2>
|
||||||
|
<div className="p-5 bg-primary-50 border border-primary-100 rounded-md flex flex-wrap gap-2.5">
|
||||||
|
<For data={TOPICS}>
|
||||||
|
{(item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="px-2.5 py-2 text-neutral-800 bg-white border border-primary-100 rounded-md shadow text-[10px] font-medium"
|
||||||
|
>
|
||||||
|
<span>{item.icon} </span>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { FC, useState } from 'react'
|
||||||
|
import { ProfileSection } from './_components/sections/profile'
|
||||||
|
import { StatisticsSection } from './_components/sections/senpai-statistics'
|
||||||
|
import { TopicsSection } from './_components/sections/topics'
|
||||||
|
import { ExperienceSection } from './_components/sections/experience'
|
||||||
|
import { EducationSection } from './_components/sections/education'
|
||||||
|
import { SenpaiScheduleSection } from './_components/sections/senpai-schedule'
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||||
|
import { AppointmentModal } from './_components/modals/appointment'
|
||||||
|
|
||||||
|
export const Components: FC = () => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<section className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-8 md:bg-white xl:bg-transparent">
|
||||||
|
<ProfileSection onBook={() => setOpen(true)} />
|
||||||
|
|
||||||
|
<Button type="button" size="sm" className="w-full md:hidden" onClick={() => setOpen(true)}>
|
||||||
|
Book Your Senpai!
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="bg-white px-4 py-5 md:px-8 md:pb-6 md:pt-0 xl:py-7 xl:flex xl:gap-x-10">
|
||||||
|
<div className="space-y-10 md:space-y-7 xl:flex-1">
|
||||||
|
<StatisticsSection />
|
||||||
|
<TopicsSection />
|
||||||
|
|
||||||
|
<div className="px-6 py-8 rounded-md shadow-md">
|
||||||
|
<h2 className="text-xs text-neutral-800 font-semibold mb-5 md:text-[15px] xl:text-[19px]">Senpai Resume</h2>
|
||||||
|
<p className="text-[10px] font-medium text-neutral-600 text-pretty md:text-[15px]">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna. Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu. Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu eros, eget tempus orci facilisis id.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExperienceSection />
|
||||||
|
<EducationSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden xl:block xl:w-[400px]">
|
||||||
|
<SenpaiScheduleSection onBook={() => setOpen(true)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AppointmentModal open={open} setOpen={setOpen} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Components
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import { FC, useRef } from 'react';
|
||||||
|
import { motion, useInView, Variants } from 'framer-motion';
|
||||||
|
|
||||||
|
export const BannerSection: FC = () => {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const childVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={ref} className="w-full p-8 md:py-14 md:px-[60px] lg:py-16 lg:px-20">
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
"relative max-w-7xl mx-auto bg-white p-6 rounded-lg overflow-hidden shadow-md",
|
||||||
|
"md:flex md:justify-between md:items-center md:px-5 md:rounded-xl lg:px-16 lg:py-10",
|
||||||
|
)}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="hidden absolute -left-9 -top-24 w-[17rem] md:block lg:w-[28rem] lg:-left-14 lg:-top-52"
|
||||||
|
initial={{ x: -120, opacity: 0, rotate: -6 }}
|
||||||
|
animate={{ x: 0, opacity: 1, rotate: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<img src="/image/mentoring-banner.webp" alt="" className="w-full" />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="text-center md:text-start md:max-w-[calc(100%-14rem)] md:ms-auto lg:max-w-[calc(100%-20rem)]"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? 'visible' : 'hidden'}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
>
|
||||||
|
<motion.h1 className="mb-4 text-xl font-semibold text-primary-500 leading-tight md:text-2xl lg:text-5xl lg:font-bold lg:mb-6" variants={childVariants}>
|
||||||
|
Find your perfect sensei
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p className="text-xs font-semibold text-neutral-500 md:text-[15px] md:font-medium lg:text-xl" variants={childVariants}>
|
||||||
|
Di sini, kamu nggak cuma grinding sendirian—sensei dari dunia nyata siap membimbingmu, dan AI mentor bakal jadi support system terbaikmu. Saatnya jadi protagonist dalam perjalanan karier IT-mu!
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const MentorCard: FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="p-2.5 rounded-md bg-white shadow flex gap-x-4 items-start md:p-4 md:flex-col md:rounded-lg md:gap-y-4">
|
||||||
|
<div className="size-[60px] rounded-md overflow-hidden md:w-full md:h-auto md:aspect-square">
|
||||||
|
<img src="/image/testimonial.webp" alt="Mentor" className="w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button variant="text" size="sm" className="bg-primary-100 h-auto px-1.5 py-1 text-[8px] font-normal mb-2 hover:bg-primary-100 md:text-[10px] md:font-medium md:mb-4">
|
||||||
|
3-5 Years Experience
|
||||||
|
</Button>
|
||||||
|
<h2 className="mb-1">
|
||||||
|
<Link to="/mentoring/detail" className="text-xs font-semibold text-primary-500 md:text-[15px] md:font-semibold lg:text-[19px]">
|
||||||
|
Fullname
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
<p className="text-[8px] text-neutral-500 mb-3 md:text-[10px] md:font-medium md:mb-4 lg:text-xs">
|
||||||
|
Full Stack Enjoyer at name company
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[8px] text-neutral-500 mb-1 md:mb-2 lg:text-[10px]">Soft Skills :</p>
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button variant="text" size="sm" className="bg-primary-200 h-auto px-1.5 py-1 text-[8px] font-normal hover:bg-primary-200 lg:text-[10px]">
|
||||||
|
Communication
|
||||||
|
</Button>
|
||||||
|
<Button variant="text" size="sm" className="bg-primary-200 h-auto px-1.5 py-1 text-[8px] font-normal hidden hover:bg-primary-200 md:inline-block lg:text-[10px]">
|
||||||
|
Communication
|
||||||
|
</Button>
|
||||||
|
<Button variant="text" size="sm" className="h-auto px-1.5 py-1 text-[8px] font-normal text-neutral-500 hover:bg-transparent lg:text-[10px]">
|
||||||
|
+2
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { CloudServerOutlined, CodeOutlined, DatabaseOutlined, ProjectOutlined, TabletOutlined } from '@ant-design/icons'
|
||||||
|
import { Icon } from '@iconify/react'
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||||
|
import { For } from '@imphnen-frontend-service/utils'
|
||||||
|
import { FC, ReactElement, useRef } from 'react'
|
||||||
|
import { motion, useInView, Variants } from 'framer-motion'
|
||||||
|
|
||||||
|
const TOPICS: {
|
||||||
|
name: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
}[] = [
|
||||||
|
{ name: 'UI/UX & Design', icon: <TabletOutlined /> },
|
||||||
|
{ name: 'Software/Web Dev', icon: <CodeOutlined /> },
|
||||||
|
{ name: 'Data & AI', icon: <DatabaseOutlined /> },
|
||||||
|
{ name: 'Cloud & DevOps', icon: <CloudServerOutlined /> },
|
||||||
|
{ name: 'Cybersecurity', icon: <Icon icon="mage:security-shield" /> },
|
||||||
|
{ name: 'IT & Networking', icon: <Icon icon="carbon:data-structured" /> },
|
||||||
|
{ name: 'Project Management', icon: <ProjectOutlined /> },
|
||||||
|
{ name: 'QA & Testing', icon: <Icon icon="carbon:exam-mode" /> }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const Topics: FC = (): ReactElement => {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.3,
|
||||||
|
delay: 0.4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const childVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<motion.div
|
||||||
|
className="grid grid-cols-4 gap-6 px-3 md:px-12 md:gap-x-10 md:gap-y-14 lg:grid-cols-8 lg:px-0"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? 'visible' : 'hidden'}
|
||||||
|
>
|
||||||
|
<For data={TOPICS}>
|
||||||
|
{(topic) => (
|
||||||
|
<motion.div key={topic.name} className="flex flex-col items-center" variants={childVariants}>
|
||||||
|
<Button variant="secondary" size='sm' className="size-7 mb-3 p-0 md:mb-4 md:size-8 md:text-base lg:size-10 lg:text-lg">
|
||||||
|
{topic.icon}
|
||||||
|
</Button>
|
||||||
|
<p className="text-[8px] text-neutral-700 text-center break-words md:text-xs md:font-medium lg:text-[15px]">
|
||||||
|
{topic.name}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { FC, ReactElement, useRef, useState } from 'react';
|
||||||
|
import { BannerSection } from './_components/banner-section';
|
||||||
|
import { Topics } from './_components/topics';
|
||||||
|
import { Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { For } from '@imphnen-frontend-service/utils';
|
||||||
|
import { MentorCard } from './_components/mentor-card';
|
||||||
|
import { Pagination } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { getCoreRowModel, getPaginationRowModel, PaginationState, useReactTable } from '@tanstack/react-table';
|
||||||
|
import { motion, useInView, Variants } from 'framer-motion';
|
||||||
|
|
||||||
|
const TEMP_DATA = [
|
||||||
|
{ id: 1, name: 'John Doe' },
|
||||||
|
{ id: 2, name: 'John Doe' },
|
||||||
|
{ id: 3, name: 'John Doe' },
|
||||||
|
{ id: 4, name: 'John Doe' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const Components: FC = (): ReactElement => {
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: TEMP_DATA,
|
||||||
|
columns: [],
|
||||||
|
state: { pagination },
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ref = useRef(null)
|
||||||
|
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1,
|
||||||
|
delayChildren: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const childVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<BannerSection />
|
||||||
|
|
||||||
|
<section className="w-full px-8 md:px-[60px] md:pb-[60px] lg:px-20">
|
||||||
|
<motion.div
|
||||||
|
className="max-w-7xl mx-auto bg-white rounded-lg py-6 md:rounded-4xl"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
>
|
||||||
|
<div className="mb-10 md:px-6 lg:mb-[60px]">
|
||||||
|
<motion.h1
|
||||||
|
className="text-center text-[15px] text-primary-500 font-semibold mb-8 md:text-2xl md:mb-10 lg:text-3xl lg:mb-[60px]"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
Choose Your Topics
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<Topics />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div ref={ref} className="md:px-6">
|
||||||
|
<motion.div
|
||||||
|
className="relative mb-6 md:px-12 lg:px-0 lg:w-[480px] lg:mx-auto"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="Cari berdasarkan nama, posisi/peran"
|
||||||
|
className="relative min-w-full w-full"
|
||||||
|
/>
|
||||||
|
<SearchOutlined className="absolute right-2.5 top-1/2 -translate-y-1/2 text-primary-500 size-2.5 cursor-text md:me-12 lg:me-0" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="grid gap-2 mb-10 md:grid-cols-2 md:gap-6 lg:grid-cols-4"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate={isInView ? 'visible' : 'hidden'}
|
||||||
|
>
|
||||||
|
<For data={Array.from({ length: 8 })}>
|
||||||
|
{(_, index) => (
|
||||||
|
<motion.div key={index} variants={childVariants}>
|
||||||
|
<MentorCard />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<Pagination table={table} />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Components
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FC, ReactElement, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { ProfileForm, ProfileSidebar, ProfileHeader } from '../_components';
|
||||||
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { NotificationModal, NotificationType } from '../_components/modals/notification-modal';
|
||||||
|
import { ProfileProvider, useProfile } from '../_components/contexts/profile-context';
|
||||||
|
import { EditProfileModal } from '../_components/modals/edit-profile-modal';
|
||||||
|
|
||||||
|
const ProfileByIdPage: FC = (): ReactElement => {
|
||||||
|
const params = useParams();
|
||||||
|
const id = (params && params.id) ? params.id as string : undefined;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-600 text-lg">Profile ID not found.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileProvider profileId={id} profileType="user">
|
||||||
|
<ProfileByIdContent />
|
||||||
|
</ProfileProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfileByIdContent: FC = (): ReactElement => {
|
||||||
|
const { profileData, isLoading, error, profileType } = useProfile();
|
||||||
|
|
||||||
|
const [notification, setNotification] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
type: 'success',
|
||||||
|
title: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isEditProfileModalOpen, setIsEditProfileModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const getProfileTitle = () => {
|
||||||
|
if (profileData?.fullname) {
|
||||||
|
return `${profileData.fullname}'s Profile`;
|
||||||
|
}
|
||||||
|
return profileType === 'user' ? 'User Profile' : 'Mentor Profile';
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNotification = (type: NotificationType['type'], title: string, message?: string) => {
|
||||||
|
setNotification({
|
||||||
|
isOpen: true,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideNotification = () => {
|
||||||
|
setNotification(prev => ({ ...prev, isOpen: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditProfileModal = () => {
|
||||||
|
setIsEditProfileModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditProfileModal = () => {
|
||||||
|
setIsEditProfileModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set isViewOnly to true for this page
|
||||||
|
const isViewOnly = true;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600">Loading profile...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-600 text-lg">Failed to load profile</p>
|
||||||
|
<p className="text-gray-600 mt-2">Profile not found or you don't have permission to view it.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen">
|
||||||
|
<div className="">
|
||||||
|
<div className="w-full px-8 md:px-[60px] lg:px-20 py-4">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<Button variant="primary" className="flex items-center gap-2">
|
||||||
|
<ArrowLeftOutlined />
|
||||||
|
Kembali ke Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full px-8 md:px-[60px] lg:px-20 py-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">
|
||||||
|
{getProfileTitle()}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full px-8 md:px-[60px] lg:px-20 pb-12">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="grid gap-8 lg:grid-cols-12">
|
||||||
|
<div className="lg:col-span-12">
|
||||||
|
<ProfileHeader onEditProfileClick={openEditProfileModal} isViewOnly={isViewOnly} />
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-8 order-1">
|
||||||
|
<ProfileForm
|
||||||
|
showNotification={showNotification}
|
||||||
|
isViewOnly={isViewOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-4 order-2">
|
||||||
|
<ProfileSidebar
|
||||||
|
showNotification={showNotification}
|
||||||
|
isViewOnly={isViewOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NotificationModal
|
||||||
|
isOpen={notification.isOpen}
|
||||||
|
onClose={hideNotification}
|
||||||
|
type={notification.type}
|
||||||
|
title={notification.title}
|
||||||
|
message={notification.message}
|
||||||
|
header="Profile"
|
||||||
|
/>
|
||||||
|
{/* Only render EditProfileModal if not in view-only mode */}
|
||||||
|
{!isViewOnly && (
|
||||||
|
<EditProfileModal
|
||||||
|
isOpen={isEditProfileModalOpen}
|
||||||
|
onClose={closeEditProfileModal}
|
||||||
|
showNotification={showNotification}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileByIdPage;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface EditSectionButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditSectionButton: FC<EditSectionButtonProps> = ({ onClick, disabled = false }) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex items-center gap-1 px-3 py-1 text-sm transition-colors duration-200 rounded-md ${
|
||||||
|
disabled
|
||||||
|
? 'text-gray-400 cursor-not-allowed opacity-50'
|
||||||
|
: 'text-[#23A1EB] hover:text-[#1e90d6] hover:bg-[#23A1EB]/10'
|
||||||
|
}`}
|
||||||
|
aria-label="Edit section"
|
||||||
|
>
|
||||||
|
<span>Edit</span>
|
||||||
|
<EditOutlined className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { EditSectionButton } from './edit-section-button';
|
||||||
|
export { ModalButton } from './modal-button';
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { FC, ReactNode } from 'react';
|
||||||
|
|
||||||
|
type ButtonVariant = 'primary' | 'secondary' | 'danger';
|
||||||
|
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
interface ModalButtonProps {
|
||||||
|
children: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVariantClasses = (variant: ButtonVariant): string => {
|
||||||
|
switch (variant) {
|
||||||
|
case 'primary':
|
||||||
|
return 'bg-[#23A1EB] hover:bg-[#1e90d6] text-white shadow-lg hover:shadow-xl';
|
||||||
|
case 'secondary':
|
||||||
|
return 'bg-white hover:bg-gray-200 text-[#23A1EB] shadow-md hover:shadow-lg';
|
||||||
|
case 'danger':
|
||||||
|
return 'bg-red-600 hover:bg-red-500 text-white shadow-lg hover:shadow-xl';
|
||||||
|
default:
|
||||||
|
return 'bg-[#23A1EB] hover:bg-[#1e90d6] text-white shadow-lg hover:shadow-xl';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSizeClasses = (size: ButtonSize): string => {
|
||||||
|
switch (size) {
|
||||||
|
case 'sm':
|
||||||
|
return 'px-3 py-1.5 text-sm';
|
||||||
|
case 'md':
|
||||||
|
return 'px-4 py-2 text-sm';
|
||||||
|
case 'lg':
|
||||||
|
return 'px-6 py-3 text-base';
|
||||||
|
default:
|
||||||
|
return 'px-4 py-2 text-sm';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModalButton: FC<ModalButtonProps> = ({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
type = 'button',
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const baseClasses = 'font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#23A1EB] disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
|
const variantClasses = getVariantClasses(variant);
|
||||||
|
const sizeClasses = getSizeClasses(size);
|
||||||
|
|
||||||
|
const combinedClasses = `${baseClasses} ${variantClasses} ${sizeClasses} ${className}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
className={combinedClasses}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './profile-context';
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useMemo, useCallback } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
useAuthStore,
|
||||||
|
useUserMe,
|
||||||
|
useUserById,
|
||||||
|
useUpdateUserMe,
|
||||||
|
useUpdateUserById,
|
||||||
|
UserDetailResponseDto,
|
||||||
|
UserUpdateRequestDto,
|
||||||
|
|
||||||
|
useMentorMe,
|
||||||
|
useMentorById,
|
||||||
|
useUpdateMentorMe,
|
||||||
|
useUpdateMentorById,
|
||||||
|
MentorDetailResponseDto,
|
||||||
|
MentorUpdateRequestDto
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
|
||||||
|
type ProfileData = UserDetailResponseDto | MentorDetailResponseDto;
|
||||||
|
type ProfileUpdateData = UserUpdateRequestDto | MentorUpdateRequestDto;
|
||||||
|
|
||||||
|
|
||||||
|
const canAccessMentorFeatures = (user: { role?: { name?: string; permissions?: Array<{ name?: string }> } } | null) => {
|
||||||
|
if (!user?.role) return false;
|
||||||
|
|
||||||
|
const roleName = user.role.name?.toLowerCase() || '';
|
||||||
|
const isMentorRole = roleName.includes('mentor') || roleName.includes('admin');
|
||||||
|
|
||||||
|
if (isMentorRole) return true;
|
||||||
|
|
||||||
|
|
||||||
|
const permissions = user.role.permissions || [];
|
||||||
|
const hasMentorPermission = permissions.some((permission: { name?: string }) =>
|
||||||
|
permission.name?.toLowerCase().includes('mentor')
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasMentorPermission;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProfileContextType {
|
||||||
|
profileData: ProfileData | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: unknown;
|
||||||
|
isOwnProfile: boolean;
|
||||||
|
profileId: string | null;
|
||||||
|
profileType: 'user' | 'mentor';
|
||||||
|
updateProfile: (data: ProfileUpdateData) => Promise<void>;
|
||||||
|
isUpdating: boolean;
|
||||||
|
canAccessMentor: boolean;
|
||||||
|
}const ProfileContext = createContext<ProfileContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface ProfileProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
profileId?: string;
|
||||||
|
profileType?: 'user' | 'mentor';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileProvider: React.FC<ProfileProviderProps> = ({
|
||||||
|
children,
|
||||||
|
profileId,
|
||||||
|
profileType: forcedProfileType
|
||||||
|
}) => {
|
||||||
|
const params = useParams();
|
||||||
|
const { session } = useAuthStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
||||||
|
const canAccessMentor = useMemo(() => {
|
||||||
|
return canAccessMentorFeatures(session?.user || null);
|
||||||
|
}, [session?.user]);
|
||||||
|
|
||||||
|
|
||||||
|
const isMentorRole = useMemo(() => {
|
||||||
|
const roleName = session?.user?.role?.name?.toLowerCase() || '';
|
||||||
|
return roleName === 'mentor';
|
||||||
|
}, [session?.user?.role?.name]);
|
||||||
|
|
||||||
|
|
||||||
|
const profileType: 'user' | 'mentor' = useMemo(() => {
|
||||||
|
if (forcedProfileType) {
|
||||||
|
|
||||||
|
if (forcedProfileType === 'mentor' && !isMentorRole) {
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
|
return forcedProfileType;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if ((params?.mentor || (typeof window !== 'undefined' && window.location.pathname.includes('/mentor'))) && isMentorRole) {
|
||||||
|
return 'mentor';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'user';
|
||||||
|
}, [forcedProfileType, params, isMentorRole]);
|
||||||
|
|
||||||
|
|
||||||
|
const id = profileId || (params?.id as string) || undefined;
|
||||||
|
const isOwnProfile = !id;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const userMeQuery = useUserMe({
|
||||||
|
queryKey: ['user-me'],
|
||||||
|
enabled: isOwnProfile && profileType === 'user',
|
||||||
|
});
|
||||||
|
const userByIdQuery = useUserById(id || '', {
|
||||||
|
queryKey: ['user-by-id', id],
|
||||||
|
enabled: !isOwnProfile && !!id && profileType === 'user',
|
||||||
|
});
|
||||||
|
const updateUserMeMutation = useUpdateUserMe();
|
||||||
|
const updateUserByIdMutation = useUpdateUserById();
|
||||||
|
|
||||||
|
const mentorMeQuery = useMentorMe({
|
||||||
|
queryKey: ['mentor-me'],
|
||||||
|
enabled: isOwnProfile && profileType === 'mentor' && canAccessMentor,
|
||||||
|
});
|
||||||
|
const mentorByIdQuery = useMentorById(id || '', {
|
||||||
|
queryKey: ['mentor-by-id', id],
|
||||||
|
enabled: !isOwnProfile && !!id && profileType === 'mentor' && canAccessMentor,
|
||||||
|
});
|
||||||
|
const updateMentorMeMutation = useUpdateMentorMe();
|
||||||
|
const updateMentorByIdMutation = useUpdateMentorById();
|
||||||
|
|
||||||
|
|
||||||
|
const selectedUserQuery = isOwnProfile ? userMeQuery : userByIdQuery;
|
||||||
|
const selectedMentorQuery = isOwnProfile ? mentorMeQuery : mentorByIdQuery;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: profileData,
|
||||||
|
isLoading,
|
||||||
|
error
|
||||||
|
} = useMemo(() => {
|
||||||
|
|
||||||
|
if (canAccessMentor && profileType === 'mentor') {
|
||||||
|
return selectedMentorQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedUserQuery;
|
||||||
|
}, [profileType, canAccessMentor, selectedUserQuery, selectedMentorQuery]);
|
||||||
|
|
||||||
|
|
||||||
|
const selectedUserMutation = isOwnProfile ? updateUserMeMutation : updateUserByIdMutation;
|
||||||
|
const selectedMentorMutation = isOwnProfile ? updateMentorMeMutation : updateMentorByIdMutation;
|
||||||
|
|
||||||
|
const updateMutation = useMemo(() => {
|
||||||
|
|
||||||
|
if (canAccessMentor && profileType === 'mentor') {
|
||||||
|
return selectedMentorMutation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedUserMutation;
|
||||||
|
}, [profileType, canAccessMentor, selectedUserMutation, selectedMentorMutation]);
|
||||||
|
|
||||||
|
|
||||||
|
const updateProfile = useCallback(async (data: ProfileUpdateData) => {
|
||||||
|
try {
|
||||||
|
if (canAccessMentor && profileType === 'mentor') {
|
||||||
|
|
||||||
|
if (isOwnProfile) {
|
||||||
|
await updateMentorMeMutation.mutateAsync(data as MentorUpdateRequestDto);
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['mentor-me'] });
|
||||||
|
} else if (id) {
|
||||||
|
await updateMentorByIdMutation.mutateAsync({ id, data: data as MentorUpdateRequestDto });
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['mentor-by-id', id] });
|
||||||
|
}
|
||||||
|
} else if (isOwnProfile) {
|
||||||
|
|
||||||
|
await updateUserMeMutation.mutateAsync(data as UserUpdateRequestDto);
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['user-me'] });
|
||||||
|
} else if (id) {
|
||||||
|
await updateUserByIdMutation.mutateAsync({ id, data: data as UserUpdateRequestDto });
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['user-by-id', id] });
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Failed to update profile:', error);
|
||||||
|
|
||||||
|
let apiMessage = '';
|
||||||
|
if (typeof error === 'object' && error !== null) {
|
||||||
|
const errObj = error as { response?: { data?: unknown } };
|
||||||
|
const data = errObj.response?.data;
|
||||||
|
if (data) {
|
||||||
|
try {
|
||||||
|
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
||||||
|
if (parsed && typeof parsed.message === 'string') {
|
||||||
|
apiMessage = parsed.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
apiMessage = typeof data === 'string' ? data : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (apiMessage) {
|
||||||
|
throw new Error(apiMessage);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
profileType,
|
||||||
|
isOwnProfile,
|
||||||
|
canAccessMentor,
|
||||||
|
id,
|
||||||
|
queryClient,
|
||||||
|
updateUserMeMutation,
|
||||||
|
updateUserByIdMutation,
|
||||||
|
updateMentorMeMutation,
|
||||||
|
updateMentorByIdMutation
|
||||||
|
]); const isUpdating = updateMutation.isPending;
|
||||||
|
|
||||||
|
const value: ProfileContextType = useMemo(() => ({
|
||||||
|
profileData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isOwnProfile,
|
||||||
|
profileId: isOwnProfile ? null : (id || null),
|
||||||
|
profileType,
|
||||||
|
updateProfile,
|
||||||
|
isUpdating,
|
||||||
|
canAccessMentor
|
||||||
|
}), [profileData, isLoading, error, isOwnProfile, id, profileType, updateProfile, isUpdating, canAccessMentor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ProfileContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const useProfile = (): ProfileContextType => {
|
||||||
|
const context = useContext(ProfileContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useProfile must be used within a ProfileProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type { ProfileContextType };
|
||||||
|
export type { ProfileData, ProfileUpdateData };
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Guard } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
|
interface MentorGuardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MentorGuard: React.FC<MentorGuardProps> = ({
|
||||||
|
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 (
|
||||||
|
<Guard
|
||||||
|
permissions={['mentor', 'admin']}
|
||||||
|
fallback={fallback}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Guard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
export * from './profile';
|
||||||
|
|
||||||
|
|
||||||
|
export * from './sections';
|
||||||
|
|
||||||
|
|
||||||
|
export * from './buttons';
|
||||||
|
|
||||||
|
|
||||||
|
export * from './shared';
|
||||||
|
|
||||||
|
|
||||||
|
export * from './modals';
|
||||||
|
|
||||||
|
|
||||||
|
export * from './contexts';
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
import { useUploadCV } from '@imphnen-frontend-service/service';
|
||||||
|
import { FileUploader } from '../shared/file-uploader';
|
||||||
|
|
||||||
|
interface CVData {
|
||||||
|
fileName: string;
|
||||||
|
fileUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CVModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: CVData;
|
||||||
|
onSave: (value: CVData) => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CVModal: FC<CVModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
const [cvData, setCvData] = useState(initialValue);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const uploadCVMutation = useUploadCV();
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCvData(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await onSave(cvData);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setCvData(initialValue);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = async (file: File) => {
|
||||||
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
|
||||||
|
if (!file.type.includes('pdf')) {
|
||||||
|
throw new Error('Please select a PDF file');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const uploadResult = await uploadCVMutation.mutateAsync(file);
|
||||||
|
|
||||||
|
console.log('CV upload response:', uploadResult);
|
||||||
|
|
||||||
|
|
||||||
|
interface UploadData {
|
||||||
|
original_filename?: string;
|
||||||
|
filename?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData);
|
||||||
|
|
||||||
|
setCvData({
|
||||||
|
fileName: uploadData.original_filename || uploadData.filename || file.name,
|
||||||
|
fileUrl: uploadData.url || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('CV uploaded successfully, URL:', uploadData.url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CV upload error:', error);
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('cv-upload') as HTMLInputElement;
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label="Close modal"
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
|
||||||
|
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit CV/Resume</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="px-6 py-6 space-y-6">
|
||||||
|
{}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Upload CV/Resume
|
||||||
|
</h3>
|
||||||
|
<FileUploader
|
||||||
|
accept=".pdf"
|
||||||
|
maxSize={10 * 1024 * 1024}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
isLoading={isUploading}
|
||||||
|
dragAndDrop={true}
|
||||||
|
description="Klik atau tarik file PDF yang ingin di upload"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Format yang didukung: PDF • Maksimal ukuran: 10MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
{cvData.fileName && (
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-blue-900 mb-3">File Terpilih</h4>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-white text-xs font-bold">PDF</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-blue-900 text-sm">{cvData.fileName}</p>
|
||||||
|
<p className="text-xs text-blue-600">Siap untuk disimpan</p>
|
||||||
|
</div>
|
||||||
|
{cvData.fileUrl && (
|
||||||
|
<a
|
||||||
|
href={cvData.fileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:text-blue-800 text-sm font-medium underline"
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{}
|
||||||
|
<div className="flex gap-3 p-6 pt-4 border-t border-gray-100">
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading || isUploading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading || isUploading || !cvData.fileName}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Menyimpan...' : 'Simpan CV'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
|
||||||
|
interface DescriptionModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: string;
|
||||||
|
onSave: (value: string) => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DescriptionModal: FC<DescriptionModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
const [description, setDescription] = useState(initialValue);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDescription(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await onSave(description);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setDescription(initialValue);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label="Close modal"
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto">
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Description</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description-textarea" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description-textarea"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={8}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#23A1EB] focus:border-[#23A1EB] outline-none transition-colors resize-none"
|
||||||
|
placeholder="Write your description here..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 p-6 pt-4">
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import React, { FC, useState, useEffect } from 'react';
|
||||||
|
import { Modal, InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { useProfile } from '../contexts/profile-context';
|
||||||
|
import { CameraOutlined } from '@ant-design/icons';
|
||||||
|
import { useUploadAvatar } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
interface EditProfileModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
showNotification: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditProfileModal: FC<EditProfileModalProps> = ({ isOpen, onClose, showNotification }) => {
|
||||||
|
const { profileData, profileType, updateProfile } = useProfile();
|
||||||
|
const uploadAvatarMutation = useUploadAvatar();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
fullname: '',
|
||||||
|
avatar: ''
|
||||||
|
});
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string>('/image/testimonial.webp');
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profileData) {
|
||||||
|
const fullname = profileData.fullname ||
|
||||||
|
(profileType === 'mentor' && 'legal_name' in profileData ? profileData.legal_name : '') || '';
|
||||||
|
|
||||||
|
|
||||||
|
const avatar = (profileType === 'user' && 'avatar' in profileData)
|
||||||
|
? profileData.avatar || '/image/testimonial.webp'
|
||||||
|
: '/image/testimonial.webp';
|
||||||
|
|
||||||
|
console.log('Modal - Profile data avatar URL:', avatar);
|
||||||
|
console.log('Modal - Profile data:', profileData);
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
fullname,
|
||||||
|
avatar: (profileType === 'user' && 'avatar' in profileData) ? profileData.avatar || '' : ''
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
setPreviewUrl(avatar);
|
||||||
|
} else {
|
||||||
|
|
||||||
|
setPreviewUrl('/image/testimonial.webp');
|
||||||
|
}
|
||||||
|
}, [profileData, profileType]);
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
console.log('Image failed to load:', previewUrl);
|
||||||
|
setPreviewUrl('/image/testimonial.webp');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData(prev => ({ ...prev, fullname: e.target.value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = async (file: File) => {
|
||||||
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
setPreviewUrl(result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
|
||||||
|
const uploadResult = await uploadAvatarMutation.mutateAsync(file);
|
||||||
|
|
||||||
|
console.log('Avatar upload response:', uploadResult);
|
||||||
|
|
||||||
|
|
||||||
|
interface UploadData {
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadData = ('data' in uploadResult ? (uploadResult as { data: UploadData }).data : uploadResult as UploadData);
|
||||||
|
|
||||||
|
|
||||||
|
setFormData(prev => ({ ...prev, avatar: uploadData.url || '' }));
|
||||||
|
|
||||||
|
|
||||||
|
setPreviewUrl(uploadData.url || '/image/testimonial.webp');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Avatar upload error:', error);
|
||||||
|
showNotification('error', 'Upload Failed', 'Failed to upload avatar image');
|
||||||
|
|
||||||
|
const originalAvatar = (profileType === 'user' && profileData && 'avatar' in profileData) ? profileData.avatar : '';
|
||||||
|
setPreviewUrl(originalAvatar || '/image/testimonial.webp');
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const updates: Record<string, string> = {};
|
||||||
|
|
||||||
|
|
||||||
|
if (formData.fullname.trim() !== '') {
|
||||||
|
if (profileType === 'user') {
|
||||||
|
updates.fullname = formData.fullname;
|
||||||
|
} else if (profileType === 'mentor') {
|
||||||
|
updates.legal_name = formData.fullname;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (formData.avatar && formData.avatar !== (profileData && 'avatar' in profileData ? profileData.avatar : '')) {
|
||||||
|
updates.avatar = formData.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await updateProfile(updates);
|
||||||
|
showNotification('success', 'Profile Updated', 'Your profile has been successfully updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Profile update error:', err);
|
||||||
|
let apiMessage = '';
|
||||||
|
if (typeof err === 'object' && err !== null) {
|
||||||
|
const errObj = err as { response?: { data?: { message?: string } } };
|
||||||
|
let backendMsg = '';
|
||||||
|
if (errObj.response?.data?.message) {
|
||||||
|
backendMsg = errObj.response.data.message;
|
||||||
|
}
|
||||||
|
let msg = '';
|
||||||
|
if ('message' in err && typeof (err as { message?: string }).message === 'string') {
|
||||||
|
msg = (err as { message?: string }).message || '';
|
||||||
|
|
||||||
|
if (msg.trim().startsWith('{') && msg.trim().endsWith('}')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(msg);
|
||||||
|
if (parsed && typeof parsed.message === 'string') {
|
||||||
|
msg = parsed.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore JSON parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (backendMsg && msg && backendMsg !== msg) {
|
||||||
|
apiMessage = backendMsg + '\n' + msg;
|
||||||
|
} else if (backendMsg) {
|
||||||
|
apiMessage = backendMsg;
|
||||||
|
} else if (msg) {
|
||||||
|
apiMessage = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showNotification('error', 'Failed to save changes', apiMessage || 'Please try again.');
|
||||||
|
}
|
||||||
|
}; return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>Edit Profile</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Content>
|
||||||
|
{}
|
||||||
|
<div className="flex justify-center pt-6 pb-4">
|
||||||
|
<div className="relative">
|
||||||
|
{}
|
||||||
|
<input
|
||||||
|
id="avatar-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleImageUpload(file);
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="avatar-upload" className="cursor-pointer block relative group">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Profile"
|
||||||
|
className="w-24 h-24 rounded-full object-cover border-4 border-blue-100 shadow-lg transition-all duration-300 group-hover:border-blue-200"
|
||||||
|
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">
|
||||||
|
<span className="text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
Change Photo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{}
|
||||||
|
<label htmlFor="avatar-upload" className="cursor-pointer">
|
||||||
|
<div className="absolute bottom-0 right-0 w-8 h-8 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-300 shadow-lg border-2 border-white">
|
||||||
|
{isUploading ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
) : (
|
||||||
|
<CameraOutlined className="text-xs" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div> <div className="px-6 pb-6 space-y-6">
|
||||||
|
{}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<InputField
|
||||||
|
label="Full Name"
|
||||||
|
name="fullname"
|
||||||
|
value={formData.fullname}
|
||||||
|
onChange={handleNameChange}
|
||||||
|
placeholder="Enter your full name"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 pl-1">
|
||||||
|
This name will be displayed on your profile
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal.Content>
|
||||||
|
<Modal.Footer>
|
||||||
|
<div className="flex gap-3 w-full">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save Changes'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
|
||||||
|
interface Education {
|
||||||
|
id: string;
|
||||||
|
institution: string;
|
||||||
|
degree: string;
|
||||||
|
field: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EducationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: Education[];
|
||||||
|
onSave: (value: Education[]) => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
showNotification?: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EducationModal: FC<EducationModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
showNotification,
|
||||||
|
}) => {
|
||||||
|
const [educations, setEducations] = useState<Education[]>(initialValue);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEducations(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
|
||||||
|
const hasEmpty = educations.some(edu =>
|
||||||
|
!edu.institution.trim() || !edu.degree.trim() || !edu.field.trim() || !edu.period.trim()
|
||||||
|
);
|
||||||
|
if (hasEmpty) {
|
||||||
|
if (showNotification) {
|
||||||
|
showNotification('error', 'Data Tidak Lengkap', 'Semua field harus diisi pada setiap pendidikan.');
|
||||||
|
} else {
|
||||||
|
alert('Semua field harus diisi pada setiap pendidikan.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await onSave(educations);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEducations(initialValue);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEducation = () => {
|
||||||
|
const newEducation: Education = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
institution: '',
|
||||||
|
degree: '',
|
||||||
|
field: '',
|
||||||
|
period: '',
|
||||||
|
};
|
||||||
|
setEducations([...educations, newEducation]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEducation = (id: string) => {
|
||||||
|
setEducations(educations.filter(edu => edu.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEducation = (id: string, field: keyof Education, value: string) => {
|
||||||
|
setEducations(educations.map(edu =>
|
||||||
|
edu.id === id ? { ...edu, [field]: value } : edu
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label="Close modal"
|
||||||
|
></button>
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl max-w-5xl w-full max-h-[95vh] overflow-hidden">
|
||||||
|
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Education</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{educations.map((education, index) => (
|
||||||
|
<div key={education.id} className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Education {index + 1}</h3>
|
||||||
|
<ModalButton
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeEducation(education.id)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<InputField
|
||||||
|
label="Institution"
|
||||||
|
value={education.institution}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'institution', e.target.value)}
|
||||||
|
placeholder="Enter institution name"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Degree"
|
||||||
|
value={education.degree}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'degree', e.target.value)}
|
||||||
|
placeholder="Enter degree"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Field"
|
||||||
|
value={education.field}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'field', e.target.value)}
|
||||||
|
placeholder="Enter field of study"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Period"
|
||||||
|
value={education.period}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateEducation(education.id, 'period', e.target.value)}
|
||||||
|
placeholder="e.g., Sep 2022 - Current"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={addEducation}
|
||||||
|
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-4 text-gray-500 hover:border-[#23A1EB] hover:text-[#23A1EB] transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Add Education
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-white shadow-md"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
import { InputField } from '@imphnen-frontend-service/ui/molecules';
|
||||||
|
|
||||||
|
|
||||||
|
interface Experience {
|
||||||
|
id: string;
|
||||||
|
company: string;
|
||||||
|
position: string;
|
||||||
|
duration: string;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExperienceModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: Experience[];
|
||||||
|
onSave: (value: Experience[]) => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
showNotification?: (type: 'success' | 'error', title: string, message?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExperienceModal: FC<ExperienceModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
showNotification,
|
||||||
|
}) => {
|
||||||
|
const [experiences, setExperiences] = useState<Experience[]>(initialValue);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExperiences(initialValue);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
|
||||||
|
const hasEmpty = experiences.some(exp =>
|
||||||
|
!exp.company.trim() || !exp.position.trim() || !exp.duration.trim() || !exp.period.trim()
|
||||||
|
);
|
||||||
|
if (hasEmpty) {
|
||||||
|
if (showNotification) {
|
||||||
|
showNotification('error', 'Data Tidak Lengkap', 'Semua field harus diisi pada setiap pengalaman kerja.');
|
||||||
|
} else {
|
||||||
|
alert('Semua field harus diisi pada setiap pengalaman kerja.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await onSave(experiences);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setExperiences(initialValue);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addExperience = () => {
|
||||||
|
const newExperience: Experience = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
company: '',
|
||||||
|
position: '',
|
||||||
|
duration: '',
|
||||||
|
period: '',
|
||||||
|
};
|
||||||
|
setExperiences([...experiences, newExperience]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExperience = (id: string) => {
|
||||||
|
setExperiences(experiences.filter(exp => exp.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateExperience = (id: string, field: keyof Experience, value: string) => {
|
||||||
|
setExperiences(experiences.map(exp =>
|
||||||
|
exp.id === id ? { ...exp, [field]: value } : exp
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
aria-label="Close modal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl max-w-5xl w-full max-h-[95vh] overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Experience</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{experiences.map((experience, index) => (
|
||||||
|
<div key={experience.id} className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Experience {index + 1}</h3>
|
||||||
|
<ModalButton
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeExperience(experience.id)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<InputField
|
||||||
|
label="Company"
|
||||||
|
value={experience.company}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'company', e.target.value)}
|
||||||
|
placeholder="Enter company name"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Position"
|
||||||
|
value={experience.position}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'position', e.target.value)}
|
||||||
|
placeholder="Enter position"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Duration"
|
||||||
|
value={experience.duration}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'duration', e.target.value)}
|
||||||
|
placeholder="e.g., 7 Months"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Period"
|
||||||
|
value={experience.period}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateExperience(experience.id, 'period', e.target.value)}
|
||||||
|
placeholder="e.g., Jan 2024 - Present"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={addExperience}
|
||||||
|
className="w-full border-2 border-dashed border-gray-300 rounded-lg p-4 text-gray-500 hover:border-[#23A1EB] hover:text-[#23A1EB] transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Add Experience
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-white shadow-md"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Menyimpan...' : 'Simpan'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { NotificationModal } from './notification-modal';
|
||||||
|
export { CVModal } from './cv-modal';
|
||||||
|
export { DescriptionModal } from './description-modal';
|
||||||
|
export { EducationModal } from './education-modal';
|
||||||
|
export { ExperienceModal } from './experience-modal';
|
||||||
|
export { SocialMediaModal } from './social-media-modal';
|
||||||
|
export { SkillsModal } from './skills-modal';
|
||||||
|
export { EditProfileModal } from './edit-profile-modal';
|
||||||
|
export { LanguagesModal } from './languages-modal';
|
||||||
|
export { PersonalInfoModal } from './personal-info-modal';
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { Input } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
|
||||||
|
interface Language {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguagesModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialValue: Language[];
|
||||||
|
onSave: (languages: Language[]) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LanguagesModal: FC<LanguagesModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialValue,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
const [languages, setLanguages] = useState<Language[]>(initialValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const languagesWithIds = initialValue.map(lang => ({
|
||||||
|
...lang,
|
||||||
|
id: lang.id || `lang-${Date.now()}-${Math.random()}`
|
||||||
|
}));
|
||||||
|
setLanguages(languagesWithIds);
|
||||||
|
}, [initialValue]);
|
||||||
|
|
||||||
|
const handleAddLanguage = () => {
|
||||||
|
setLanguages([...languages, {
|
||||||
|
id: `lang-${Date.now()}-${Math.random()}`,
|
||||||
|
name: '',
|
||||||
|
level: ''
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLanguageChange = (index: number, field: keyof Language, value: string) => {
|
||||||
|
const newLanguages = [...languages];
|
||||||
|
newLanguages[index] = { ...newLanguages[index], [field]: value };
|
||||||
|
setLanguages(newLanguages);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLanguage = (index: number) => {
|
||||||
|
const newLanguages = languages.filter((_, i) => i !== index);
|
||||||
|
setLanguages(newLanguages);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
|
||||||
|
const languagesToSave = languages.map(({ id, ...lang }) => lang);
|
||||||
|
onSave(languagesToSave);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
const languagesWithIds = initialValue.map(lang => ({
|
||||||
|
...lang,
|
||||||
|
id: lang.id || `lang-${Date.now()}-${Math.random()}`
|
||||||
|
}));
|
||||||
|
setLanguages(languagesWithIds);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{}
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
type="button"
|
||||||
|
aria-label="Close modal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{}
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-5xl mx-auto max-h-[95vh] overflow-hidden">
|
||||||
|
{}
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">Edit Languages</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{languages.map((language, index) => (
|
||||||
|
<div key={language.id} className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-700 mb-1">Language Name</p>
|
||||||
|
<Input
|
||||||
|
value={language.name}
|
||||||
|
onChange={(e) => handleLanguageChange(index, 'name', e.target.value)}
|
||||||
|
placeholder="e.g., English"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-700 mb-1">Level</p>
|
||||||
|
<Input
|
||||||
|
value={language.level}
|
||||||
|
onChange={(e) => handleLanguageChange(index, 'level', e.target.value)}
|
||||||
|
placeholder="e.g., Fluent"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:flex-shrink-0">
|
||||||
|
<ModalButton variant="danger" onClick={() => handleRemoveLanguage(index)} className="w-full sm:w-auto">
|
||||||
|
Remove
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<ModalButton variant="secondary" onClick={handleAddLanguage} className="w-full">
|
||||||
|
Add Language
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
<div className="flex gap-3 p-6 pt-4">
|
||||||
|
<ModalButton
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex-1 bg-white shadow-md"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ModalButton>
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Saving...' : 'Save'}
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
|
import { ModalButton } from '../buttons/modal-button';
|
||||||
|
|
||||||
|
export type NotificationType = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
header: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationModalProps extends NotificationType {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
header: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationModal: FC<NotificationModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
header,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const isSuccess = type === 'success';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||||
|
<button
|
||||||
|
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close modal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 px-3 py-1 bg-[#23A1EB]/10 rounded-md flex-1">{header}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
{isSuccess ? (
|
||||||
|
<div className="w-30 h-30 mx-auto mb-4">
|
||||||
|
<img
|
||||||
|
src="/image/success.png"
|
||||||
|
alt="Success"
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-20 h-20 bg-red-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CloseOutlined className="text-white text-3xl" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className={`text-lg font-medium ${isSuccess ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{!isSuccess && message && (
|
||||||
|
<div className="mt-2 text-sm text-red-500 whitespace-pre-line">{message}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full bg-[#23A1EB] hover:bg-[#23A1EB]/90"
|
||||||
|
>
|
||||||
|
Selesai
|
||||||
|
</ModalButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||