Compare commits

..
Author SHA1 Message Date
Hafid Nur d5cfed24ca fix(qr): fix empty state logic in the user management page 2026-02-17 18:47:27 +07:00
Hafid Nur f018da21c1 fix(qr): add empty state for campaign & user management page to handle unexcepted null error 2026-02-17 18:38:47 +07:00
Hafid Nur a610e180ba fix(qr): update API url to https 2026-02-14 18:29:30 +07:00
Hafid Nur ce8cb8188c fix(qr): hardcode API url 2026-02-14 18:13:28 +07:00
maulanasdqn 3aaf022393 feat(infra): update to include all apps and CI/CD flow 2026-02-14 18:03:58 +07:00
Hafid Nur ebe27a3f05 fix: update API endpoint for QR campaign app 2026-02-14 17:57:03 +07:00
maulanasdqn f846a5a785 fix: use nodejs binary instead of nodejs_22 (cached) 2026-02-14 17:43:42 +07:00
maulanasdqnandClaude Opus 4.5 708cbde596 fix: update npmDepsHash for CI build
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 17:29:40 +07:00
Hafid NurandGitHub 9d37cba65f Add a new app: QR code generator for campaign (#75)
* Initialize a new NX project - QR campaign

* Develop QR code generator app for campaign

* Add deployment script

Add Dockerfile, docker compose, and update Nix config for deployment

* Revert package.json to fix ci

* Revert package-lock.json to ensure ci (Nix build & cache) successful
2026-02-14 17:15:46 +07:00
maulanasdqnandClaude Opus 4.5 8728d0abd7 feat(landing): add Powered By Ancikri badge to hero section
Add clickable "Powered By Ancikri" badge beside the community badge
with matching pill design, linking to ancikri.com

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:58:35 +07:00
maulanasdqnandClaude Opus 4.5 1f4f4ddaa1 feat(landing): add Powered by Ancikri to footer
Add clickable "Powered by Ancikri" link in the footer
pointing to ancikri.com

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:38:19 +07:00
maulanasdqnandClaude Opus 4.5 4fcc249ff4 ci: add Nix build workflow with Cachix
- Build all packages (landing, backoffice, gacha, dimentorin, hackathon, infra)
- Push to msdqn Cachix cache
- Server can pull pre-built packages from Cachix

Requires CACHIX_AUTH_TOKEN secret in GitHub repository settings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:23:45 +07:00
maulanasdqnandClaude Opus 4.5 c2bed98b06 security: patch Next.js RCE vulnerability (CVE-2025-55182)
Upgrade Next.js from 16.0.8 to 16.0.10 to fix critical RCE
vulnerability (CVSS 10.0) in React Server Components.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:09:51 +07:00
maulanasdqn ae1864c134 chore: remove Supabase dependency completely 2026-01-21 20:52:03 +07:00
maulanasdqn 3f30fbe89c fix: make Supabase client lazy to avoid errors when credentials missing 2026-01-21 20:41:28 +07:00
maulanasdqn 951db0f28a feat: add infra app for infrastructure documentation 2026-01-21 20:22:27 +07:00
maulanasdqn ad59d39f55 fix: add react-is dependency for recharts
This is a peer dependency required for recharts to work properly
in production builds.
2026-01-21 18:54:39 +07:00
maulanasdqn 1283689d84 fix(nix): use script command to create pseudo-terminal for Nx
This provides a pty that satisfies Nx's terminal requirements,
preventing the raw terminal mode crash in sandboxed builds.
2026-01-21 18:45:45 +07:00
maulanasdqn 7d1370d7f2 fix(nix): run nx build in background to handle SIGABRT
Background process signal handling allows wait to catch SIGABRT
and || true to suppress the non-zero exit status.
2026-01-21 18:39:05 +07:00
maulanasdqn ab0b56780f fix(nix): use set +e to handle SIGABRT from Nx crash
Signal handling in bash requires set +e to prevent errexit
from terminating the script when a process is killed by SIGABRT.
2026-01-21 18:33:03 +07:00
maulanasdqn 1a39ac37db fix(nix): improve Nx crash recovery for Vite builds
Use '|| true' to always continue past Nx terminal crash,
then verify build output directory exists separately.
2026-01-21 18:25:53 +07:00
maulanasdqnandClaude Opus 4.5 e5dc784301 fix(nix): handle Nx terminal crash for Vite apps
Add fallback check to continue if build output exists even when
Nx crashes after successful build completion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:14:52 +07:00
maulanasdqnandClaude Opus 4.5 b2de22b0ef fix(nix): add --jitless flag to workaround V8 memory issue
Add --jitless Node.js flag to work around V8 JIT compilation
memory allocation issues on the server.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:16:51 +07:00
maulanasdqnandClaude Opus 4.5 ad20eda182 fix(nix): use proper bash path in wrapper script
Fix wrapper script to use explicit Nix bash path instead of
/usr/bin/env bash which doesn't work on NixOS.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:11:39 +07:00
maulanasdqnandClaude Opus 4.5 da65aa4109 chore(nix): update npmDepsHash for local fonts
Update hash after adding font files to the repository.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:02:14 +07:00
maulanasdqnandClaude Opus 4.5 1f0798752c feat(landing): use local fonts instead of Google Fonts
- Download Poppins and Bai Jamjuree font files locally
- Convert from next/font/google to next/font/local
- Enables builds in sandboxed environments without network access

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:55:26 +07:00
maulanasdqnandClaude Opus 4.5 5f28b9b406 fix(nix): allow network access for Google Fonts during build
Add __noChroot=true to enable network access for next/font/google
to fetch Poppins and Bai_Jamjuree fonts during the Next.js build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:44:07 +07:00
maulanasdqnandClaude Opus 4.5 9e8fc7c07f fix(nix): use --output-style=static to bypass Nx terminal crash
Call Nx directly with --output-style=static flag instead of through
npm scripts to completely bypass the pseudo terminal initialization
that crashes in non-TTY environments.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:34:39 +07:00
maulanasdqnandClaude Opus 4.5 ca51f8aca2 fix(nix): disable Nx native terminal to prevent build crash
Add NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false and NX_NATIVE=false
to completely disable Nx's Rust-based terminal handling that
crashes in non-TTY build environments.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:25:27 +07:00
maulanasdqnandClaude Opus 4.5 c086b1371c fix(nix): add CI environment flags to prevent terminal rendering crash
Added CI=true, NO_COLOR=1, TERM=dumb, and NX_SKIP_NX_CACHE=true
to both mkViteApp.nix and landing.nix to prevent the vt100-rust
panic during Nix builds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:57:17 +07:00
maulanasdqn 398350795d fix: add legacy-peer-deps and makeCacheWritable for npm builds 2026-01-21 14:26:13 +07:00
maulanasdqnandClaude Opus 4.5 5be9ec1edf feat: add Nix flake for reproducible builds and deployment
- Add flake.nix with packages for all 5 apps (landing, gacha, backoffice, dimentorin, hackathon)
- Add NixOS modules for deployment (systemd service for Next.js, nginx for Vite apps)
- Add mkHackathonWithEnv function for build-time environment variables
- Add development shell with Node.js 22, npm, bun

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:02:46 +07:00
Hafid NurandGitHub f77967a092 feat(hackathon): update certificate to include project submission name (#74)
* update hackathon landing page

* feat(hackathon): draft winner certificate

* feat(hackathon): refine winner certificate

* feat(hackathon): update certificate

- add submission project name to the certificate
2025-12-16 13:58:43 +07:00
Hafid NurandGitHub fe172cc128 feat(hackathon): winner certificate (#73)
* update hackathon landing page

* feat(hackathon): draft winner certificate

* feat(hackathon): refine winner certificate
2025-12-16 12:33:23 +07:00
Hafid NurandGitHub 38ee9a9549 update hackathon landing page (#72) 2025-12-15 14:43:11 +07:00
Maulana SodiqinandClaude 3ad828e759 feat: update certificate design with SVG template and Poppins font
- Changed certificate background from PNG to SVG for better quality
- Added Poppins font import to index.html
- Updated certificate layout with Team Name and Person Name in blue (#59bef5)
- Repositioned QR code to fit in designated white box placeholder
- Removed generated copyright and date overlays (now part of SVG template)
- Adjusted text positioning for better alignment with new design
- Increased canvas scale to 4x and wait time to 1500ms for sharper rendering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 23:41:56 +07:00
Maulana SodiqinandClaude 782cce820b feat: hide certificate buttons for non-team members
- Added isTeamMember check (session user ID === certificate user ID)
- Hide all action buttons (Download, Print, View Submission) for non-members
- Hide "Back to Submission" button in header for non-members
- Unauthenticated users and other users can view certificate but cannot interact with buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 00:48:21 +07:00
Maulana SodiqinandClaude f5882e3cdc feat: integrate new certificate endpoint from backend
- Updated certificate page to use useCertificatePublicData hook
- Removed old multi-API-call approach
- Now uses single /certificates/{userId} endpoint
- Simplified data fetching for user, team, submission data
- Certificate now works for unauthenticated users
- Removed duplicate certificate hook file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 00:16:29 +07:00
Maulana SodiqinandClaude afaaff4a7f feat(service): add public certificate data hook
Add new useCertificatePublicData hook to fetch certificate data from public endpoint without authentication. This hook combines user, team, submission, and winner data in a single request.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 00:02:31 +07:00
Maulana SodiqinandClaude da8b08cd73 fix: allow unauthenticated users to view certificates
- Updated API error interceptor to skip redirect on certificate pages
- Prevents 401 errors from redirecting to login on public certificate pages
- Certificate pages can now be viewed without authentication

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 22:01:37 +07:00
Maulana SodiqinandClaude 1dbe704230 feat: implement persistent personalized certificates
- Modified encodeCertificateId to include userId parameter
- Updated decodeCertificateId to extract and return userId
- Added backward compatibility for old 2-part certificate IDs
- Certificate page now fetches and displays certificate owner's name
- Submission page passes current user's ID when generating certificate
- Fixed certificate template to use certificate owner's name instead of team leader
- Certificates now show the encoded user's name regardless of who views them

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 21:40:34 +07:00
Maulana SodiqinandClaude 59c1bb0366 feat: make certificate page publicly accessible
- Add /certificate/* to public routes in root layout
- Display team leader name instead of current user
- Remove authentication requirement for viewing certificates
- Certificates now work without login

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 21:30:28 +07:00
Maulana SodiqinandClaude c518784810 fix: encode certificate ID in QR code URL
- Use encodeURIComponent to properly encode certId
- Fixes QR code URLs with special characters like /, +, =
- QR codes now generate with properly encoded URLs (%2F, %2B, %3D)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 21:23:35 +07:00
Maulana SodiqinandGitHub 0267de664b Delete .github/workflows/deploy-landing.yml 2025-12-10 12:28:05 +07:00
Maulana SodiqinandGitHub 134ff14946 Delete .github/workflows/deploy-hackathon.yml 2025-12-10 12:27:55 +07:00
Maulana SodiqinandGitHub ed3b711c89 Delete .github/workflows/deploy-gacha.yml 2025-12-10 12:27:44 +07:00
Maulana SodiqinandGitHub 6b7b1c4d20 Delete .github/workflows/deploy-dimentorin.yml 2025-12-10 12:27:31 +07:00
Maulana SodiqinandGitHub b4b19e02fa Delete .github/workflows/deploy-backoffice.yml 2025-12-10 12:27:20 +07:00
DentaandGitHub e61979cbb6 chore(landing): adjust landing page (#71)
* chore: adjust landing page
- change typo text in footer "patners" -> "partnerts"
- add github in community section, and footer
- update the value of stat members in hero and cta section

* chore: adjust landing page
- fix logo github in community section
2025-12-10 11:22:37 +07:00
Hafid NurandGitHub 0c7887268c feat(backoffice): FE integration (data fetch) for dashboard, teams, submission, and user management page (#70)
* feat(backoffice): create a boilerplate page for Hackathon dashboard

- Create an empty page for Hackathon dashboard
- Comment out and hide the existing backoffice sidebar

* feat(backoffice): Create a nested/dropdown sidebar list

- Create a dropdown sidebar list
- Show back the old navigation and group them
- Make the sidebar responsive for mobile view

* test datatable with mock data

* base UI for Hackathon backoffice

TODO:
- Organize table schema for users, teams, and submissions management
- Create API Contract for additional back-end endpoint

* feat(hackathon): draft data table column & API Contract

* update endpoint

* feat(backoffice): update page hackathon user management

- update data table component
- update filtering & pagination
- add modal display to edit and add user
- hide notification icon in backoffice wrapper

* feat(backoffice): add API contract for hackathon users

* feat(backoffice): little adjustment in hackathon users management UI and API contract

* feat(backoffice): update hackathon team management page

- add modal for manage team, add new team, and view project submission
- reorganize the table column and data table

* feat(backoffice): add searchable city filter

- add component for city filter
- apply to user management and team management pages

* feat(backoffice): improve hackathon team modal UI and add API contract

- Add feature to select city in team detail modal using CityFilterSelect component
- Add feature to change team logo and banner
- Add API contract documentation for hackathon teams in backoffice

* feat(backoffice): authentication middleware, error pages, and 404 page

* feat(backoffice): hackathon dashboard integration

* feat(backoffice): users page integration

- Get users data from API
- Set up server-side pagination and match URL params
- Hide filter that doesn't exist in back-end

* feat(backoffice): teams page integration

- Get teams data from API
- Hide filter that doesn't exists in back-end
- Simplify modal according to the back-end

* feat(backoffice): submission page integration

- Fetch submissions data from API
- Move submission modal to hackathon-submissions page
2025-12-10 08:41:30 +07:00
Maulana Sodiqin 76933c0be7 chore: upgrade react version 2025-12-09 16:28:32 +07:00
111 changed files with 9244 additions and 647 deletions
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e
if [[ ! -d "/Users/ms/Development/personal/imphnen-frontend-service" ]]; then
echo "Cannot find source directory; Did you move it?"
echo "(Looking for "/Users/ms/Development/personal/imphnen-frontend-service")"
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
exit 1
fi
# rebuild the cache forcefully
_nix_direnv_force_reload=1 direnv exec "/Users/ms/Development/personal/imphnen-frontend-service" true
# Update the mtime for .envrc.
# This will cause direnv to reload again - but without re-building.
touch "/Users/ms/Development/personal/imphnen-frontend-service/.envrc"
# Also update the timestamp of whatever profile_rc we have.
# This makes sure that we know we are up to date.
touch -r "/Users/ms/Development/personal/imphnen-frontend-service/.envrc" "/Users/ms/Development/personal/imphnen-frontend-service/.direnv"/*.rc
@@ -0,0 +1 @@
/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
@@ -0,0 +1 @@
/nix/store/8hx8jwa24q6rkzhca9iqy5ckk9rbphi1-source
@@ -0,0 +1 @@
/nix/store/jizz7ld5r2w11y03wrmnvwsskc45krj9-source
@@ -0,0 +1 @@
/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
@@ -0,0 +1 @@
/nix/store/y4p9szcab9xd7v1c7k9ghr86hb6myyg4-nix-shell-env
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
VITE_SUPABASE_URL=https://your-project-id.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
+1
View File
@@ -0,0 +1 @@
use flake
-44
View File
@@ -1,44 +0,0 @@
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
-44
View File
@@ -1,44 +0,0 @@
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
-44
View File
@@ -1,44 +0,0 @@
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
-45
View File
@@ -1,45 +0,0 @@
name: Deploy Hackathon
on:
push:
branches:
- develop
paths:
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
else
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
docker builder prune -af || true
DOCKER_BUILDKIT=1 docker compose --env-file ~/imphnen-frontend-service-hackathon/.env.local -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
docker image prune -af
-44
View File
@@ -1,44 +0,0 @@
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
+40
View File
@@ -0,0 +1,40 @@
name: Nix Build & Cache
on:
push:
branches: ['develop']
pull_request:
branches: ['develop']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Setup Cachix
uses: cachix/cachix-action@v15
with:
name: msdqn
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: Build all packages
run: |
nix build .#landing -o result-landing
nix build .#backoffice -o result-backoffice
nix build .#gacha -o result-gacha
nix build .#dimentorin -o result-dimentorin
nix build .#hackathon -o result-hackathon
nix build .#infra -o result-infra
- name: Show build outputs
run: |
echo "Landing: $(readlink result-landing)"
echo "Backoffice: $(readlink result-backoffice)"
echo "Gacha: $(readlink result-gacha)"
echo "Dimentorin: $(readlink result-dimentorin)"
echo "Hackathon: $(readlink result-hackathon)"
echo "Infra: $(readlink result-infra)"
+13
View File
@@ -10,6 +10,7 @@ This repository is a **monorepo** for all frontend services of IMPHNEN. The mono
2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
5. **QR Campaign** - Application for QR Campaign Management.
## How to install
@@ -54,6 +55,10 @@ Use the following commands to run in development mode:
```sh
npm run landing:dev
```
- **QR Campaign**:
```sh
npm run qrcampaign:dev
```
### Build
@@ -75,6 +80,10 @@ Use the following commands to build the applications:
```sh
npm run landing:build
```
- **QR Campaign**:
```sh
npm run qrcampaign:build
```
### Production
@@ -96,6 +105,10 @@ Use the following commands to run the applications in production mode:
```sh
npm run landing:prod
```
- **QR Campaign**:
```sh
npm run qrcampaign:prod
```
### Storybook
+3
View File
@@ -18,6 +18,9 @@
type="image/svg+xml"
href="/images/imphnen-logo-simple.svg"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/index.css" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://hackathon.imphnen.dev/" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 263 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 506 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 818 KiB

@@ -3,8 +3,7 @@ import { useParams, useNavigate } from 'react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { decodeCertificateId } from '../../../utils/certificate';
import {
useTeamById,
useTeamSubmission,
useCertificatePublicData,
useAuthStore,
} from '@imphnen-frontend-service/service';
import QRCode from 'qrcode';
@@ -13,6 +12,7 @@ import html2canvas from 'html2canvas';
interface DecodedCert {
teamId: string;
submissionId: string;
userId: string;
}
const CertificatePage: FC = (): ReactElement => {
@@ -41,10 +41,16 @@ const CertificatePage: FC = (): ReactElement => {
}
}, [certId]);
// Fetch certificate data using the new endpoint
const { data: certificateData, isLoading: isLoadingCertificate } =
useCertificatePublicData(decodedInfo?.userId || '', !!decodedInfo?.userId);
// Generate QR Code
useEffect(() => {
if (certId) {
const certificateUrl = `${window.location.origin}/certificate/${certId}`;
// Use encodeURIComponent to properly encode the certId for the URL
const encodedCertId = encodeURIComponent(certId);
const certificateUrl = `${window.location.origin}/certificate/${encodedCertId}`;
QRCode.toDataURL(certificateUrl, {
width: 200,
margin: 1,
@@ -58,18 +64,18 @@ const CertificatePage: FC = (): ReactElement => {
}
}, [certId]);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
decodedInfo?.teamId || '',
!!decodedInfo?.teamId
);
const { data: submissionData, isLoading: isLoadingSubmission } =
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
const certificate = certificateData?.data;
const team = certificate?.team;
const submission = certificate?.submission;
const certificateUser = certificate?.user;
const team = teamData?.data;
const submission = submissionData?.data;
const isLoading = (!decodedInfo && !error) || isLoadingCertificate;
const isLoading =
(!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission;
// Certificate name from the user data
const certificateName = certificateUser?.fullname;
// Check if current user is viewing their own certificate (team member)
const isTeamMember = session?.user?.id === decodedInfo?.userId;
// Dynamic font sizing: shrink by 2px if height exceeds 80px
useEffect(() => {
@@ -98,7 +104,7 @@ const CertificatePage: FC = (): ReactElement => {
}, 0);
return () => clearTimeout(timer);
}, [team?.name, session?.user?.fullname]);
}, [team?.name, certificateName]);
// Generate certificate canvas screenshot
useEffect(() => {
@@ -107,19 +113,22 @@ const CertificatePage: FC = (): ReactElement => {
setIsGenerating(true);
try {
// Wait a bit for fonts and images to load
await new Promise((resolve) => setTimeout(resolve, 500));
// Wait longer for fonts and images to load properly
await new Promise((resolve) => setTimeout(resolve, 1500));
const canvas = await html2canvas(certificateRef.current, {
scale: 2,
scale: 4,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
width: 1000,
height: (1000 * 2480) / 3508,
allowTaint: true,
imageTimeout: 0,
removeContainer: true,
});
const imageUrl = canvas.toDataURL('image/png');
const imageUrl = canvas.toDataURL('image/png', 1.0);
setCertificateImage(imageUrl);
setShowTemplate(false);
} catch (error) {
@@ -130,7 +139,7 @@ const CertificatePage: FC = (): ReactElement => {
};
generateCertificate();
}, [team, submission, qrCodeUrl, session?.user?.fullname]);
}, [team, submission, qrCodeUrl]);
// Download certificate
const handleDownloadCertificate = () => {
@@ -202,7 +211,7 @@ const CertificatePage: FC = (): ReactElement => {
);
}
if (!submission || submission.id !== decodedInfo?.submissionId) {
if (!certificateUser) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-6xl mb-4">📄</div>
@@ -210,7 +219,7 @@ const CertificatePage: FC = (): ReactElement => {
Certificate Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
The submission associated with this certificate could not be found.
The user associated with this certificate could not be found.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
@@ -278,14 +287,14 @@ const CertificatePage: FC = (): ReactElement => {
{team?.name}
</p>
</div>
<Button
variant="secondary"
onClick={() =>
navigate(`/teams/${decodedInfo?.teamId}/submission`)
}
>
Back to Submission
</Button>
{team && isTeamMember && (
<Button
variant="secondary"
onClick={() => navigate(`/teams/${team.id}/submission`)}
>
Back to Submission
</Button>
)}
</div>
</div>
</div>
@@ -305,57 +314,30 @@ const CertificatePage: FC = (): ReactElement => {
id="certificate-template"
style={{
position: 'relative',
backgroundImage: 'url(/images/blank_cert.png)',
backgroundImage: 'url(/images/blank_cert.svg)',
backgroundSize: 'cover',
backgroundPosition: 'center',
width: '1000px',
height: `${(1000 * 2480) / 3508}px`,
}}
>
{/* User Name (from session) */}
{/* Team Name - positioned in middle between "Diberikan Kepada" and "Telah Berpartisipasi" */}
<div
style={{
position: 'absolute',
top: '40%',
left: '50%',
transform: 'translateX(-50%)',
width: '80%',
}}
>
<h3
ref={userNameRef}
style={{
fontWeight: 'bold',
color: '#111827',
textAlign: 'center',
fontSize: userNameFontSize,
lineHeight: '1.2',
wordBreak: 'break-word',
textShadow: '0 1px 2px rgba(0,0,0,0.1)',
margin: 0,
}}
>
{session?.user?.fullname || 'N/A'}
</h3>
</div>
{/* Team Name */}
<div
style={{
position: 'absolute',
top: '46%',
left: '50%',
transform: 'translateX(-50%)',
width: '70%',
top: '41%',
left: '3.5%',
width: '55%',
}}
>
<h3
ref={teamNameRef}
style={{
fontWeight: '600',
color: '#1f2937',
textAlign: 'center',
fontSize: teamNameFontSize,
fontFamily: 'Poppins, sans-serif',
fontWeight: 'bold',
color: '#59bef5',
textAlign: 'left',
fontSize: '32px',
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
@@ -365,84 +347,53 @@ const CertificatePage: FC = (): ReactElement => {
</h3>
</div>
{/* Participation Text */}
{/* User Name - positioned below team name */}
<div
style={{
position: 'absolute',
top: '60%',
left: '50%',
transform: 'translateX(-50%)',
width: '70%',
top: '45%',
left: '3.5%',
width: '55%',
}}
>
<p
<h3
ref={userNameRef}
style={{
textAlign: 'center',
color: '#374151',
fontSize: '18px',
fontWeight: '500',
fontFamily: 'Poppins, sans-serif',
fontWeight: 'bold',
color: '#59bef5',
textAlign: 'left',
fontSize: '40px',
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
}}
>
<span style={{ fontWeight: 'bold' }}>
Peserta Hackathon IMPHNEN x KOLOSAL AI
</span>
</p>
{certificateName || 'N/A'}
</h3>
</div>
{/* QR Code */}
{/* QR Code - positioned in the white box area */}
<div
style={{
position: 'absolute',
bottom: '8%',
left: '8%',
top: '33%',
right: '9.3%',
width: '190px',
height: '190px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{qrCodeUrl && (
<div
style={{
backgroundColor: '#ffffff',
padding: '8px',
borderRadius: '4px',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
}}
>
<img
src={qrCodeUrl}
alt="Certificate QR Code"
style={{ width: '96px', height: '96px' }}
/>
</div>
<img
src={qrCodeUrl}
alt="Certificate QR Code"
style={{ width: '190px', height: '190px', display: 'block' }}
/>
)}
</div>
{/* Date */}
<div
style={{
position: 'absolute',
bottom: '8%',
right: '8%',
}}
>
<p
style={{
fontSize: '14px',
color: '#374151',
margin: 0,
}}
>
{submission.submitted_at
? new Date(submission.submitted_at).toLocaleDateString(
'id-ID',
{
day: 'numeric',
month: 'long',
year: 'numeric',
}
)
: 'N/A'}
</p>
</div>
</div>
</div>
@@ -469,33 +420,35 @@ const CertificatePage: FC = (): ReactElement => {
)}
{/* Actions */}
<div className="bg-gray-50 dark:bg-gray-800 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
<Button
variant="secondary"
onClick={handleDownloadCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? '⏳ Generating...' : '📥 Download'}
</Button>
<Button
variant="secondary"
onClick={handlePrintCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
</Button>
<Button
onClick={() =>
navigate(`/teams/${decodedInfo?.teamId}/submission`)
}
variant="secondary"
className="col-span-2 flex items-center gap-2 xl:col-span-1"
>
View Submission
</Button>
</div>
{isTeamMember && (
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
<Button
variant="secondary"
onClick={handleDownloadCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? '⏳ Generating...' : '📥 Download'}
</Button>
<Button
variant="secondary"
onClick={handlePrintCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
</Button>
{team && (
<Button
onClick={() => navigate(`/teams/${team.id}/submission`)}
variant="secondary"
className="col-span-2 flex items-center gap-2 xl:col-span-1"
>
View Submission
</Button>
)}
</div>
)}
</div>
{/* Info Box */}
@@ -0,0 +1,605 @@
import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import { decodeWinnerCertificateId } from '../../../../utils/certificate';
import {
useAuthStore,
useMyTeams,
useTeamById,
useTeamSubmission,
useWinners,
} from '@imphnen-frontend-service/service';
import QRCode from 'qrcode';
import html2canvas from 'html2canvas';
type WinnerEntry = {
team_id: string;
rank: number;
team?: {
id: string;
name: string;
};
};
const formatOrdinalRank = (rank: number): string => {
const mod100 = rank % 100;
if (mod100 >= 11 && mod100 <= 13) return `${rank}th`;
switch (rank % 10) {
case 1:
return `${rank}st`;
case 2:
return `${rank}nd`;
case 3:
return `${rank}rd`;
default:
return `${rank}th`;
}
};
// Keep the template size aligned with the SVG native size (842x595) using an integer multiplier.
// This reduces sub-pixel scaling artifacts (blur) on thin lines when rasterizing with html2canvas.
const CERT_WIDTH = 842 * 2;
const CERT_HEIGHT = 595 * 2;
// Balance between output sharpness and file size.
// Output resolution will be (CERT_WIDTH * EXPORT_SCALE) x (CERT_HEIGHT * EXPORT_SCALE).
const EXPORT_SCALE = 2;
// The original layout was tuned around a ~1000px-wide template.
// We keep the same visual proportions by scaling fixed pixel values.
const LAYOUT_BASE_WIDTH = 1000;
const LAYOUT_SCALE = CERT_WIDTH / LAYOUT_BASE_WIDTH;
const s = (px: number) => Math.round(px * LAYOUT_SCALE);
const CertificateWinnerPage: FC = (): ReactElement => {
const { certId } = useParams<{ certId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const { data: myTeamsData } = useMyTeams();
const {
data: winnersResponse,
isLoading: isLoadingWinners,
isError: isWinnersError,
} = useWinners();
const [decodedTeamId, setDecodedTeamId] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const certificateRef = useRef<HTMLDivElement>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [certificateImage, setCertificateImage] = useState<string>('');
const [showTemplate, setShowTemplate] = useState(true);
useEffect(() => {
if (!certId) return;
decodeWinnerCertificateId(certId)
.then((decoded) => setDecodedTeamId(decoded.teamId))
.catch(() => setError('Invalid certificate ID'));
}, [certId]);
const winners = useMemo(
() => (winnersResponse?.data || []) as WinnerEntry[],
[winnersResponse?.data]
);
const winnerEntry = useMemo(() => {
if (!decodedTeamId) return undefined;
return winners.find((w) => w.team_id === decodedTeamId);
}, [decodedTeamId, winners]);
const rankLabel = useMemo(() => {
if (!winnerEntry?.rank) return '';
return formatOrdinalRank(winnerEntry.rank);
}, [winnerEntry?.rank]);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
decodedTeamId,
!!decodedTeamId
);
const { data: submissionData, isLoading: isLoadingSubmission } =
useTeamSubmission(decodedTeamId, !!decodedTeamId);
const team = teamData?.data;
const submission = submissionData?.data;
const submissionName = submission?.project_name || '(Submission unavailable)';
const memberNames = useMemo(() => {
const members = team?.members || [];
return members
.map((m) => m.user?.fullname)
.filter((name): name is string => !!name);
}, [team?.members]);
const isWinnerTeam = !!winnerEntry;
const isTeamMember =
!!session?.user?.id &&
!!decodedTeamId &&
(myTeamsData?.data || []).some(
(t) => (t as { id?: string } | null | undefined)?.id === decodedTeamId
);
// Generate QR Code (public link)
useEffect(() => {
if (!certId) return;
const encodedCertId = encodeURIComponent(certId);
const certificateUrl = `${window.location.origin}/certificate/winner/${encodedCertId}`;
QRCode.toDataURL(certificateUrl, {
width: s(200),
margin: 1,
color: {
dark: '#000000',
light: '#ffffff',
},
})
.then(setQrCodeUrl)
.catch((err) => console.error('QR Code generation failed:', err));
}, [certId]);
// Generate certificate canvas screenshot
useEffect(() => {
const generateCertificate = async () => {
if (!certificateRef.current) return;
if (!team?.name) return;
if (!qrCodeUrl) return;
if (!winnerEntry?.rank) return;
if (isLoadingSubmission) return;
setIsGenerating(true);
try {
setShowTemplate(true);
await new Promise((resolve) => setTimeout(resolve, 1500));
const canvas = await html2canvas(certificateRef.current, {
scale: EXPORT_SCALE,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
width: CERT_WIDTH,
height: CERT_HEIGHT,
allowTaint: true,
imageTimeout: 0,
removeContainer: true,
});
const imageUrl = canvas.toDataURL('image/png', 1.0);
setCertificateImage(imageUrl);
setShowTemplate(false);
} catch (e) {
console.error('Failed to generate certificate:', e);
} finally {
setIsGenerating(false);
}
};
generateCertificate();
}, [
team?.name,
memberNames,
qrCodeUrl,
winnerEntry?.rank,
isLoadingSubmission,
submissionName,
]);
const handleDownloadCertificate = () => {
if (!certificateImage) return;
const link = document.createElement('a');
link.href = certificateImage;
link.download = `winner-certificate-${team?.name || 'hackathon'}.png`;
link.click();
};
const handlePrintCertificate = () => {
if (!certificateImage) return;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
printWindow.document.write(`
<html>
<head>
<title>Certificate - ${team?.name}</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
img { max-width: 100%; height: auto; }
@media print {
@page { size: A4 landscape; margin: 0; }
body { margin: 0; }
img { width: 100%; height: auto; }
}
</style>
</head>
<body>
<img src="${certificateImage}" />
</body>
</html>
`);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
};
if (error || !certId) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Invalid Certificate
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{error || 'The certificate ID is invalid or malformed.'}
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (!decodedTeamId || isLoadingTeam) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Loading certificate...
</div>
</div>
</div>
);
}
if (isLoadingWinners) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Loading winners...
</div>
</div>
</div>
);
}
if (isWinnersError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Unable to Load Winners
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Please try again later.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (!isWinnerTeam) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Certificate Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
This team is not listed as a hackathon winner.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (!team?.name) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Team Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
The team associated with this certificate could not be loaded.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Winner Certificate
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{team.name}
</p>
</div>
{isTeamMember && (
<Button
variant="secondary"
onClick={() => navigate('/dashboard')}
>
Back to Dashboard
</Button>
)}
</div>
</div>
</div>
<div
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
id="certificate-wrapper"
>
{/* Hidden Template for Canvas Generation */}
<div
className={showTemplate ? 'block' : 'hidden'}
style={{ position: 'absolute', left: '-9999px' }}
>
<div
ref={certificateRef}
id="certificate-template"
style={{
position: 'relative',
backgroundImage: 'url(/images/blank_winner_cert.svg)',
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
width: `${CERT_WIDTH}px`,
height: `${CERT_HEIGHT}px`,
}}
>
<style>{`
#winner-members li::marker {
color: #59bef5;
}
`}</style>
{/* Team Name */}
<div
style={{
position: 'absolute',
top: '35%',
left: '3.5%',
width: '55%',
}}
>
<h3
style={{
fontFamily: 'Poppins, sans-serif',
fontWeight: 'bold',
color: '#59bef5',
textAlign: 'left',
fontSize: `${s(28)}px`,
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
}}
>
{team.name}
</h3>
</div>
{/* Members list */}
<div
style={{
position: 'absolute',
top: '40.5%',
left: '3.5%',
width: '55%',
}}
>
<ul
id="winner-members"
style={{
margin: 0,
fontFamily: 'Poppins, sans-serif',
fontSize: `${s(18)}px`,
lineHeight: '1.35',
color: '#59bef5',
}}
>
{(memberNames.length
? memberNames
: ['(Members unavailable)']
).map((name) => (
<li key={name}> {name}</li>
))}
</ul>
</div>
{/* Award text */}
<div
style={{
position: 'absolute',
top: '60%',
left: '3.5%',
width: '60%',
}}
>
<p
style={{
margin: 0,
fontFamily: 'Poppins, sans-serif',
fontSize: `${s(18)}px`,
lineHeight: '1.35',
color: '#6B6B6B',
}}
>
Diberikan sebagai penghargaan atas pencapaian meraih
<br />
<b>JUARA {winnerEntry.rank}</b> pada Hackathon IMPHNEN x
Kolosal.ai
<br />
<span>
dengan nama project: <b>{submissionName}</b>
</span>
</p>
</div>
{/* Rank badge */}
{!!rankLabel && (
<div
style={{
position: 'absolute',
top: '8%',
right: '8.5%',
width: `${s(190)}px`,
display: 'flex',
justifyContent: 'center',
}}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: `${s(6)}px ${s(16)}px`,
textAlign: 'center',
fontFamily: 'Poppins, sans-serif',
fontWeight: 700,
color: '#78350F',
fontSize: `${s(24)}px`,
lineHeight: '1',
}}
>
{rankLabel}
</div>
</div>
)}
{/* QR Code (same placement as existing certificate page) */}
<div
style={{
position: 'absolute',
top: '33%',
right: '9.3%',
width: `${s(190)}px`,
height: `${s(190)}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{qrCodeUrl && (
<img
src={qrCodeUrl}
alt="Certificate QR Code"
style={{
width: `${s(190)}px`,
height: `${s(190)}px`,
display: 'block',
}}
/>
)}
</div>
</div>
</div>
{/* Display Certificate Image */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
{isGenerating && (
<div className="flex items-center justify-center p-12">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Generating certificate...
</div>
</div>
</div>
)}
{certificateImage && !isGenerating && (
<img
src={certificateImage}
alt="Winner Certificate"
className="w-full h-auto"
style={{ maxWidth: '100%', height: 'auto' }}
/>
)}
{/* Actions */}
{isTeamMember && (
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
<Button
variant="secondary"
onClick={handleDownloadCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? (
<>
<Icon
icon="svg-spinners:ring-resize"
width="18"
height="18"
/>
Generating...
</>
) : (
<>
<Icon
icon="heroicons:arrow-down-tray"
width="18"
height="18"
/>
Download
</>
)}
</Button>
<Button
variant="secondary"
onClick={handlePrintCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? (
<>
<Icon
icon="svg-spinners:ring-resize"
width="18"
height="18"
/>
Generating...
</>
) : (
<>
<Icon icon="mdi:printer" width="18" height="18" />
Print
</>
)}
</Button>
<Button
onClick={() => navigate('/dashboard')}
variant="secondary"
className="col-span-2 flex items-center gap-2 xl:col-span-1"
>
Back to Dashboard
</Button>
</div>
)}
</div>
{/* Info Box */}
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
Certificate Information
</h3>
<p className="text-sm text-blue-800 dark:text-blue-200">
This certificate is a digital record of your hackathon achievement.
</p>
</div>
</div>
</div>
);
};
export default CertificateWinnerPage;
+67 -7
View File
@@ -1,15 +1,17 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { Link } from 'react-router';
import { FC, ReactElement, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import {
useMyTeams,
useMyInvitations,
useRespondToInvitation,
useAuthStore,
useWinners,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import ProfilePage from '../profile/page';
import { encodeWinnerCertificateId } from '../../utils/certificate';
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
@@ -39,6 +41,7 @@ type Invitation = {
const DashboardPage: FC = (): ReactElement => {
const { session } = useAuthStore();
const navigate = useNavigate();
const [showProfileModal, setShowProfileModal] = useState(false);
const [timeLeft, setTimeLeft] = useState<{
days: number;
@@ -91,6 +94,7 @@ const DashboardPage: FC = (): ReactElement => {
}
}, [showProfileModal]);
const { data: teamsData } = useMyTeams();
const { data: winnersResponse } = useWinners();
const { data: invitationsData } = useMyInvitations();
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
@@ -99,6 +103,13 @@ const DashboardPage: FC = (): ReactElement => {
const invitations: Invitation[] = (invitationsData?.data ||
[]) as Invitation[];
const winnerEntry = useMemo(() => {
const team = (myTeams[0] as { id?: string } | null | undefined) || null;
const winners = winnersResponse?.data || [];
if (!team?.id) return null;
return winners.find((w) => w.team_id === team.id) || null;
}, [myTeams, winnersResponse?.data]);
const handleAcceptInvitation = async (invitationId: string) => {
try {
await respondToInvitation({ invitationId, action: 'accept' });
@@ -134,6 +145,37 @@ const DashboardPage: FC = (): ReactElement => {
)}
</div>
{/* Winner Banner */}
{winnerEntry && myTeams.length > 0 && (
<div className="mb-8 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center space-x-3 flex-1 min-w-0">
<span className="text-4xl shrink-0">🏆</span>
<div className="min-w-0">
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
Selamat! Tim Anda meraih JUARA {winnerEntry.rank}
</h3>
<p className="text-amber-700 dark:text-amber-300 text-sm">
Anda dapat generate sertifikat penghargaan dan
membagikannya.
</p>
</div>
</div>
<button
onClick={async () => {
const team = myTeams[0] as { id?: string } | null | undefined;
if (!team?.id) return;
const certId = await encodeWinnerCertificateId(team.id);
navigate(`/certificate/winner/${encodeURIComponent(certId)}`);
}}
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors cursor-pointer"
>
Generate Sertifikat Juara
</button>
</div>
</div>
)}
{/* Countdown Timer */}
{timeLeft && !isSubmissionDeadlinePassed && (
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6">
@@ -194,7 +236,8 @@ const DashboardPage: FC = (): ReactElement => {
<div className="mb-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-700 dark:text-amber-300 flex items-center gap-2">
<Icon icon="mdi:clock-alert" className="text-lg shrink-0" />
Team features are closed. You can no longer accept invitations.
Team features are closed. You can no longer accept
invitations.
</p>
</div>
)}
@@ -263,7 +306,10 @@ const DashboardPage: FC = (): ReactElement => {
/>
) : (
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
<Icon icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-2xl" />
<Icon
icon="mdi:account-group"
className="text-gray-500 dark:text-gray-400 text-2xl"
/>
</div>
)}
<div className="flex-1 min-w-0">
@@ -273,19 +319,33 @@ const DashboardPage: FC = (): ReactElement => {
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
{team.has_submission && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
<Icon icon="mdi:check-circle" className="text-sm" />
<Icon
icon="mdi:check-circle"
className="text-sm"
/>
Submitted
</span>
)}
{team.city && (
<span className="flex items-center gap-1 truncate">
<Icon icon="mdi:map-marker" className="shrink-0" />
<Icon
icon="mdi:map-marker"
className="shrink-0"
/>
<span className="truncate">{team.city}</span>
</span>
)}
<span className="flex items-center gap-1 shrink-0">
<Icon icon="mdi:account-group" />
{team.member_count || team.members?.length || 0} member{(team.member_count || team.members?.length || 0) !== 1 ? 's' : ''}
{team.member_count ||
team.members?.length ||
0}{' '}
member
{(team.member_count ||
team.members?.length ||
0) !== 1
? 's'
: ''}
</span>
</div>
</div>
+6
View File
@@ -52,6 +52,12 @@ export default function RootLayout() {
return;
}
// Certificate page - allow public access
if (pathname.startsWith('/certificate/')) {
setIsChecking(false);
return;
}
// Require authentication for all other routes
if (!session) {
navigate('/auth/login', { replace: true });
+1 -1
View File
@@ -554,7 +554,7 @@ export default function HomePage() {
{/* Judge 2 */}
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
<h3 className="text-p3 font-bold mb-1 dark:text-white">
Anka Tama
Muhammad Alif Ramadhan
</h3>
<div>
<p className="text-primary-500 font-semibold mb-1">Admin</p>
@@ -1,12 +1,13 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
import { useTeamById, useTeamSubmission, useAuthStore } from '@imphnen-frontend-service/service';
import { encodeCertificateId } from '../../../../utils/certificate';
const SubmissionViewPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
@@ -113,13 +114,17 @@ const SubmissionViewPage: FC = (): ReactElement => {
View Your Certificate
</h3>
<p className="text-amber-700 dark:text-amber-300 text-sm">
Congratulations! Your certificate is ready to download and share.
Congratulations! Your personalized certificate is ready to download and share.
</p>
</div>
</div>
<button
onClick={async () => {
const certId = await encodeCertificateId(teamId || '', submission.id);
const certId = await encodeCertificateId(
teamId || '',
submission.id,
session?.user?.id || ''
);
navigate(`/certificate/${encodeURIComponent(certId)}`);
}}
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
+52 -10
View File
@@ -3,27 +3,69 @@ import { decryptText, encryptText } from "./aesclient";
const SECRET_KEY = 'imphnen-hackathon-2025';
/**
* Encode teamId and submissionId into a certificate ID
* Uses base64 encoding for simple obfuscation
* Encode teamId, submissionId, and userId into a certificate ID
* Uses AES encryption for secure encoding
* @param teamId - The team ID
* @param submissionId - The submission ID
* @param userId - The user ID (team member)
* @returns Encoded certificate ID
*/
export const encodeCertificateId = async (teamId: string, submissionId: string): Promise<string> => {
const combined = `${teamId}::${submissionId}`;
export const encodeCertificateId = async (teamId: string, submissionId: string, userId: string): Promise<string> => {
const combined = `${teamId}::${submissionId}::${userId}`;
return encryptText(combined, SECRET_KEY);
};
/**
* Decode certificate ID back to teamId and submissionId
* @param certId - The encoded certificate ID
* @returns Object containing teamId and submissionId
* Encode winner certificate ID (team-based)
* @param teamId - Winner team ID
* @returns Encoded winner certificate ID
*/
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string }> => {
export const encodeWinnerCertificateId = async (teamId: string): Promise<string> => {
const combined = `winner::${teamId}`;
return encryptText(combined, SECRET_KEY);
};
/**
* Decode certificate ID back to teamId, submissionId, and userId
* @param certId - The encoded certificate ID
* @returns Object containing teamId, submissionId, and userId
*/
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string; userId: string }> => {
try {
const decoded = await decryptText(certId, SECRET_KEY);
const [teamId, submissionId] = decoded.split('::');
return { teamId, submissionId };
const parts = decoded.split('::');
// Handle both old format (teamId::submissionId) and new format (teamId::submissionId::userId)
if (parts.length === 2) {
const [teamId, submissionId] = parts;
return { teamId, submissionId, userId: '' };
} else if (parts.length === 3) {
const [teamId, submissionId, userId] = parts;
return { teamId, submissionId, userId };
}
throw new Error('Invalid certificate format');
} catch {
throw new Error('Invalid certificate ID');
}
};
/**
* Decode winner certificate ID back to teamId
* @param certId - The encoded winner certificate ID
* @returns Object containing teamId
*/
export const decodeWinnerCertificateId = async (certId: string): Promise<{ teamId: string }> => {
try {
const decoded = await decryptText(certId, SECRET_KEY);
const parts = decoded.split('::');
// winner::teamId
if (parts.length === 2 && parts[0] === 'winner') {
return { teamId: parts[1] };
}
throw new Error('Invalid winner certificate format');
} catch {
throw new Error('Invalid certificate ID');
}
+3
View File
@@ -0,0 +1,3 @@
import baseConfig from '../../eslint.config.mjs';
export default [...baseConfig];
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>IMPHNEN Infrastructure</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
<link rel="stylesheet" href="/src/index.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import { join } from 'path';
export default {
plugins: {
'@tailwindcss/postcss': {
base: join(import.meta.dirname, '../../'),
},
},
};
+8
View File
@@ -0,0 +1,8 @@
{
"name": "infra",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/infra/src",
"projectType": "application",
"tags": [],
"targets": {}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 351 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 511 KiB

+248
View File
@@ -0,0 +1,248 @@
const apps = [
{ name: 'Landing', domain: 'imphnen.dev', type: 'Next.js', color: 'blue' },
{ name: 'WWW', domain: 'www.imphnen.dev', type: 'Redirect', color: 'purple' },
{ name: 'Gacha', domain: 'gacha.imphnen.dev', type: 'Vite', color: 'green' },
{ name: 'Backoffice', domain: 'backoffice.imphnen.dev', type: 'Vite', color: 'green' },
{ name: 'Dimentorin', domain: 'dimentorin.imphnen.dev', type: 'Vite', color: 'green' },
{ name: 'Hackathon', domain: 'hackathon.imphnen.dev', type: 'Vite', color: 'green' },
{ name: 'QR Campaign', domain: 'qr.imphnen.dev', type: 'Vite', color: 'green' },
{ name: 'Infra', domain: 'infra.imphnen.dev', type: 'Vite', color: 'green' },
{ name: 'API QR', domain: 'api-qr.imphnen.dev', type: 'Backend', color: 'orange' },
];
const techStack = [
{ category: 'OS', items: ['NixOS 25.05'] },
{ category: 'Web Server', items: ['Nginx'] },
{ category: 'SSL', items: ['Let\'s Encrypt (ACME)'] },
{ category: 'Build', items: ['Nix Flakes', 'Nx Monorepo'] },
{ category: 'CI/CD', items: ['GitHub Actions', 'Cachix'] },
{ category: 'Frontend', items: ['React', 'Next.js', 'Vite', 'TailwindCSS'] },
{ category: 'Backend', items: ['Go', 'Supabase'] },
{ category: 'Secrets', items: ['sops-nix'] },
];
export default function App() {
return (
<div className="min-h-screen bg-[#0a0a0a]">
{/* Header */}
<header className="border-b border-zinc-800 bg-[#0a0a0a]/80 backdrop-blur-sm sticky top-0 z-50">
<div className="container mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center font-bold">
I
</div>
<span className="text-xl font-semibold">IMPHNEN Infrastructure</span>
</div>
<a
href="https://github.com/IMPHNEN"
target="_blank"
rel="noopener noreferrer"
className="text-zinc-400 hover:text-white transition-colors"
>
GitHub
</a>
</div>
</div>
</header>
<main className="container mx-auto px-6 py-12">
{/* Hero */}
<section className="text-center mb-16">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Infrastructure Overview
</h1>
<p className="text-zinc-400 text-lg max-w-2xl mx-auto">
Declarative NixOS infrastructure powering IMPHNEN's frontend applications
on Hetzner Cloud.
</p>
</section>
{/* Architecture Diagram */}
<section className="mb-16">
<h2 className="text-2xl font-semibold mb-6">Architecture</h2>
<div className="card bg-[#141414] border-zinc-800 p-8">
<div className="flex flex-col items-center gap-6">
{/* Internet */}
<div className="flex items-center gap-2 text-zinc-400">
<span className="text-2xl">🌐</span>
<span>Internet</span>
</div>
<div className="w-px h-8 bg-zinc-700" />
{/* Cloudflare */}
<div className="card bg-orange-500/10 border-orange-500/30 px-6 py-3">
<span className="badge badge-orange">Cloudflare</span>
<p className="text-sm text-zinc-400 mt-1">DNS + CDN + DDoS Protection</p>
</div>
<div className="w-px h-8 bg-zinc-700" />
{/* Hetzner VPS */}
<div className="card bg-[#1a1a1a] border-zinc-700 w-full max-w-3xl">
<div className="text-center mb-4">
<span className="badge badge-purple">Hetzner Cloud VPS</span>
<p className="text-sm text-zinc-400 mt-1">NixOS | 167.235.70.37</p>
</div>
{/* Nginx */}
<div className="card bg-green-500/10 border-green-500/30 mb-4">
<div className="text-center">
<span className="badge badge-green">Nginx</span>
<p className="text-sm text-zinc-400 mt-1">Reverse Proxy + Static Files + SSL Termination</p>
</div>
</div>
{/* Apps Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{apps.map((app) => (
<div
key={app.name}
className="card bg-[#0a0a0a] border-zinc-800 text-center p-3"
>
<p className="font-medium text-sm">{app.name}</p>
<p className="text-xs text-zinc-500">{app.domain}</p>
<span className={`badge badge-${app.color} mt-2 text-xs`}>
{app.type}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</section>
{/* Deployed Apps */}
<section className="mb-16">
<h2 className="text-2xl font-semibold mb-6">Deployed Applications</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
{apps.map((app) => (
<a
key={app.name}
href={`https://${app.domain}`}
target="_blank"
rel="noopener noreferrer"
className="card bg-[#141414] border-zinc-800 hover:border-zinc-600 transition-colors group"
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold group-hover:text-blue-400 transition-colors">
{app.name}
</h3>
<p className="text-sm text-zinc-500">{app.domain}</p>
</div>
<span className={`badge badge-${app.color}`}>{app.type}</span>
</div>
</a>
))}
</div>
</section>
{/* Tech Stack */}
<section className="mb-16">
<h2 className="text-2xl font-semibold mb-6">Technology Stack</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
{techStack.map((tech) => (
<div key={tech.category} className="card bg-[#141414] border-zinc-800">
<h3 className="text-sm text-zinc-500 uppercase tracking-wider mb-2">
{tech.category}
</h3>
<div className="flex flex-wrap gap-2">
{tech.items.map((item) => (
<span key={item} className="badge badge-blue">
{item}
</span>
))}
</div>
</div>
))}
</div>
</section>
{/* Deployment Flow */}
<section className="mb-16">
<h2 className="text-2xl font-semibold mb-6">Deployment Flow</h2>
<div className="card bg-[#141414] border-zinc-800">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="text-center">
<div className="w-12 h-12 bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-2">
<span>1</span>
</div>
<p className="text-sm font-medium">Push to GitHub</p>
<p className="text-xs text-zinc-500">develop branch</p>
</div>
<div className="hidden md:block text-zinc-600"></div>
<div className="text-center">
<div className="w-12 h-12 bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-2">
<span>2</span>
</div>
<p className="text-sm font-medium">GitHub Actions</p>
<p className="text-xs text-zinc-500">nix build</p>
</div>
<div className="hidden md:block text-zinc-600"></div>
<div className="text-center">
<div className="w-12 h-12 bg-orange-500 rounded-full flex items-center justify-center mx-auto mb-2">
<span>3</span>
</div>
<p className="text-sm font-medium">Push to Cachix</p>
<p className="text-xs text-zinc-500">binary cache</p>
</div>
<div className="hidden md:block text-zinc-600"></div>
<div className="text-center">
<div className="w-12 h-12 bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-2">
<span>4</span>
</div>
<p className="text-sm font-medium">Server Pull</p>
<p className="text-xs text-zinc-500">nixos-rebuild</p>
</div>
<div className="hidden md:block text-zinc-600"></div>
<div className="text-center">
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center mx-auto mb-2">
<span></span>
</div>
<p className="text-sm font-medium">Live</p>
<p className="text-xs text-zinc-500">Zero downtime</p>
</div>
</div>
</div>
</section>
{/* Repositories */}
<section>
<h2 className="text-2xl font-semibold mb-6">Repositories</h2>
<div className="grid md:grid-cols-2 gap-4">
<a
href="https://github.com/IMPHNEN/imphnen-frontend-service"
target="_blank"
rel="noopener noreferrer"
className="card bg-[#141414] border-zinc-800 hover:border-zinc-600 transition-colors"
>
<h3 className="font-semibold mb-1">imphnen-frontend-service</h3>
<p className="text-sm text-zinc-400">
Nx monorepo containing all frontend applications
</p>
</a>
<a
href="https://github.com/IMPHNEN/imphnen-infrastructure"
target="_blank"
rel="noopener noreferrer"
className="card bg-[#141414] border-zinc-800 hover:border-zinc-600 transition-colors"
>
<h3 className="font-semibold mb-1">imphnen-infrastructure</h3>
<p className="text-sm text-zinc-400">
NixOS flake configuration for server deployment
</p>
</a>
</div>
</section>
</main>
{/* Footer */}
<footer className="border-t border-zinc-800 mt-16">
<div className="container mx-auto px-6 py-8 text-center text-zinc-500 text-sm">
<p>IMPHNEN Infrastructure &copy; {new Date().getFullYear()}</p>
</div>
</footer>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
@import 'tailwindcss';
@theme {
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
}
@layer base {
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #0a0a0a;
color: #ffffff;
line-height: 1.6;
}
}
@layer components {
.card {
background: #141414;
border: 1px solid #27272a;
border-radius: 12px;
padding: 1.5rem;
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-blue {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.badge-green {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
.badge-purple {
background: rgba(168, 85, 247, 0.2);
color: #c084fc;
}
.badge-orange {
background: rgba(249, 115, 22, 0.2);
color: #fb923c;
}
}
+14
View File
@@ -0,0 +1,14 @@
import { createRoot } from 'react-dom/client';
import { StrictMode } from 'react';
import App from './app/App';
import './index.css';
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>
);
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node", "vite/client"]
},
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.spec.tsx", "src/**/*.test.tsx"],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["vite/client"]
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
}
],
"extends": "../../tsconfig.base.json"
}
+27
View File
@@ -0,0 +1,27 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/infra',
server: {
port: 3006,
host: 'localhost',
},
preview: {
port: 3007,
host: 'localhost',
},
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
build: {
outDir: '../../dist/apps/infra',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
},
}));
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -47,7 +47,7 @@ export function CommunitySection() {
case 'FaDiscord':
return FaDiscord;
case 'FaGithub':
return FaDiscord;
return FaGithub;
case 'FaInstagram':
return FaInstagram;
case 'FaTiktok':
@@ -54,9 +54,19 @@ export function HeroSection() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm w-fit">
{communityLabel}
</span>
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm">
{communityLabel}
</span>
<a
href="https://ancikri.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center rounded-full border px-3 py-1 text-sm hover:bg-muted transition-colors"
>
Powered By Ancikri
</a>
</div>
<div className="space-y-4">
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
@@ -104,11 +104,21 @@ export default function Footer() {
<ul className="space-y-2"></ul>
</div>
</div>
<div className="mt-8 border-t pt-8 text-center">
<div className="mt-8 border-t pt-8 text-center space-y-2">
<p className="text-xs text-muted-foreground">
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
Handal, Namun Enggan Ngoding. All rights reserved.
</p>
<p className="text-xs text-muted-foreground">
Powered by{' '}
<Link
href="https://ancikri.com"
target="_blank"
className="font-medium text-foreground hover:underline"
>
Ancikri
</Link>
</p>
</div>
</div>
</footer>
+1 -1
View File
@@ -16,7 +16,7 @@
{
"name": "Github",
"icon": "FaGithub",
"color": "#5865F2",
"color": "#000000",
"description": "Platforms to collaborate on your code",
"link": "https://github.com/IMPHNEN/"
},
+17 -7
View File
@@ -1,13 +1,23 @@
import { Bai_Jamjuree, Poppins } from 'next/font/google';
import localFont from 'next/font/local';
export const baiJamjureeFont = Bai_Jamjuree({
subsets: ['latin'],
weight: ['300', '400', '500', '600', '700'],
export const baiJamjureeFont = localFont({
src: [
{ path: '../../public/fonts/BaiJamjuree-Light.ttf', weight: '300', style: 'normal' },
{ path: '../../public/fonts/BaiJamjuree-Regular.ttf', weight: '400', style: 'normal' },
{ path: '../../public/fonts/BaiJamjuree-Medium.ttf', weight: '500', style: 'normal' },
{ path: '../../public/fonts/BaiJamjuree-SemiBold.ttf', weight: '600', style: 'normal' },
{ path: '../../public/fonts/BaiJamjuree-Bold.ttf', weight: '700', style: 'normal' },
],
display: 'swap',
});
export const poppinsFont = Poppins({
subsets: ['latin'],
weight: ['300', '400', '500', '600', '700'],
export const poppinsFont = localFont({
src: [
{ path: '../../public/fonts/Poppins-Light.ttf', weight: '300', style: 'normal' },
{ path: '../../public/fonts/Poppins-Regular.ttf', weight: '400', style: 'normal' },
{ path: '../../public/fonts/Poppins-Medium.ttf', weight: '500', style: 'normal' },
{ path: '../../public/fonts/Poppins-SemiBold.ttf', weight: '600', style: 'normal' },
{ path: '../../public/fonts/Poppins-Bold.ttf', weight: '700', style: 'normal' },
],
display: 'swap',
});
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --prefer-offline --no-audit --no-fund
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NX_DAEMON=false
RUN npm run qrcampaign:build
FROM nginx:alpine AS runner
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist/apps/qrcampaign /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+12
View File
@@ -0,0 +1,12 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/react'],
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {},
},
];
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>QR Code Generator - IMPHNEN</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="QR Code Generator - IMPHNEN" />
<meta name="keywords" content="qr, campaign, imphnen" />
<link rel="icon" type="image/svg+xml" href="/images/imphnen-logo.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import { join } from 'path';
export default {
plugins: {
'@tailwindcss/postcss': {
base: join(import.meta.dirname, '../../'),
},
},
};
+9
View File
@@ -0,0 +1,9 @@
{
"name": "qrcampaign",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/qrcampaign/src",
"projectType": "application",
"tags": [],
"// targets": "to see all targets run: nx show project qrcampaign --web",
"targets": {}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 351 KiB

+23
View File
@@ -0,0 +1,23 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
<h2 className="text-3xl font-semibold text-gray-900 mb-4">
Page Not Found
</h2>
<p className="text-gray-600 mb-8">
The page you are looking for doesn't exist or has been moved.
</p>
<Link
to="/"
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Go Back Home
</Link>
</div>
</div>
);
}
@@ -0,0 +1,286 @@
import { useState, useEffect } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
import {
campaignService,
Campaign,
CreateCampaignRequest,
} from '../../features/admin/api/campaign.service';
import {
DeleteOutlined,
CheckCircleOutlined,
PlusOutlined,
FileOutlined,
} from '@ant-design/icons';
export default function CampaignsPage() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [createLoading, setCreateLoading] = useState(false);
const [formData, setFormData] = useState<CreateCampaignRequest>({
name: '',
url: '',
});
const fetchCampaigns = async () => {
try {
setLoading(true);
setError(null);
const data = await campaignService.getCampaigns();
setCampaigns(data);
} catch (err) {
setError('Failed to load campaigns');
console.error('Error fetching campaigns:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCampaigns();
}, []);
const handleCreateCampaign = async (e: React.FormEvent) => {
e.preventDefault();
try {
setCreateLoading(true);
await campaignService.createCampaign(formData);
setShowCreateModal(false);
setFormData({ name: '', url: '' });
await fetchCampaigns();
} catch (err) {
console.error('Error creating campaign:', err);
alert('Failed to create campaign');
} finally {
setCreateLoading(false);
}
};
const handleActivateCampaign = async (campaignId: string) => {
try {
await campaignService.activateCampaign(campaignId);
await fetchCampaigns();
} catch (err) {
console.error('Error activating campaign:', err);
alert('Failed to activate campaign');
}
};
const handleDeleteCampaign = async (
campaignId: string,
campaignName: string
) => {
if (!confirm(`Are you sure you want to delete "${campaignName}"?`)) {
return;
}
try {
await campaignService.deleteCampaign(campaignId);
await fetchCampaigns();
} catch (err) {
console.error('Error deleting campaign:', err);
alert('Failed to delete campaign');
}
};
const columns: ColumnDef<Campaign>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<div className="font-medium text-gray-900">{row.original.name}</div>
),
},
{
accessorKey: 'url',
header: 'URL',
cell: ({ row }) => (
<a
href={row.original.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:underline"
>
{row.original.url}
</a>
),
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<span
className={`px-2 py-1 rounded-2xl text-xs font-medium ${
row.original.is_active
? 'bg-success-100 text-success-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{row.original.is_active ? 'Active' : 'Inactive'}
</span>
),
},
{
accessorKey: 'created_at',
header: 'Created At',
cell: ({ row }) => (
<span className="text-gray-600">
{new Date(row.original.created_at).toLocaleDateString()}
</span>
),
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => (
<div className="flex gap-2">
{!row.original.is_active && (
<button
onClick={() => handleActivateCampaign(row.original.id)}
className="p-2 text-success-600 hover:bg-success-50 rounded-md transition-colors cursor-pointer"
title="Activate Campaign"
>
<CheckCircleOutlined />
</button>
)}
<button
onClick={() =>
handleDeleteCampaign(row.original.id, row.original.name)
}
className="p-2 text-danger-600 hover:bg-danger-50 rounded-md transition-colors cursor-pointer"
title="Delete Campaign"
>
<DeleteOutlined />
</button>
</div>
),
},
];
if (loading) {
return (
<div className="p-6">
<div className="text-center py-12">
<div className="text-gray-600">Loading campaigns...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800">
{error}
</div>
</div>
);
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">
Campaign Management
</h1>
<p className="text-gray-600 mt-1">Manage your QR campaigns</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 bg-primary-500 text-white px-4 py-2 rounded-lg hover:bg-primary-600 transition-colors cursor-pointer"
>
<PlusOutlined />
Create Campaign
</button>
</div>
{!campaigns || campaigns.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center shadow-sm">
<div className="max-w-sm mx-auto">
<div className="text-gray-400 mb-4">
<FileOutlined className="text-[48px] mx-auto" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No campaigns yet
</h3>
<p className="text-gray-500 mb-6">
Get started by creating your first QR campaign.
</p>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 bg-primary-500 text-white px-4 py-2 rounded-lg hover:bg-primary-600 transition-colors cursor-pointer"
>
<PlusOutlined />
Create Your First Campaign
</button>
</div>
</div>
) : (
<DataTable data={campaigns} columns={columns} pageSize={10} />
)}
{/* Create Campaign Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Create New Campaign</h2>
<form onSubmit={handleCreateCampaign}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Campaign Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="IMPHNEN Promo Campaign"
required
/>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Campaign URL
</label>
<input
type="url"
value={formData.url}
onChange={(e) =>
setFormData({ ...formData, url: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://imphnen.dev/promo"
required
/>
</div>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={() => {
setShowCreateModal(false);
setFormData({ name: '', url: '' });
}}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors cursor-pointer"
disabled={createLoading}
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-primary-500 text-white rounded-md hover:bg-primary-600 transition-colors disabled:opacity-50 cursor-pointer"
disabled={createLoading}
>
{createLoading ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Outlet } from 'react-router-dom';
import { RequireAdmin } from '../features/admin/components/RequireAdmin';
export default function AdminLayout() {
return (
<RequireAdmin>
<Outlet />
</RequireAdmin>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function AdminPage() {
return <div className="p-4">Select a menu item from the sidebar.</div>;
}
@@ -0,0 +1,215 @@
import { useState, useEffect } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
import { userService, User } from '../../features/admin/api/user.service';
import { DeleteOutlined, EditOutlined, UserOutlined } from '@ant-design/icons';
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingUserId, setEditingUserId] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<string>('');
const fetchUsers = async () => {
try {
setLoading(true);
setError(null);
const data = await userService.getUsers();
setUsers(data);
} catch (err) {
setError('Failed to load users');
console.error('Error fetching users:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
const handleUpdateRole = async (userId: string, currentRole: string) => {
if (editingUserId === userId) {
// Save the role
try {
await userService.updateUserRole(userId, selectedRole);
setEditingUserId(null);
setSelectedRole('');
await fetchUsers();
} catch (err) {
console.error('Error updating user role:', err);
alert('Failed to update user role');
}
} else {
// Start editing
setEditingUserId(userId);
setSelectedRole(currentRole);
}
};
const handleDeleteUser = async (userId: string, userName: string) => {
if (!confirm(`Are you sure you want to delete user "${userName}"?`)) {
return;
}
try {
await userService.deleteUser(userId);
await fetchUsers();
} catch (err) {
console.error('Error deleting user:', err);
alert('Failed to delete user');
}
};
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<div className="font-medium text-gray-900">{row.original.name}</div>
),
},
{
accessorKey: 'email',
header: 'Email',
cell: ({ row }) => (
<div className="text-gray-600">{row.original.email}</div>
),
},
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => {
const isEditing = editingUserId === row.original.id;
return (
<div className="flex items-center gap-2">
{isEditing ? (
<select
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value)}
className="px-2 py-1 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
) : (
<span
className={`px-2 py-1 rounded-2xl text-xs font-medium ${
row.original.role === 'admin'
? 'bg-danger-100 text-danger-800'
: 'bg-primary-100 text-primary-800'
}`}
>
{row.original.role.charAt(0).toUpperCase() +
row.original.role.slice(1)}
</span>
)}
</div>
);
},
},
{
accessorKey: 'created_at',
header: 'Created At',
cell: ({ row }) => (
<span className="text-gray-600">
{new Date(row.original.created_at).toLocaleDateString()}
</span>
),
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => {
const isEditing = editingUserId === row.original.id;
return (
<div className="flex gap-2">
<button
onClick={() =>
handleUpdateRole(row.original.id, row.original.role)
}
className={`p-2 rounded-md transition-colors cursor-pointer ${
isEditing
? 'text-success-600 hover:bg-success-50'
: 'text-primary-600 hover:bg-primary-50'
}`}
title={isEditing ? 'Save Role' : 'Change Role'}
>
<EditOutlined />
</button>
{isEditing && (
<button
onClick={() => {
setEditingUserId(null);
setSelectedRole('');
}}
className="p-2 text-gray-600 hover:bg-gray-50 rounded-md transition-colors cursor-pointer"
title="Cancel"
>
</button>
)}
{!isEditing && (
<button
onClick={() =>
handleDeleteUser(row.original.id, row.original.name)
}
className="p-2 text-danger-600 hover:bg-danger-50 rounded-md transition-colors cursor-pointer"
title="Delete User"
>
<DeleteOutlined />
</button>
)}
</div>
);
},
},
];
if (loading) {
return (
<div className="p-6">
<div className="text-center py-12">
<div className="text-gray-600">Loading users...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800">
{error}
</div>
</div>
);
}
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="text-gray-600 mt-1">Manage users and their roles</p>
</div>
{!users || users.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center shadown-sm">
<div className="max-w-sm mx-auto">
<div className="text-gray-400 mb-4">
<UserOutlined className="text-[48px]" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No users found
</h3>
<p className="text-gray-500">
There are currently no users in the system.
</p>
</div>
</div>
) : (
<DataTable data={users} columns={columns} pageSize={10} />
)}
</div>
);
}
@@ -0,0 +1,183 @@
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useGitHubCallback } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const CallbackPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback();
const [isProcessing, setIsProcessing] = useState(true);
const [error, setError] = useState<string | null>(null);
const hasRunRef = useRef(false);
useEffect(() => {
const handleCallback = async () => {
if (hasRunRef.current) {
return;
}
hasRunRef.current = true;
try {
// Check URL hash for Supabase email confirmation callback
const hashParams = new URLSearchParams(
globalThis.location.hash.substring(1)
);
const urlParams = new URLSearchParams(globalThis.location.search);
const type = hashParams.get('type') || urlParams.get('type');
const accessToken =
hashParams.get('access_token') || urlParams.get('access_token');
// Debug: log what we received
console.log('[Callback] Params:', {
type,
accessToken: !!accessToken,
hash: globalThis.location.hash,
search: globalThis.location.search,
});
// Handle Supabase email callbacks (has access_token in hash or query)
// This includes: signup confirmation, email confirmation, password recovery
if (accessToken) {
setIsProcessing(false);
// Password recovery - type is 'recovery' or we have access_token from reset email
if (type === 'recovery' || type === 'magiclink') {
toast.success('Email verified! Please set your new password.');
navigate('/auth/reset-password?access_token=' + accessToken);
return;
}
// Signup/Email confirmation
if (type === 'signup' || type === 'email_confirmation') {
toast.success(
'Email verified successfully! Please log in to continue.'
);
navigate('/auth/login');
return;
}
// If we have access_token but unknown type, assume it's password recovery
// (Supabase sometimes sends without explicit type)
toast.success('Email verified! Please set your new password.');
navigate('/auth/reset-password?access_token=' + accessToken);
return;
}
// Get the code from URL query params (GitHub OAuth)
const code = urlParams.get('code');
if (!code) {
throw new Error('No authorization code received');
}
// Exchange the code for tokens using backend API (GitHub OAuth)
const result = await exchangeGitHubCode({ code });
toast.success('Login successful!');
setIsProcessing(false);
// Check if user has completed onboarding (has location)
if (result.user.location) {
globalThis.location.replace('/dashboard');
} else {
globalThis.location.replace('/onboarding/user');
}
} catch (err) {
console.error('[Callback] Error:', err);
setError((err as Error).message);
setIsProcessing(false);
toast.error('An error occurred during login');
setTimeout(() => {
navigate('/auth/login');
}, 3000);
}
};
handleCallback();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (error) {
// Check if error is related to private email
const isPrivateEmailError =
error.toLowerCase().includes('failed to create user') ||
error.toLowerCase().includes('email') ||
error.toLowerCase().includes('user record');
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
<div className="bg-white w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200">
<div className="text-center mb-6">
<div className="text-red-500 text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
GitHub Login Failed
</h2>
<p className="text-red-600 mb-4 whitespace-pre-line">{error}</p>
</div>
{isPrivateEmailError && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-amber-800 mb-2">
Is your GitHub email set to private?
</h3>
<p className="text-amber-700 text-sm mb-3">
GitHub login requires a public email address. Please follow
these steps:
</p>
<ol className="text-amber-700 text-sm list-decimal list-inside space-y-1 mb-3">
<li>
Go to{' '}
<a
href="https://github.com/settings/emails"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-amber-900"
>
GitHub Email Settings
</a>
</li>
<li>Uncheck "Keep my email addresses private"</li>
<li>
Or go to{' '}
<a
href="https://github.com/settings/profile"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-amber-900"
>
Profile Settings
</a>{' '}
and set a public email
</li>
<li>Try signing in with GitHub again</li>
</ol>
<p className="text-amber-600 text-xs">
Alternatively, you can sign up using email and password instead.
</p>
</div>
)}
<p className="text-gray-600 text-sm mt-6 text-center">
Redirecting to login page in 3 seconds...
</p>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Completing login...
</h2>
<p className="text-gray-600">Please wait</p>
</div>
</div>
);
};
export default CallbackPage;
@@ -0,0 +1,125 @@
import { useState } from 'react';
import { useForgotPassword } from '@imphnen-frontend-service/service';
import { Link, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [emailSent, setEmailSent] = useState(false);
const navigate = useNavigate();
const forgotPasswordMutation = useForgotPassword();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
toast.error('Please enter your email');
return;
}
try {
await forgotPasswordMutation.mutateAsync({ email });
setEmailSent(true);
toast.success('Password reset email sent! Check your inbox.');
} catch (err) {
toast.error((err as Error).message || 'Failed to send reset email');
}
};
if (emailSent) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<span className="text-3xl"></span>
</div>
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Check Your Email
</h2>
<p className="text-gray-600">
We've sent a password reset link to <strong>{email}</strong>
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600">
Click the link in the email to reset your password. The link will
expire in 1 hour.
</p>
<Link to="/auth/login">
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors">
Back to Login
</button>
</Link>
<button
onClick={() => setEmailSent(false)}
className="w-full py-3 text-gray-600 hover:text-gray-900 transition-colors"
>
Send another email
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/auth/login')}
className="cursor-pointer text-primary-500 hover:text-primary-600 text-base font-sans flex items-center"
>
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
Back to Login
</button>
</div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Forgot Password?
</h2>
<p className="text-gray-600">
No worries, we'll send you reset instructions
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email Address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={forgotPasswordMutation.isPending}
className="bg-white text-gray-900 w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={forgotPasswordMutation.isPending}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{forgotPasswordMutation.isPending
? 'Sending...'
: 'Send Reset Link'}
</button>
</form>
</div>
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
import { useState, useEffect } from 'react';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { useAuthStore } from '../../features/auth/store/auth.store';
export default function LoginPage() {
const navigate = useNavigate();
const login = useAuthStore((state) => state.login);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (isAuthenticated) {
navigate('/');
}
}, [isAuthenticated, navigate]);
// Check for password reset tokens in URL and redirect to reset-password page
useEffect(() => {
const hashParams = new URLSearchParams(
globalThis.location.hash.substring(1)
);
const urlParams = new URLSearchParams(globalThis.location.search);
const accessToken =
hashParams.get('access_token') || urlParams.get('access_token');
const type = hashParams.get('type') || urlParams.get('type');
// If we have an access_token, this is likely a password reset redirect that landed on the wrong page
if (accessToken) {
console.log(
'[Login] Detected access_token, redirecting to reset-password page'
);
// Check if it's a password recovery
if (type === 'recovery' || type === 'magiclink' || !type) {
toast.info('Redirecting to password reset...');
navigate('/auth/reset-password?access_token=' + accessToken);
}
}
}, [navigate]);
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!email || !password) {
setError('Please enter both email and password');
return;
}
setIsSubmitting(true);
try {
const success = await login(email, password);
if (success) {
toast.success('Login successful!');
navigate('/');
} else {
setError('Login failed. Please check your credentials.');
}
} catch (err) {
console.error('[Login] Email login failed:', err);
setError('Login failed. Please try again.');
} finally {
setIsSubmitting(false);
}
};
const handleGithubLogin = async () => {
// TODO: Implement GitHub login with new auth service if needed
toast.info('GitHub login coming soon');
};
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
{/* <div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/')}
className="cursor-pointer text-primary-500 hover:text-primary-600 text-base font-sans flex items-center"
>
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
Back to Homepage
</button>
</div> */}
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Welcome Back
</h2>
<p className="text-gray-600">Sign in to continue to IMPHNEN</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
{/* Traditional Login Form */}
<form onSubmit={handleEmailLogin} className="space-y-4 mb-6">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="yourname@example.com"
disabled={isSubmitting}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<Link
to="/auth/forgot-password"
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Forgot password?
</Link>
</div>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isSubmitting}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed pr-10"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
<Icon
icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'}
className="text-xl"
/>
</button>
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">
Or continue with
</span>
</div>
</div>
<button
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold text-gray-900 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
<GithubOutlined className="text-xl" />
<span>
{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}
</span>
</button>
<p className="mt-3 text-xs text-center text-gray-500">
Make sure your GitHub email is{' '}
<a
href="https://github.com/settings/emails"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:underline"
>
set to public
</a>{' '}
for GitHub sign in to work.
</p>
<div className="mt-6 text-center">
<p className="text-gray-600 text-sm">
Don't have an account?{' '}
<Link
to="/auth/signup"
className="text-primary-600 hover:text-primary-700 font-semibold"
>
Sign up
</Link>
</p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 text-xs">
By signing in, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,171 @@
import { useState, useEffect } from 'react';
import {
useResetPassword,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
export default function ResetPasswordPage() {
const navigate = useNavigate();
const { clearSession } = useAuthStore();
const resetPasswordMutation = useResetPassword();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [accessToken, setAccessToken] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
useEffect(() => {
// Get the access_token from URL hash (Supabase sends it as hash fragment)
// or from query params (when redirected from callback page)
const hashParams = new URLSearchParams(
globalThis.location.hash.substring(1)
);
const queryParams = new URLSearchParams(globalThis.location.search);
const token =
hashParams.get('access_token') || queryParams.get('access_token');
if (token) {
setAccessToken(token);
} else {
toast.error('Invalid or expired reset link');
setTimeout(() => navigate('/auth/forgot-password'), 2000);
}
}, [navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
if (password.length < 6) {
toast.error('Password must be at least 6 characters');
return;
}
if (!accessToken) {
toast.error('Invalid reset token');
return;
}
try {
await resetPasswordMutation.mutateAsync({
access_token: accessToken,
new_password: password,
});
toast.success('Password updated successfully!');
// Clear session and redirect to login
clearSession();
navigate('/auth/login');
} catch (err) {
toast.error((err as Error).message || 'Failed to reset password');
}
};
if (!accessToken) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<p className="text-gray-600">Verifying reset link...</p>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Set New Password
</h2>
<p className="text-gray-600">Enter your new password below</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
New Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={resetPasswordMutation.isPending}
className="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white text-gray-900"
required
minLength={6}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
<Icon
icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'}
className="text-xl"
/>
</button>
</div>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-1"
>
Confirm New Password
</label>
<div className="relative">
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
disabled={resetPasswordMutation.isPending}
className="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white text-gray-900"
required
minLength={6}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
<Icon
icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'}
className="text-xl"
/>
</button>
</div>
</div>
<button
type="submit"
disabled={resetPasswordMutation.isPending}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{resetPasswordMutation.isPending
? 'Updating...'
: 'Update Password'}
</button>
</form>
</div>
</div>
);
}
@@ -0,0 +1,305 @@
import { useState } from 'react';
import { useGitHubAuth } from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { useAuthStore } from '../../features/auth/store/auth.store';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const signupSchema = z
.object({
fullname: z
.string()
.min(1, 'Full name is required')
.min(2, 'Full name must be at least 2 characters'),
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
password: z
.string()
.min(1, 'Password is required')
.min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type SignupFormData = z.infer<typeof signupSchema>;
export default function SignupPage() {
const navigate = useNavigate();
const registerUser = useAuthStore((state) => state.register);
const { signInWithGitHub } = useGitHubAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isValid },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
mode: 'onChange',
});
const onSubmit = async (data: SignupFormData) => {
setError(null);
setIsSubmitting(true);
try {
await registerUser(data.fullname, data.email, data.password);
toast.success('Registration successful! Redirecting...');
navigate('/');
} catch (err: any) {
console.error('[Signup] Email signup failed:', err);
// Construct a user-friendly error message
const errorMessage =
err.response?.data?.message || err.message || 'Signup failed';
setError(errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
const result = await signInWithGitHub();
if (result?.url) {
globalThis.location.href = result.url;
} else {
setIsGithubLoading(false);
setError('Failed to get GitHub OAuth URL');
}
} catch (err) {
console.error('[Signup] GitHub login failed:', err);
setError((err as Error).message || 'GitHub login failed');
setIsGithubLoading(false);
}
};
const inputBaseClass =
'w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed';
const inputErrorClass = 'border-red-500';
const inputNormalClass = 'border-gray-300';
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/')}
className="cursor-pointer text-primary-500 hover:text-primary-600 text-base font-sans flex items-center"
>
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
Back to Homepage
</button>
</div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Create Account
</h2>
<p className="text-gray-600">Join IMPHNEN community</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label
htmlFor="fullname"
className="block text-sm font-medium text-gray-700 mb-1"
>
Full Name
</label>
<input
id="fullname"
type="text"
{...register('fullname')}
placeholder="John Doe"
disabled={isSubmitting}
className={`${inputBaseClass} ${
errors.fullname ? inputErrorClass : inputNormalClass
}`}
/>
{errors.fullname && (
<p className="mt-1 text-sm text-red-500">
{errors.fullname.message}
</p>
)}
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
id="email"
type="email"
{...register('email')}
placeholder="yourname@example.com"
disabled={isSubmitting}
className={`${inputBaseClass} ${
errors.email ? inputErrorClass : inputNormalClass
}`}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">
{errors.email.message}
</p>
)}
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
{...register('password')}
placeholder="••••••••"
disabled={isSubmitting}
className={`${inputBaseClass} pr-12 ${
errors.password ? inputErrorClass : inputNormalClass
}`}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
<Icon
icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'}
className="text-xl"
/>
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-500">
{errors.password.message}
</p>
)}
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-1"
>
Confirm Password
</label>
<div className="relative">
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
{...register('confirmPassword')}
placeholder="••••••••"
disabled={isSubmitting}
className={`${inputBaseClass} pr-12 ${
errors.confirmPassword ? inputErrorClass : inputNormalClass
}`}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
<Icon
icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'}
className="text-xl"
/>
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-500">
{errors.confirmPassword.message}
</p>
)}
</div>
<button
type="submit"
disabled={!isValid || isSubmitting}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
{isSubmitting ? 'Creating account...' : 'Create Account'}
</button>
</form>
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300"></div>
<span className="px-4 text-sm text-gray-500">OR</span>
<div className="flex-1 border-t border-gray-300"></div>
</div>
<button
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold text-gray-900 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
<GithubOutlined className="text-xl" />
<span>
{isGithubLoading ? 'Connecting...' : 'Sign up with GitHub'}
</span>
</button>
<p className="mt-3 text-xs text-center text-gray-500">
Make sure your GitHub email is{' '}
<a
href="https://github.com/settings/emails"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:underline"
>
set to public
</a>{' '}
for GitHub sign up to work.
</p>
<div className="mt-6 text-center">
<p className="text-gray-600 text-sm">
Already have an account?{' '}
<Link
to="/auth/login"
className="text-primary-600 hover:text-primary-700 font-semibold"
>
Sign in
</Link>
</p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 text-xs">
By signing up, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
export default function ErrorPage() {
const error = useRouteError();
let errorMessage: string;
if (isRouteErrorResponse(error)) {
errorMessage = error.statusText;
} else if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
} else {
console.error(error);
errorMessage = 'Unknown error';
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
<p className="text-xl text-gray-700 mb-2">
Sorry, an unexpected error has occurred.
</p>
<p className="text-gray-500 italic">{errorMessage}</p>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { api } from '../../auth/api/auth.service';
// Types
export interface Campaign {
id: string;
name: string;
url: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateCampaignRequest {
name: string;
url: string;
}
interface CampaignsResponse {
success: boolean;
message: string;
data: Campaign[];
}
interface CampaignResponse {
success: boolean;
message: string;
data: Campaign;
}
interface DeleteResponse {
success: boolean;
message: string;
}
export const campaignService = {
getCampaigns: async (): Promise<Campaign[]> => {
const response = await api.get<CampaignsResponse>('/campaigns');
return response.data.data;
},
createCampaign: async (data: CreateCampaignRequest): Promise<Campaign> => {
const response = await api.post<CampaignResponse>('/campaigns', data);
return response.data.data;
},
activateCampaign: async (campaignId: string): Promise<Campaign> => {
const response = await api.put<CampaignResponse>(
`/campaigns/${campaignId}/activate`
);
return response.data.data;
},
deleteCampaign: async (campaignId: string): Promise<void> => {
await api.delete<DeleteResponse>(`/campaigns/${campaignId}`);
},
};
@@ -0,0 +1,53 @@
import { api } from '../../auth/api/auth.service';
// Types
export interface User {
id: string;
email: string;
name: string;
role: string;
created_at: string;
updated_at: string;
}
export interface UpdateUserRoleRequest {
role: string;
}
interface UsersResponse {
success: boolean;
message: string;
data: User[];
}
interface UserResponse {
success: boolean;
message: string;
data: User;
}
interface DeleteResponse {
success: boolean;
message: string;
}
export const userService = {
getUsers: async (): Promise<User[]> => {
const response = await api.get<UsersResponse>('/users');
return response.data.data;
},
updateUserRole: async (
userId: string,
role: string
): Promise<User> => {
const response = await api.put<UserResponse>(`/users/${userId}/role`, {
role,
});
return response.data.data;
},
deleteUser: async (userId: string): Promise<void> => {
await api.delete<DeleteResponse>(`/users/${userId}`);
},
};
@@ -0,0 +1,27 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../auth/store/auth.store';
interface RequireAdminProps {
children: JSX.Element;
}
export const RequireAdmin = ({ children }: RequireAdminProps) => {
const user = useAuthStore((state) => state.user);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
// Check if user has admin role
// user.role is now an object { id, name, permissions }
const userRole = user?.role?.name;
if (userRole !== 'Admin' && userRole !== 'Super Admin') {
// Redirect non-admins to home
return <Navigate to="/" replace />;
}
return children;
};
@@ -0,0 +1,64 @@
import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../auth/store/auth.store';
export const AdminDashboard = () => {
const logout = useAuthStore((state) => state.logout);
const location = useLocation();
const isActive = (path: string) => location.pathname.startsWith(path);
return (
<div className="min-h-screen bg-slate-100 flex">
{/* Sidebar */}
<aside className="w-64 bg-white border-r border-slate-200 flex flex-col">
<div className="p-6 border-b border-slate-200">
<h1 className="text-xl font-bold text-slate-800">Admin Panel</h1>
<p className="text-xs text-slate-500 mt-1">QR Campaign Manager</p>
</div>
<nav className="flex-1 p-4 space-y-1">
<Link
to="/admin/campaigns"
className={`block px-4 py-2 rounded-md transition-colors ${
isActive('/admin/campaigns')
? 'bg-blue-50 text-blue-700'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
Campaigns
</Link>
<Link
to="/admin/users"
className={`block px-4 py-2 rounded-md transition-colors ${
isActive('/admin/users')
? 'bg-blue-50 text-blue-700'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
Users
</Link>
</nav>
<div className="p-4 border-t border-slate-200">
<Link
to="/"
className="block px-4 py-2 text-sm text-slate-600 hover:text-slate-900 mb-2"
>
&larr; Back to App
</Link>
<button
onClick={logout}
className="w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-md transition-colors text-left"
>
Logout
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 p-8 overflow-auto">
<Outlet />
</main>
</div>
);
};
@@ -0,0 +1,237 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
interface Campaign {
id: string;
name: string;
url: string;
image_url?: string; // QR code image URL if we want to show it
is_active: boolean;
created_at: string;
}
interface CreateCampaignInputs {
name: string;
url: string;
}
export const CampaignManagement = () => {
const queryClient = useQueryClient();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<CreateCampaignInputs>();
// Fetch Campaigns
const {
data: campaigns,
isLoading,
isError,
} = useQuery({
queryKey: ['campaigns'],
queryFn: async () => {
const res = await axios.get('http://localhost:8080/api/v1/campaigns');
return res.data.data as Campaign[];
},
});
// Create Campaign
const createMutation = useMutation({
mutationFn: async (data: CreateCampaignInputs) => {
await axios.post('http://localhost:8080/api/v1/campaigns', data);
},
onSuccess: () => {
toast.success('Campaign created successfully');
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
setIsCreateModalOpen(false);
reset();
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to create campaign');
},
});
// Activate Campaign
const activateMutation = useMutation({
mutationFn: async (id: string) => {
await axios.put(`http://localhost:8080/api/v1/campaigns/${id}/activate`);
},
onSuccess: () => {
toast.success('Campaign activated');
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
// Also invalidate active QR for the main app
queryClient.invalidateQueries({ queryKey: ['active-campaign-qr'] });
},
onError: () => toast.error('Failed to activate campaign'),
});
// Delete Campaign
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await axios.delete(`http://localhost:8080/api/v1/campaigns/${id}`);
},
onSuccess: () => {
toast.success('Campaign deleted');
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
},
onError: () => toast.error('Failed to delete campaign'),
});
const onCreateSubmit = (data: CreateCampaignInputs) => {
createMutation.mutate(data);
};
if (isLoading) return <div>Loading campaigns...</div>;
if (isError) return <div>Error loading campaigns.</div>;
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-slate-800">Campaigns</h2>
<button
onClick={() => setIsCreateModalOpen(true)}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
>
+ New Campaign
</button>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase font-medium">
<tr>
<th className="px-6 py-3">Name</th>
<th className="px-6 py-3">URL</th>
<th className="px-6 py-3">Status</th>
<th className="px-6 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{campaigns?.map((campaign) => (
<tr key={campaign.id} className="hover:bg-slate-50">
<td className="px-6 py-4 font-medium text-slate-900">
{campaign.name}
</td>
<td className="px-6 py-4 text-slate-500 text-sm max-w-xs truncate">
{campaign.url}
</td>
<td className="px-6 py-4">
{campaign.is_active ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-sm font-medium bg-green-100 text-green-800">
Active
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-800">
Inactive
</span>
)}
</td>
<td className="px-6 py-4 space-x-2">
{!campaign.is_active && (
<button
onClick={() => activateMutation.mutate(campaign.id)}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Activate
</button>
)}
<button
onClick={() => {
if (
window.confirm(
'Are you sure you want to delete this campaign?'
)
) {
deleteMutation.mutate(campaign.id);
}
}}
className="text-red-600 hover:text-red-800 text-sm font-medium"
>
Delete
</button>
</td>
</tr>
))}
{campaigns?.length === 0 && (
<tr>
<td
colSpan={4}
className="px-6 py-8 text-center text-slate-500"
>
No campaigns found. Create one to get started.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Basic Create Modal */}
{isCreateModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h3 className="text-xl font-bold mb-4 text-slate-900">
Create New Campaign
</h3>
<form onSubmit={handleSubmit(onCreateSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Campaign Name
</label>
<input
type="text"
{...register('name', { required: 'Name is required' })}
className="w-full px-3 py-2 border border-slate-300 rounded-md bg-white"
placeholder="e.g. Summer Sale 2026"
/>
{errors.name && (
<p className="text-red-500 text-sm mt-1">
{errors.name.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Target URL
</label>
<input
type="url"
{...register('url', { required: 'URL is required' })}
className="w-full px-3 py-2 border border-slate-300 rounded-md bg-white"
placeholder="https://example.com/promo"
/>
{errors.url && (
<p className="text-red-500 text-sm mt-1">
{errors.url.message}
</p>
)}
</div>
<div className="flex justify-end gap-2 mt-6">
<button
type="button"
onClick={() => setIsCreateModalOpen(false)}
className="px-4 py-2 text-slate-700 hover:bg-slate-100 rounded-md"
>
Cancel
</button>
<button
type="submit"
disabled={createMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white hover:bg-blue-700 rounded-md disabled:opacity-50"
>
{createMutation.isPending ? 'Creating...' : 'Create Campaign'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,121 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { toast } from 'sonner';
interface User {
id: string;
name: string;
email: string;
role: string;
created_at: string;
}
export const UserManagement = () => {
const queryClient = useQueryClient();
// Fetch Users
const {
data: users,
isLoading,
isError,
} = useQuery({
queryKey: ['users'],
queryFn: async () => {
const res = await axios.get('http://localhost:8080/api/v1/users');
return res.data.data as User[];
},
});
// Update Role
const updateRoleMutation = useMutation({
mutationFn: async ({ id, role }: { id: string; role: string }) => {
await axios.put(`http://localhost:8080/api/v1/users/${id}/role`, {
role,
});
},
onSuccess: () => {
toast.success('User role updated');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: () => toast.error('Failed to update user role'),
});
// Delete User
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await axios.delete(`http://localhost:8080/api/v1/users/${id}`);
},
onSuccess: () => {
toast.success('User deleted');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: () => toast.error('Failed to delete user'),
});
if (isLoading) return <div>Loading users...</div>;
if (isError) return <div>Error loading users.</div>;
return (
<div>
<h2 className="text-2xl font-bold text-slate-800 mb-6">Users</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase font-medium">
<tr>
<th className="px-6 py-3">Name</th>
<th className="px-6 py-3">Email</th>
<th className="px-6 py-3">Role</th>
<th className="px-6 py-3">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{users?.map((user) => (
<tr key={user.id} className="hover:bg-slate-50">
<td className="px-6 py-4 font-medium text-slate-900">
{user.name}
</td>
<td className="px-6 py-4 text-slate-500 text-sm">
{user.email}
</td>
<td className="px-6 py-4">
<select
value={user.role}
onChange={(e) =>
updateRoleMutation.mutate({
id: user.id,
role: e.target.value,
})
}
disabled={user.email === 'admin@demo.com'} // Prevent changing main admin role for safety in demo
className="bg-transparent border border-slate-300 rounded text-sm px-2 py-1 text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</td>
<td className="px-6 py-4">
<button
onClick={() => {
if (
window.confirm(
'Are you sure you want to delete this user?'
)
) {
deleteMutation.mutate(user.id);
}
}}
disabled={user.email === 'admin@demo.com'}
className="text-red-600 hover:text-red-800 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
@@ -0,0 +1,129 @@
import axios from 'axios';
// Define the base URL for the API
const API_URL = 'https://api-qr.imphnen.dev/api/v1';
// Create a configured axios instance
export const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add interceptor to add token to requests
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Types
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest {
name: string;
email: string;
password: string;
}
// Backend user response
interface BackendUser {
id: string;
email: string;
name: string;
role: string;
provider: string;
created_at: string;
updated_at: string;
}
// Frontend user type
export interface User {
id: string;
email: string;
fullname: string;
role?: {
id: string;
name: string;
permissions: string[];
};
}
// Backend auth response
interface BackendAuthResponse {
success: boolean;
message: string;
data: {
tokens: {
access_token: string;
refresh_token: string;
};
user: BackendUser;
};
}
export interface AuthResponse {
success: boolean;
message: string;
data: {
tokens: {
access_token: string;
refresh_token: string;
};
user: User;
};
}
// Helper to transform backend user to frontend user
const transformUser = (backendUser: BackendUser): User => {
return {
id: backendUser.id,
email: backendUser.email,
fullname: backendUser.name,
role: {
id: '',
name: backendUser.role === 'admin' ? 'Admin' : backendUser.role === 'user' ? 'User' : 'User',
permissions: [],
},
};
};
export const authService = {
login: async (data: LoginRequest): Promise<AuthResponse> => {
const response = await api.post<BackendAuthResponse>('/auth/login', data);
return {
success: response.data.success,
message: response.data.message,
data: {
tokens: response.data.data.tokens,
user: transformUser(response.data.data.user),
},
};
},
register: async (data: RegisterRequest): Promise<AuthResponse> => {
const response = await api.post<BackendAuthResponse>('/auth/register', data);
return {
success: response.data.success,
message: response.data.message,
data: {
tokens: response.data.data.tokens,
user: transformUser(response.data.data.user),
},
};
},
getProfile: async (): Promise<User> => {
const response = await api.get<User>('/users/me');
return response.data;
},
};
@@ -0,0 +1,21 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../../features/auth/store/auth.store';
interface RequireAuthProps {
children: JSX.Element;
}
export const RequireAuth = ({ children }: RequireAuthProps) => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const location = useLocation();
if (!isAuthenticated) {
// Redirect them to the /login page, but save the current location they were
// trying to go to when they were redirected. This allows us to send them
// along to that page after they login, which is a nicer user experience.
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
@@ -0,0 +1,102 @@
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useAuthStore } from '../store/auth.store';
import { useNavigate, useLocation } from 'react-router-dom';
// Reusing UI components logic or standard HTML for now to keep it simple and dependency-free if UI lib issues arise
// But user mentioned shared UI libs, let's try to use standard Tailwind first to ensure speed.
interface LoginFormInputs {
email: string;
pass: string;
}
export const LoginPage = () => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormInputs>();
const login = useAuthStore((state) => state.login);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || '/';
useEffect(() => {
if (isAuthenticated) {
navigate(from, { replace: true });
}
}, [isAuthenticated, navigate, from]);
const onSubmit = async (data: LoginFormInputs) => {
const success = await login(data.email, data.pass);
if (success) {
// Get user from store to check role
const user = useAuthStore.getState().user;
const userRole = user?.role?.name;
// Redirect admin to admin dashboard
if (userRole === 'Admin' || userRole === 'Super Admin') {
navigate('/admin/campaigns', { replace: true });
} else {
navigate(from, { replace: true });
}
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-4">
<div className="w-full max-w-md bg-white rounded-lg shadow-lg p-8">
<h1 className="text-2xl font-bold text-center mb-6 text-slate-800">
Login
</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Email
</label>
<input
type="email"
{...register('email', { required: 'Email is required' })}
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">
{errors.email.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Password
</label>
<input
type="password"
{...register('pass', { required: 'Password is required' })}
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.pass && (
<p className="text-red-500 text-sm mt-1">{errors.pass.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
<div className="mt-4 text-center text-sm text-slate-500">
<p>Demo credentials available in backend seeder.</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,84 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { authService, User } from '../api/auth.service';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
login: (email: string, pass: string) => Promise<boolean>;
register: (name: string, email: string, pass: string) => Promise<boolean>;
logout: () => void;
setUser: (user: User) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (email, password) => {
try {
const response = await authService.login({ email, password });
const token = response.data.tokens.access_token;
const refreshToken = response.data.tokens.refresh_token;
localStorage.setItem('token', token);
localStorage.setItem('refreshToken', refreshToken);
set({
user: response.data.user,
token: token,
isAuthenticated: true
});
return true;
} catch (error) {
console.error('Login failed:', error);
return false;
}
},
register: async (name, email, password) => {
try {
const response = await authService.register({ name, email, password });
const token = response.data.tokens.access_token;
const refreshToken = response.data.tokens.refresh_token;
localStorage.setItem('token', token);
localStorage.setItem('refreshToken', refreshToken);
set({
user: response.data.user,
token: token,
isAuthenticated: true
});
return true;
} catch (error) {
console.error('Registration failed:', error);
throw error;
}
},
logout: () => {
localStorage.removeItem('token');
set({ user: null, token: null, isAuthenticated: false });
},
setUser: (user) => set({ user }),
}),
{
name: 'auth-storage', // name of the item in the storage (must be unique)
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated
}),
}
)
);
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
export const useActiveCampaignQR = () => {
return useQuery({
queryKey: ['active-campaign-qr'],
queryFn: async () => {
// Assuming backend is running on localhost:8080
// In production, this should be an env var or relative path if proxied
const response = await axios.get('http://localhost:8080/api/v1/campaigns/active/qr', {
responseType: 'blob',
});
return URL.createObjectURL(response.data);
},
staleTime: 1000 * 60 * 5, // 5 minutes
});
};
@@ -0,0 +1,99 @@
import React, { useCallback, useState } from 'react';
import { toast } from 'sonner';
interface DropzoneProps {
onImageDropped: (file: File) => void;
}
export const Dropzone: React.FC<DropzoneProps> = ({ onImageDropped }) => {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const file = files[0];
if (!file.type.startsWith('image/')) {
toast.error('Please upload an image file.');
return;
}
onImageDropped(file);
},
[onImageDropped]
);
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
if (!file.type.startsWith('image/')) {
toast.error('Please upload an image file.');
return;
}
onImageDropped(file);
}
},
[onImageDropped]
);
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-lg p-12 text-center transition-colors cursor-pointer ${
isDragging
? 'border-blue-500 bg-blue-50'
: 'border-slate-300 hover:border-slate-400'
}`}
onClick={() => document.getElementById('file-upload')?.click()}
>
<input
id="file-upload"
type="file"
className="hidden"
accept="image/png, image/jpeg, image/jpg"
onChange={handleFileInput}
/>
<div className="space-y-2">
<div className="flex justify-center">
{/* Simple upload icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-12 h-12 text-slate-400"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
</div>
<p className="text-lg font-medium text-slate-700">
Drop your image here, or click to upload
</p>
<p className="text-sm text-slate-500">Supports JPG and PNG</p>
</div>
</div>
);
};
@@ -0,0 +1,188 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import html2canvas from 'html2canvas';
import { toast } from 'sonner';
interface WatermarkEditorProps {
imageFile: File;
qrCodeUrl: string;
onReset: () => void;
}
export const WatermarkEditor: React.FC<WatermarkEditorProps> = ({
imageFile,
qrCodeUrl,
onReset,
}) => {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const qrRef = useRef<HTMLDivElement>(null);
// State for QR code
const [position, setPosition] = useState({ x: 20, y: 20 });
const [size, setSize] = useState(100);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [startResizePos, setStartResizePos] = useState({ x: 0, y: 0 });
const [startResizeSize, setStartResizeSize] = useState(100);
// Load image
useEffect(() => {
const url = URL.createObjectURL(imageFile);
setImageUrl(url);
return () => URL.revokeObjectURL(url);
}, [imageFile]);
// Drag handlers
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
setDragOffset({
x: e.clientX - position.x,
y: e.clientY - position.y,
});
};
const handleResizeMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setStartResizePos({ x: e.clientX, y: e.clientY });
setStartResizeSize(size);
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (isDragging) {
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
// Boundaries check (optional, but good UX)
if (containerRef.current) {
// const container = containerRef.current.getBoundingClientRect();
// Simple clamp? Or allow partial off-screen?
// Let's allow it to move freely within container
}
setPosition({ x: newX, y: newY });
}
if (isResizing) {
const deltaX = e.clientX - startResizePos.x;
const newSize = Math.max(50, startResizeSize + deltaX); // Min size 50px
setSize(newSize);
}
},
[isDragging, isResizing, dragOffset, startResizePos, startResizeSize]
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setIsResizing(false);
}, []);
useEffect(() => {
if (isDragging || isResizing) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
} else {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
const handleDownload = async () => {
if (!containerRef.current) return;
try {
const canvas = await html2canvas(containerRef.current, {
useCORS: true, // Important for QR if from external URL
backgroundColor: null,
});
const link = document.createElement('a');
link.download = `qr-campaign-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
toast.success('Image downloaded successfully!');
} catch (error) {
console.error('Download failed:', error);
toast.error('Failed to download image.');
}
};
if (!imageUrl) return <div>Loading image...</div>;
return (
<div className="flex flex-col items-center gap-4 w-full h-full">
<div className="flex gap-2 mb-4">
<button
onClick={onReset}
className="px-4 py-2 bg-slate-200 text-slate-700 rounded hover:bg-slate-300 transition-colors"
>
Change Image
</button>
<button
onClick={handleDownload}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors shadow-lg"
>
Download Image
</button>
</div>
<div className="border border-slate-200 shadow-xl rounded-lg overflow-hidden bg-slate-50 inline-block relative">
<div
ref={containerRef}
className="relative inline-block"
style={{ lineHeight: 0 }}
>
<img
src={imageUrl}
alt="Uploaded"
className="max-h-[70vh] w-auto h-auto object-contain select-none"
draggable={false}
/>
<div
ref={qrRef}
className="absolute cursor-move select-none group"
style={{
left: position.x,
top: position.y,
width: size,
height: size,
zIndex: 10,
}}
onMouseDown={handleMouseDown}
>
<img
src={qrCodeUrl}
alt="QR Code"
className="w-full h-full select-none pointer-events-none"
crossOrigin="anonymous" // Important for html2canvas
/>
{/* Outline on hover/interaction */}
<div className="absolute inset-0 border-2 border-transparent group-hover:border-blue-400 group-active:border-blue-500 pointer-events-none rounded-sm transition-colors" />
{/* Resize handle */}
<div
className="absolute bottom-0 right-0 w-4 h-4 bg-blue-500 rounded-full cursor-nwse-resize opacity-0 group-hover:opacity-100 transition-opacity"
onMouseDown={handleResizeMouseDown}
style={{ transform: 'translate(50%, 50%)' }}
/>
</div>
</div>
</div>
<p className="text-sm text-slate-500 mt-2">
Drag to move the QR code. Drag the blue dot to resize.
</p>
</div>
);
};
+83
View File
@@ -0,0 +1,83 @@
import {
Outlet,
ScrollRestoration,
useLocation,
useNavigate,
} from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useAuthStore } from './features/auth/store/auth.store';
import { Sidebar } from '../components/Sidebar';
import { MenuOutlined } from '@ant-design/icons';
// Helper to determine route types
const isPublicRoute = (pathname: string) => {
return pathname.startsWith('/auth') || pathname === '/auth/callback';
};
export default function RootLayout() {
const location = useLocation();
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
useEffect(() => {
// If we're on a public route, no auth check needed
if (isPublicRoute(location.pathname)) {
return;
}
// AUTH CHECK
if (!isAuthenticated) {
// No session -> Redirect to login
navigate('/auth/login', { replace: true });
return;
}
}, [location.pathname, navigate, isAuthenticated]);
// RENDER LOGIC
// 1. Public Pages (Full Layout Control)
if (isPublicRoute(location.pathname)) {
return (
<>
<Outlet />
<ScrollRestoration />
</>
);
}
// 2. Protected Pages
if (!isAuthenticated) {
return null;
}
return (
<div className="flex min-h-screen bg-gray-50">
<Sidebar
isOpen={mobileSidebarOpen}
onClose={() => setMobileSidebarOpen(false)}
/>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* Mobile Header */}
<header className="lg:hidden bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between sticky top-0 z-30">
<div className="flex items-center gap-3">
<button
onClick={() => setMobileSidebarOpen(true)}
className="p-2 -ml-2 rounded-md hover:bg-gray-100 text-gray-700"
>
<MenuOutlined className="text-lg" />
</button>
<h1 className="font-semibold text-gray-900">QR Campaign</h1>
</div>
</header>
<main className="flex-1 overflow-y-auto p-4 md:p-8">
<Outlet />
</main>
</div>
<ScrollRestoration />
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useState } from 'react';
import { Dropzone } from './features/watermark/components/Dropzone';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { toast } from 'sonner';
import { api } from './features/auth/api/auth.service';
export default function HomePage() {
const [imageFile, setImageFile] = useState<File | null>(null);
const [generatedImage, setGeneratedImage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleImageDropped = (file: File) => {
setImageFile(file);
setGeneratedImage(null); // Reset previous result
toast.success('Image selected ready for generation!');
};
const handleReset = () => {
setImageFile(null);
setGeneratedImage(null);
};
const handleGenerate = async () => {
if (!imageFile) return;
setIsLoading(true);
const formData = new FormData();
formData.append('image', imageFile);
try {
const response = await api.post('/campaigns/process-image', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
responseType: 'blob',
});
const imageUrl = URL.createObjectURL(response.data);
setGeneratedImage(imageUrl);
toast.success('QR Code generated successfully!');
} catch (error: any) {
console.error(error);
const message =
error.response?.data?.message || 'Failed to generate QR code.';
toast.error(message);
} finally {
setIsLoading(false);
}
};
const handleDownload = () => {
if (!generatedImage) return;
const link = document.createElement('a');
link.href = generatedImage;
link.download = `qr-campaign-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="max-w-4xl mx-auto py-8 px-4 flex flex-col gap-8">
<header className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
QR Code Generator
</h1>
<p className="text-gray-600">
Upload your image to add the campaign QR code watermark.
</p>
</header>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
{!imageFile ? (
<Dropzone onImageDropped={handleImageDropped} />
) : (
<div className="flex flex-col items-center gap-6">
<div className="relative w-full max-w-2xl bg-gray-50 rounded-lg overflow-hidden border border-gray-200">
{isLoading ? (
<div className="flex flex-col items-center justify-center p-20 gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
<p className="text-gray-500 font-medium">
Processing image...
</p>
</div>
) : generatedImage ? (
<img
src={generatedImage}
alt="Generated with QR"
className="w-full h-auto object-contain max-h-[600px]"
/>
) : (
<div className="relative">
<img
src={URL.createObjectURL(imageFile)}
alt="Original"
className="w-full h-auto object-contain max-h-[400px]"
/>
<div className="absolute inset-0 bg-black/5 flex items-center justify-center pointer-events-none">
<span className="bg-black/60 text-white px-3 py-1 rounded-2xl text-sm">
Original Image
</span>
</div>
</div>
)}
</div>
<div className="flex gap-4">
{!generatedImage && !isLoading && (
<>
<Button
variant="bordered"
onClick={handleReset}
className="w-32"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleGenerate}
className="w-40"
disabled={isLoading}
>
Generate QR
</Button>
</>
)}
{generatedImage && (
<>
<Button variant="bordered" onClick={handleReset}>
Upload Another
</Button>
<Button variant="primary" onClick={handleDownload}>
Download
</Button>
</>
)}
</div>
</div>
)}
</div>
</div>
);
}
View File
+229
View File
@@ -0,0 +1,229 @@
import {
AppstoreOutlined,
UsergroupAddOutlined,
QrcodeOutlined,
LogoutOutlined,
DownOutlined,
RightOutlined,
} from '@ant-design/icons';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { FC, ReactElement, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn, For } from '@imphnen-frontend-service/utils';
import { useAuthStore } from '../app/features/auth/store/auth.store';
type MenuItem = {
label: string;
href?: string;
icon?: ReactElement;
children?: Array<{ label: string; href: string; icon?: ReactElement }>;
roles?: string[]; // Roles that can see this menu
};
const MENUS: MenuItem[] = [
{
label: 'QR Generator',
href: '/',
icon: <QrcodeOutlined className="text-[20px]" />,
roles: ['User', 'Admin', 'Super Admin'],
},
{
label: 'Campaign Management',
href: '/admin/campaigns',
icon: <AppstoreOutlined className="text-[20px]" />,
roles: ['Admin', 'Super Admin'],
},
{
label: 'User Management',
href: '/admin/users',
icon: <UsergroupAddOutlined className="text-[20px]" />,
roles: ['Admin', 'Super Admin'],
},
];
interface SidebarProps {
isOpen?: boolean;
onClose?: () => void;
}
export const Sidebar: FC<SidebarProps> = ({
isOpen = false,
onClose,
}): ReactElement => {
const { user, logout } = useAuthStore();
const location = useLocation();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const userRole = user?.role?.name || 'User';
const isActive = (path: string) => {
if (path === '/' && location.pathname !== '/') return false;
return location.pathname.startsWith(path);
};
const toggleGroup = (groupLabel: string) => {
setOpenGroups((prev) => ({ ...prev, [groupLabel]: !prev[groupLabel] }));
};
// Filter menus based on role
const filteredMenus = MENUS.filter((menu) => {
if (!menu.roles) return true;
return menu.roles.includes(userRole);
});
const sidebarContent = (
<div className="w-[280px] bg-white h-dvh px-7 shadow-xl flex flex-col border-r border-gray-100">
<div className="shrink-0 py-10 lg:py-[60px]">
<div className="flex justify-between lg:justify-center items-center w-full">
<img
src="/images/imphnen-logo.svg"
alt="IMPHNEN Logo"
className="w-[150px]"
/>
{onClose && (
<button
onClick={onClose}
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
aria-label="Close sidebar"
>
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
</div>
{/* Navigation - Scrollable area */}
<nav className="flex-1 flex flex-col gap-4 w-full overflow-y-auto min-h-0 pb-4">
<For data={filteredMenus}>
{(menu) =>
menu.children && menu.children.length > 0 ? (
<div key={menu.label} className="w-full">
<button
type="button"
onClick={() => toggleGroup(menu.label)}
className={cn(
'flex items-center justify-between w-full gap-3 px-2 py-2.5 rounded-md cursor-pointer',
openGroups[menu.label]
? 'bg-primary-50 text-primary-700'
: 'text-gray-700 hover:bg-gray-50'
)}
>
<div className="flex items-center gap-3">
{menu.icon}
<span className="text-sm font-medium">{menu.label}</span>
</div>
<span className="text-xs">
{openGroups[menu.label] ? (
<DownOutlined />
) : (
<RightOutlined />
)}
</span>
</button>
{openGroups[menu.label] && (
<div className="mt-2 ml-6 flex flex-col gap-2">
{menu.children.map((child) => (
<Link
key={child.href}
to={child.href}
className={cn(
'flex items-center gap-3 px-2 py-2.5 rounded-md',
isActive(child.href)
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:bg-gray-50'
)}
>
{child.icon}
<span className="text-sm font-medium">
{child.label}
</span>
</Link>
))}
</div>
)}
</div>
) : (
<Link
key={menu.href ?? menu.label}
to={menu.href ?? '#'}
className={cn(
'flex items-center justify-start gap-3 px-2 py-2.5 rounded-md transition-colors',
menu.href && isActive(menu.href)
? 'bg-primary-500 text-white hover:bg-primary-600 shadow-sm'
: 'text-gray-700 hover:bg-gray-100'
)}
>
{menu.icon}
<span className="text-sm font-medium">{menu.label}</span>
</Link>
)
}
</For>
</nav>
{/* Footer - Fixed at bottom */}
<div className="shrink-0 w-full pb-10 lg:pb-[60px]">
<hr className="mb-5 border-gray-100" />
<div className="px-2 mb-4">
<div className="text-xs text-gray-500 font-medium uppercase mb-2 select-none">
Signed in as
</div>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 font-bold text-xs ring-2 ring-white">
{user?.fullname?.charAt(0) || 'U'}
</div>
<div className="flex flex-col overflow-hidden">
<span className="text-sm font-medium truncate text-gray-900">
{user?.fullname}
</span>
<span className="text-xs text-gray-500 truncate">
{user?.role?.name}
</span>
</div>
</div>
</div>
<Button
onClick={logout}
variant="text"
className="items-center justify-start gap-3 px-2 py-2.5 text-gray-600 hover:text-red-600 dark:hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-50 transition-colors w-full rounded-md"
>
<LogoutOutlined className="text-lg" />
<span className="text-sm font-medium">Log Out</span>
</Button>
</div>
</div>
);
return (
<>
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto border-r border-gray-200">
{sidebarContent}
</div>
{isOpen && (
<div className="lg:hidden fixed inset-0 z-50">
<div
className="fixed inset-0 bg-black/50 transition-opacity backdrop-blur-sm"
onClick={onClose}
/>
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
{sidebarContent}
</div>
</div>
)}
</>
);
};
+136
View File
@@ -0,0 +1,136 @@
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
@import 'tailwindcss';
@source "../../../libs/ui/**/*.{ts,tsx}";
@theme {
--color-primary-50: #f0f8ff;
--color-primary-100: #e1f0fd;
--color-primary-200: #bce1fb;
--color-primary-300: #81cbf8;
--color-primary-400: #3eb0f2;
--color-primary-500: #23a1eb;
--color-primary-600: #0877c1;
--color-primary-700: #085f9c;
--color-primary-800: #0b5181;
--color-primary-900: #0f446b;
--color-primary-950: #0a2b47;
--color-neutral-50: #f6f6f6;
--color-neutral-100: #e7e7e7;
--color-neutral-200: #d1d1d1;
--color-neutral-300: #b0b0b0;
--color-neutral-400: #888888;
--color-neutral-500: #6e6a86;
--color-neutral-600: #56526e;
--color-neutral-700: #44415a;
--color-neutral-800: #393552;
--color-neutral-900: #2a273f;
--color-neutral-950: #232136;
--color-success-100: #e0fbd8;
--color-success-200: #bcf8b0;
--color-success-300: #8eea85;
--color-success-400: #63d564;
--color-success-500: #35ba43;
--color-success-600: #269f3e;
--color-success-700: #1a8439;
--color-success-800: #106b32;
--color-success-900: #0b592f;
--color-info-100: #ccfcfe;
--color-info-200: #9bf3fd;
--color-info-300: #67e3fb;
--color-info-400: #42cdf8;
--color-info-500: #04acf3;
--color-info-600: #0185d0;
--color-info-700: #0264af;
--color-info-800: #01478d;
--color-info-900: #003375;
--color-warning-100: #fffcd3;
--color-warning-200: #fffaa9;
--color-warning-300: #fff67d;
--color-warning-400: #fff25d;
--color-warning-500: #ffed27;
--color-warning-600: #dbc91d;
--color-warning-700: #b7a714;
--color-warning-800: #93850b;
--color-warning-900: #7a6d07;
--color-danger-100: #ffe8da;
--color-danger-200: #ffcbb3;
--color-danger-300: #ffaa8d;
--color-danger-400: #ff8870;
--color-danger-500: #ff5242;
--color-danger-600: #da3030;
--color-danger-700: #b7212d;
--color-danger-800: #93152a;
--color-danger-900: #7a0c27;
--radius-none: 0px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-full: 50%;
--font-bai-jamjuree: 'Bai Jamjuree', sans-serif;
--text-h1: 3.833rem;
/* ~46px */
--text-h2: 3.083rem;
/* ~37px */
--text-h3: 2.417rem;
/* ~29px */
--text-p1: 1.917rem;
/* ~23px */
--text-p2: 1.583rem;
/* ~19px */
--text-p3: 1.25rem;
/* 15px */
--text-label1: 1rem;
/* 12px */
--text-label2: 0.833rem;
/* ~10px */
--text-label3: 0.677rem;
/* ~8px */
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-neutral-200, currentColor);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-neutral-50);
}
::-webkit-scrollbar-thumb {
background: #cccccc;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-neutral-300);
}
html {
font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 1.2;
}
}
+42
View File
@@ -0,0 +1,42 @@
import { createRoot } from 'react-dom/client';
import { StrictMode } from 'react';
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router';
import {
add404PageToRoutesChildren,
addErrorElementToRoutes,
convertPagesToRoute,
ModalLoginProvider,
QueryProvider,
} from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner';
import './index.css';
const files = import.meta.glob('./app/**/*(page|layout).tsx');
const errorFiles = import.meta.glob('./app/**/*error.tsx');
const notFoundFiles = import.meta.glob('./app/**/*404.tsx');
const loadingFiles = import.meta.glob('./app/**/*loading.tsx');
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject;
addErrorElementToRoutes(errorFiles, routes);
add404PageToRoutesChildren(notFoundFiles, routes);
const router = createBrowserRouter([
{
...routes,
},
]);
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render(
<StrictMode>
<QueryProvider>
<ModalLoginProvider>
<Toaster position="top-right" richColors />
<RouterProvider router={router} />
</ModalLoginProvider>
</QueryProvider>
</StrictMode>
);
+1
View File
@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */
+27
View File
@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
]
},
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.jsx",
"src/**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["vite/client", "vitest"]
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../tsconfig.base.json"
}
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}
+43
View File
@@ -0,0 +1,43 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: import.meta.dirname,
cacheDir: '../../node_modules/.vite/apps/qrcampaign',
server: {
port: 3008,
host: 'localhost',
},
preview: {
port: 3008,
host: 'localhost',
},
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
build: {
outDir: '../../dist/apps/qrcampaign',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
},
test: {
name: 'qrcampaign',
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/apps/qrcampaign',
provider: 'v8' as const,
},
},
}));
+9
View File
@@ -0,0 +1,9 @@
services:
qrcampaign:
build:
context: .
dockerfile: docker/qrcampaign.Dockerfile
ports:
- '127.0.0.1:3006:80'
restart: unless-stopped
container_name: qrcampaign
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --prefer-offline --no-audit --no-fund
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NX_DAEMON=false
RUN npm run qrcampaign:build
FROM nginx:alpine AS runner
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist/apps/qrcampaign /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1769170682,
"narHash": "sha256-oMmN1lVQU0F0W2k6OI3bgdzp2YOHWYUAw79qzDSjenU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c5296fdd05cfa2c187990dd909864da9658df755",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+124
View File
@@ -0,0 +1,124 @@
{
description = "Imphnen Frontend Service - Nx Monorepo";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
# NPM dependencies hash (update with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json)
npmDepsHash = "sha256-6O36QDvjiD7j5MgBhID3+5IeJ47MUeK/025ZTKD0ltU=";
# Import helpers
mkViteApp = import ./nix/mkViteApp.nix {
inherit pkgs npmDepsHash;
src = ./.;
};
landingApp = import ./nix/landing.nix {
inherit pkgs npmDepsHash;
src = ./.;
};
in {
packages = {
# Static Vite apps
backoffice = mkViteApp { name = "backoffice"; buildScript = "backoffice:build"; };
gacha = mkViteApp { name = "gacha"; buildScript = "gacha:build"; };
dimentorin = mkViteApp { name = "dimentorin"; buildScript = "dimentorin:build"; };
hackathon = mkViteApp { name = "hackathon"; buildScript = "hackathon:build"; };
infra = mkViteApp { name = "infra"; buildScript = "infra:build"; };
qrcampaign = mkViteApp { name = "qrcampaign"; buildScript = "qrcampaign:build"; };
# Next.js app
landing = landingApp;
# Default package
default = self.packages.${system}.landing;
};
# Development shell
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs
nodePackages.npm
bun
# Useful dev tools
git
jq
];
shellHook = ''
echo "🚀 Imphnen Frontend Development Shell"
echo "Node.js: $(node --version)"
echo "npm: $(npm --version)"
echo ""
echo "Available commands:"
echo " npm install - Install dependencies"
echo " nx dev <app> - Start development server"
echo " nx build <app> - Build an app"
echo ""
echo "Apps: landing, backoffice, gacha, dimentorin, hackathon, qrcampaign"
echo ""
echo "Build packages with:"
echo " nix build .#landing"
echo " nix build .#backoffice"
echo " nix build .#gacha"
echo " nix build .#dimentorin"
echo " nix build .#hackathon"
echo " nix build .#qrcampaign"
'';
};
}
) // {
# NixOS modules for deployment
nixosModules = {
landing = import ./nix/modules/landing.nix { inherit self; };
backoffice = import ./nix/modules/static-app.nix { inherit self; } { appName = "backoffice"; };
gacha = import ./nix/modules/static-app.nix { inherit self; } { appName = "gacha"; };
dimentorin = import ./nix/modules/static-app.nix { inherit self; } { appName = "dimentorin"; };
hackathon = import ./nix/modules/static-app.nix { inherit self; } { appName = "hackathon"; };
infra = import ./nix/modules/static-app.nix { inherit self; } { appName = "infra"; };
qrcampaign = import ./nix/modules/static-app.nix { inherit self; } { appName = "qrcampaign"; };
# All-in-one module that enables all apps
all = { config, lib, pkgs, ... }: {
imports = [
self.nixosModules.landing
self.nixosModules.backoffice
self.nixosModules.gacha
self.nixosModules.dimentorin
self.nixosModules.hackathon
self.nixosModules.infra
self.nixosModules.qrcampaign
];
};
};
# Overlay for easy integration
overlays.default = final: prev: {
imphnen = {
landing = self.packages.${final.system}.landing;
backoffice = self.packages.${final.system}.backoffice;
gacha = self.packages.${final.system}.gacha;
dimentorin = self.packages.${final.system}.dimentorin;
hackathon = self.packages.${final.system}.hackathon;
infra = self.packages.${final.system}.infra;
qrcampaign = self.packages.${final.system}.qrcampaign;
# Function to build hackathon with custom environment variables (e.g., Supabase)
mkHackathonWithEnv = envVars: import ./nix/mkViteApp.nix {
pkgs = final;
src = self;
npmDepsHash = "sha256-6O36QDvjiD7j5MgBhID3+5IeJ47MUeK/025ZTKD0ltU=";
} { name = "hackathon"; buildScript = "hackathon:build"; inherit envVars; };
};
};
};
}
+4 -3
View File
@@ -31,12 +31,13 @@ hackathonApi.interceptors.response.use(
(response) => response,
(error) => {
// Handle 401 - clear session and redirect to login
// But skip redirect if already on auth pages (to avoid reload on login failure)
// But skip redirect if already on auth pages or certificate pages (to avoid reload on login failure)
if (error.response?.status === 401) {
const isAuthPage = globalThis.window !== undefined && globalThis.location.pathname.startsWith('/auth');
const isCertificatePage = globalThis.window !== undefined && globalThis.location.pathname.startsWith('/certificate/');
// Only clear session and redirect if not on auth page
if (!isAuthPage) {
// Only clear session and redirect if not on auth page or certificate page
if (!isAuthPage && !isCertificatePage) {
useAuthStore.getState().clearSession();
if (globalThis.window !== undefined) {
globalThis.location.href = '/auth/login';

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