Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae3dd8c3f1 | ||
|
|
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 | ||
|
|
ec13f2c693 | ||
|
|
302ee37ca1 | ||
|
|
b7282f9c99 | ||
|
|
2c347d1cc5 |
@@ -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,6 @@
|
||||
VITE_API_URL=https://forsomereason.dev/api/v1
|
||||
VITE_GITHUB_CLIENT_ID=SuperSecretClientID12345
|
||||
|
||||
VITE_SUPABASE_URL=https://mamahakumaurtx5090ti.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=anonkeykekgimanasihbang
|
||||
SUPABASE_SERVICE_ROLE_KEY=service.role.keyisahsecret
|
||||
@@ -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
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Deploy Landing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
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 ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
||||
else
|
||||
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
|
||||
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
||||
docker image prune -af
|
||||
@@ -62,3 +62,7 @@ storybook-static
|
||||
# Next.js
|
||||
.next
|
||||
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,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 { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||
<BackofficeSidebar />
|
||||
<div className="flex-1 overflow-auto lg:max-w-[1000px] 2xl:max-w-[1280px] mx-auto">
|
||||
<BackofficeSidebar
|
||||
isOpen={mobileSidebarOpen}
|
||||
onClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Sticky top header */}
|
||||
<header
|
||||
className={
|
||||
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
||||
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||
}
|
||||
>
|
||||
{/* Mobile menu button (shown on small screens) */}
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-p3 font-semibold text-primary-700">
|
||||
IMPHNEN Backoffice
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
font-family: 'Bai Jamjuree', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session) return redirect('/auth/login');
|
||||
// if (!session) return redirect('/auth/login');
|
||||
|
||||
const matchedRoute = mappingRoutePermissions.find(
|
||||
(route) => route.path === pathname
|
||||
|
||||
@@ -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: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 4.3 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: 11 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 511 KiB |
@@ -0,0 +1,154 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
import { FC, useRef } from 'react';
|
||||
import { motion, useInView, Variants } from 'framer-motion';
|
||||
|
||||
export const CTASection: 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 scaleVariant: Variants = {
|
||||
hidden: { scale: 0 },
|
||||
visible: {
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const contentVariant: Variants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<section ref={ref} className="w-full mx-auto px-8 py-4 md:px-[60px] md:py-8 lg:px-20">
|
||||
<motion.div
|
||||
className="relative max-w-7xl mx-auto bg-white rounded-lg border-2 border-primary-200 py-20 overflow-hidden md:rounded-3xl lg:py-24"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
>
|
||||
{/* Background gradients and shapes */}
|
||||
<div className="absolute inset-0">
|
||||
<motion.div
|
||||
className={cn("absolute top-8 -left-14 size-28 bg-gradient-to-tl from-primary-400 to-primary-300 rounded-full",
|
||||
"md:size-52 md:-top-20 md:-left-10 lg:size-72 lg:-top-16 lg:-left-14"
|
||||
)}
|
||||
variants={scaleVariant}
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
/>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute top-24 -right-14 size-[89px] bg-gradient-to-b from-primary-400 to-primary-300 rounded-full blur-[1.8px]",
|
||||
"md:size-[182px] md:-right-24 md:blur-[2px] lg:size-[392px] lg:top-16 lg:-right-52 lg:blur-[4px]"
|
||||
)}
|
||||
variants={scaleVariant}
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
/>
|
||||
<motion.div
|
||||
variants={scaleVariant}
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
className={cn(
|
||||
"absolute bottom-10 -left-[88px] size-28 bg-primary-200 rounded-full flex justify-center items-center",
|
||||
"md:size-56 md:-bottom-28 md:-left-28 lg:size-[274px] lg:-bottom-24 lg:-left-24"
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: isInView ? 1 : 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
className="size-[72px] bg-primary-50 rounded-full md:size-40 lg:size-[200px]"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 h-1/4 bg-gradient-to-t from-primary-400 via-primary-200 opacity-90",
|
||||
"md:h-[22%] md:opacity-85 lg:h-1/4"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.img
|
||||
src="/logos/logo.svg"
|
||||
alt="IMPHNEN Logo"
|
||||
className="relative w-28 mx-auto mb-8 md:w-[186px]"
|
||||
variants={contentVariant}
|
||||
/>
|
||||
|
||||
<div className="relative mb-8 max-w-52 mx-auto md:max-w-[446px] lg:max-w-[688px]">
|
||||
<motion.h1
|
||||
className={cn(
|
||||
"mb-3 text-xl text-center font-semibold leading-tight text-primary-500",
|
||||
"md:text-4xl md:mb-2 lg:text-5xl lg:font-bold"
|
||||
)}
|
||||
variants={contentVariant}
|
||||
>
|
||||
Start Your IT Journey Now!
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
className={cn(
|
||||
"mb-6 text-[15px] font-medium text-center leading-tight text-primary-400",
|
||||
"md:text-2xl md:mb-8 lg:text-[29px]"
|
||||
)}
|
||||
variants={contentVariant}
|
||||
>
|
||||
Jangan biarkan progress mu hanya jadi side story!
|
||||
</motion.p>
|
||||
<motion.p
|
||||
className={cn(
|
||||
"text-xs font-semibold text-center leading-tight text-primary-400",
|
||||
"md:text-[19px] lg:text-[23px] lg:max-w-[628px] lg:mx-auto"
|
||||
)}
|
||||
variants={contentVariant}
|
||||
>
|
||||
Dapatkan mentor terbaik dan gunakan AI untuk belajar lebih cepat. Waktunya jadi protagonist dalam perjalanan IT-mu!
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="relative max-w-52 mx-auto space-y-3 md:max-w-full md:flex md:gap-x-6 md:justify-center md:space-y-0 md:items-center"
|
||||
variants={contentVariant}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
|
||||
>
|
||||
Mulai Belajar Dengan AI
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full h-auto py-2 text-[10px] font-medium md:w-max md:text-sm md:px-2.5 md:font-semibold lg:text-base"
|
||||
>
|
||||
Temukan Mentor
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { Accordion, AccordionOptions } from '@imphnen-frontend-service/ui/molecules'
|
||||
import { FC, useRef } from 'react'
|
||||
import { motion, useInView, Variants } from 'framer-motion'
|
||||
import { cn, For } from '@imphnen-frontend-service/utils'
|
||||
|
||||
const FAQ_DATA: AccordionOptions[] = [
|
||||
{ title: 'Apakah saya bisa memilih mentor sendiri?', description: 'Tentu! Kamu bisa memilih mentor yang sesuai dengan kebutuhan dan level skill kamu.' },
|
||||
{ title: 'Apakah mentoring ini cocok untuk pemula?', description: 'Yap! Baik newbie maupun seasoned dev bisa menemukan mentor yang pas untuk mempercepat progres belajarnya.' },
|
||||
{ title: 'Apakah ini seperti sekolah coding biasa?', description: 'Nope! Ini lebih dari sekadar kursus-kursus AI guide dan mentor berpengalaman, kamu mendapatkan pengalaman belajar yang jauh lebih personal dan efektif.' },
|
||||
]
|
||||
|
||||
export const FAQSection: 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="relative mx-auto py-4 px-8 overflow-x-clip md:py-8 md:px-[60px] lg:px-20 md:pb-8">
|
||||
{/* Background shapes */}
|
||||
<div className="absolute inset-0">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: isInView ? 1 : 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className={cn(
|
||||
"absolute -top-10 -right-14 size-60 bg-primary-200 rounded-full justify-center items-center hidden",
|
||||
"md:flex lg:size-[344px] lg:top-48 lg:-right-[88px]"
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: isInView ? 1 : 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
className="size-[168px] bg-primary-50 rounded-full lg:size-64"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
className="relative mb-6 flex justify-center md:mb-10"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
|
||||
>
|
||||
Frequently Ask Question
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative grid max-w-7xl mx-auto gap-y-7 divide-y-2 divide-primary-200",
|
||||
"md:grid-cols-11 md:gap-x-10 md:divide-y-0 md:divide-x-2 lg:grid-cols-7 lg:gap-x-[72px]"
|
||||
)}
|
||||
>
|
||||
<motion.h1
|
||||
className={cn(
|
||||
"pb-7 text-2xl font-semibold text-primary-500 text-center leading-none",
|
||||
"md:pe-10 md:pb-0 md:text-start md:col-span-5 lg:text-4xl lg:col-span-3 lg:pe-[72px]"
|
||||
)}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
Console log for new developers
|
||||
</motion.h1>
|
||||
|
||||
<motion.div
|
||||
className="flex flex-col gap-4 md:col-span-6 lg:col-span-4"
|
||||
variants={containerVariants}
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
>
|
||||
<For data={FAQ_DATA}>
|
||||
{(props) => (
|
||||
<motion.div key={props.title} variants={childVariants}>
|
||||
<Accordion {...props} />
|
||||
</motion.div>
|
||||
)}
|
||||
</For>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="relative w-full z-0 py-20 md:py-28 lg:py-32">
|
||||
{/* Background gradients and shapes */}
|
||||
<div className="absolute inset-0 -z-10 overflow-x-clip hidden md:block">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
className="absolute top-10 -left-64 size-[448px] bg-primary-200 rounded-full justify-center items-center hidden lg:flex"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="size-[296px] bg-primary-50 rounded-full"
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
className="absolute top-3/5 -right-56 size-96 bg-primary-500 rounded-full flex justify-center items-center lg:top-1/2 lg:-right-64 lg:size-[600px]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
viewport={{ once: true }}
|
||||
className="size-64 bg-primary-50 rounded-full lg:size-[400px]"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="absolute -top-28 -left-20 size-[248px] bg-radial from-primary-300 rounded-full blur-3xl" />
|
||||
<div className="absolute top-0 -right-72 size-[480px] bg-radial from-primary-400 rounded-full blur-[80px]" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-y-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
className="flex flex-col items-center gap-y-6"
|
||||
>
|
||||
<h1 className="text-2xl font-semibold text-primary-500 md:text-4xl md:font-bold lg:text-5xl">DIMENTORIN</h1>
|
||||
<p className="max-w-52 text-center font-semibold text-xl leading-tight text-primary-500 md:max-w-72 lg:text-4xl lg:max-w-lg">
|
||||
Learn IT smarter with AI & expert mentors!
|
||||
</p>
|
||||
<p className="max-w-3xs text-xs text-neutral-500 text-center font-medium md:max-w-lg md:text-base lg:text-lg lg:max-w-[646px]">
|
||||
Apakah kamu siap untuk grind skill TI-mu ke level Ultimate? Dengan AI sebagai navigator dan mentor profesional sebagai sensei-mu, perjalanan belajarmu akan lebih cepat, efektif, dan penuh EXP!
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.2 }} viewport={{ once: true }}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="text-[10px] h-auto px-2.5 py-2 md:text-sm"
|
||||
>
|
||||
Belajar Dengan AI Sekarang
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
||||
import { For } from '@imphnen-frontend-service/utils'
|
||||
import { FC, useRef } from 'react'
|
||||
import { motion, useInView, Variants } from 'framer-motion'
|
||||
import { StarFilled } from '@ant-design/icons'
|
||||
|
||||
export const TestimonialSection: 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="mx-auto py-4 md:py-8 xl:px-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
|
||||
>
|
||||
Testimonial
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
className="my-6 text-lg font-semibold mx-auto text-center text-primary-500 md:text-2xl lg:my-10 lg:text-4xl"
|
||||
>
|
||||
Words from our dellow devs
|
||||
</motion.h1>
|
||||
|
||||
<div className="scrollbar-hide w-full mx-auto max-w-7xl px-8 overflow-auto md:px-[60px] xl:px-0">
|
||||
<motion.div
|
||||
className="mb-2 flex gap-x-5 min-w-max w-auto md:gap-x-6 xl:min-w-full xl:max-w-7xl xl:grid xl:grid-cols-2"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
>
|
||||
<For data={Array.from({ length: 2 })}>
|
||||
{(_, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={childVariants}
|
||||
className="flex flex-col gap-6 p-6 w-52 bg-white rounded-xl shadow-md md:w-xl md:flex-row md:gap-8 xl:w-full"
|
||||
>
|
||||
<div className="w-full aspect-[8/7] rounded-lg overflow-hidden md:w-40 md:aspect-[7/6] lg:aspect-square">
|
||||
<img
|
||||
src="/image/testimonial.webp"
|
||||
alt="Testimonial"
|
||||
className="w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="mb-3 font-semibold text-primary-500 md:text-lg lg:text-2xl">
|
||||
Riko, Junior Web Developer
|
||||
</h3>
|
||||
<p className="mb-6 text-[10px] text-neutral-500 font-medium md:text-sm lg:text-base">
|
||||
Rasanya seperti punya AI waifu yang ngajarin ngoding! Mentoringnya juga super insightful. Thanks, Dimentorin!
|
||||
</p>
|
||||
<motion.div className="w-max" whileHover={{ scale: 1.1, rotate: -3 }}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
className="bg-primary-50 flex items-center gap-x-2 h-auto px-2 py-1 hover:bg-primary-50"
|
||||
>
|
||||
<StarFilled className="md:text-lg" />
|
||||
<span>
|
||||
<span className="text-sm font-medium md:text-lg md:font-semibold lg:text-xl">4.8</span>
|
||||
<span className="text-[10px] text-primary-300 md:text-sm md:font-medium lg:text-base">/5.0</span>
|
||||
</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</For>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { For } from "@imphnen-frontend-service/utils";
|
||||
import { FC, useRef } from "react";
|
||||
import { motion, useInView, Variants } from 'framer-motion'
|
||||
|
||||
const OFFERS: { title: string; description: string; image: string }[] = [
|
||||
{ title: "AI-Powered Learning", description: "Level Up Instantly!", image: "/image/what-we-offer/ai-powered-learning.webp" },
|
||||
{ title: "1-on-1 Mentoring", description: "Sensei dari Dunia Nyata!", image: "/image/what-we-offer/1-on-1-mentoring.webp" },
|
||||
{ title: "Community & Resources", description: "Connect dengan Fellow Devs!", image: "/image/what-we-offer/community.webp" },
|
||||
]
|
||||
|
||||
export const WhatWeOfferSection: 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 mx-auto px-8 pb-4 md:px-[60px] lg:px-20 lg:pt-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
className="mb-6 flex justify-center lg:mb-10"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
className="h-auto px-3 py-2 font-semibold bg-primary-100 md:text-base md:px-5 md:py-2.5 lg:text-2xl lg:px-6 lg:py-3"
|
||||
>
|
||||
Apa yang Kami Tawarkan
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="grid gap-6 max-w-7xl mx-auto md:grid-cols-3"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
>
|
||||
<For data={OFFERS}>
|
||||
{(offer) => (
|
||||
<motion.div
|
||||
key={offer.title}
|
||||
className="relative aspect-[16/10] overflow-hidden rounded-lg md:aspect-[24/35] lg:aspect-[6/7]"
|
||||
variants={childVariants}
|
||||
>
|
||||
<img src={offer.image} alt={offer.title} className="size-full object-cover" />
|
||||
<div
|
||||
className="absolute bottom-0 inset-x-0 p-4 h-1/2 text-primary-50 bg-gradient-to-t from-primary-500 via-primary-500/80 flex flex-col items-center justify-end gap-y-1 md:items-start lg:px-6 lg:pb-8"
|
||||
>
|
||||
<p className="font-semibold lg:text-[26px] lg:font-bold">{offer.title}</p>
|
||||
<p className="text-xs lg:text-[22px] lg:font-medium">{offer.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</For>
|
||||
</motion.div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { HeroSection } from './_components/hero-section';
|
||||
import { WhatWeOfferSection } from './_components/what-we-offer-section';
|
||||
import { TestimonialSection } from './_components/testimonial-section';
|
||||
import { FAQSection } from './_components/faq-section';
|
||||
import { CTASection } from './_components/cta-section';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
<WhatWeOfferSection />
|
||||
<TestimonialSection />
|
||||
<FAQSection />
|
||||
<CTASection />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Components;
|
||||
@@ -0,0 +1,157 @@
|
||||
import { DiscordOutlined, GithubOutlined, InstagramOutlined } from '@ant-design/icons';
|
||||
import { cn, For } from '@imphnen-frontend-service/utils';
|
||||
import React, { FC, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { motion, useInView, Variants } from 'framer-motion';
|
||||
import { SteamIcon } from '../../_components/icons';
|
||||
|
||||
const PAGES: { label: string; href: string }[] = [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: 'Mentoring', href: '/mentoring' },
|
||||
{ label: 'Resources', href: '/resources' },
|
||||
{ label: 'Articles', href: '/articles' },
|
||||
]
|
||||
|
||||
const COMMUNITY: { label: string; href: string; icon: React.ReactNode }[] = [
|
||||
{ label: 'Discord', href: 'https://discord.gg/imphnen', icon: <DiscordOutlined /> },
|
||||
{ label: 'Steam', href: 'https://steamcommunity.com/groups/IMPHNEN', icon: <SteamIcon /> },
|
||||
{ label: 'Github', href: 'https://github.com/IMPHNEN', icon: <GithubOutlined /> },
|
||||
{ label: 'Instagram', href: 'https://www.instagram.com/imphnen.dev', icon: <InstagramOutlined /> },
|
||||
]
|
||||
|
||||
export const Footer: FC = () => {
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, amount: 0.2 })
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
}
|
||||
|
||||
const childVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const mascotVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20, rotate: -12 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const mascotTextVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
delay: 0.4,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<footer ref={ref} className="w-full mx-auto px-8 py-4 md:px-[60px] md:pt-8 md:pb-16 lg:px-20 lg:pb-20">
|
||||
<motion.div
|
||||
className={cn(
|
||||
"relative max-w-7xl mx-auto px-10 py-8 bg-primary-500 text-primary-50 grid gap-10 rounded-lg",
|
||||
"lg:grid-cols-3 lg:rounded-xl"
|
||||
)}
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
>
|
||||
{/* Mascot */}
|
||||
<motion.div
|
||||
className="hidden absolute bottom-0 right-0 size-40 md:block lg:size-72"
|
||||
variants={mascotVariants}
|
||||
>
|
||||
<motion.img
|
||||
src="/image/mascot-character.webp"
|
||||
alt="IMPHNEN Mascot"
|
||||
className="w-full h-auto absolute inset-x-0 bottom-0"
|
||||
/>
|
||||
<motion.img
|
||||
src="/image/mascot-text.webp"
|
||||
alt="IMPHNEN Mascot"
|
||||
className="w-4/6 absolute top-0 left-0"
|
||||
variants={mascotTextVariants}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="text-center lg:text-start lg:mb-16">
|
||||
<motion.img
|
||||
src="/logos/logo.svg"
|
||||
alt="IMPHNEN Logo"
|
||||
className="w-[84px] mb-6 mx-auto md:w-32 lg:w-40 lg:ms-0"
|
||||
variants={childVariants}
|
||||
/>
|
||||
|
||||
<motion.h4 className="mb-2 font-semibold text-[15px] md:mb-2.5 md:text-xl" variants={childVariants}>
|
||||
Dimentorin by IMPHNEN
|
||||
</motion.h4>
|
||||
<motion.p className="font-medium text-xs md:text-[15px] md:max-w-[232px] md:mx-auto lg:ms-0" variants={childVariants}>
|
||||
Karena Belajar IT Itu Harusnya Se-Seru Anime Favoritmu!
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<motion.p className="relative hidden text-xs font-medium text-[15px] lg:block" variants={childVariants}>
|
||||
© IMPHNEN {new Date().getFullYear()} All Rights Reserved
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<motion.div className="relative grid gap-10 md:flex md:justify-center lg:gap-x-20" variants={childVariants}>
|
||||
<div className="text-center text-xs md:text-[15px] lg:text-start">
|
||||
<h4 className="mb-5 font-medium md:mb-7 lg:text-xl">Pages</h4>
|
||||
<ul className="font-semibold space-y-5">
|
||||
<For data={PAGES}>
|
||||
{({ label, href }) => (
|
||||
<li key={label}>
|
||||
<Link to={href}>{label}</Link>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-xs md:text-[15px] lg:text-start">
|
||||
<h4 className="mb-5 font-medium md:mb-7 lg:text-xl">Join Our Community</h4>
|
||||
<ul className="font-semibold space-y-5">
|
||||
<For data={COMMUNITY}>
|
||||
{({ label, href, icon }) => (
|
||||
<li key={label}>
|
||||
<Link to={href} target="_blank" className="flex items-center justify-center gap-x-3 lg:justify-start">
|
||||
<span className="text-primary-900">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p className="relative text-center text-xs font-medium md:text-[15px] lg:hidden" variants={childVariants}>
|
||||
© IMPHNEN {new Date().getFullYear()} All Rights Reserved
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { CloseOutlined, MenuOutlined } from "@ant-design/icons";
|
||||
import { Button } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { cn, For, Show } from "@imphnen-frontend-service/utils";
|
||||
import { motion, useMotionValueEvent, useScroll, Variants } from "framer-motion";
|
||||
import { FC, useMemo, useState } from "react";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
|
||||
const MENUS: { label: string; href: string }[] = [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: 'Mentoring', href: '/mentoring' },
|
||||
{ label: 'Resources', href: '/resources' },
|
||||
{ label: 'Articles', href: '/articles' },
|
||||
]
|
||||
|
||||
export const Header: FC = () => {
|
||||
const location = useLocation();
|
||||
const { scrollY } = useScroll()
|
||||
|
||||
const [expandMenu, setExpandMenu] = useState(false);
|
||||
const [show, setShow] = useState(true)
|
||||
|
||||
const headerVariants: Variants = {
|
||||
hidden: { opacity: 0, y: -100 },
|
||||
show: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
useMotionValueEvent(scrollY, "change", (latest) => {
|
||||
const prev = scrollY.getPrevious() || 0
|
||||
if (latest > prev && latest > 100) setShow(false)
|
||||
else setShow(true)
|
||||
})
|
||||
|
||||
const isActive = useMemo(() => {
|
||||
return (path: string) => {
|
||||
if (path === '/' && location.pathname !== '/') return false
|
||||
return location.pathname.includes(path)
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<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
|
||||
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"
|
||||
variants={headerVariants}
|
||||
animate={show ? "show" : "hidden"}
|
||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between p-4 md:px-8 md:py-2.5">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src="/logos/simple.svg"
|
||||
alt="IMPHNEN Logo"
|
||||
className="h-8 md:h-10 lg:h-[52px] w-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
"flex flex-col items-center py-2 fixed top-24 bg-white shadow-lg rounded-lg transition-all duration-300",
|
||||
"md:top-32 lg:static lg:py-0 lg:flex-row lg:bg-transparent lg:shadow-none lg:gap-x-4",
|
||||
!expandMenu ? "-right-96" : "right-8 md:right-[60px]"
|
||||
)}
|
||||
>
|
||||
<For data={MENUS}>
|
||||
{(menu) => (
|
||||
<Button
|
||||
key={menu.label}
|
||||
type="button"
|
||||
variant="text"
|
||||
className={cn(
|
||||
"px-10 py-1.5 text-neutral-300 hover:text-neutral-400 lg:px-2.5 lg:py-2",
|
||||
isActive(menu.href) && "text-primary-500 hover:text-primary-500"
|
||||
)}
|
||||
>
|
||||
<NavLink to={menu.href}>{menu.label}</NavLink>
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
<Link to="/auth/login">
|
||||
<Button type="button" className="px-12 py-1 lg:hidden">Login</Button>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<Link to="/auth/login">
|
||||
<Button type="button" className="px-5 py-2 hidden lg:block">Login</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
className={cn("transition-transform duration-300 rotate-0 lg:hidden", expandMenu && "rotate-90")}
|
||||
onClick={() => setExpandMenu(prev => !prev)}
|
||||
>
|
||||
<Show condition={!expandMenu} fallback={<CloseOutlined />}>
|
||||
<MenuOutlined />
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Header } from "./_components/header";
|
||||
import { Footer } from "./_components/footer";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-primary-50">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||