first init

This commit is contained in:
putriayuwdd 2025-08-08 13:25:39 +07:00
commit cc403fc5ec
1192 changed files with 154592 additions and 0 deletions

View File

@ -0,0 +1,11 @@
{
"extends": "next/core-web-vitals",
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-unused-vars": "warn",
"import/no-unresolved": "off",
"import/named": "off",
"no-console": "warn",
"react-hooks/exhaustive-deps": "warn"
}
}

14
denta-admin/.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,14 @@
# These are supported funding model platforms
github: #[kiranism]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: kir4n
custom: ['https://www.paypal.me/imkir4n'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

45
denta-admin/.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.idea/
# cursor
.cursorrules
/memory-bank
# Sentry Config File
.env.sentry-build-plugin

View File

@ -0,0 +1 @@
npx lint-staged

View File

@ -0,0 +1 @@
pnpm run build

4
denta-admin/.npmrc Normal file
View File

@ -0,0 +1,4 @@
legacy-peer-deps=true
shamefully-hoist=true
# added this to fix sentry warning
# https://docs.sentry.io/platforms/javascript/troubleshooting/#pnpm-resolving-import-in-the-middle-external-package-errors

View File

@ -0,0 +1,48 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
.next
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# changelog
CHANGELOG.md
pnpm-lock.yaml
# Other common ignores
node_modules
.next
build
dist
ico

12
denta-admin/.prettierrc Normal file
View File

@ -0,0 +1,12 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"semi": true,
"useTabs": false,
"trailingComma": "none",
"jsxSingleQuote": true,
"singleQuote": true,
"tabWidth": 2,
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"]
}

31
denta-admin/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,31 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev",
"serverReadyAction": {
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
},
"env": {
"NEXT_PUBLIC_SENTRY_DISABLED": "true"
}
}
]
}

21
denta-admin/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Kiranism
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

0
denta-admin/README.md Normal file
View File

View File

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@ -0,0 +1,57 @@
# =================================================================
# Authentication Configuration (Firebase)
# =================================================================
# Firebase configuration for authentication
# Get these values from your Firebase project console
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=
# =================================================================
# Error Tracking Configuration (Sentry)
# =================================================================
# To set up Sentry error tracking:
# 1. Create an account at https://sentry.io
# 2. Create a new project for Next.js
# 3. Follow the setup instructions below
# Step 1: Sentry DSN (Required)
# Found at: Settings > Projects > [Your Project] > Client Keys (DSN)
NEXT_PUBLIC_SENTRY_DSN= #Example: https://****@****.ingest.sentry.io/****
# Step 2: Organization & Project Details
# Found at: Settings > Organization > General Settings
NEXT_PUBLIC_SENTRY_ORG= # Example: acme-corp
NEXT_PUBLIC_SENTRY_PROJECT= # Example: nextjs-dashboard
# Step 3: Sentry Auth Token
# Sentry can automatically provide readable stack traces for errors using source maps, requiring a Sentry auth token.
# More info: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#step-4-add-readable-stack-traces-with-source-maps-optional
SENTRY_AUTH_TOKEN= #Example: sntrys_************************************
# Step 4: Environment Control (Optional)
# Set to 'true' to disable Sentry in development
NEXT_PUBLIC_SENTRY_DISABLED= "false"
# =================================================================
# Important Notes:
# =================================================================
# 1. Rename this file to '.env' for local development
# 2. Never commit the actual '.env' file to version control
# 3. Make sure to replace all placeholder values with real ones
# 4. Keep your secret keys private and never share them

View File

@ -0,0 +1,178 @@
{
"project_info": {
"project_number": "32327803117",
"project_id": "ta-dentakoas-73626",
"storage_bucket": "ta-dentakoas-73626.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:32327803117:android:c3d28dbe4aa4a4bbbe6ce4",
"android_client_info": {
"package_name": "com.dentakoas.app"
}
},
"oauth_client": [
{
"client_id": "32327803117-anqq7kasfpgkq8mptuf2vo3pn9autlgg.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.dentakoas.app",
"certificate_hash": "9756b541f9a99c507bc1dff5af47f98a349651ba"
}
},
{
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDt4x8WK99EBw1T4_lQBsCVtYJko5_oIFA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "32327803117-v1smdefgi2qdneobft1acnjelk1v5l6u.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.example.dentaKoas"
}
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:32327803117:android:f93b17d6cc3486dabe6ce4",
"android_client_info": {
"package_name": "com.dentakoas.com"
}
},
"oauth_client": [
{
"client_id": "32327803117-7ke405dr33senpusjo9h2iidhcfhnqv8.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.dentakoas.com",
"certificate_hash": "9756b541f9a99c507bc1dff5af47f98a349651ba"
}
},
{
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDt4x8WK99EBw1T4_lQBsCVtYJko5_oIFA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "32327803117-v1smdefgi2qdneobft1acnjelk1v5l6u.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.example.dentaKoas"
}
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:32327803117:android:ef30747f79a105c9be6ce4",
"android_client_info": {
"package_name": "com.example.denta_koas"
}
},
"oauth_client": [
{
"client_id": "32327803117-3v517dooucfeqog94f1hfmfst3k96g78.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.example.denta_koas",
"certificate_hash": "9756b541f9a99c507bc1dff5af47f98a349651ba"
}
},
{
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDt4x8WK99EBw1T4_lQBsCVtYJko5_oIFA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "32327803117-v1smdefgi2qdneobft1acnjelk1v5l6u.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.example.dentaKoas"
}
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:32327803117:android:a255675e744c553dbe6ce4",
"android_client_info": {
"package_name": "com.putricantik.denta_koas"
}
},
"oauth_client": [
{
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDt4x8WK99EBw1T4_lQBsCVtYJko5_oIFA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "32327803117-eqeo68ppcveo0j0moblvl0o87eh0ct10.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "32327803117-v1smdefgi2qdneobft1acnjelk1v5l6u.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.example.dentaKoas"
}
}
]
}
}
}
],
"configuration_version": "1"
}

View File

@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "ta-dentakoas-73626",
"private_key_id": "3756bd3a1dfc9b214a3ef123727f41c7b824dd8f",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCThot63+0JRbMO\nELWR9BayeH2nY33UvRZHQ6YY9p1nzWL/3UrKhkjGyRoMgYwp+kBeLcI+ZuIn+BSu\n9Hb0HZ0VEqHEeRq/4iQryaFn0t8VjqKBKKwJDJ1Ef0rNSxb21ZzdfZM4Nqfj1q//\nKlRSqNWJRAxW6v0TCm9PkSM3VTEYRZXPs+FUIvFntGsvu1BjYyiKNCEbgvsylBcK\nLGrYiPgxrScjzQyaQkw7ONQJr/lxYRRsmuowmTkQUQwpXB6Pp8nKCQ07LZsUMGnK\nBtFg41vBULACdjCH7A02EJy2mNWxiTBzk+5ShlQHW7LRikI/exRL16aThrrAUGWa\ns4BoTHYbAgMBAAECggEAAKDxJ4LBEbMwe3Al7xSUyC41RSkzhe61fyrSNNArqY1w\nX5DDtmaphrvphFz70zcdFa47YhvWayzmeIWKdxb/gM0Oz7Ws+102WNH+uM5nGmbX\n6978W44WEFfzGTpL+lPhSWV+ULBMFN0yEM9POhyqXW/QqIEF2+2gvc7G9cj6J951\n0WFj/imjWAAiKXMdvQV+27sKCZI+VTGaiaZD/Nrj95sM4vre5Ood0POzOQji12CL\nN7ZBkrvXHeOY3uokkPeFXf2nVLU0w4wOYTsangYeDKFqLkLC1hDawjeEb6TpB3on\nmkmfwzEBTlaB7QPdyYA4ypwPBk2E3pNy+mg1BByi8QKBgQDCgpVR8IeJJqmIaun0\ntBf6MIb/z+gxuAcXnaJXJzUoJwaWCI5VImSNjVLUUeYs5uMMyzUQ/dmfFCqaQGuw\nlWGzO/8U+Y2VzSip1s11jypgRt/EdO6wZacmhNLE3ZuF7BEzckyDsVq8ZrC7X9pu\nyLFzwY82ZxJHWySdgoYtmFIfkwKBgQDCKZKhFWCsBc9rErzjnJ2oDdJFAgRq7XLs\nd3UQs4i/N/El1WlCNM/Zdedo+t0pZwuxO7350Cjjbpj7MkGzBEbUWcW2trj96Kbq\noypO3olht23zUG5FGtOpmx7ddhHG1tjQu4nkL5dMR+0dVRGxeWX4j0PtroKQ5uif\nPXU3zpgUWQKBgQCEdZTmRhymo7OT+Or1/6bMdDua/aSsJxx9UpX5L/QdMeZkSdNT\n2qYLV5Buko9jSKCaV8/9kEGg+YTBotkzWac8/cCKMh/fQeVNM8CbLDG5espr6Q2b\nBr67lnxKIHgGzWoxVMcSb7bQ0kXMdEw2YWmm4Mt8StjByym2MeraJf7XDQKBgEjm\nDvWH7znnHI9ZA655uP/F4LKkJUB4UnPSDhPtNGSG/nV0AQcoiPleuTnDJpJK7Crp\nAVTwAMoTVkDKB7zM2EMWSZNmW2KJnVHvXQ0rpG0Zz6BRDHXJsrq1UQtZLyd2twT3\nxLf13lp3juE6dWAq8kw2jgyJPuBkseZHzuDbImJRAoGAUM2tkFOX3XTUD+B38vFI\nHVNA12ekCJOYi9L+/MaAzot2juVxGJuGs8tJVmi0ibJTjaEw/jGEpjzyhrkR6WwT\nMXBsJ/nsqr0LoFmr/pzDnBB7/oKm1eZuQZQcEsSAOoWeSEt4qSH4pnx2B2zxWB1N\nqoGKzZtgB02z2CihOZzTKak=\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-hdtla@ta-dentakoas-73626.iam.gserviceaccount.com",
"client_id": "110480883504754847948",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-hdtla%40ta-dentakoas-73626.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@ -0,0 +1,57 @@
import type { NextConfig } from 'next';
import { withSentryConfig } from '@sentry/nextjs';
// Define the base Next.js configuration
const baseConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'api.slingacademy.com',
port: ''
}
]
},
transpilePackages: ['geist']
};
let configWithPlugins = baseConfig;
// Conditionally enable Sentry configuration
if (!process.env.NEXT_PUBLIC_SENTRY_DISABLED) {
configWithPlugins = withSentryConfig(configWithPlugins, {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
// FIXME: Add your Sentry organization and project names
org: process.env.NEXT_PUBLIC_SENTRY_ORG,
project: process.env.NEXT_PUBLIC_SENTRY_PROJECT,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Upload a larger set of source maps for prettier stack traces (increases build time)
reactComponentAnnotation: {
enabled: true
},
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Disable Sentry telemetry
telemetry: false
});
}
const nextConfig = configWithPlugins;
export default nextConfig;

14600
denta-admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

128
denta-admin/package.json Normal file
View File

@ -0,0 +1,128 @@
{
"name": "next-shadcn-dashboard-starter",
"version": "1.0.0",
"private": true,
"author": {
"name": "Kiran",
"url": "https://github.com/Kiranism"
},
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "eslint src --fix && pnpm format",
"lint:strict": "eslint --max-warnings=0 src",
"format": "prettier --write .",
"format:check": "prettier -c -w .",
"prepare": "husky"
},
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},
"lint-staged": {
"**/*.{js,jsx,tsx,ts,css,less,scss,sass}": [
"prettier --write --no-error-on-unmatched-pattern"
]
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1",
"@prisma/client": "^5.22.0",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-context-menu": "^2.2.6",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@sentry/nextjs": "^9.19.0",
"@tabler/icons-react": "^3.31.0",
"@tailwindcss/postcss": "^4.0.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query-devtools": "^5.83.0",
"@tanstack/react-table": "^8.21.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"eslint": "8.48.0",
"eslint-config-next": "15.1.0",
"firebase": "^12.0.0",
"firebase-admin": "^13.4.0",
"input-otp": "^1.4.2",
"kbar": "^0.1.0-beta.45",
"lucide-react": "^0.476.0",
"match-sorter": "^8.0.0",
"motion": "^11.17.0",
"next": "15.3.2",
"next-firebase-auth-edge": "^1.9.1",
"next-themes": "^0.4.6",
"nextjs-toploader": "^3.7.15",
"nuqs": "^2.4.3",
"postcss": "8.4.49",
"prisma": "^5.22.0",
"react": "19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "19.0.0",
"react-dropzone": "^14.3.5",
"react-firebase-hooks": "^5.1.1",
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^2.1.7",
"react-responsive": "^10.0.0",
"recharts": "^2.15.1",
"sharp": "^0.33.5",
"sonner": "^2.0.6",
"sort-by": "^1.2.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.7.2",
"uuid": "^11.0.3",
"vaul": "^1.1.2",
"zod": "^3.24.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@faker-js/faker": "^9.3.0",
"@types/node": "22.10.2",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.2",
"@types/sort-by": "^1.2.3",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"husky": "^9.1.7",
"lint-staged": "^15.2.11",
"prettier": "3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tw-animate-css": "^1.2.4"
},
"overrides": {
"@types/react": "19.0.1",
"@types/react-dom": "19.0.2"
}
}

View File

@ -0,0 +1,5 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {}
}
};

9
denta-admin/prisma/db.ts Normal file
View File

@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
const db = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
export default db;

View File

@ -0,0 +1,341 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id String @id
name String? @unique @db.VarChar(50)
email String? @unique
emailVerified DateTime? @map("email_verified")
password String?
phone String? @db.VarChar(13)
address String?
image String?
role Role?
createdAt DateTime @default(now()) @map("created_at")
updateAt DateTime @updatedAt @map("updated_at")
familyName String?
givenName String?
Account Account[]
FasilitatorProfile FasilitatorProfile?
KoasProfile KoasProfile?
Like Like[]
sender Notification[] @relation("Sender")
recipient Notification[] @relation("Recipient")
PasienProfile PasienProfile?
Post Post[]
Review Review[]
sessions Session[]
@@map("users")
}
model Account {
type String?
provider String
scope String?
access_token String? @db.Text
expires_at Int?
id_token String? @db.Text
providerAccountId String @map("provider_account_id")
refresh_token String? @db.Text
token_type String?
userId String @map("user_id")
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId], map: "accounts_user_id_fkey")
@@map("accounts")
}
model Session {
id String @id @default(cuid())
expires DateTime
sessionToken String @map("session_token") @db.Text
userId String? @map("user_id")
accessToken String? @map("access_token") @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId], map: "sessions_user_id_fkey")
@@map("sessions")
}
model Otp {
id String @id @default(cuid())
email String
otp String @unique
expires DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([email, otp])
@@map("otps")
}
model KoasProfile {
id String @id @default(cuid())
userId String @unique @map("user_id")
koasNumber String? @unique @map("koas_number")
age String?
gender String?
departement String?
university String?
bio String?
whatsappLink String? @map("whatsapp_link")
status StatusKoas @default(Pending)
createdAt DateTime @default(now()) @map("created_at")
updateAt DateTime @updatedAt @map("updated_at")
universityId String?
experience Int? @default(0)
Appointment Appointment[]
University University? @relation(fields: [universityId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
Notification Notification[]
Post Post[]
Review Review[]
@@index([universityId], map: "koas-profile_universityId_fkey")
@@map("koas_profile")
}
model PasienProfile {
id String @id @default(cuid())
userId String @unique @map("user_id")
age String?
gender String?
bio String?
createdAt DateTime @default(now()) @map("created_at")
updateAt DateTime @updatedAt @map("updated_at")
Appointment Appointment[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("pasien_profile")
}
model FasilitatorProfile {
id String @id @default(cuid())
userId String @unique @map("user_id")
university String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("fasilitator_profile")
}
model University {
id String @id @default(cuid())
name String
alias String
location String
latitude Float?
longitude Float?
image String?
createdAt DateTime @default(now()) @map("created_at")
updateAt DateTime @updatedAt @map("updated_at")
koasProfile KoasProfile[]
@@map("universities")
}
model Post {
id String @id @default(cuid())
userId String @map("user_id")
koasId String @map("koas_id")
treatmentId String @map("treatment_id")
title String
desc String @db.VarChar(500)
patientRequirement Json? @map("patient_requirement")
status StatusPost @default(Pending)
published Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updateAt DateTime @updatedAt @map("updated_at")
requiredParticipant Int @default(0) @map("required_participant")
images Json?
likes Like[]
koas KoasProfile @relation(fields: [koasId], references: [id], onDelete: Cascade)
treatment TreatmentType @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
Review Review[]
Schedule Schedule[]
@@index([userId], map: "user_id")
@@index([koasId], map: "koas_id")
@@index([treatmentId], map: "treatment_id")
@@map("posts")
}
model Like {
id String @id @default(cuid())
postId String @map("post_id")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
Post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([postId], map: "post_id")
@@index([userId], map: "user_id")
@@map("likes")
}
model Notification {
id String @id @default(cuid())
message String
createdAt DateTime @default(now())
koasId String?
senderId String?
status StatusNotification @default(Unread)
title String
updatedAt DateTime @updatedAt
userId String?
koasProfile KoasProfile? @relation(fields: [koasId], references: [id], onDelete: Cascade)
sender User? @relation("Sender", fields: [senderId], references: [id], onDelete: Cascade)
recipient User? @relation("Recipient", fields: [userId], references: [id], onDelete: Cascade)
@@index([koasId], map: "notifications_koasId_fkey")
@@index([senderId], map: "notifications_senderId_fkey")
@@index([userId], map: "notifications_userId_fkey")
@@map("notifications")
}
model TreatmentType {
id String @id @default(cuid())
name String
createdAt DateTime @default(now()) @map("created_at")
updateAt DateTime @updatedAt @map("updated_at")
alias String
image String?
Post Post[]
@@map("treatment_types")
}
model Schedule {
id String @id @default(cuid())
postId String @map("post_id")
createdAt DateTime @default(now()) @map("created_at")
dateEnd DateTime @map("date_end")
dateStart DateTime @map("date_start")
updateAt DateTime @updatedAt @map("updated_at")
Appointment Appointment[]
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
timeslot Timeslot[]
@@index([postId], map: "post_id")
@@map("schedules")
}
model Timeslot {
id String @id @default(cuid())
startTime String @map("start_time")
endTime String @map("end_time")
maxParticipants Int? @map("max_participants")
currentParticipants Int @default(0) @map("current_participants")
isAvailable Boolean @default(true) @map("is_available")
scheduleId String @map("schedule_id")
Appointment Appointment[]
schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
@@unique([scheduleId, startTime, endTime], name: "unique_timeslot")
@@map("timeslots")
}
model Review {
id String @id @default(cuid())
postId String @map("post_id")
pasienId String @map("user_id")
rating Decimal @default(0.000000000000000000000000000000)
comment String? @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
koasId String? @map("koas_id")
KoasProfile KoasProfile? @relation(fields: [koasId], references: [userId])
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User @relation(fields: [pasienId], references: [id], onDelete: Cascade)
@@index([postId], map: "post_id")
@@index([pasienId], map: "pasien_Id")
@@index([koasId], map: "koas_id")
@@map("reviews")
}
model Appointment {
id String @id @default(cuid())
pasienId String @map("pasien_id")
koasId String @map("koas_id")
scheduleId String @map("schedule_id")
timeslotId String @map("timeslot_id")
date String
status StatusAppointment @default(Pending)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
koas KoasProfile @relation(fields: [koasId], references: [id], onDelete: Cascade)
pasien PasienProfile @relation(fields: [pasienId], references: [id], onDelete: Cascade)
schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
timeslot Timeslot @relation(fields: [timeslotId], references: [id], onDelete: Cascade)
@@index([koasId], map: "appointment_koas_id_fkey")
@@index([pasienId], map: "appointment_pasien_id_fkey")
@@index([scheduleId], map: "appointment_schedule_id_fkey")
@@index([timeslotId], map: "appointment_timeslot_id_fkey")
@@map("appointments")
}
model verificationrequest {
id String @id
token String @unique(map: "VerificationRequest_token_key")
expires DateTime
createdAt DateTime @default(now())
updatedAt DateTime
email String
@@unique([email, token], map: "VerificationRequest_email_token_key")
}
enum Role {
Admin
Koas
Pasien
Fasilitator
@@map("roles")
}
enum StatusPost {
Pending
Open
Closed
@@map("status_post")
}
enum StatusKoas {
Rejected
Pending
Approved
@@map("status_koas")
}
enum StatusAppointment {
Canceled
Rejected
Pending
Confirmed
Ongoing
Completed
@@map("status_appointment")
}
enum StatusNotification {
Unread
Read
}

View File

@ -0,0 +1 @@
<svg class="css-lfbo6j e1igk8x04" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 222 66" width="80" height="24" style="background-color: rgb(88, 70, 116);"><path d="M29,2.26a4.67,4.67,0,0,0-8,0L14.42,13.53A32.21,32.21,0,0,1,32.17,40.19H27.55A27.68,27.68,0,0,0,12.09,17.47L6,28a15.92,15.92,0,0,1,9.23,12.17H4.62A.76.76,0,0,1,4,39.06l2.94-5a10.74,10.74,0,0,0-3.36-1.9l-2.91,5a4.54,4.54,0,0,0,1.69,6.24A4.66,4.66,0,0,0,4.62,44H19.15a19.4,19.4,0,0,0-8-17.31l2.31-4A23.87,23.87,0,0,1,23.76,44H36.07a35.88,35.88,0,0,0-16.41-31.8l4.67-8a.77.77,0,0,1,1.05-.27c.53.29,20.29,34.77,20.66,35.17a.76.76,0,0,1-.68,1.13H40.6q.09,1.91,0,3.81h4.78A4.59,4.59,0,0,0,50,39.43a4.49,4.49,0,0,0-.62-2.28Z M124.32,28.28,109.56,9.22h-3.68V34.77h3.73V15.19l15.18,19.58h3.26V9.22h-3.73ZM87.15,23.54h13.23V20.22H87.14V12.53h14.93V9.21H83.34V34.77h18.92V31.45H87.14ZM71.59,20.3h0C66.44,19.06,65,18.08,65,15.7c0-2.14,1.89-3.59,4.71-3.59a12.06,12.06,0,0,1,7.07,2.55l2-2.83a14.1,14.1,0,0,0-9-3c-5.06,0-8.59,3-8.59,7.27,0,4.6,3,6.19,8.46,7.52C74.51,24.74,76,25.78,76,28.11s-2,3.77-5.09,3.77a12.34,12.34,0,0,1-8.3-3.26l-2.25,2.69a15.94,15.94,0,0,0,10.42,3.85c5.48,0,9-2.95,9-7.51C79.75,23.79,77.47,21.72,71.59,20.3ZM195.7,9.22l-7.69,12-7.64-12h-4.46L186,24.67V34.78h3.84V24.55L200,9.22Zm-64.63,3.46h8.37v22.1h3.84V12.68h8.37V9.22H131.08ZM169.41,24.8c3.86-1.07,6-3.77,6-7.63,0-4.91-3.59-8-9.38-8H154.67V34.76h3.8V25.58h6.45l6.48,9.2h4.44l-7-9.82Zm-10.95-2.5V12.6h7.17c3.74,0,5.88,1.77,5.88,4.84s-2.29,4.86-5.84,4.86Z" transform="translate(11, 11)" fill="#ffffff"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@ -0,0 +1,11 @@
import { Metadata } from 'next';
import ForgotPasswordView from '@/features/auth/components/forgot-password-view';
export const metadata: Metadata = {
title: 'Forgot Password',
description: 'Reset your password by entering your email address.'
};
export default function ForgotPasswordPage() {
return <ForgotPasswordView />;
}

View File

@ -0,0 +1,14 @@
import AuthLayout from '@/features/auth/components/auth-layout';
export default async function Layout({
children
}: {
children: React.ReactNode;
}) {
return (
<>
{/* <Meteors number={30} className="fixed top-0 left-0 w-full h-full" /> */}
<AuthLayout variant='center'>{children}</AuthLayout>
</>
);
}

View File

@ -0,0 +1,10 @@
import OtpVerificationView from '@/features/auth/components/otp-verification-view';
export default async function OtpVerification({
params
}: {
params: Promise<any>;
}) {
await params;
return <OtpVerificationView />;
}

View File

@ -0,0 +1,11 @@
import { Metadata } from 'next';
import PasswordResetSentView from '@/features/auth/components/password-reset-sent-view';
export const metadata: Metadata = {
title: 'Password Reset Email Sent',
description: 'Password reset email has been sent to your inbox.'
};
export default async function Page() {
return <PasswordResetSentView />;
}

View File

@ -0,0 +1,10 @@
import ResetPasswordView from '@/features/auth/components/reset-password-view';
export default async function ResetPassword({
params
}: {
params: Promise<any>;
}) {
await params;
return <ResetPasswordView />;
}

View File

@ -0,0 +1,11 @@
import { Metadata } from 'next';
import SignInViewPage from '@/features/auth/components/sign-in-view';
export const metadata: Metadata = {
title: 'Authentication | Sign In',
description: 'Sign In page for authentication.'
};
export default async function Page() {
return <SignInViewPage />;
}

View File

@ -0,0 +1,30 @@
'use server';
import { RoleEnum } from '@/types/models';
import { Role } from '@prisma/client';
import db from 'prisma/db';
// Helper function to check user role
export async function checkUserRole(email: string) {
try {
const user = await db.user.findUnique({
where: { email },
select: { role: true }
});
if (!user) {
throw new Error('User not found in database');
}
if (user.role !== RoleEnum.Admin) {
throw new Error(
'Access denied. Only administrators can access this application.'
);
}
return user;
} catch (error) {
console.error('Role check error:', error);
throw error;
}
}

View File

@ -0,0 +1,77 @@
'use server';
import { ROUTES } from '@/constants/routes';
import { apiRequest } from '@/lib/api-utils';
import { IAppointmentModel, StatusAppointmentEnum } from '@/types/models';
// Types for mutations
export interface CreateAppointmentData {
pasienId: string;
koasId: string;
scheduleId: string;
timeslotId: string;
date: string;
status?: StatusAppointmentEnum;
}
export interface UpdateAppointmentData {
id: string;
pasienId?: string;
koasId?: string;
scheduleId?: string;
timeslotId?: string;
date?: string;
status?: StatusAppointmentEnum;
}
export interface DeleteAppointmentData {
id: string;
}
// Mutation functions
export const createAppointment = async (
data: CreateAppointmentData
): Promise<IAppointmentModel> => {
return apiRequest<IAppointmentModel>(ROUTES.API.APPOINTMENTS, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...data,
status: data.status || StatusAppointmentEnum.Pending
})
});
};
export const updateAppointment = async (
data: UpdateAppointmentData
): Promise<IAppointmentModel> => {
const { id, ...updateData } = data;
return apiRequest<IAppointmentModel>(ROUTES.API.APPOINTMENT(id), {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updateData)
});
};
export const deleteAppointment = async (id: string): Promise<void> => {
return apiRequest<void>(ROUTES.API.APPOINTMENT(id), {
method: 'DELETE'
});
};
export const deleteManyAppointments = async (
appointmentIds: string[]
): Promise<void> => {
return apiRequest<void>(`${ROUTES.API.APPOINTMENTS}/bulk-delete`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: appointmentIds })
});
};

View File

@ -0,0 +1,37 @@
import PageContainer from '@/components/layout/page-container';
import { Heading } from '@/components/ui/heading';
import { Separator } from '@/components/ui/separator';
import AppointmentsViewPage from '@/features/appointments/components/appointment-view-page';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import React from 'react';
import { fetchAppointments } from './queries';
import { getQueryClient } from '@/lib/get-query-client';
import { appointmentKeys } from '@/features/appointments/hooks/use-queries';
const AppointmentsPage = () => {
const queryClient = getQueryClient();
queryClient.prefetchQuery({
queryKey: appointmentKeys.list({}),
queryFn: () => fetchAppointments()
});
return (
<PageContainer>
<div className='flex flex-1 flex-col space-y-4'>
<div className='flex items-start justify-between'>
<Heading
title='Appointments Management'
description='Manage appointments'
/>
</div>
<Separator />
<HydrationBoundary state={dehydrate(queryClient)}>
<AppointmentsViewPage />
</HydrationBoundary>
</div>
</PageContainer>
);
};
export default AppointmentsPage;

View File

@ -0,0 +1,105 @@
'use server';
import { ROUTES } from '@/constants/routes';
import { apiRequest, handleArrayResponse } from '@/lib/api-utils';
import {
IAppointmentModel,
IScheduleModel,
ITimeslotModel,
StatusAppointmentEnum
} from '@/types/models';
// Use existing model types
export type Appointment = IAppointmentModel;
export type Schedule = IScheduleModel;
export type Timeslot = ITimeslotModel;
export interface AppointmentsResponse {
appointments: Appointment[];
totalCount?: number;
totalPages?: number;
currentPage?: number;
}
export interface AppointmentFilters {
pasienId?: string;
koasId?: string;
scheduleId?: string;
timeslotId?: string;
status?: StatusAppointmentEnum;
date?: string;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface AppointmentStats {
totalAppointments: number;
pendingAppointments: number;
confirmedAppointments: number;
completedAppointments: number;
canceledAppointments: number;
todayAppointments: number;
upcomingAppointments: number;
}
// Fetch functions
export const fetchAppointments = async (
filters: AppointmentFilters = {}
): Promise<AppointmentsResponse> => {
const searchParams = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, value.toString());
}
});
const url = `${ROUTES.API.APPOINTMENTS}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch appointments: ${response.statusText}`);
}
return response.json();
};
export const fetchAppointmentById = async (
id: string
): Promise<Appointment> => {
return apiRequest<Appointment>(`${ROUTES.API.APPOINTMENT(id)}`);
};
export const fetchAppointmentStats = async (): Promise<AppointmentStats> => {
return apiRequest<AppointmentStats>(`${ROUTES.API.APPOINTMENTS}/stats`);
};
export const fetchAppointmentsByUser = async (
userId: string
): Promise<Appointment[]> => {
return handleArrayResponse<Appointment>(
`${ROUTES.API.APPOINTMENT_WITH_SPECIFIC_USER(userId)}`,
'appointments'
);
};
export const fetchTodayAppointments = async (): Promise<Appointment[]> => {
const today = new Date().toISOString().split('T')[0];
return handleArrayResponse<Appointment>(
`${ROUTES.API.APPOINTMENTS}?date=${today}`,
'appointments'
);
};
export const fetchUpcomingAppointments = async (): Promise<Appointment[]> => {
const today = new Date().toISOString().split('T')[0];
return handleArrayResponse<Appointment>(
`${ROUTES.API.APPOINTMENTS}?dateFrom=${today}&status=Confirmed`,
'appointments'
);
};

View File

@ -0,0 +1,37 @@
import KBar from '@/components/kbar';
import AppSidebar from '@/components/layout/app-sidebar';
import Header from '@/components/layout/header';
import ProtectedRoute from '@/components/layout/protected-route';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import type { Metadata } from 'next';
import { cookies } from 'next/headers';
export const metadata: Metadata = {
title: 'Next Shadcn Dashboard Starter',
description: 'Basic dashboard with Next.js and Shadcn'
};
export default async function DashboardLayout({
children
}: {
children: React.ReactNode;
}) {
// Persisting the sidebar state in the cookie.
const cookieStore = await cookies();
const defaultOpen = cookieStore.get('sidebar_state')?.value === 'true';
return (
<ProtectedRoute>
<KBar>
<SidebarProvider defaultOpen={defaultOpen}>
<AppSidebar />
<SidebarInset>
<Header />
{/* page main content */}
{children}
{/* page main content ends */}
</SidebarInset>
</SidebarProvider>
</KBar>
</ProtectedRoute>
);
}

View File

@ -0,0 +1,33 @@
import PageContainer from '@/components/layout/page-container';
import { Heading } from '@/components/ui/heading';
import { Separator } from '@/components/ui/separator';
import OverviewViewPage from '@/features/overviews/overviews-page';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import React from 'react';
import { fetchStats } from './queries';
import { getQueryClient } from '@/lib/get-query-client';
const Page = () => {
const queryClient = getQueryClient();
queryClient.prefetchQuery({
queryKey: ['stats'],
queryFn: () => fetchStats()
});
return (
<PageContainer>
<div className='flex flex-1 flex-col space-y-2'>
<div className='flex items-start justify-between'>
<Heading title='Welcome back✋' description='' />
</div>
{/* <Separator /> */}
<HydrationBoundary state={dehydrate(queryClient)}>
<OverviewViewPage />
</HydrationBoundary>
</div>
</PageContainer>
);
};
export default Page;

View File

@ -0,0 +1,48 @@
'use server';
import { ROUTES } from '@/constants/routes';
export interface StatsResponse {
status: string;
message: string;
data: {
users: number;
koas: number;
pasien: number;
fasilitator: number;
appointments: number;
posts: number;
reviews: number;
schedules: number;
timeslots: number;
universities: number;
treatmentTypes: number;
notifications: number;
likes: number;
appointmentStatus: { status: string; count: number }[];
postStatus: { status: string; count: number }[];
koasStatus: { status: string; count: number }[];
chartData: {
appointmentPerDay: { date: string; count: number }[];
postPerDay: { date: string; count: number }[];
appointmentStatusDonut: { label: string; value: number }[];
postStatusDonut: { label: string; value: number }[];
userRole: { role: string; count: number }[];
koasPerUniversity: {
universityId: string;
university: string;
count: number;
}[];
pasienGender: { gender: string | null; count: number }[];
koasStatus: { label: string; value: number }[];
reviewRating: { rating: string; count: number }[];
timeslotAvailable: { isAvailable: boolean; count: number }[];
};
};
}
export const fetchStats = async (): Promise<StatsResponse> => {
const res = await fetch(ROUTES.API.STATS);
if (!res.ok) throw new Error('Failed to fetch stats');
return res.json();
};

View File

@ -0,0 +1,84 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ROUTES } from '@/constants/routes';
import { auth, onAuthStateChanged, signOut, User } from '@/lib/firebase';
import { toast } from 'sonner';
export default function Dashboard() {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const router = useRouter();
const checkUserRole = async (email: string) => {
try {
const response = await fetch(ROUTES.API.CHECK_ROLE, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email })
});
if (!response.ok) {
return false;
}
return true;
} catch (error) {
console.error('Role check error:', error);
return false;
}
};
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
if (currentUser && currentUser.email) {
console.log('User is authenticated:', currentUser);
// Check if user has admin role
const hasAdminRole = await checkUserRole(currentUser.email);
if (hasAdminRole) {
setUser(currentUser);
} else {
// Sign out user if they don't have admin role
await signOut(auth);
toast.error(
'Access denied. Only administrators can access this application.'
);
router.push(ROUTES.AUTH.SIGN_IN);
return;
}
} else {
console.log('User is not authenticated, redirecting to sign-in page');
router.push(ROUTES.AUTH.SIGN_IN);
return;
}
setLoading(false);
});
return () => unsubscribe();
}, [router]);
// Redirect authenticated users to overview page
useEffect(() => {
if (user && !loading) {
router.push(ROUTES.APP.DASHBOARD + '/overview');
}
}, [user, loading, router]);
if (loading) {
return (
<div className='flex h-screen w-full items-center justify-center'>
<div className='h-32 w-32 animate-spin rounded-full border-b-2 border-gray-900'></div>
</div>
);
}
if (!user) {
return null; // Will redirect to sign-in
}
return null; // Will redirect to overview
}

View File

@ -0,0 +1,63 @@
'use server';
import { ROUTES } from '@/constants/routes';
import { apiRequest } from '@/lib/api-utils';
import { IPostModel, StatusPostEnum } from '@/types/models';
// Types for mutations
export interface CreatePostData {
title: string;
desc?: string;
patientRequirement?: string;
status: StatusPostEnum;
published: boolean;
requiredParticipant?: number;
userId?: string;
koasId?: string;
treatmentId?: string;
startDate?: Date;
endDate?: Date;
}
export interface UpdatePostData extends Partial<CreatePostData> {
id: string;
}
// Mutation functions
export const createPost = async (data: CreatePostData): Promise<IPostModel> => {
return apiRequest<IPostModel>(ROUTES.API.POSTS, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
};
export const updatePost = async (data: UpdatePostData): Promise<IPostModel> => {
const { id, ...updateData } = data;
return apiRequest<IPostModel>(ROUTES.API.POST(id), {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updateData)
});
};
export const deletePost = async (id: string): Promise<void> => {
return apiRequest<void>(ROUTES.API.POST(id), {
method: 'DELETE'
});
};
export const deleteManyPosts = async (ids: string[]): Promise<void> => {
return apiRequest<void>(`${ROUTES.API.POSTS}/bulk-delete`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids })
});
};

View File

@ -0,0 +1,35 @@
import PageContainer from '@/components/layout/page-container';
import { Heading } from '@/components/ui/heading';
import { Separator } from '@/components/ui/separator';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/get-query-client';
import PostsViewPage from '@/features/posts/components/post-view-page';
import { fetchPosts } from './queries';
const PostsPage = () => {
const queryClient = getQueryClient();
queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: () => fetchPosts()
});
return (
<PageContainer>
<div className='flex flex-1 flex-col space-y-4'>
<div className='flex items-start justify-between'>
<Heading
title='Posts Management'
description='Manage dental treatment posts, patient requirements, and case studies.'
/>
</div>
<Separator />
<HydrationBoundary state={dehydrate(queryClient)}>
<PostsViewPage />
</HydrationBoundary>
</div>
</PageContainer>
);
};
export default PostsPage;

View File

@ -0,0 +1,120 @@
'use server';
import { ROUTES } from '@/constants/routes';
import {
apiRequest,
handleArrayResponse,
handleListResponse
} from '@/lib/api-utils';
import { IPostModel, StatusPostEnum } from '@/types/models';
export type Post = IPostModel;
export interface PostsResponse {
posts: Post[];
totalCount?: number;
totalPages?: number;
currentPage?: number;
}
export interface PostFilters {
userId?: string;
koasId?: string;
treatmentId?: string;
status?: StatusPostEnum;
published?: boolean;
search?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface PostStats {
totalPosts: number;
publishedPosts: number;
draftPosts: number;
pendingPosts: number;
openPosts: number;
closedPosts: number;
}
// Fetch functions
export const fetchPosts = async (
filters: PostFilters = {}
): Promise<PostsResponse> => {
const searchParams = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, value.toString());
}
});
const url = `${ROUTES.API.POSTS}`;
const data = await apiRequest(url);
const result = handleListResponse<Post>(data, 'posts');
return {
posts: result.items,
totalCount: result.totalCount,
totalPages: result.totalPages,
currentPage: result.currentPage
};
};
export const fetchPostById = async (id: string): Promise<Post> => {
const response = await fetch(`/api/posts/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch post: ${response.statusText}`);
}
const result = await response.json();
return result.data;
};
export const fetchPostStats = async (): Promise<PostStats> => {
const response = await fetch('/api/posts/stats');
if (!response.ok) {
throw new Error(`Failed to fetch post stats: ${response.statusText}`);
}
const result = await response.json();
return result.data;
};
export const fetchPostsByUser = async (userId: string): Promise<Post[]> => {
const response = await fetch(`/api/posts?userId=${userId}`);
if (!response.ok) {
throw new Error(`Failed to fetch posts by user: ${response.statusText}`);
}
const result = await response.json();
return result.data || [];
};
export const fetchPostsByKoas = async (koasId: string): Promise<Post[]> => {
const response = await fetch(`/api/posts?koasId=${koasId}`);
if (!response.ok) {
throw new Error(`Failed to fetch posts by koas: ${response.statusText}`);
}
const result = await response.json();
return result.data || [];
};
export const fetchPublishedPosts = async (): Promise<Post[]> => {
const response = await fetch('/api/posts?published=true');
if (!response.ok) {
throw new Error(`Failed to fetch published posts: ${response.statusText}`);
}
const result = await response.json();
return result.data || [];
};

View File

@ -0,0 +1,23 @@
import FormCardSkeleton from '@/components/form-card-skeleton';
import PageContainer from '@/components/layout/page-container';
import { Suspense } from 'react';
import ProductViewPage from '@/features/products/components/product-view-page';
export const metadata = {
title: 'Dashboard : Product View'
};
type PageProps = { params: Promise<{ productId: string }> };
export default async function Page(props: PageProps) {
const params = await props.params;
return (
<PageContainer scrollable>
<div className='flex-1 space-y-4'>
<Suspense fallback={<FormCardSkeleton />}>
<ProductViewPage productId={params.productId} />
</Suspense>
</div>
</PageContainer>
);
}

View File

@ -0,0 +1,57 @@
import PageContainer from '@/components/layout/page-container';
import { buttonVariants } from '@/components/ui/button';
import { Heading } from '@/components/ui/heading';
import { Separator } from '@/components/ui/separator';
import { DataTableSkeleton } from '@/components/ui/table/data-table-skeleton';
import ProductListingPage from '@/features/products/components/product-listing';
import { searchParamsCache, serialize } from '@/lib/searchparams';
import { cn } from '@/lib/utils';
import { IconPlus } from '@tabler/icons-react';
import Link from 'next/link';
import { SearchParams } from 'nuqs/server';
import { Suspense } from 'react';
export const metadata = {
title: 'Dashboard: Products'
};
type pageProps = {
searchParams: Promise<SearchParams>;
};
export default async function Page(props: pageProps) {
const searchParams = await props.searchParams;
// Allow nested RSCs to access the search params (in a type-safe way)
searchParamsCache.parse(searchParams);
// This key is used for invoke suspense if any of the search params changed (used for filters).
// const key = serialize({ ...searchParams });
return (
<PageContainer scrollable={false}>
<div className='flex flex-1 flex-col space-y-4'>
<div className='flex items-start justify-between'>
<Heading
title='Products'
description='Manage products (Server side table functionalities.)'
/>
<Link
href='/dashboard/product/new'
className={cn(buttonVariants(), 'text-xs md:text-sm')}
>
<IconPlus className='mr-2 h-4 w-4' /> Add New
</Link>
</div>
<Separator />
<Suspense
// key={key}
fallback={
<DataTableSkeleton columnCount={5} rowCount={8} filterCount={2} />
}
>
<ProductListingPage />
</Suspense>
</div>
</PageContainer>
);
}

View File

@ -0,0 +1,35 @@
import PageContainer from '@/components/layout/page-container';
import { Heading } from '@/components/ui/heading';
import { Separator } from '@/components/ui/separator';
import AdminViewPage from '@/features/users/admin/components/admin-view-page';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import Link from 'next/link';
import React from 'react';
import { fetchUsersByRole } from '../queries';
import { getQueryClient } from '@/lib/get-query-client';
import { RoleEnum } from '@/types/models';
const Page = () => {
const queryClient = getQueryClient();
queryClient.prefetchQuery({
queryKey: ['users', 'role', RoleEnum.Admin],
queryFn: () => fetchUsersByRole(RoleEnum.Admin)
});
return (
<PageContainer>
<div className='flex flex-1 flex-col space-y-4'>
<div className='flex items-start justify-between'>
<Heading title='Admin Users' description='Manage admin users' />
</div>
<Separator />
<HydrationBoundary state={dehydrate(queryClient)}>
<AdminViewPage />
</HydrationBoundary>
</div>
</PageContainer>
);
};
export default Page;

View File

@ -0,0 +1,37 @@
import PageContainer from '@/components/layout/page-container';
import { Heading } from '@/components/ui/heading';
import { Separator } from '@/components/ui/separator';
import FasilitatorViewPage from '@/features/users/fasilitator/components/fasilitator-view-page';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import React from 'react';
import { fetchUsersByRole } from '../queries';
import { RoleEnum } from '@/types/models';
import { getQueryClient } from '@/lib/get-query-client';
const Page = () => {
const queryClient = getQueryClient();
queryClient.prefetchQuery({
queryKey: ['users', 'role', RoleEnum.Fasilitator],
queryFn: () => fetchUsersByRole(RoleEnum.Fasilitator)
});
return (
<PageContainer>
<div className='flex flex-1 flex-col space-y-4'>
<div className='flex items-start justify-between'>
<Heading
title='Facilitator Users'
description='Manage facilitator users'
/>
</div>
<Separator />
<HydrationBoundary state={dehydrate(queryClient)}>
<FasilitatorViewPage />
</HydrationBoundary>
</div>
</PageContainer>
);
};
export default Page;

View File

@ -0,0 +1,34 @@
import PageContainer from '@/components/layout/page-container';
import { Heading } from '@/components/ui/heading';
import { Separator } from '@/components/ui/separator';
import KoasViewPage from '@/features/users/koas/components/koas-view-page';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import React from 'react';
import { fetchUsersByRole } from '../queries';
import { RoleEnum } from '@/types/models';
import { getQueryClient } from '@/lib/get-query-client';
const Page = () => {
// Prefetching data can be added here if needed
const queryClient = getQueryClient();
queryClient.prefetchQuery({
queryKey: ['users', 'role', RoleEnum.Koas],
queryFn: () => fetchUsersByRole(RoleEnum.Koas)
});
return (
<PageContainer>
<div className='flex flex-1 flex-col space-y-4'>
<div className='flex items-start justify-between'>
<Heading title='Koas Users' description='Manage koas users' />
</div>
<Separator />
<HydrationBoundary state={dehydrate(queryClient)}>
<KoasViewPage />
</HydrationBoundary>
</div>
</PageContainer>
);
};
export default Page;

View File

@ -0,0 +1,94 @@
'use server';
import { ROUTES } from '@/constants/routes';
import {
CreateUserData,
UpdateUserData
} from '@/features/users/admin/hooks/use-mutations';
import { apiRequest } from '@/lib/api-utils';
import { initFirebase } from '@/lib/firebase';
import { initFirebaseAdmin } from '@/lib/firebase-admin';
import { IUserModel } from '@/types/models';
import { getAuth } from 'firebase-admin/auth';
// Mutation functions
export const createUser = async (
userData: CreateUserData
): Promise<IUserModel> => {
const { profile, ...userDataWithoutProfile } = userData;
initFirebaseAdmin();
// Create user in Firebase Auth first
const auth = getAuth();
const firebaseUser = await auth.createUser({
email: userData.email,
password: userData.password,
displayName: userData.givenName + ' ' + userData.familyName,
phoneNumber: userData.phoneNumber
});
// Optionally, you can set custom claims for role
if (userData.role) {
await auth.setCustomUserClaims(firebaseUser.uid, { role: userData.role });
}
// Create user in database
const newUser = await apiRequest<IUserModel>(ROUTES.API.USERS, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...userDataWithoutProfile,
profile,
password: firebaseUser.passwordHash, // Use Firebase's password hash
id: firebaseUser.uid
})
});
return newUser;
};
export const updateUser = async (
userData: UpdateUserData
): Promise<IUserModel> => {
const { id, profile, ...updateData } = userData;
// Update user basic information
const updatedUser = await apiRequest<IUserModel>(ROUTES.API.USER_DETAIL(id), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updateData)
});
// Update user profile if profile data is provided
if (profile && Object.keys(profile).length > 0) {
await apiRequest(ROUTES.API.USER_PROFILE(id), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(profile)
});
}
return updatedUser;
};
export const deleteUser = async (userId: string): Promise<void> => {
return apiRequest<void>(ROUTES.API.USER_DETAIL(userId), {
method: 'DELETE'
});
};
export const deleteManyUsers = async (userIds: string[]): Promise<void> => {
return apiRequest<void>(`${ROUTES.API.USERS}/bulk-delete`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: userIds })
});
};

View File

@ -0,0 +1,33 @@
import PageContainer from '@/components/layout/page-container';
import { Heading } from '@/components/ui/heading';
import { Separator } from '@/components/ui/separator';
import PasienViewPage from '@/features/users/pasien/components/pasien-view-page';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import React from 'react';
import { fetchUsersByRole } from '../queries';
import { RoleEnum } from '@/types/models';
import { getQueryClient } from '@/lib/get-query-client';
const Page = () => {
const queryClient = getQueryClient();
queryClient.prefetchQuery({
queryKey: ['users', 'role', RoleEnum.Pasien],
queryFn: () => fetchUsersByRole(RoleEnum.Pasien)
});
return (
<PageContainer>
<div className='flex flex-1 flex-col space-y-4'>
<div className='flex items-start justify-between'>
<Heading title='Pasien Users' description='Manage pasien users' />
</div>
<Separator />
<HydrationBoundary state={dehydrate(queryClient)}>
<PasienViewPage />
</HydrationBoundary>
</div>
</PageContainer>
);
};
export default Page;

View File

@ -0,0 +1,60 @@
import { ROUTES } from '@/constants/routes';
import {
User,
UserFilters,
UsersResponse
} from '@/features/users/admin/hooks/use-queries';
import { IUserModel } from '@/types/models';
import {
apiRequest,
handleListResponse,
handleArrayResponse
} from '@/lib/api-utils';
// Fetch functions
export const fetchUsers = async (
filters: UserFilters = {}
): Promise<UsersResponse> => {
const searchParams = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, value.toString());
}
});
const url = `${ROUTES.API.USERS}?${searchParams.toString()}`;
const data = await apiRequest(url);
const result = handleListResponse<User>(data, 'users');
return {
users: result.items,
totalCount: result.totalCount,
totalPages: result.totalPages,
currentPage: result.currentPage
};
};
export const fetchUserById = async (id: string): Promise<User> => {
const data = await apiRequest<User>(ROUTES.API.USER_DETAIL(id));
return data;
};
export const fetchUserProfile = async (id: string): Promise<IUserModel> => {
const data = await apiRequest<IUserModel>(ROUTES.API.USER_PROFILE(id));
return data;
};
export const fetchUsersByRole = async (role: string): Promise<User[]> => {
const data = await apiRequest(ROUTES.API.USER_WITH_ROLE(role));
return handleArrayResponse<User>(data, 'users');
};
export const checkEmailExists = async (
email: string
): Promise<{ exists: boolean }> => {
const url = `${ROUTES.API.USERS}/is-email-exist?email=${encodeURIComponent(email)}`;
const data = await apiRequest<{ exists: boolean }>(url);
return data;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,27 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import NextError from 'next/error';
import { useEffect } from 'react';
export default function GlobalError({
error
}: {
error: Error & { digest?: string };
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body>
</html>
);
}

View File

@ -0,0 +1,216 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@import './theme.css';
:root {
--background: oklch(0.9754 0.0084 325.6414);
--foreground: oklch(0.3257 0.1161 325.0372);
--card: oklch(0.9754 0.0084 325.6414);
--card-foreground: oklch(0.3257 0.1161 325.0372);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.3257 0.1161 325.0372);
--primary: oklch(0.5316 0.1409 355.1999);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.8696 0.0675 334.8991);
--secondary-foreground: oklch(0.4448 0.1341 324.7991);
--muted: oklch(0.9395 0.026 331.5454);
--muted-foreground: oklch(0.4924 0.1244 324.4523);
--accent: oklch(0.8696 0.0675 334.8991);
--accent-foreground: oklch(0.4448 0.1341 324.7991);
--destructive: oklch(0.55 0.19 27); /* Lebih merah, lebih vivid */
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.8568 0.0829 328.911);
--input: oklch(0.8517 0.0558 336.6002);
--ring: oklch(0.5916 0.218 0.5844);
--chart-1: oklch(0.6038 0.2363 344.4657);
--chart-2: oklch(0.4445 0.2251 300.6246);
--chart-3: oklch(0.379 0.0438 226.1538);
--chart-4: oklch(0.833 0.1185 88.3461);
--chart-5: oklch(0.7843 0.1256 58.9964);
--sidebar: oklch(0.936 0.0288 320.5788);
--sidebar-foreground: oklch(0.4948 0.1909 354.5435);
--sidebar-primary: oklch(0.3963 0.0251 285.1962);
--sidebar-primary-foreground: oklch(0.9668 0.0124 337.5228);
--sidebar-accent: oklch(0.9789 0.0013 106.4235);
--sidebar-accent-foreground: oklch(0.3963 0.0251 285.1962);
--sidebar-border: oklch(0.9383 0.0026 48.7178);
--sidebar-ring: oklch(0.5916 0.218 0.5844);
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
--radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
0 2px 4px -1px hsl(0 0% 0% / 0.1);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
0 4px 6px -1px hsl(0 0% 0% / 0.1);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
0 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0.2409 0.0201 307.5346);
--foreground: oklch(0.8398 0.0387 309.5391);
--card: oklch(0.2803 0.0232 307.5413);
--card-foreground: oklch(0.8456 0.0302 341.4597);
--popover: oklch(0.1548 0.0132 338.9015);
--popover-foreground: oklch(0.9647 0.0091 341.8035);
--primary: oklch(0.4607 0.1853 4.0994);
--primary-foreground: oklch(0.856 0.0618 346.3684);
--secondary: oklch(0.3137 0.0306 310.061);
--secondary-foreground: oklch(0.8483 0.0382 307.9613);
--muted: oklch(0.2634 0.0219 309.4748);
--muted-foreground: oklch(0.794 0.0372 307.1032);
--accent: oklch(0.3649 0.0508 308.4911);
--accent-foreground: oklch(0.9647 0.0091 341.8035);
--destructive: oklch(
0.45 0.19 27
); /* Lebih merah, lebih vivid untuk dark mode */
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.3286 0.0154 343.4461);
--input: oklch(0.3387 0.0195 332.8347);
--ring: oklch(0.5916 0.218 0.5844);
--chart-1: oklch(0.5316 0.1409 355.1999);
--chart-2: oklch(0.5633 0.1912 306.8561);
--chart-3: oklch(0.7227 0.1502 60.5799);
--chart-4: oklch(0.6193 0.2029 312.7422);
--chart-5: oklch(0.6118 0.2093 6.1387);
--sidebar: oklch(0.1893 0.0163 331.0475);
--sidebar-foreground: oklch(0.8607 0.0293 343.6612);
--sidebar-primary: oklch(0.4882 0.2172 264.3763);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.2337 0.0261 338.1961);
--sidebar-accent-foreground: oklch(0.9674 0.0013 286.3752);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.5916 0.218 0.5844);
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
--radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
0 2px 4px -1px hsl(0 0% 0% / 0.1);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
0 4px 6px -1px hsl(0 0% 0% / 0.1);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
0 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* View Transition Wave Effect */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
/* Ensure the outgoing view (old theme) is beneath */
z-index: 0;
}
::view-transition-new(root) {
/* Ensure the incoming view (new theme) is always on top */
z-index: 1;
}
@keyframes reveal {
from {
/* Use CSS variables for the origin, defaulting to center if not set */
clip-path: circle(0% at var(--x, 50%) var(--y, 50%));
opacity: 0.7;
}
to {
/* Use CSS variables for the origin, defaulting to center if not set */
clip-path: circle(150% at var(--x, 50%) var(--y, 50%));
opacity: 1;
}
}
::view-transition-new(root) {
/* Apply the reveal animation */
animation: reveal 0.4s ease-in-out forwards;
}

View File

@ -0,0 +1,83 @@
import Providers from '@/components/layout/providers';
import { Toaster } from '@/components/ui/sonner';
import { fontVariables } from '@/lib/font';
import ThemeProvider from '@/components/layout/ThemeToggle/theme-provider';
import { cn } from '@/lib/utils';
import type { Metadata, Viewport } from 'next';
import { cookies } from 'next/headers';
import NextTopLoader from 'nextjs-toploader';
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import './globals.css';
import './theme.css';
import ReactQueryProvider from '@/lib/react-query-provider';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const META_THEME_COLORS = {
light: '#ffffff',
dark: '#09090b'
};
export const metadata: Metadata = {
title: 'Next Shadcn',
description: 'Basic dashboard with Next.js and Shadcn'
};
export const viewport: Viewport = {
themeColor: META_THEME_COLORS.light
};
export default async function RootLayout({
children
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const activeThemeValue = cookieStore.get('active_theme')?.value;
const isScaled = activeThemeValue?.endsWith('-scaled');
return (
<html lang='en' suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
try {
if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.querySelector('meta[name="theme-color"]').setAttribute('content', '${META_THEME_COLORS.dark}')
}
} catch (_) {}
`
}}
/>
</head>
<body
className={cn(
'bg-background overflow-hidden overscroll-none font-sans antialiased',
activeThemeValue ? `theme-${activeThemeValue}` : '',
isScaled ? 'theme-scaled' : '',
fontVariables
)}
suppressHydrationWarning={true}
>
<NextTopLoader showSpinner={false} />
<ReactQueryProvider>
<ReactQueryDevtools />
<NuqsAdapter>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
enableColorScheme
>
<Providers activeThemeValue={activeThemeValue as string}>
<Toaster position='top-right' />
{children}
</Providers>
</ThemeProvider>
</NuqsAdapter>
</ReactQueryProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,36 @@
'use client';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
export default function NotFound() {
const router = useRouter();
return (
<div className='absolute top-1/2 left-1/2 mb-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center text-center'>
<span className='from-foreground bg-linear-to-b to-transparent bg-clip-text text-[10rem] leading-none font-extrabold text-transparent'>
404
</span>
<h2 className='font-heading my-2 text-2xl font-bold'>
Something&apos;s missing
</h2>
<p>
Sorry, the page you are looking for doesn&apos;t exist or has been
moved.
</p>
<div className='mt-8 flex justify-center gap-2'>
<Button onClick={() => router.back()} variant='default' size='lg'>
Go back
</Button>
<Button
onClick={() => router.push('/dashboard')}
variant='ghost'
size='lg'
>
Back to Home
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,70 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ROUTES } from '@/constants/routes';
import { auth, onAuthStateChanged, signOut } from '@/lib/firebase';
import { toast } from 'sonner';
export default function Page() {
const [loading, setLoading] = useState(true);
const router = useRouter();
const checkUserRole = async (email: string) => {
try {
const response = await fetch(ROUTES.API.CHECK_ROLE, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email })
});
if (!response.ok) {
return false;
}
return true;
} catch (error) {
console.error('Role check error:', error);
return false;
}
};
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (user) => {
if (user && user.email) {
console.log('User is authenticated:', user);
// Check if user has admin role
const hasAdminRole = await checkUserRole(user.email);
if (hasAdminRole) {
router.push(ROUTES.APP.DASHBOARD);
} else {
// Sign out user if they don't have admin role
await signOut(auth);
toast.error(
'Access denied. Only administrators can access this application.'
);
router.push(ROUTES.AUTH.SIGN_IN);
}
} else {
console.log('User is not authenticated, redirecting to sign-in page');
router.push(ROUTES.AUTH.SIGN_IN);
}
setLoading(false);
});
return () => unsubscribe();
}, [router]);
if (loading) {
return (
<div className='flex h-screen w-full items-center justify-center'>
<div className='h-32 w-32 animate-spin rounded-full border-b-2 border-gray-900'></div>
</div>
);
}
return null;
}

View File

@ -0,0 +1,105 @@
body {
@apply overscroll-none bg-transparent;
}
:root {
--font-sans: var(--font-inter);
--header-height: calc(var(--spacing) * 12 + 1px);
}
.theme-scaled {
@media (min-width: 1024px) {
--radius: 0.6rem;
--text-lg: 1.05rem;
--text-base: 0.85rem;
--text-sm: 0.8rem;
--spacing: 0.222222rem;
}
[data-slot='card'] {
--spacing: 0.16rem;
}
[data-slot='select-trigger'],
[data-slot='toggle-group-item'] {
--spacing: 0.222222rem;
}
}
.theme-pink,
.theme-pink-scaled {
--primary: var(--color-pink-600);
--primary-foreground: var(--color-pink-50);
@variant dark {
--primary: var(--color-pink-500);
--primary-foreground: var(--color-pink-50);
}
}
.theme-blue,
.theme-blue-scaled {
--primary: var(--color-blue-600);
--primary-foreground: var(--color-blue-50);
@variant dark {
--primary: var(--color-blue-500);
--primary-foreground: var(--color-blue-50);
}
}
.theme-green,
.theme-green-scaled {
--primary: var(--color-lime-600);
--primary-foreground: var(--color-lime-50);
@variant dark {
--primary: var(--color-lime-600);
--primary-foreground: var(--color-lime-50);
}
}
.theme-amber,
.theme-amber-scaled {
--primary: var(--color-amber-600);
--primary-foreground: var(--color-amber-50);
@variant dark {
--primary: var(--color-amber-500);
--primary-foreground: var(--color-amber-50);
}
}
.theme-mono,
.theme-mono-scaled {
--font-sans: var(--font-mono);
--primary: var(--color-neutral-600);
--primary-foreground: var(--color-neutral-50);
@variant dark {
--primary: var(--color-neutral-500);
--primary-foreground: var(--color-neutral-50);
}
.rounded-xs,
.rounded-sm,
.rounded-md,
.rounded-lg,
.rounded-xl {
@apply !rounded-none;
border-radius: 0;
}
.shadow-xs,
.shadow-sm,
.shadow-md,
.shadow-lg,
.shadow-xl {
@apply !shadow-none;
}
[data-slot='toggle-group'],
[data-slot='toggle-group-item'] {
@apply !rounded-none !shadow-none;
}
}

View File

@ -0,0 +1,67 @@
'use client';
import {
ReactNode,
createContext,
useContext,
useEffect,
useState
} from 'react';
const COOKIE_NAME = 'active_theme';
const DEFAULT_THEME = 'default';
function setThemeCookie(theme: string) {
if (typeof window === 'undefined') return;
document.cookie = `${COOKIE_NAME}=${theme}; path=/; max-age=31536000; SameSite=Lax; ${window.location.protocol === 'https:' ? 'Secure;' : ''}`;
}
type ThemeContextType = {
activeTheme: string;
setActiveTheme: (theme: string) => void;
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ActiveThemeProvider({
children,
initialTheme
}: {
children: ReactNode;
initialTheme?: string;
}) {
const [activeTheme, setActiveTheme] = useState<string>(
() => initialTheme || DEFAULT_THEME
);
useEffect(() => {
setThemeCookie(activeTheme);
Array.from(document.body.classList)
.filter((className) => className.startsWith('theme-'))
.forEach((className) => {
document.body.classList.remove(className);
});
document.body.classList.add(`theme-${activeTheme}`);
if (activeTheme.endsWith('-scaled')) {
document.body.classList.add('theme-scaled');
}
}, [activeTheme]);
return (
<ThemeContext.Provider value={{ activeTheme, setActiveTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useThemeConfig() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error(
'useThemeConfig must be used within an ActiveThemeProvider'
);
}
return context;
}

View File

@ -0,0 +1,41 @@
'use client';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from '@/components/ui/breadcrumb';
import { useBreadcrumbs } from '@/hooks/use-breadcrumbs';
import { IconSlash } from '@tabler/icons-react';
import { Fragment } from 'react';
export function Breadcrumbs() {
const items = useBreadcrumbs();
if (items.length === 0) return null;
return (
<Breadcrumb>
<BreadcrumbList>
{items.map((item, index) => (
<Fragment key={item.title}>
{index !== items.length - 1 && (
<BreadcrumbItem className='hidden md:block'>
<BreadcrumbLink href={item.link}>{item.title}</BreadcrumbLink>
</BreadcrumbItem>
)}
{index < items.length - 1 && (
<BreadcrumbSeparator className='hidden md:block'>
<IconSlash />
</BreadcrumbSeparator>
)}
{index === items.length - 1 && (
<BreadcrumbPage>{item.title}</BreadcrumbPage>
)}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@ -0,0 +1,114 @@
'use client';
import { useQueryState } from 'nuqs';
import * as React from 'react';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Tooltip,
TooltipContent,
TooltipTrigger
} from '@/components/ui/tooltip';
import { type FlagConfig, flagConfig } from '@/config/flag';
type FilterFlag = FlagConfig['featureFlags'][number]['value'];
interface FeatureFlagsContextValue {
filterFlag: FilterFlag;
enableAdvancedFilter: boolean;
}
const FeatureFlagsContext =
React.createContext<FeatureFlagsContextValue | null>(null);
export function useFeatureFlags() {
const context = React.useContext(FeatureFlagsContext);
if (!context) {
throw new Error(
'useFeatureFlags must be used within a FeatureFlagsProvider'
);
}
return context;
}
interface FeatureFlagsProviderProps {
children: React.ReactNode;
}
export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
const [filterFlag, setFilterFlag] = useQueryState<FilterFlag | null>(
'filterFlag',
{
parse: (value) => {
if (!value) return null;
const validValues = flagConfig.featureFlags.map((flag) => flag.value);
return validValues.includes(value as FilterFlag)
? (value as FilterFlag)
: null;
},
serialize: (value) => value ?? '',
defaultValue: null,
clearOnDefault: true,
shallow: false,
eq: (a, b) => (!a && !b) || a === b
}
);
const onFilterFlagChange = React.useCallback(
(value: FilterFlag) => {
setFilterFlag(value);
},
[setFilterFlag]
);
const contextValue = React.useMemo<FeatureFlagsContextValue>(
() => ({
filterFlag,
enableAdvancedFilter:
filterFlag === 'advancedFilters' || filterFlag === 'commandFilters'
}),
[filterFlag]
);
return (
<FeatureFlagsContext.Provider value={contextValue}>
<div className='w-full overflow-x-auto p-1'>
<ToggleGroup
type='single'
variant='outline'
size='sm'
value={filterFlag}
onValueChange={onFilterFlagChange}
className='w-fit gap-0'
>
{flagConfig.featureFlags.map((flag) => (
<Tooltip key={flag.value} delayDuration={700}>
<ToggleGroupItem
value={flag.value}
className='data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90 px-3 text-xs whitespace-nowrap'
asChild
>
<TooltipTrigger>
<flag.icon className='size-3.5 shrink-0' />
{flag.label}
</TooltipTrigger>
</ToggleGroupItem>
<TooltipContent
align='start'
side='bottom'
sideOffset={6}
className='bg-background text-foreground flex flex-col gap-1.5 border py-2 font-semibold [&>span]:hidden'
>
<div>{flag.tooltipTitle}</div>
<p className='text-muted-foreground text-xs text-balance'>
{flag.tooltipDescription}
</p>
</TooltipContent>
</Tooltip>
))}
</ToggleGroup>
</div>
{children}
</FeatureFlagsContext.Provider>
);
}

View File

@ -0,0 +1,316 @@
'use client';
import { IconX, IconUpload } from '@tabler/icons-react';
import Image from 'next/image';
import * as React from 'react';
import Dropzone, {
type DropzoneProps,
type FileRejection
} from 'react-dropzone';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useControllableState } from '@/hooks/use-controllable-state';
import { cn, formatBytes } from '@/lib/utils';
interface FileUploaderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Value of the uploader.
* @type File[]
* @default undefined
* @example value={files}
*/
value?: File[];
/**
* Function to be called when the value changes.
* @type React.Dispatch<React.SetStateAction<File[]>>
* @default undefined
* @example onValueChange={(files) => setFiles(files)}
*/
onValueChange?: React.Dispatch<React.SetStateAction<File[]>>;
/**
* Function to be called when files are uploaded.
* @type (files: File[]) => Promise<void>
* @default undefined
* @example onUpload={(files) => uploadFiles(files)}
*/
onUpload?: (files: File[]) => Promise<void>;
/**
* Progress of the uploaded files.
* @type Record<string, number> | undefined
* @default undefined
* @example progresses={{ "file1.png": 50 }}
*/
progresses?: Record<string, number>;
/**
* Accepted file types for the uploader.
* @type { [key: string]: string[]}
* @default
* ```ts
* { "image/*": [] }
* ```
* @example accept={["image/png", "image/jpeg"]}
*/
accept?: DropzoneProps['accept'];
/**
* Maximum file size for the uploader.
* @type number | undefined
* @default 1024 * 1024 * 2 // 2MB
* @example maxSize={1024 * 1024 * 2} // 2MB
*/
maxSize?: DropzoneProps['maxSize'];
/**
* Maximum number of files for the uploader.
* @type number | undefined
* @default 1
* @example maxFiles={5}
*/
maxFiles?: DropzoneProps['maxFiles'];
/**
* Whether the uploader should accept multiple files.
* @type boolean
* @default false
* @example multiple
*/
multiple?: boolean;
/**
* Whether the uploader is disabled.
* @type boolean
* @default false
* @example disabled
*/
disabled?: boolean;
}
export function FileUploader(props: FileUploaderProps) {
const {
value: valueProp,
onValueChange,
onUpload,
progresses,
accept = { 'image/*': [] },
maxSize = 1024 * 1024 * 2,
maxFiles = 1,
multiple = false,
disabled = false,
className,
...dropzoneProps
} = props;
const [files, setFiles] = useControllableState({
prop: valueProp,
onChange: onValueChange
});
const onDrop = React.useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
if (!multiple && maxFiles === 1 && acceptedFiles.length > 1) {
toast.error('Cannot upload more than 1 file at a time');
return;
}
if ((files?.length ?? 0) + acceptedFiles.length > maxFiles) {
toast.error(`Cannot upload more than ${maxFiles} files`);
return;
}
const newFiles = acceptedFiles.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file)
})
);
const updatedFiles = files ? [...files, ...newFiles] : newFiles;
setFiles(updatedFiles);
if (rejectedFiles.length > 0) {
rejectedFiles.forEach(({ file }) => {
toast.error(`File ${file.name} was rejected`);
});
}
if (
onUpload &&
updatedFiles.length > 0 &&
updatedFiles.length <= maxFiles
) {
const target =
updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`;
toast.promise(onUpload(updatedFiles), {
loading: `Uploading ${target}...`,
success: () => {
setFiles([]);
return `${target} uploaded`;
},
error: `Failed to upload ${target}`
});
}
},
[files, maxFiles, multiple, onUpload, setFiles]
);
function onRemove(index: number) {
if (!files) return;
const newFiles = files.filter((_, i) => i !== index);
setFiles(newFiles);
onValueChange?.(newFiles);
}
// Revoke preview url when component unmounts
React.useEffect(() => {
return () => {
if (!files) return;
files.forEach((file) => {
if (isFileWithPreview(file)) {
URL.revokeObjectURL(file.preview);
}
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isDisabled = disabled || (files?.length ?? 0) >= maxFiles;
return (
<div className='relative flex flex-col gap-6 overflow-hidden'>
<Dropzone
onDrop={onDrop}
accept={accept}
maxSize={maxSize}
maxFiles={maxFiles}
multiple={maxFiles > 1 || multiple}
disabled={isDisabled}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
{...getRootProps()}
className={cn(
'group border-muted-foreground/25 hover:bg-muted/25 relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed px-5 py-2.5 text-center transition',
'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden',
isDragActive && 'border-muted-foreground/50',
isDisabled && 'pointer-events-none opacity-60',
className
)}
{...dropzoneProps}
>
<input {...getInputProps()} />
{isDragActive ? (
<div className='flex flex-col items-center justify-center gap-4 sm:px-5'>
<div className='rounded-full border border-dashed p-3'>
<IconUpload
className='text-muted-foreground size-7'
aria-hidden='true'
/>
</div>
<p className='text-muted-foreground font-medium'>
Drop the files here
</p>
</div>
) : (
<div className='flex flex-col items-center justify-center gap-4 sm:px-5'>
<div className='rounded-full border border-dashed p-3'>
<IconUpload
className='text-muted-foreground size-7'
aria-hidden='true'
/>
</div>
<div className='space-y-px'>
<p className='text-muted-foreground font-medium'>
Drag {`'n'`} drop files here, or click to select files
</p>
<p className='text-muted-foreground/70 text-sm'>
You can upload
{maxFiles > 1
? ` ${maxFiles === Infinity ? 'multiple' : maxFiles}
files (up to ${formatBytes(maxSize)} each)`
: ` a file with ${formatBytes(maxSize)}`}
</p>
</div>
</div>
)}
</div>
)}
</Dropzone>
{files?.length ? (
<ScrollArea className='h-fit w-full px-3'>
<div className='max-h-48 space-y-4'>
{files?.map((file, index) => (
<FileCard
key={index}
file={file}
onRemove={() => onRemove(index)}
progress={progresses?.[file.name]}
/>
))}
</div>
</ScrollArea>
) : null}
</div>
);
}
interface FileCardProps {
file: File;
onRemove: () => void;
progress?: number;
}
function FileCard({ file, progress, onRemove }: FileCardProps) {
return (
<div className='relative flex items-center space-x-4'>
<div className='flex flex-1 space-x-4'>
{isFileWithPreview(file) ? (
<Image
src={file.preview}
alt={file.name}
width={48}
height={48}
loading='lazy'
className='aspect-square shrink-0 rounded-md object-cover'
/>
) : null}
<div className='flex w-full flex-col gap-2'>
<div className='space-y-px'>
<p className='text-foreground/80 line-clamp-1 text-sm font-medium'>
{file.name}
</p>
<p className='text-muted-foreground text-xs'>
{formatBytes(file.size)}
</p>
</div>
{progress ? <Progress value={progress} /> : null}
</div>
</div>
<div className='flex items-center gap-2'>
<Button
type='button'
variant='ghost'
size='icon'
onClick={onRemove}
disabled={progress !== undefined && progress < 100}
className='size-8 rounded-full'
>
<IconX className='text-muted-foreground' />
<span className='sr-only'>Remove file</span>
</Button>
</div>
</div>
);
}
function isFileWithPreview(file: File): file is File & { preview: string } {
return 'preview' in file && typeof file.preview === 'string';
}

View File

@ -0,0 +1,52 @@
import React from 'react';
import { Card, CardContent, CardHeader } from './ui/card';
import { Skeleton } from './ui/skeleton';
export default function FormCardSkeleton() {
return (
<Card className='mx-auto w-full'>
<CardHeader>
<Skeleton className='h-8 w-48' /> {/* Title */}
</CardHeader>
<CardContent>
<div className='space-y-8'>
{/* Image upload area skeleton */}
<div className='space-y-6'>
<Skeleton className='h-4 w-16' /> {/* Label */}
<Skeleton className='h-32 w-full rounded-lg' /> {/* Upload area */}
</div>
{/* Grid layout for form fields */}
<div className='grid grid-cols-1 gap-6 md:grid-cols-2'>
{/* Product Name field */}
<div className='space-y-2'>
<Skeleton className='h-4 w-24' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Input */}
</div>
{/* Category field */}
<div className='space-y-2'>
<Skeleton className='h-4 w-20' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Select */}
</div>
{/* Price field */}
<div className='space-y-2'>
<Skeleton className='h-4 w-16' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Input */}
</div>
</div>
{/* Description field */}
<div className='space-y-2'>
<Skeleton className='h-4 w-24' /> {/* Label */}
<Skeleton className='h-32 w-full' /> {/* Textarea */}
</div>
{/* Submit button */}
<Skeleton className='h-10 w-28' />
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,76 @@
import {
IconAlertTriangle,
IconArrowRight,
IconCheck,
IconChevronLeft,
IconChevronRight,
IconCommand,
IconCreditCard,
IconFile,
IconFileText,
IconHelpCircle,
IconPhoto,
IconDeviceLaptop,
IconLayoutDashboard,
IconLoader2,
IconLogin,
IconProps,
IconShoppingBag,
IconMoon,
IconDotsVertical,
IconPizza,
IconPlus,
IconSettings,
IconSun,
IconTrash,
IconBrandTwitter,
IconUser,
IconUserCircle,
IconUserEdit,
IconUserX,
IconX,
IconLayoutKanban,
IconBrandGithub,
IconCalendar
} from '@tabler/icons-react';
export type Icon = React.ComponentType<IconProps>;
export const Icons = {
dashboard: IconLayoutDashboard,
logo: IconCommand,
login: IconLogin,
close: IconX,
product: IconShoppingBag,
spinner: IconLoader2,
kanban: IconLayoutKanban,
chevronLeft: IconChevronLeft,
chevronRight: IconChevronRight,
trash: IconTrash,
employee: IconUserX,
post: IconFileText,
page: IconFile,
userPen: IconUserEdit,
user2: IconUserCircle,
media: IconPhoto,
settings: IconSettings,
billing: IconCreditCard,
ellipsis: IconDotsVertical,
add: IconPlus,
warning: IconAlertTriangle,
user: IconUser,
arrowRight: IconArrowRight,
help: IconHelpCircle,
pizza: IconPizza,
sun: IconSun,
moon: IconMoon,
laptop: IconDeviceLaptop,
github: IconBrandGithub,
twitter: IconBrandTwitter,
check: IconCheck,
appointment: IconCalendar, // gunakan IconCalendar untuk appointment
admin: IconUserCircle,
fasilitator: IconUserEdit, // gunakan IconUserEdit untuk fasilitator
koas: IconUser, // gunakan IconUser untuk koas
pasien: IconUserCircle // tetap gunakan IconUserCircle untuk pasien
};

View File

@ -0,0 +1,83 @@
'use client';
import { navItems } from '@/constants/data';
import {
KBarAnimator,
KBarPortal,
KBarPositioner,
KBarProvider,
KBarSearch
} from 'kbar';
import { useRouter } from 'next/navigation';
import { useMemo } from 'react';
import RenderResults from './render-result';
import useThemeSwitching from './use-theme-switching';
export default function KBar({ children }: { children: React.ReactNode }) {
const router = useRouter();
// These action are for the navigation
const actions = useMemo(() => {
// Define navigateTo inside the useMemo callback to avoid dependency array issues
const navigateTo = (url: string) => {
router.push(url);
};
return navItems.flatMap((navItem) => {
// Only include base action if the navItem has a real URL and is not just a container
const baseAction =
navItem.url !== '#'
? {
id: `${navItem.title.toLowerCase()}Action`,
name: navItem.title,
shortcut: navItem.shortcut,
keywords: navItem.title.toLowerCase(),
section: 'Navigation',
subtitle: `Go to ${navItem.title}`,
perform: () => navigateTo(navItem.url)
}
: null;
// Map child items into actions
const childActions =
navItem.items?.map((childItem) => ({
id: `${childItem.title.toLowerCase()}Action`,
name: childItem.title,
shortcut: childItem.shortcut,
keywords: childItem.title.toLowerCase(),
section: navItem.title,
subtitle: `Go to ${childItem.title}`,
perform: () => navigateTo(childItem.url)
})) ?? [];
// Return only valid actions (ignoring null base actions for containers)
return baseAction ? [baseAction, ...childActions] : childActions;
});
}, [router]);
return (
<KBarProvider actions={actions}>
<KBarComponent>{children}</KBarComponent>
</KBarProvider>
);
}
const KBarComponent = ({ children }: { children: React.ReactNode }) => {
useThemeSwitching();
return (
<>
<KBarPortal>
<KBarPositioner className='bg-background/80 fixed inset-0 z-99999 p-0! backdrop-blur-sm'>
<KBarAnimator className='bg-card text-card-foreground relative mt-64! w-full max-w-[600px] -translate-y-12! overflow-hidden rounded-lg border shadow-lg'>
<div className='bg-card border-border sticky top-0 z-10 border-b'>
<KBarSearch className='bg-card w-full border-none px-6 py-4 text-lg outline-hidden focus:ring-0 focus:ring-offset-0 focus:outline-hidden' />
</div>
<div className='max-h-[400px]'>
<RenderResults />
</div>
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
{children}
</>
);
};

View File

@ -0,0 +1,25 @@
import { KBarResults, useMatches } from 'kbar';
import ResultItem from './result-item';
export default function RenderResults() {
const { results, rootActionId } = useMatches();
return (
<KBarResults
items={results}
onRender={({ item, active }) =>
typeof item === 'string' ? (
<div className='text-primary-foreground px-4 py-2 text-sm uppercase opacity-50'>
{item}
</div>
) : (
<ResultItem
action={item}
active={active}
currentRootActionId={rootActionId ?? ''}
/>
)
}
/>
);
}

View File

@ -0,0 +1,77 @@
import type { ActionId, ActionImpl } from 'kbar';
import * as React from 'react';
const ResultItem = React.forwardRef(
(
{
action,
active,
currentRootActionId
}: {
action: ActionImpl;
active: boolean;
currentRootActionId: ActionId;
},
ref: React.Ref<HTMLDivElement>
) => {
const ancestors = React.useMemo(() => {
if (!currentRootActionId) return action.ancestors;
const index = action.ancestors.findIndex(
(ancestor) => ancestor.id === currentRootActionId
);
return action.ancestors.slice(index + 1);
}, [action.ancestors, currentRootActionId]);
return (
<div
ref={ref}
className={`relative z-10 flex cursor-pointer items-center justify-between px-4 py-3`}
>
{active && (
<div
id='kbar-result-item'
className='border-primary bg-accent/50 absolute inset-0 z-[-1]! border-l-4'
></div>
)}
<div className='relative z-10 flex items-center gap-2'>
{action.icon && action.icon}
<div className='flex flex-col'>
<div>
{ancestors.length > 0 &&
ancestors.map((ancestor) => (
<React.Fragment key={ancestor.id}>
<span className='text-muted-foreground mr-2'>
{ancestor.name}
</span>
<span className='mr-2'>&rsaquo;</span>
</React.Fragment>
))}
<span>{action.name}</span>
</div>
{action.subtitle && (
<span className='text-muted-foreground text-sm'>
{action.subtitle}
</span>
)}
</div>
</div>
{action.shortcut?.length ? (
<div className='relative z-10 grid grid-flow-col gap-1'>
{action.shortcut.map((sc, i) => (
<kbd
key={sc + i}
className='bg-muted flex h-5 items-center gap-1 rounded-md border px-1.5 text-[10px] font-medium'
>
{sc}
</kbd>
))}
</div>
) : null}
</div>
);
}
);
ResultItem.displayName = 'KBarResultItem';
export default ResultItem;

View File

@ -0,0 +1,36 @@
import { useRegisterActions } from 'kbar';
import { useTheme } from 'next-themes';
const useThemeSwitching = () => {
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
const themeAction = [
{
id: 'toggleTheme',
name: 'Toggle Theme',
shortcut: ['t', 't'],
section: 'Theme',
perform: toggleTheme
},
{
id: 'setLightTheme',
name: 'Set Light Theme',
section: 'Theme',
perform: () => setTheme('light')
},
{
id: 'setDarkTheme',
name: 'Set Dark Theme',
section: 'Theme',
perform: () => setTheme('dark')
}
];
useRegisterActions(themeAction, [theme]);
};
export default useThemeSwitching;

View File

@ -0,0 +1,13 @@
'use client';
import {
ThemeProvider as NextThemesProvider,
ThemeProviderProps
} from 'next-themes';
export default function ThemeProvider({
children,
...props
}: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,46 @@
'use client';
import { IconBrightness } from '@tabler/icons-react';
import { useTheme } from 'next-themes';
import * as React from 'react';
import { Button } from '@/components/ui/button';
export function ModeToggle() {
const { setTheme, resolvedTheme } = useTheme();
const handleThemeToggle = React.useCallback(
(e?: React.MouseEvent) => {
const newMode = resolvedTheme === 'dark' ? 'light' : 'dark';
const root = document.documentElement;
if (!document.startViewTransition) {
setTheme(newMode);
return;
}
// Set coordinates from the click event
if (e) {
root.style.setProperty('--x', `${e.clientX}px`);
root.style.setProperty('--y', `${e.clientY}px`);
}
document.startViewTransition(() => {
setTheme(newMode);
});
},
[resolvedTheme, setTheme]
);
return (
<Button
variant='secondary'
size='icon'
className='group/toggle size-8'
onClick={handleThemeToggle}
>
<IconBrightness />
<span className='sr-only'>Toggle theme</span>
</Button>
);
}

View File

@ -0,0 +1,239 @@
'use client';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail
} from '@/components/ui/sidebar';
import { UserAvatarProfile } from '@/components/user-avatar-profile';
import { navItems } from '@/constants/data';
import { useMediaQuery } from '@/hooks/use-media-query';
import useAuthUser from '@/features/auth/hooks/use-auth-user';
import { useSignOutMutation } from '@/features/auth/hooks/use-mutation';
import {
IconBell,
IconChevronRight,
IconChevronsDown,
IconCreditCard,
IconLogout,
IconPhotoUp,
IconUserCircle
} from '@tabler/icons-react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import * as React from 'react';
import { Icons } from '../icons';
import { OrgSwitcher } from '../org-switcher';
import { toast } from 'sonner';
export const company = {
name: 'Acme Inc',
logo: IconPhotoUp,
plan: 'Enterprise'
};
const tenants = [
{ id: '1', name: 'Acme Inc' },
{ id: '2', name: 'Beta Corp' },
{ id: '3', name: 'Gamma Ltd' }
];
export default function AppSidebar() {
const pathname = usePathname();
const { isOpen } = useMediaQuery();
const { data: user } = useAuthUser();
const signOutMutation = useSignOutMutation();
const router = useRouter();
const handleSwitchTenant = (_tenantId: string) => {
// Tenant switching functionality would be implemented here
};
const handleSignOut = () => {
signOutMutation.mutate(undefined, {
onSuccess: () => {
toast.success('Signed out successfully');
router.push('/auth/sign-in');
},
onError: (error: any) => {
toast.error(error.message || 'Sign out failed');
}
});
};
const activeTenant = tenants[0];
React.useEffect(() => {
// Side effects based on sidebar state changes
}, [isOpen]);
return (
<Sidebar collapsible='icon'>
<SidebarHeader>
<OrgSwitcher
tenants={tenants}
defaultTenant={activeTenant}
onTenantSwitch={handleSwitchTenant}
/>
</SidebarHeader>
<SidebarContent className='overflow-x-hidden'>
<SidebarGroup>
<SidebarGroupLabel>Overview</SidebarGroupLabel>
<SidebarMenu>
{navItems.map((item) => {
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
return item?.items && item?.items?.length > 0 ? (
<Collapsible
key={item.title}
asChild
defaultOpen={item.isActive}
className='group/collapsible'
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
>
{item.icon && <Icon />}
<span>{item.title}</span>
<IconChevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={pathname === subItem.url}
>
<Link href={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
) : (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
tooltip={item.title}
isActive={pathname === item.url}
>
<Link href={item.url}>
<Icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size='lg'
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
>
{user && (
<UserAvatarProfile
className='h-8 w-8 rounded-lg'
showInfo
user={{
imageUrl: user.photoURL || undefined,
fullName: user.displayName,
emailAddresses: [{ emailAddress: user.email || '' }]
}}
/>
)}
<IconChevronsDown className='ml-auto size-4' />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
side='bottom'
align='end'
sideOffset={4}
>
<DropdownMenuLabel className='p-0 font-normal'>
<div className='px-1 py-1.5'>
{user && (
<UserAvatarProfile
className='h-8 w-8 rounded-lg'
showInfo
user={{
imageUrl: user.photoURL || undefined,
fullName: user.displayName,
emailAddresses: [{ emailAddress: user.email || '' }]
}}
/>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* <DropdownMenuGroup>
<DropdownMenuItem
onClick={() => router.push('/dashboard/profile')}
>
<IconUserCircle className='mr-2 h-4 w-4' />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<IconCreditCard className='mr-2 h-4 w-4' />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<IconBell className='mr-2 h-4 w-4' />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator /> */}
<DropdownMenuItem
onClick={handleSignOut}
disabled={signOutMutation.isPending}
>
<IconLogout className='mr-2 h-4 w-4' />
{signOutMutation.isPending ? 'Signing out...' : 'Sign out'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}

View File

@ -0,0 +1,18 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { IconBrandGithub } from '@tabler/icons-react';
export default function CtaGithub() {
return (
<Button variant='ghost' asChild size='sm' className='hidden sm:flex'>
<a
href='/'
rel='noopener noreferrer'
target='_blank'
className='dark:text-foreground'
>
<IconBrandGithub />
</a>
</Button>
);
}

View File

@ -0,0 +1,31 @@
import React from 'react';
import { SidebarTrigger } from '../ui/sidebar';
import { Separator } from '../ui/separator';
import { Breadcrumbs } from '../breadcrumbs';
import SearchInput from '../search-input';
import { UserNav } from './user-nav';
import { ThemeSelector } from '../theme-selector';
import { ModeToggle } from './ThemeToggle/theme-toggle';
import CtaGithub from './cta-github';
export default function Header() {
return (
<header className='flex h-16 shrink-0 items-center justify-between gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12'>
<div className='flex items-center gap-2 px-4'>
<SidebarTrigger className='-ml-1' />
<Separator orientation='vertical' className='mr-2 h-4' />
<Breadcrumbs />
</div>
<div className='flex items-center gap-2 px-4'>
<CtaGithub />
<UserNav />
<div className='hidden md:flex'>
<SearchInput />
</div>
<ModeToggle />
{/* <ThemeSelector /> */}
</div>
</header>
);
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
export default function PageContainer({
children,
scrollable = true
}: {
children: React.ReactNode;
scrollable?: boolean;
}) {
return (
<>
{scrollable ? (
<ScrollArea className='h-[calc(100dvh-52px)]'>
<div className='flex flex-1 p-4 md:px-6'>{children}</div>
</ScrollArea>
) : (
<div className='flex flex-1 p-4 md:px-6'>{children}</div>
)}
</>
);
}

View File

@ -0,0 +1,84 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ROUTES } from '@/constants/routes';
import { auth, onAuthStateChanged, signOut, type User } from '@/lib/firebase';
import { toast } from 'sonner';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [isAuthorized, setIsAuthorized] = useState(false);
const router = useRouter();
const checkUserRole = async (email: string) => {
try {
const response = await fetch(ROUTES.API.CHECK_ROLE, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Role check failed');
}
return true;
} catch (error) {
console.error('Role check error:', error);
return false;
}
};
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
if (currentUser && currentUser.email) {
console.log('User is authenticated:', currentUser);
// Check if user has admin role
const hasAdminRole = await checkUserRole(currentUser.email);
if (hasAdminRole) {
setUser(currentUser);
setIsAuthorized(true);
} else {
// Sign out user if they don't have admin role
await signOut(auth);
toast.error(
'Access denied. Only administrators can access this application.'
);
router.push(ROUTES.AUTH.SIGN_IN);
}
} else {
console.log('User is not authenticated, redirecting to sign-in page');
setUser(null);
setIsAuthorized(false);
router.push(ROUTES.AUTH.SIGN_IN);
}
setLoading(false);
});
return () => unsubscribe();
}, [router]);
if (loading) {
return (
<div className='flex h-screen w-full items-center justify-center'>
<div className='h-32 w-32 animate-spin rounded-full border-b-2 border-gray-900 dark:border-gray-100'></div>
</div>
);
}
if (!user || !isAuthorized) {
return null; // Will redirect to sign-in
}
return <>{children}</>;
}

View File

@ -0,0 +1,17 @@
'use client';
import React from 'react';
import { ActiveThemeProvider } from '../active-theme';
export default function Providers({
activeThemeValue,
children
}: {
activeThemeValue: string;
children: React.ReactNode;
}) {
return (
<ActiveThemeProvider initialTheme={activeThemeValue}>
{children}
</ActiveThemeProvider>
);
}

View File

@ -0,0 +1,85 @@
'use client';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { UserAvatarProfile } from '@/components/user-avatar-profile';
import { useSignOutMutation } from '@/features/auth/hooks/use-mutation';
import useAuthUser from '@/features/auth/hooks/use-auth-user';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
export function UserNav() {
const { data: user } = useAuthUser();
const signOutMutation = useSignOutMutation();
const router = useRouter();
const handleSignOut = () => {
signOutMutation.mutate(undefined, {
onSuccess: () => {
toast.success('Signed out successfully');
router.push('/auth/sign-in');
},
onError: (error: any) => {
toast.error(error.message || 'Sign out failed');
}
});
};
if (user) {
const userData = {
imageUrl: user.photoURL || undefined,
fullName: user.displayName,
emailAddresses: [{ emailAddress: user.email || '' }]
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
<UserAvatarProfile user={userData} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-56'
align='end'
sideOffset={10}
forceMount
>
<DropdownMenuLabel className='font-normal'>
<div className='flex flex-col space-y-1'>
<p className='text-sm leading-none font-medium'>
{user.displayName || 'User'}
</p>
<p className='text-muted-foreground text-xs leading-none'>
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* <DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push('/dashboard/profile')}>
Profile
</DropdownMenuItem>
<DropdownMenuItem>Billing</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>New Team</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator /> */}
<DropdownMenuItem
onClick={handleSignOut}
disabled={signOutMutation.isPending}
>
{signOutMutation.isPending ? 'Signing out...' : 'Sign out'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
}

View File

@ -0,0 +1,92 @@
'use client';
import React, { useState } from 'react';
import {
motion,
useTransform,
AnimatePresence,
useMotionValue,
useSpring
} from 'framer-motion';
export const AnimatedTooltip = ({
items
}: {
items: {
id: number;
name: string;
designation: string;
image: string;
}[];
}) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const springConfig = { stiffness: 100, damping: 5 };
const x = useMotionValue(0); // going to set this value on mouse move
// rotate the tooltip
const rotate = useSpring(
useTransform(x, [-100, 100], [-45, 45]),
springConfig
);
// translate the tooltip
const translateX = useSpring(
useTransform(x, [-100, 100], [-50, 50]),
springConfig
);
const handleMouseMove = (event: any) => {
const halfWidth = event.target.offsetWidth / 2;
x.set(event.nativeEvent.offsetX - halfWidth); // set the x value, which is then used in transform and rotate
};
return (
<>
{items.map((item, idx) => (
<div
className='group relative -mr-4'
key={item.name}
onMouseEnter={() => setHoveredIndex(item.id)}
onMouseLeave={() => setHoveredIndex(null)}
>
<AnimatePresence mode='popLayout'>
{hoveredIndex === item.id && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.6 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
transition: {
type: 'spring',
stiffness: 260,
damping: 10
}
}}
exit={{ opacity: 0, y: 20, scale: 0.6 }}
style={{
translateX: translateX,
rotate: rotate,
whiteSpace: 'nowrap'
}}
className='absolute -top-16 left-1/2 z-50 flex -translate-x-1/2 flex-col items-center justify-center rounded-md bg-black px-4 py-2 text-xs shadow-xl'
>
<div className='absolute inset-x-10 -bottom-px z-30 h-px w-[20%] bg-linear-to-r from-transparent via-emerald-500 to-transparent' />
<div className='absolute -bottom-px left-10 z-30 h-px w-[40%] bg-linear-to-r from-transparent via-sky-500 to-transparent' />
<div className='relative z-30 text-base font-bold text-white'>
{item.name}
</div>
<div className='text-xs text-white'>{item.designation}</div>
</motion.div>
)}
</AnimatePresence>
<img
onMouseMove={handleMouseMove}
height={100}
width={100}
src={item.image}
alt={item.name}
className='relative m-0! h-14 w-14 rounded-full border-2 border-white object-cover object-top p-0! transition duration-500 group-hover:z-30 group-hover:scale-105'
/>
</div>
))}
</>
);
};

View File

@ -0,0 +1,11 @@
'use client';
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot='aspect-ratio' {...props} />;
}
export { AspectRatio };

View File

@ -0,0 +1,260 @@
'use client';
import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
import React, { useRef, useState, useEffect } from 'react';
export const BackgroundBeamsWithCollision = ({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null);
const beams = [
{
initialX: 10,
translateX: 10,
duration: 7,
repeatDelay: 3,
delay: 2
},
{
initialX: 600,
translateX: 600,
duration: 3,
repeatDelay: 3,
delay: 4
},
{
initialX: 100,
translateX: 100,
duration: 7,
repeatDelay: 7,
className: 'h-6'
},
{
initialX: 400,
translateX: 400,
duration: 5,
repeatDelay: 14,
delay: 4
},
{
initialX: 800,
translateX: 800,
duration: 11,
repeatDelay: 2,
className: 'h-20'
},
{
initialX: 1000,
translateX: 1000,
duration: 4,
repeatDelay: 2,
className: 'h-12'
},
{
initialX: 1200,
translateX: 1200,
duration: 6,
repeatDelay: 4,
delay: 2,
className: 'h-6'
}
];
return (
<div
ref={parentRef}
className={cn(
'relative flex h-96 w-full items-center justify-center overflow-hidden bg-linear-to-b from-white to-neutral-100 md:h-160 dark:from-neutral-950 dark:to-neutral-800',
// h-screen if you want bigger
className
)}
>
{beams.map((beam) => (
<CollisionMechanism
key={beam.initialX + 'beam-idx'}
beamOptions={beam}
containerRef={containerRef as React.RefObject<HTMLDivElement>}
parentRef={parentRef as React.RefObject<HTMLDivElement>}
/>
))}
{children}
<div
ref={containerRef}
className='pointer-events-none absolute inset-x-0 bottom-0 w-full bg-neutral-100'
style={{
boxShadow:
'0 0 24px rgba(34, 42, 53, 0.06), 0 1px 1px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(34, 42, 53, 0.04), 0 0 4px rgba(34, 42, 53, 0.08), 0 16px 68px rgba(47, 48, 55, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1) inset'
}}
></div>
</div>
);
};
const CollisionMechanism = React.forwardRef<
HTMLDivElement,
{
containerRef: React.RefObject<HTMLDivElement>;
parentRef: React.RefObject<HTMLDivElement>;
beamOptions?: {
initialX?: number;
translateX?: number;
initialY?: number;
translateY?: number;
rotate?: number;
className?: string;
duration?: number;
delay?: number;
repeatDelay?: number;
};
}
>(({ parentRef, containerRef, beamOptions = {} }, ref) => {
const beamRef = useRef<HTMLDivElement>(null);
const [collision, setCollision] = useState<{
detected: boolean;
coordinates: { x: number; y: number } | null;
}>({
detected: false,
coordinates: null
});
const [beamKey, setBeamKey] = useState(0);
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
useEffect(() => {
const checkCollision = () => {
if (
beamRef.current &&
containerRef.current &&
parentRef.current &&
!cycleCollisionDetected
) {
const beamRect = beamRef.current.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
const parentRect = parentRef.current.getBoundingClientRect();
if (beamRect.bottom >= containerRect.top) {
const relativeX =
beamRect.left - parentRect.left + beamRect.width / 2;
const relativeY = beamRect.bottom - parentRect.top;
setCollision({
detected: true,
coordinates: {
x: relativeX,
y: relativeY
}
});
setCycleCollisionDetected(true);
}
}
};
const animationInterval = setInterval(checkCollision, 50);
return () => clearInterval(animationInterval);
}, [cycleCollisionDetected, containerRef]);
useEffect(() => {
if (collision.detected && collision.coordinates) {
setTimeout(() => {
setCollision({ detected: false, coordinates: null });
setCycleCollisionDetected(false);
}, 2000);
setTimeout(() => {
setBeamKey((prevKey) => prevKey + 1);
}, 2000);
}
}, [collision]);
return (
<>
<motion.div
key={beamKey}
ref={beamRef}
animate='animate'
initial={{
y: beamOptions.initialY || '-200px',
x: beamOptions.initialX || '0px',
rotate: beamOptions.rotate || 0
}}
variants={{
animate: {
y: beamOptions.translateY || '1800px',
x: beamOptions.translateX || '0px',
rotate: beamOptions.rotate || 0
}
}}
transition={{
duration: beamOptions.duration || 8,
repeat: Infinity,
repeatType: 'loop',
ease: 'linear',
delay: beamOptions.delay || 0,
repeatDelay: beamOptions.repeatDelay || 0
}}
className={cn(
'absolute top-20 left-0 m-auto h-14 w-px rounded-full bg-linear-to-t from-indigo-500 via-purple-500 to-transparent',
beamOptions.className
)}
/>
<AnimatePresence>
{collision.detected && collision.coordinates && (
<Explosion
key={`${collision.coordinates.x}-${collision.coordinates.y}`}
className=''
style={{
left: `${collision.coordinates.x}px`,
top: `${collision.coordinates.y}px`,
transform: 'translate(-50%, -50%)'
}}
/>
)}
</AnimatePresence>
</>
);
});
CollisionMechanism.displayName = 'CollisionMechanism';
const Explosion = ({ ...props }: React.HTMLProps<HTMLDivElement>) => {
const spans = Array.from({ length: 20 }, (_, index) => ({
id: index,
initialX: 0,
initialY: 0,
directionX: Math.floor(Math.random() * 80 - 40),
directionY: Math.floor(Math.random() * -50 - 10)
}));
return (
<div {...props} className={cn('absolute z-50 h-2 w-2', props.className)}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.5, ease: 'easeOut' }}
className='absolute -inset-x-10 top-0 m-auto h-2 w-10 rounded-full bg-linear-to-r from-transparent via-indigo-500 to-transparent blur-sm'
></motion.div>
{spans.map((span) => (
<motion.span
key={span.id}
initial={{ x: span.initialX, y: span.initialY, opacity: 1 }}
animate={{
x: span.directionX,
y: span.directionY,
opacity: 0
}}
transition={{ duration: Math.random() * 1.5 + 0.5, ease: 'easeOut' }}
className='absolute h-1 w-1 rounded-full bg-linear-to-b from-indigo-500 to-purple-500'
/>
))}
</div>
);
};

View File

@ -0,0 +1,54 @@
'use client';
import React from 'react';
import { motion } from 'framer-motion';
export default function ColourfulText({ text }: { text: string }) {
const colors = [
'rgb(131, 179, 32)',
'rgb(47, 195, 106)',
'rgb(42, 169, 210)',
'rgb(4, 112, 202)',
'rgb(107, 10, 255)',
'rgb(183, 0, 218)',
'rgb(218, 0, 171)',
'rgb(230, 64, 92)',
'rgb(232, 98, 63)',
'rgb(249, 129, 47)'
];
const [currentColors, setCurrentColors] = React.useState(colors);
const [count, setCount] = React.useState(0);
React.useEffect(() => {
const interval = setInterval(() => {
const shuffled = [...colors].sort(() => Math.random() - 0.5);
setCurrentColors(shuffled);
setCount((prev) => prev + 1);
}, 5000);
return () => clearInterval(interval);
}, []);
return text.split('').map((char, index) => (
<motion.span
key={`${char}-${count}-${index}`}
initial={{
y: 0
}}
animate={{
color: currentColors[index % currentColors.length],
y: [0, -3, 0],
scale: [1, 1.01, 1],
filter: ['blur(0px)', `blur(5px)`, 'blur(0px)'],
opacity: [1, 0.8, 1]
}}
transition={{
duration: 0.5,
delay: index * 0.05
}}
className='inline-block font-sans tracking-tight whitespace-pre'
>
{char}
</motion.span>
));
}

View File

@ -0,0 +1,112 @@
'use client';
import React, { JSX, useState } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
type Card = {
id: number;
content: JSX.Element | React.ReactNode | string;
className: string;
thumbnail: string;
};
export const LayoutGrid = ({ cards }: { cards: Card[] }) => {
const [selected, setSelected] = useState<Card | null>(null);
const [lastSelected, setLastSelected] = useState<Card | null>(null);
const handleClick = (card: Card) => {
setLastSelected(selected);
setSelected(card);
};
const handleOutsideClick = () => {
setLastSelected(selected);
setSelected(null);
};
return (
<div className='relative mx-auto grid h-full w-full max-w-7xl grid-cols-1 gap-4 p-10 md:grid-cols-3'>
{cards.map((card, i) => (
<div key={i} className={cn(card.className, '')}>
<motion.div
onClick={() => handleClick(card)}
className={cn(
card.className,
'relative overflow-hidden',
selected?.id === card.id
? 'absolute inset-0 z-50 m-auto flex h-1/2 w-full cursor-pointer flex-col flex-wrap items-center justify-center rounded-lg md:w-1/2'
: lastSelected?.id === card.id
? 'z-40 h-full w-full rounded-xl bg-white'
: 'h-full w-full rounded-xl bg-white'
)}
layoutId={`card-${card.id}`}
>
{selected?.id === card.id && <SelectedCard selected={selected} />}
<ImageComponent card={card} />
</motion.div>
</div>
))}
<motion.div
onClick={handleOutsideClick}
className={cn(
'absolute top-0 left-0 z-10 h-full w-full bg-black opacity-0',
selected?.id ? 'pointer-events-auto' : 'pointer-events-none'
)}
animate={{ opacity: selected?.id ? 0.3 : 0 }}
/>
</div>
);
};
const ImageComponent = ({ card }: { card: Card }) => {
return (
<motion.img
layoutId={`image-${card.id}-image`}
src={card.thumbnail}
height='500'
width='500'
className={cn(
'absolute inset-0 h-full w-full object-cover object-top transition duration-200'
)}
alt='thumbnail'
/>
);
};
const SelectedCard = ({ selected }: { selected: Card | null }) => {
return (
<div className='relative z-60 flex h-full w-full flex-col justify-end rounded-lg bg-transparent shadow-2xl'>
<motion.div
initial={{
opacity: 0
}}
animate={{
opacity: 0.6
}}
className='absolute inset-0 z-10 h-full w-full bg-black opacity-60'
/>
<motion.div
layoutId={`content-${selected?.id}`}
initial={{
opacity: 0,
y: 100
}}
animate={{
opacity: 1,
y: 0
}}
exit={{
opacity: 0,
y: 100
}}
transition={{
duration: 0.3,
ease: 'easeInOut'
}}
className='relative z-70 px-8 pb-4'
>
{selected?.content}
</motion.div>
</div>
);
};

View File

@ -0,0 +1,73 @@
import { cn } from '@/lib/utils';
import { ComponentPropsWithoutRef } from 'react';
interface MarqueeProps extends ComponentPropsWithoutRef<'div'> {
/**
* Optional CSS class name to apply custom styles
*/
className?: string;
/**
* Whether to reverse the animation direction
* @default false
*/
reverse?: boolean;
/**
* Whether to pause the animation on hover
* @default false
*/
pauseOnHover?: boolean;
/**
* Content to be displayed in the marquee
*/
children: React.ReactNode;
/**
* Whether to animate vertically instead of horizontally
* @default false
*/
vertical?: boolean;
/**
* Number of times to repeat the content
* @default 4
*/
repeat?: number;
}
export function Marquee({
className,
reverse = false,
pauseOnHover = false,
children,
vertical = false,
repeat = 4,
...props
}: MarqueeProps) {
return (
<div
{...props}
className={cn(
'group flex gap-(--gap) overflow-hidden p-2 [--duration:40s] [--gap:1rem]',
{
'flex-row': !vertical,
'flex-col': vertical
},
className
)}
>
{Array(repeat)
.fill(0)
.map((_, i) => (
<div
key={i}
className={cn('flex shrink-0 justify-around gap-(--gap)', {
'animate-marquee flex-row': !vertical,
'animate-marquee-vertical flex-col': vertical,
'group-hover:paused': pauseOnHover,
'[animation-direction:reverse]': reverse
})}
>
{children}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,67 @@
'use client';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
import React, { useMemo } from 'react';
export const Meteors = ({
number,
className
}: {
number?: number;
className?: string;
}) => {
const meteors = new Array(number || 20).fill(true);
// Generate deterministic values based on index to avoid hydration mismatch
const meteorData = useMemo(() => {
return meteors.map((_, idx) => {
const meteorCount = number || 20;
// Calculate position to evenly distribute meteors across container width
const position = idx * (800 / meteorCount) - 400; // Spread across 800px range, centered
// Use deterministic values based on index instead of Math.random()
const delayMultiplier = (idx * 0.7) % 5; // Deterministic delay between 0-5s
const durationBase = ((idx * 1.3) % 5) + 5; // Deterministic duration between 5-10s
return {
position,
delay: delayMultiplier,
duration: Math.floor(durationBase)
};
});
}, [number, meteors.length]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
{meteors.map((el, idx) => {
const meteorInfo = meteorData[idx];
return (
<span
key={'meteor' + idx}
className={cn(
'animate-meteor-effect absolute h-0.5 w-0.5 rotate-45 rounded-[9999px]',
'bg-cyan-300/80 dark:bg-cyan-400/90',
'shadow-[0_0_8px_2px_#67e8f9,0_0_0_1px_#ffffff20] dark:shadow-[0_0_8px_2px_#22d3ee,0_0_0_1px_#ffffff30]',
'before:absolute before:top-1/2 before:h-px before:w-[50px] before:-translate-y-[50%] before:transform',
'before:bg-gradient-to-r before:from-cyan-400 before:to-transparent',
'dark:before:from-cyan-300 dark:before:to-transparent',
"before:content-['']",
className
)}
style={{
top: '-40px', // Start above the container
left: meteorInfo.position + 'px',
animationDelay: meteorInfo.delay + 's',
animationDuration: meteorInfo.duration + 's'
}}
></span>
);
})}
</motion.div>
);
};

View File

@ -0,0 +1,184 @@
import { SVGProps } from 'react';
type SafariMode = 'default' | 'simple';
export interface SafariProps extends SVGProps<SVGSVGElement> {
url?: string;
imageSrc?: string;
videoSrc?: string;
width?: number;
height?: number;
mode?: SafariMode;
}
export function Safari({
imageSrc,
videoSrc,
url,
width = 1203,
height = 753,
mode = 'default',
...props
}: SafariProps) {
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<g clipPath='url(#path0)'>
<path
d='M0 52H1202V741C1202 747.627 1196.63 753 1190 753H12C5.37258 753 0 747.627 0 741V52Z'
className='fill-[#E5E5E5] dark:fill-[#404040]'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M0 12C0 5.37258 5.37258 0 12 0H1190C1196.63 0 1202 5.37258 1202 12V52H0L0 12Z'
className='fill-[#E5E5E5] dark:fill-[#404040]'
/>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M1.06738 12C1.06738 5.92487 5.99225 1 12.0674 1H1189.93C1196.01 1 1200.93 5.92487 1200.93 12V51H1.06738V12Z'
className='fill-white dark:fill-[#262626]'
/>
<circle
cx='27'
cy='25'
r='6'
className='fill-[#E5E5E5] dark:fill-[#404040]'
/>
<circle
cx='47'
cy='25'
r='6'
className='fill-[#E5E5E5] dark:fill-[#404040]'
/>
<circle
cx='67'
cy='25'
r='6'
className='fill-[#E5E5E5] dark:fill-[#404040]'
/>
<path
d='M286 17C286 13.6863 288.686 11 292 11H946C949.314 11 952 13.6863 952 17V35C952 38.3137 949.314 41 946 41H292C288.686 41 286 38.3137 286 35V17Z'
className='fill-[#E5E5E5] dark:fill-[#404040]'
/>
<g className='mix-blend-luminosity'>
<path
d='M566.269 32.0852H572.426C573.277 32.0852 573.696 31.6663 573.696 30.7395V25.9851C573.696 25.1472 573.353 24.7219 572.642 24.6521V23.0842C572.642 20.6721 571.036 19.5105 569.348 19.5105C567.659 19.5105 566.053 20.6721 566.053 23.0842V24.6711C565.393 24.7727 565 25.1917 565 25.9851V30.7395C565 31.6663 565.418 32.0852 566.269 32.0852ZM567.272 22.97C567.272 21.491 568.211 20.6785 569.348 20.6785C570.478 20.6785 571.423 21.491 571.423 22.97V24.6394L567.272 24.6458V22.97Z'
fill='#A3A3A3'
/>
</g>
<g className='mix-blend-luminosity'>
<text
x='580'
y='30'
fill='#A3A3A3'
fontSize='12'
fontFamily='Arial, sans-serif'
>
{url}
</text>
</g>
{mode === 'default' ? (
<>
<g className='mix-blend-luminosity'>
<path
d='M265.5 33.8984C265.641 33.8984 265.852 33.8516 266.047 33.7422C270.547 31.2969 272.109 30.1641 272.109 27.3203V21.4219C272.109 20.4844 271.742 20.1484 270.961 19.8125C270.094 19.4453 267.18 18.4297 266.328 18.1406C266.07 18.0547 265.766 18 265.5 18C265.234 18 264.93 18.0703 264.672 18.1406C263.82 18.3828 260.906 19.4531 260.039 19.8125C259.258 20.1406 258.891 20.4844 258.891 21.4219V27.3203C258.891 30.1641 260.461 31.2812 264.945 33.7422C265.148 33.8516 265.359 33.8984 265.5 33.8984ZM265.922 19.5781C266.945 19.9766 269.172 20.7656 270.344 21.1875C270.562 21.2656 270.617 21.3828 270.617 21.6641V27.0234C270.617 29.3125 269.469 29.9375 265.945 32.0625C265.727 32.1875 265.617 32.2344 265.508 32.2344V19.4844C265.617 19.4844 265.734 19.5156 265.922 19.5781Z'
fill='#A3A3A3'
/>
</g>
<g className='mix-blend-luminosity'>
<path
d='M936.273 24.9766C936.5 24.9766 936.68 24.9062 936.82 24.7578L940.023 21.5312C940.195 21.3594 940.273 21.1719 940.273 20.9531C940.273 20.7422 940.188 20.5391 940.023 20.3828L936.82 17.125C936.68 16.9688 936.5 16.8906 936.273 16.8906C935.852 16.8906 935.516 17.2422 935.516 17.6719C935.516 17.8828 935.594 18.0547 935.727 18.2031L937.594 20.0312C937.227 19.9766 936.852 19.9453 936.477 19.9453C932.609 19.9453 929.516 23.0391 929.516 26.9141C929.516 30.7891 932.633 33.9062 936.5 33.9062C940.375 33.9062 943.484 30.7891 943.484 26.9141C943.484 26.4453 943.156 26.1094 942.688 26.1094C942.234 26.1094 941.93 26.4453 941.93 26.9141C941.93 29.9297 939.516 32.3516 936.5 32.3516C933.492 32.3516 931.07 29.9297 931.07 26.9141C931.07 23.875 933.469 21.4688 936.477 21.4688C936.984 21.4688 937.453 21.5078 937.867 21.5781L935.734 23.6875C935.594 23.8281 935.516 24 935.516 24.2109C935.516 24.6406 935.852 24.9766 936.273 24.9766Z'
fill='#A3A3A3'
/>
</g>
<g className='mix-blend-luminosity'>
<path
d='M1134 33.0156C1134.49 33.0156 1134.89 32.6094 1134.89 32.1484V27.2578H1139.66C1140.13 27.2578 1140.54 26.8594 1140.54 26.3672C1140.54 25.8828 1140.13 25.4766 1139.66 25.4766H1134.89V20.5859C1134.89 20.1172 1134.49 19.7188 1134 19.7188C1133.52 19.7188 1133.11 20.1172 1133.11 20.5859V25.4766H1128.34C1127.88 25.4766 1127.46 25.8828 1127.46 26.3672C1127.46 26.8594 1127.88 27.2578 1128.34 27.2578H1133.11V32.1484C1133.11 32.6094 1133.52 33.0156 1134 33.0156Z'
fill='#A3A3A3'
/>
</g>
<g className='mix-blend-luminosity'>
<path
d='M1161.8 31.0703H1163.23V32.375C1163.23 34.0547 1164.12 34.9219 1165.81 34.9219H1174.2C1175.89 34.9219 1176.77 34.0547 1176.77 32.3828V24.0469C1176.77 22.375 1175.89 21.5 1174.2 21.5H1172.77V20.2578C1172.77 18.5859 1171.88 17.7109 1170.19 17.7109H1161.8C1160.1 17.7109 1159.23 18.5781 1159.23 20.2578V28.5234C1159.23 30.1953 1160.1 31.0703 1161.8 31.0703ZM1161.9 29.5078C1161.18 29.5078 1160.78 29.1328 1160.78 28.3828V20.3984C1160.78 19.6406 1161.18 19.2656 1161.9 19.2656H1170.09C1170.8 19.2656 1171.2 19.6406 1171.2 20.3984V21.5H1165.81C1164.12 21.5 1163.23 22.375 1163.23 24.0469V29.5078H1161.9ZM1165.91 33.3672C1165.19 33.3672 1164.8 32.9922 1164.8 32.2422V24.1875C1164.8 23.4297 1165.19 23.0625 1165.91 23.0625H1174.1C1174.81 23.0625 1175.21 23.4297 1175.21 24.1875V32.2422C1175.21 32.9922 1174.81 33.3672 1174.1 33.3672H1165.91Z'
fill='#A3A3A3'
/>
</g>
<g className='mix-blend-luminosity'>
<path
d='M1099.51 28.4141C1099.91 28.4141 1100.24 28.0859 1100.24 27.6953V19.8359L1100.18 18.6797L1100.66 19.25L1101.75 20.4141C1101.88 20.5547 1102.06 20.625 1102.24 20.625C1102.6 20.625 1102.9 20.3672 1102.9 20C1102.9 19.8047 1102.82 19.6641 1102.69 19.5312L1100.06 17.0078C1099.88 16.8203 1099.7 16.7578 1099.51 16.7578C1099.32 16.7578 1099.14 16.8203 1098.95 17.0078L1096.33 19.5312C1096.2 19.6641 1096.12 19.8047 1096.12 20C1096.12 20.3672 1096.41 20.625 1096.77 20.625C1096.95 20.625 1097.14 20.5547 1097.27 20.4141L1098.35 19.25L1098.84 18.6719L1098.78 19.8359V27.6953C1098.78 28.0859 1099.11 28.4141 1099.51 28.4141ZM1095 34.6562H1104C1105.7 34.6562 1106.57 33.7812 1106.57 32.1094V24.4297C1106.57 22.7578 1105.7 21.8828 1104 21.8828H1101.89V23.4375H1103.9C1104.61 23.4375 1105.02 23.8125 1105.02 24.5625V31.9688C1105.02 32.7188 1104.61 33.0938 1103.9 33.0938H1095.1C1094.38 33.0938 1093.98 32.7188 1093.98 31.9688V24.5625C1093.98 23.8125 1094.38 23.4375 1095.1 23.4375H1097.13V21.8828H1095C1093.31 21.8828 1092.43 22.75 1092.43 24.4297V32.1094C1092.43 33.7812 1093.31 34.6562 1095 34.6562Z'
fill='#A3A3A3'
/>
</g>
<g className='mix-blend-luminosity'>
<path
d='M99.5703 33.6016H112.938C114.633 33.6016 115.516 32.7266 115.516 31.0547V21.5469C115.516 19.875 114.633 19 112.938 19H99.5703C97.8828 19 97 19.8672 97 21.5469V31.0547C97 32.7266 97.8828 33.6016 99.5703 33.6016ZM99.6719 32.0469C98.9531 32.0469 98.5547 31.6719 98.5547 30.9141V21.6875C98.5547 20.9297 98.9531 20.5547 99.6719 20.5547H103.234V32.0469H99.6719ZM112.836 20.5547C113.555 20.5547 113.953 20.9297 113.953 21.6875V30.9141C113.953 31.6719 113.555 32.0469 112.836 32.0469H104.711V20.5547H112.836ZM101.703 23.4141C101.984 23.4141 102.219 23.1719 102.219 22.9062C102.219 22.6406 101.984 22.4062 101.703 22.4062H100.102C99.8203 22.4062 99.5859 22.6406 99.5859 22.9062C99.5859 23.1719 99.8203 23.4141 100.102 23.4141H101.703ZM101.703 25.5156C101.984 25.5156 102.219 25.2812 102.219 25.0078C102.219 24.7422 101.984 24.5078 101.703 24.5078H100.102C99.8203 24.5078 99.5859 24.7422 99.5859 25.0078C99.5859 25.2812 99.8203 25.5156 100.102 25.5156H101.703ZM101.703 27.6094C101.984 27.6094 102.219 27.3828 102.219 27.1094C102.219 26.8438 101.984 26.6172 101.703 26.6172H100.102C99.8203 26.6172 99.5859 26.8438 99.5859 27.1094C99.5859 27.3828 99.8203 27.6094 100.102 27.6094H101.703Z'
fill='#A3A3A3'
/>
</g>
<g className='mix-blend-luminosity'>
<path
d='M143.914 32.5938C144.094 32.7656 144.312 32.8594 144.562 32.8594C145.086 32.8594 145.492 32.4531 145.492 31.9375C145.492 31.6797 145.391 31.4453 145.211 31.2656L139.742 25.9219L145.211 20.5938C145.391 20.4141 145.492 20.1719 145.492 19.9219C145.492 19.4062 145.086 19 144.562 19C144.312 19 144.094 19.0938 143.922 19.2656L137.844 25.2031C137.625 25.4062 137.516 25.6562 137.516 25.9297C137.516 26.2031 137.625 26.4375 137.836 26.6484L143.914 32.5938Z'
fill='#A3A3A3'
/>
</g>
<g className='mix-blend-luminosity'>
<path
d='M168.422 32.8594C168.68 32.8594 168.891 32.7656 169.07 32.5938L175.148 26.6562C175.359 26.4375 175.469 26.2109 175.469 25.9297C175.469 25.6562 175.367 25.4141 175.148 25.2109L169.07 19.2656C168.891 19.0938 168.68 19 168.422 19C167.898 19 167.492 19.4062 167.492 19.9219C167.492 20.1719 167.602 20.4141 167.773 20.5938L173.25 25.9375L167.773 31.2656C167.594 31.4531 167.492 31.6797 167.492 31.9375C167.492 32.4531 167.898 32.8594 168.422 32.8594Z'
fill='#A3A3A3'
/>
</g>
</>
) : null}
{imageSrc && (
<image
href={imageSrc}
width='1200'
height='700'
x='1'
y='52'
preserveAspectRatio='xMidYMid slice'
clipPath='url(#roundedBottom)'
/>
)}
{videoSrc && (
<foreignObject
x='1'
y='52'
width='1200'
height='700'
preserveAspectRatio='xMidYMid slice'
clipPath='url(#roundedBottom)'
>
<video
className='size-full overflow-hidden object-cover'
src={videoSrc}
autoPlay
loop
muted
playsInline
/>
</foreignObject>
)}
</g>
<defs>
<clipPath id='path0'>
<rect width={width} height={height} fill='white' />
</clipPath>
<clipPath id='roundedBottom'>
<path
d='M1 52H1201V741C1201 747.075 1196.08 752 1190 752H12C5.92486 752 1 747.075 1 741V52Z'
fill='white'
/>
</clipPath>
</defs>
</svg>
);
}

View File

@ -0,0 +1,208 @@
'use client';
import { motion, useSpring } from 'framer-motion';
import { FC, JSX, useEffect, useRef, useState } from 'react';
interface Position {
x: number;
y: number;
}
export interface SmoothCursorProps {
cursor?: JSX.Element;
springConfig?: {
damping: number;
stiffness: number;
mass: number;
restDelta: number;
};
}
const DefaultCursorSVG: FC = () => {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width={50}
height={54}
viewBox='0 0 50 54'
fill='none'
style={{ scale: 0.5 }}
>
<g filter='url(#filter0_d_91_7928)'>
<path
d='M42.6817 41.1495L27.5103 6.79925C26.7269 5.02557 24.2082 5.02558 23.3927 6.79925L7.59814 41.1495C6.75833 42.9759 8.52712 44.8902 10.4125 44.1954L24.3757 39.0496C24.8829 38.8627 25.4385 38.8627 25.9422 39.0496L39.8121 44.1954C41.6849 44.8902 43.4884 42.9759 42.6817 41.1495Z'
fill='black'
/>
<path
d='M43.7146 40.6933L28.5431 6.34306C27.3556 3.65428 23.5772 3.69516 22.3668 6.32755L6.57226 40.6778C5.3134 43.4156 7.97238 46.298 10.803 45.2549L24.7662 40.109C25.0221 40.0147 25.2999 40.0156 25.5494 40.1082L39.4193 45.254C42.2261 46.2953 44.9254 43.4347 43.7146 40.6933Z'
stroke='white'
strokeWidth={2.25825}
/>
</g>
<defs>
<filter
id='filter0_d_91_7928'
x={0.602397}
y={0.952444}
width={49.0584}
height={52.428}
filterUnits='userSpaceOnUse'
colorInterpolationFilters='sRGB'
>
<feFlood floodOpacity={0} result='BackgroundImageFix' />
<feColorMatrix
in='SourceAlpha'
type='matrix'
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
result='hardAlpha'
/>
<feOffset dy={2.25825} />
<feGaussianBlur stdDeviation={2.25825} />
<feComposite in2='hardAlpha' operator='out' />
<feColorMatrix
type='matrix'
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0'
/>
<feBlend
mode='normal'
in2='BackgroundImageFix'
result='effect1_dropShadow_91_7928'
/>
<feBlend
mode='normal'
in='SourceGraphic'
in2='effect1_dropShadow_91_7928'
result='shape'
/>
</filter>
</defs>
</svg>
);
};
export function SmoothCursor({
cursor = <DefaultCursorSVG />,
springConfig = {
damping: 45,
stiffness: 400,
mass: 1,
restDelta: 0.001
}
}: SmoothCursorProps) {
const [isMoving, setIsMoving] = useState(false);
const lastMousePos = useRef<Position>({ x: 0, y: 0 });
const velocity = useRef<Position>({ x: 0, y: 0 });
const lastUpdateTime = useRef(Date.now());
const previousAngle = useRef(0);
const accumulatedRotation = useRef(0);
const cursorX = useSpring(0, springConfig);
const cursorY = useSpring(0, springConfig);
const rotation = useSpring(0, {
...springConfig,
damping: 60,
stiffness: 300
});
const scale = useSpring(1, {
...springConfig,
stiffness: 500,
damping: 35
});
useEffect(() => {
const updateVelocity = (currentPos: Position) => {
const currentTime = Date.now();
const deltaTime = currentTime - lastUpdateTime.current;
if (deltaTime > 0) {
velocity.current = {
x: (currentPos.x - lastMousePos.current.x) / deltaTime,
y: (currentPos.y - lastMousePos.current.y) / deltaTime
};
}
lastUpdateTime.current = currentTime;
lastMousePos.current = currentPos;
};
const smoothMouseMove = (e: MouseEvent) => {
const currentPos = { x: e.clientX, y: e.clientY };
updateVelocity(currentPos);
const speed = Math.sqrt(
Math.pow(velocity.current.x, 2) + Math.pow(velocity.current.y, 2)
);
cursorX.set(currentPos.x);
cursorY.set(currentPos.y);
if (speed > 0.1) {
const currentAngle =
Math.atan2(velocity.current.y, velocity.current.x) * (180 / Math.PI) +
90;
let angleDiff = currentAngle - previousAngle.current;
if (angleDiff > 180) angleDiff -= 360;
if (angleDiff < -180) angleDiff += 360;
accumulatedRotation.current += angleDiff;
rotation.set(accumulatedRotation.current);
previousAngle.current = currentAngle;
scale.set(0.95);
setIsMoving(true);
const timeout = setTimeout(() => {
scale.set(1);
setIsMoving(false);
}, 150);
return () => clearTimeout(timeout);
}
};
let rafId: number;
const throttledMouseMove = (e: MouseEvent) => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
smoothMouseMove(e);
rafId = 0;
});
};
document.body.style.cursor = 'none';
window.addEventListener('mousemove', throttledMouseMove);
return () => {
window.removeEventListener('mousemove', throttledMouseMove);
document.body.style.cursor = 'auto';
if (rafId) cancelAnimationFrame(rafId);
};
}, [cursorX, cursorY, rotation, scale]);
return (
<motion.div
style={{
position: 'fixed',
left: cursorX,
top: cursorY,
translateX: '-50%',
translateY: '-50%',
rotate: rotation,
scale: scale,
zIndex: 100,
pointerEvents: 'none',
willChange: 'transform'
}}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: 'spring',
stiffness: 400,
damping: 30
}}
>
{cursor}
</motion.div>
);
}

View File

@ -0,0 +1,46 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Modal } from '@/components/ui/modal';
interface AlertModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
loading: boolean;
}
export const AlertModal: React.FC<AlertModalProps> = ({
isOpen,
onClose,
onConfirm,
loading
}) => {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return null;
}
return (
<Modal
title='Are you sure?'
description='This action cannot be undone.'
isOpen={isOpen}
onClose={onClose}
>
<div className='flex w-full items-center justify-end space-x-2 pt-6'>
<Button disabled={loading} variant='outline' onClick={onClose}>
Cancel
</Button>
<Button disabled={loading} variant='destructive' onClick={onConfirm}>
Continue
</Button>
</div>
</Modal>
);
};

View File

@ -0,0 +1,80 @@
'use client';
import { IconChevronRight } from '@tabler/icons-react';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@/components/ui/collapsible';
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem
} from '@/components/ui/sidebar';
import { Icon } from '@/components/icons';
export function NavMain({
items
}: {
items: {
title: string;
url: string;
icon?: Icon;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
}) {
return (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupContent className='flex flex-col gap-2'>
<SidebarMenu>
{items.map((item) => (
<Collapsible
key={item.title}
asChild
defaultOpen={item.isActive}
className='group/collapsible'
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
className='bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear'
>
{item.icon && <item.icon />}
<span>{item.title}</span>
<IconChevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<a href={subItem.url}>
<span>{subItem.title}</span>
</a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@ -0,0 +1,89 @@
'use client';
import {
IconFolder,
IconShare,
IconDots,
IconTrash
} from '@tabler/icons-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar
} from '@/components/ui/sidebar';
import { Icon } from '@/components/icons';
export function NavProjects({
projects
}: {
projects: {
name: string;
url: string;
icon: Icon;
}[];
}) {
const { isMobile } = useSidebar();
return (
<SidebarGroup className='group-data-[collapsible=icon]:hidden'>
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
{projects.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<IconDots />
<span className='sr-only'>More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-48 rounded-lg'
side={isMobile ? 'bottom' : 'right'}
align={isMobile ? 'end' : 'start'}
>
<DropdownMenuItem>
<IconFolder className='text-muted-foreground mr-2 h-4 w-4' />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare className='text-muted-foreground mr-2 h-4 w-4' />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<IconTrash className='text-muted-foreground mr-2 h-4 w-4' />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className='text-sidebar-foreground/70'>
<IconDots className='text-sidebar-foreground/70' />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}

View File

@ -0,0 +1,110 @@
'use client';
import {
IconCircleCheck,
IconBell,
IconChevronsDown,
IconCreditCard,
IconLogout,
IconSparkles
} from '@tabler/icons-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar
} from '@/components/ui/sidebar';
export function NavUser({
user
}: {
user: {
name: string;
email: string;
avatar: string;
};
}) {
const { isMobile } = useSidebar();
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size='lg'
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
>
<Avatar className='h-8 w-8 rounded-lg'>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className='rounded-lg'>CN</AvatarFallback>
</Avatar>
<div className='grid flex-1 text-left text-sm leading-tight'>
<span className='truncate font-semibold'>{user.name}</span>
<span className='truncate text-xs'>{user.email}</span>
</div>
<IconChevronsDown className='ml-auto size-4' />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
side={isMobile ? 'bottom' : 'right'}
align='end'
sideOffset={4}
>
<DropdownMenuLabel className='p-0 font-normal'>
<div className='flex items-center gap-2 px-1 py-1.5 text-left text-sm'>
<Avatar className='h-8 w-8 rounded-lg'>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className='rounded-lg'>CN</AvatarFallback>
</Avatar>
<div className='grid flex-1 text-left text-sm leading-tight'>
<span className='truncate font-semibold'>{user.name}</span>
<span className='truncate text-xs'>{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<IconSparkles className='mr-2 h-4 w-4' />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<IconCircleCheck className='mr-2 h-4 w-4' />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<IconCreditCard className='mr-2 h-4 w-4' />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<IconBell className='mr-2 h-4 w-4' />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<IconLogout className='mr-2 h-4 w-4' />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@ -0,0 +1,85 @@
'use client';
import { Check, ChevronsUpDown, GalleryVerticalEnd } from 'lucide-react';
import * as React from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar';
interface Tenant {
id: string;
name: string;
}
export function OrgSwitcher({
tenants,
defaultTenant,
onTenantSwitch
}: {
tenants: Tenant[];
defaultTenant: Tenant;
onTenantSwitch?: (tenantId: string) => void;
}) {
const [selectedTenant, setSelectedTenant] = React.useState<
Tenant | undefined
>(defaultTenant || (tenants.length > 0 ? tenants[0] : undefined));
const handleTenantSwitch = (tenant: Tenant) => {
setSelectedTenant(tenant);
if (onTenantSwitch) {
onTenantSwitch(tenant.id);
}
};
if (!selectedTenant) {
return null;
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size='lg'
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
>
<div className='bg-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
<GalleryVerticalEnd className='size-4' />
</div>
<div className='flex flex-col gap-0.5 leading-none'>
<span className='font-semibold'>Denta koas</span>
<span className=''>{selectedTenant.name}</span>
</div>
<ChevronsUpDown className='ml-auto' />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-[--radix-dropdown-menu-trigger-width]'
align='start'
>
{tenants.map((tenant) => (
<DropdownMenuItem
key={tenant.id}
onSelect={() => handleTenantSwitch(tenant)}
>
{tenant.name}{' '}
{tenant.id === selectedTenant.id && (
<Check className='ml-auto' />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View File

@ -0,0 +1,23 @@
'use client';
import { useKBar } from 'kbar';
import { IconSearch } from '@tabler/icons-react';
import { Button } from './ui/button';
export default function SearchInput() {
const { query } = useKBar();
return (
<div className='w-full space-y-2'>
<Button
variant='outline'
className='bg-background text-muted-foreground relative h-9 w-full justify-start rounded-[0.5rem] text-sm font-normal shadow-none sm:pr-12 md:w-40 lg:w-64'
onClick={query.toggle}
>
<IconSearch className='mr-2 h-4 w-4' />
Search...
<kbd className='bg-muted pointer-events-none absolute top-[0.3rem] right-[0.3rem] hidden h-6 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none sm:flex'>
<span className='text-xs'></span>K
</kbd>
</Button>
</div>
);
}

View File

@ -0,0 +1,102 @@
'use client';
import { useThemeConfig } from '@/components/active-theme';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
const DEFAULT_THEMES = [
{
name: 'Default',
value: 'default'
},
{
name: 'Blue',
value: 'blue'
},
{
name: 'Green',
value: 'green'
},
{
name: 'Amber',
value: 'amber'
}
];
const SCALED_THEMES = [
{
name: 'Default',
value: 'default-scaled'
},
{
name: 'Blue',
value: 'blue-scaled'
}
];
const MONO_THEMES = [
{
name: 'Mono',
value: 'mono-scaled'
}
];
export function ThemeSelector() {
const { activeTheme, setActiveTheme } = useThemeConfig();
return (
<div className='flex items-center gap-2'>
<Label htmlFor='theme-selector' className='sr-only'>
Theme
</Label>
<Select value={activeTheme} onValueChange={setActiveTheme}>
<SelectTrigger
id='theme-selector'
className='justify-start *:data-[slot=select-value]:w-12'
>
<span className='text-muted-foreground hidden sm:block'>
Select a theme:
</span>
<span className='text-muted-foreground block sm:hidden'>Theme</span>
<SelectValue placeholder='Select a theme' />
</SelectTrigger>
<SelectContent align='end'>
<SelectGroup>
<SelectLabel>Default</SelectLabel>
{DEFAULT_THEMES.map((theme) => (
<SelectItem key={theme.name} value={theme.value}>
{theme.name}
</SelectItem>
))}
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Scaled</SelectLabel>
{SCALED_THEMES.map((theme) => (
<SelectItem key={theme.name} value={theme.value}>
{theme.name}
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>Monospaced</SelectLabel>
{MONO_THEMES.map((theme) => (
<SelectItem key={theme.name} value={theme.value}>
{theme.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
);
}

View File

@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot='accordion' {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot='accordion-item'
className={cn('border-b last:border-b-0', className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className='flex'>
<AccordionPrimitive.Trigger
data-slot='accordion-trigger'
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDownIcon className='text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200' />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot='accordion-content'
className='data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm'
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -0,0 +1,157 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot='alert-dialog-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot='alert-dialog-content'
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-footer'
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot='alert-dialog-title'
className={cn('text-lg font-semibold', className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot='alert-dialog-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel
};

View File

@ -0,0 +1,66 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90'
}
},
defaultVariants: {
variant: 'default'
}
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot='alert'
role='alert'
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-title'
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-description'
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@ -0,0 +1,11 @@
'use client';
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot='aspect-ratio' {...props} />;
}
export { AspectRatio };

View File

@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn('aspect-square size-full', className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -0,0 +1,51 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
success:
'border-transparent bg-green-500 text-white [a&]:hover:bg-green-600',
warning:
'border-transparent bg-yellow-400 text-yellow-900 [a&]:hover:bg-yellow-500',
info: 'border-transparent bg-blue-500 text-white [a&]:hover:bg-blue-600'
}
},
defaultVariants: {
variant: 'default'
}
}
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return (
<Comp
data-slot='badge'
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,109 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label='breadcrumb' data-slot='breadcrumb' {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot='breadcrumb-list'
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot='breadcrumb-item'
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot='breadcrumb-link'
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot='breadcrumb-page'
role='link'
aria-disabled='true'
aria-current='page'
className={cn('text-foreground font-normal', className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot='breadcrumb-separator'
role='presentation'
aria-hidden='true'
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='breadcrumb-ellipsis'
role='presentation'
aria-hidden='true'
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className='size-4' />
<span className='sr-only'>More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis
};

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='button'
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

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