Upload 114 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- .gitignore +45 -0
- .idx/dev.nix +43 -0
- .modified +0 -0
- .vscode/settings.json +4 -0
- README.md +4 -10
- apphosting.yaml +7 -0
- components.json +21 -0
- docs/blueprint.md +21 -0
- next.config.ts +42 -0
- package-lock.json +0 -0
- package.json +67 -22
- postcss.config.mjs +8 -0
- src/.gitignore +45 -0
- src/.modified +0 -0
- src/README.md +5 -0
- src/ai/dev.ts +4 -0
- src/ai/flows/analyze-deployment-logs.ts +54 -0
- src/ai/genkit.ts +7 -0
- src/app/(auth)/login/page.tsx +31 -0
- src/app/(auth)/register/page.tsx +31 -0
- src/app/(dashboard)/deploy/page.tsx +9 -0
- src/app/(dashboard)/deployments/[id]/page.tsx +188 -0
- src/app/(dashboard)/layout.tsx +27 -0
- src/app/(dashboard)/page.tsx +88 -0
- src/app/admin/dashboard/page.tsx +270 -0
- src/app/admin/layout.tsx +54 -0
- src/app/admin/login/page.tsx +27 -0
- src/app/admin/stats/page.tsx +73 -0
- src/app/api/genkit/[...slug]/route.ts +4 -0
- src/app/dashboard/buy-coins/page.tsx +43 -0
- src/app/dashboard/deploy/page.tsx +11 -0
- src/app/dashboard/deployments/[id]/page.tsx +281 -0
- src/app/dashboard/layout.tsx +33 -0
- src/app/dashboard/page.tsx +244 -0
- src/app/dashboard/profile/page.tsx +185 -0
- src/app/favicon.ico +3 -0
- src/app/globals.css +118 -0
- src/app/layout.tsx +40 -0
- src/app/page.tsx +123 -0
- src/apphosting.yaml +7 -0
- src/components.json +21 -0
- src/components/admin/EditUserDialog.tsx +196 -0
- src/components/admin/StatCard.tsx +25 -0
- src/components/auth/AdminLoginForm.tsx +107 -0
- src/components/auth/LoginForm.tsx +106 -0
- src/components/auth/RegisterForm.tsx +144 -0
- src/components/billing/CoinPurchaseForm.tsx +412 -0
- src/components/deployment/AiLogAnalyzer.tsx +102 -0
- src/components/deployment/DeploymentCard.tsx +95 -0
.gitattributes
CHANGED
|
@@ -37,3 +37,4 @@ davidcyrilapis-main/public/docs/background-music.mp3 filter=lfs diff=lfs merge=l
|
|
| 37 |
public/docs/background-music.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 38 |
thumb.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
Image/0.jpg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 37 |
public/docs/background-music.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 38 |
thumb.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
Image/0.jpg filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
src/app/favicon.ico filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# vercel
|
| 34 |
+
.vercel
|
| 35 |
+
|
| 36 |
+
# typescript
|
| 37 |
+
*.tsbuildinfo
|
| 38 |
+
next-env.d.ts
|
| 39 |
+
|
| 40 |
+
.genkit/*
|
| 41 |
+
.env*
|
| 42 |
+
|
| 43 |
+
# firebase
|
| 44 |
+
firebase-debug.log
|
| 45 |
+
firestore-debug.log
|
.idx/dev.nix
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# To learn more about how to use Nix to configure your environment
|
| 2 |
+
# see: https://firebase.google.com/docs/studio/customize-workspace
|
| 3 |
+
{pkgs}: {
|
| 4 |
+
# Which nixpkgs channel to use.
|
| 5 |
+
channel = "stable-24.11"; # or "unstable"
|
| 6 |
+
# Use https://search.nixos.org/packages to find packages
|
| 7 |
+
packages = [
|
| 8 |
+
pkgs.nodejs_20
|
| 9 |
+
pkgs.zulu
|
| 10 |
+
];
|
| 11 |
+
# Sets environment variables in the workspace
|
| 12 |
+
env = {};
|
| 13 |
+
# This adds a file watcher to startup the firebase emulators. The emulators will only start if
|
| 14 |
+
# a firebase.json file is written into the user's directory
|
| 15 |
+
services.firebase.emulators = {
|
| 16 |
+
detect = true;
|
| 17 |
+
projectId = "demo-app";
|
| 18 |
+
services = ["auth" "firestore"];
|
| 19 |
+
};
|
| 20 |
+
idx = {
|
| 21 |
+
# Search for the extensions you want on https://open-vsx.org/ and use "publisher.id"
|
| 22 |
+
extensions = [
|
| 23 |
+
# "vscodevim.vim"
|
| 24 |
+
];
|
| 25 |
+
workspace = {
|
| 26 |
+
onCreate = {
|
| 27 |
+
default.openFiles = [
|
| 28 |
+
"src/app/page.tsx"
|
| 29 |
+
];
|
| 30 |
+
};
|
| 31 |
+
};
|
| 32 |
+
# Enable previews and customize configuration
|
| 33 |
+
previews = {
|
| 34 |
+
enable = true;
|
| 35 |
+
previews = {
|
| 36 |
+
web = {
|
| 37 |
+
command = ["npm" "run" "dev" "--" "--port" "$PORT" "--hostname" "0.0.0.0"];
|
| 38 |
+
manager = "web";
|
| 39 |
+
};
|
| 40 |
+
};
|
| 41 |
+
};
|
| 42 |
+
};
|
| 43 |
+
}
|
.modified
ADDED
|
File without changes
|
.vscode/settings.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"IDX.aI.enableInlineCompletion": true,
|
| 3 |
+
"IDX.aI.enableCodebaseIndexing": true
|
| 4 |
+
}
|
README.md
CHANGED
|
@@ -1,11 +1,5 @@
|
|
| 1 |
-
|
| 2 |
-
title: Mywork
|
| 3 |
-
emoji: 🌍
|
| 4 |
-
colorFrom: green
|
| 5 |
-
colorTo: purple
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
license: apache-2.0
|
| 9 |
-
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
# Firebase Studio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
This is a NextJS starter in Firebase Studio.
|
| 4 |
+
|
| 5 |
+
To get started, take a look at src/app/page.tsx.
|
apphosting.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Settings to manage and configure a Firebase App Hosting backend.
|
| 2 |
+
# https://firebase.google.com/docs/app-hosting/configure
|
| 3 |
+
|
| 4 |
+
runConfig:
|
| 5 |
+
# Increase this value if you'd like to automatically spin up
|
| 6 |
+
# more instances in response to increased traffic.
|
| 7 |
+
maxInstances: 1
|
components.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "default",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "tailwind.config.ts",
|
| 8 |
+
"css": "src/app/globals.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"aliases": {
|
| 14 |
+
"components": "@/components",
|
| 15 |
+
"utils": "@/lib/utils",
|
| 16 |
+
"ui": "@/components/ui",
|
| 17 |
+
"lib": "@/lib",
|
| 18 |
+
"hooks": "@/hooks"
|
| 19 |
+
},
|
| 20 |
+
"iconLibrary": "lucide"
|
| 21 |
+
}
|
docs/blueprint.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# **App Name**: Anita Deploy
|
| 2 |
+
|
| 3 |
+
## Core Features:
|
| 4 |
+
|
| 5 |
+
- User Authentication: Login/Registration: Secure user authentication system to manage deployments.
|
| 6 |
+
- Repository Selection: GitHub Repo Input: Allow users to specify the Anita-V4 GitHub repository URL for deployment. Should include form validation for security.
|
| 7 |
+
- Environment Configuration: Environment Variable Input: Provide a form for users to input required environment variables (SESSION_ID, OWNER_NUMBER, BOT_NAME, etc.) with clear descriptions for each. Ensure that the form validates presence of mandatory variables.
|
| 8 |
+
- Deployment Controls: Deployment Control: Implement UI elements (buttons, toggles) for starting, stopping, and restarting the deployment.
|
| 9 |
+
- Automated Deployment: Heroku Integration: Use the provided Heroku API key to automate the deployment process to the user's Heroku account.
|
| 10 |
+
- Log Display: Real-time Logs: Display real-time deployment logs within the UI to provide feedback on the deployment progress and any errors.
|
| 11 |
+
- AI-Powered Debugging: Intelligent Issue Detection: Analyze deployment logs using an AI tool to identify common errors or warnings and suggest potential fixes or optimizations to the user.
|
| 12 |
+
|
| 13 |
+
## Style Guidelines:
|
| 14 |
+
|
| 15 |
+
- Primary color: Vibrant blue (#29ABE2) to convey trust and stability, reflecting the app's dependable deployment capabilities.
|
| 16 |
+
- Background color: Light gray (#F0F0F0), offering a clean and modern backdrop that ensures readability and reduces visual fatigue.
|
| 17 |
+
- Accent color: Purple (#9C27B0), to highlight interactive elements such as buttons and links, complementing the primary blue and enhancing user engagement.
|
| 18 |
+
- Clean and modern sans-serif fonts to ensure readability and a professional look.
|
| 19 |
+
- Simple, outline-style icons to represent deployment status, settings, and other functionalities.
|
| 20 |
+
- Grid-based layout with clear sections for repository input, environment variables, and deployment controls.
|
| 21 |
+
- Subtle transition animations for a smooth and responsive user experience.
|
next.config.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from 'next';
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
typescript: {
|
| 5 |
+
ignoreBuildErrors: true,
|
| 6 |
+
},
|
| 7 |
+
eslint: {
|
| 8 |
+
ignoreDuringBuilds: true,
|
| 9 |
+
},
|
| 10 |
+
images: {
|
| 11 |
+
remotePatterns: [
|
| 12 |
+
{
|
| 13 |
+
protocol: 'https',
|
| 14 |
+
hostname: 'placehold.co',
|
| 15 |
+
port: '',
|
| 16 |
+
pathname: '/**',
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
protocol: 'https',
|
| 20 |
+
hostname: 'firestuff.storage.googleapis.com',
|
| 21 |
+
port: '',
|
| 22 |
+
pathname: '/**',
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
protocol: 'https',
|
| 26 |
+
hostname: 'files.catbox.moe',
|
| 27 |
+
port: '',
|
| 28 |
+
pathname: '/**',
|
| 29 |
+
},
|
| 30 |
+
],
|
| 31 |
+
},
|
| 32 |
+
webpack: (config) => {
|
| 33 |
+
config.resolve.fallback = {
|
| 34 |
+
...config.resolve.fallback,
|
| 35 |
+
fs: false,
|
| 36 |
+
module: false,
|
| 37 |
+
};
|
| 38 |
+
return config;
|
| 39 |
+
},
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
export default nextConfig;
|
package-lock.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -1,27 +1,72 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
-
"version": "1.0
|
| 4 |
-
"
|
| 5 |
"scripts": {
|
| 6 |
-
"
|
| 7 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
},
|
| 9 |
-
"keywords": [],
|
| 10 |
-
"author": "",
|
| 11 |
-
"license": "ISC",
|
| 12 |
-
"description": "",
|
| 13 |
"dependencies": {
|
| 14 |
-
"
|
| 15 |
-
|
| 16 |
-
"
|
| 17 |
-
"
|
| 18 |
-
"
|
| 19 |
-
"
|
| 20 |
-
"
|
| 21 |
-
"
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
-
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "nextn",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
"scripts": {
|
| 6 |
+
"dev": "next dev --turbopack -p 9002",
|
| 7 |
+
"genkit:dev": "genkit start -- tsx src/ai/dev.ts",
|
| 8 |
+
"genkit:watch": "genkit start -- tsx --watch src/ai/dev.ts",
|
| 9 |
+
"build": "next build",
|
| 10 |
+
"start": "next start",
|
| 11 |
+
"lint": "next lint",
|
| 12 |
+
"typecheck": "tsc --noEmit"
|
| 13 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"dependencies": {
|
| 15 |
+
"@genkit-ai/googleai": "^1.8.0",
|
| 16 |
+
"@genkit-ai/next": "^1.8.0",
|
| 17 |
+
"@hookform/resolvers": "^4.1.3",
|
| 18 |
+
"@radix-ui/react-accordion": "^1.2.3",
|
| 19 |
+
"@radix-ui/react-alert-dialog": "^1.1.6",
|
| 20 |
+
"@radix-ui/react-avatar": "^1.1.3",
|
| 21 |
+
"@radix-ui/react-checkbox": "^1.1.4",
|
| 22 |
+
"@radix-ui/react-dialog": "^1.1.6",
|
| 23 |
+
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
| 24 |
+
"@radix-ui/react-label": "^2.1.2",
|
| 25 |
+
"@radix-ui/react-menubar": "^1.1.6",
|
| 26 |
+
"@radix-ui/react-popover": "^1.1.6",
|
| 27 |
+
"@radix-ui/react-progress": "^1.1.2",
|
| 28 |
+
"@radix-ui/react-radio-group": "^1.2.3",
|
| 29 |
+
"@radix-ui/react-scroll-area": "^1.2.3",
|
| 30 |
+
"@radix-ui/react-select": "^2.1.6",
|
| 31 |
+
"@radix-ui/react-separator": "^1.1.2",
|
| 32 |
+
"@radix-ui/react-slider": "^1.2.3",
|
| 33 |
+
"@radix-ui/react-slot": "^1.1.2",
|
| 34 |
+
"@radix-ui/react-switch": "^1.1.3",
|
| 35 |
+
"@radix-ui/react-tabs": "^1.1.3",
|
| 36 |
+
"@radix-ui/react-toast": "^1.2.6",
|
| 37 |
+
"@radix-ui/react-tooltip": "^1.1.8",
|
| 38 |
+
"@opentelemetry/exporter-jaeger": "^2.0.1",
|
| 39 |
+
"@tanstack-query-firebase/react": "^1.0.5",
|
| 40 |
+
"@tanstack/react-query": "^5.66.0",
|
| 41 |
+
"bcryptjs": "^2.4.3",
|
| 42 |
+
"class-variance-authority": "^0.7.1",
|
| 43 |
+
"clsx": "^2.1.1",
|
| 44 |
+
"date-fns": "^3.6.0",
|
| 45 |
+
"dotenv": "^16.5.0",
|
| 46 |
+
"firebase": "^11.8.1",
|
| 47 |
+
"flutterwave-react-v3": "^1.3.2",
|
| 48 |
+
"genkit": "^1.8.0",
|
| 49 |
+
"lucide-react": "^0.475.0",
|
| 50 |
+
"mongodb": "^6.8.0",
|
| 51 |
+
"patch-package": "^8.0.0",
|
| 52 |
+
"react": "^18.3.1",
|
| 53 |
+
"react-day-picker": "^8.10.1",
|
| 54 |
+
"react-dom": "^18.3.1",
|
| 55 |
+
"react-hook-form": "^7.54.2",
|
| 56 |
+
"react-paystack": "^5.0.0",
|
| 57 |
+
"recharts": "^2.15.1",
|
| 58 |
+
"tailwind-merge": "^3.0.1",
|
| 59 |
+
"tailwindcss-animate": "^1.0.7",
|
| 60 |
+
"zod": "^3.24.2"
|
| 61 |
+
},
|
| 62 |
+
"devDependencies": {
|
| 63 |
+
"@types/bcryptjs": "^2.4.6",
|
| 64 |
+
"@types/node": "^20",
|
| 65 |
+
"@types/react": "^18",
|
| 66 |
+
"@types/react-dom": "^18",
|
| 67 |
+
"genkit-cli": "^1.8.0",
|
| 68 |
+
"postcss": "^8",
|
| 69 |
+
"tailwindcss": "^3.4.1",
|
| 70 |
+
"typescript": "^5"
|
| 71 |
}
|
| 72 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('postcss-load-config').Config} */
|
| 2 |
+
const config = {
|
| 3 |
+
plugins: {
|
| 4 |
+
tailwindcss: {},
|
| 5 |
+
},
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export default config;
|
src/.gitignore
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# vercel
|
| 34 |
+
.vercel
|
| 35 |
+
|
| 36 |
+
# typescript
|
| 37 |
+
*.tsbuildinfo
|
| 38 |
+
next-env.d.ts
|
| 39 |
+
|
| 40 |
+
.genkit/*
|
| 41 |
+
.env*
|
| 42 |
+
|
| 43 |
+
# firebase
|
| 44 |
+
firebase-debug.log
|
| 45 |
+
firestore-debug.log
|
src/.modified
ADDED
|
File without changes
|
src/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Firebase Studio
|
| 2 |
+
|
| 3 |
+
This is a NextJS starter in Firebase Studio.
|
| 4 |
+
|
| 5 |
+
To get started, take a look at src/app/page.tsx.
|
src/ai/dev.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { config } from 'dotenv';
|
| 2 |
+
config();
|
| 3 |
+
|
| 4 |
+
import '@/ai/flows/analyze-deployment-logs.ts';
|
src/ai/flows/analyze-deployment-logs.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @fileOverview Analyzes deployment logs to identify errors, warnings, and suggest fixes.
|
| 5 |
+
*
|
| 6 |
+
* - analyzeDeploymentLogs - A function that handles the analysis of deployment logs.
|
| 7 |
+
* - AnalyzeDeploymentLogsInput - The input type for the analyzeDeploymentLogs function.
|
| 8 |
+
* - AnalyzeDeploymentLogsOutput - The return type for the analyzeDeploymentLogs function.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import {ai} from '@/ai/genkit';
|
| 12 |
+
import {z} from 'genkit';
|
| 13 |
+
|
| 14 |
+
const AnalyzeDeploymentLogsInputSchema = z.object({
|
| 15 |
+
deploymentLogs: z
|
| 16 |
+
.string()
|
| 17 |
+
.describe('The deployment logs to analyze.'),
|
| 18 |
+
});
|
| 19 |
+
export type AnalyzeDeploymentLogsInput = z.infer<typeof AnalyzeDeploymentLogsInputSchema>;
|
| 20 |
+
|
| 21 |
+
const AnalyzeDeploymentLogsOutputSchema = z.object({
|
| 22 |
+
analysisResult: z.string().describe('The analysis result of the deployment logs, including identified errors, warnings, and suggested fixes.'),
|
| 23 |
+
});
|
| 24 |
+
export type AnalyzeDeploymentLogsOutput = z.infer<typeof AnalyzeDeploymentLogsOutputSchema>;
|
| 25 |
+
|
| 26 |
+
export async function analyzeDeploymentLogs(input: AnalyzeDeploymentLogsInput): Promise<AnalyzeDeploymentLogsOutput> {
|
| 27 |
+
return analyzeDeploymentLogsFlow(input);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const prompt = ai.definePrompt({
|
| 31 |
+
name: 'analyzeDeploymentLogsPrompt',
|
| 32 |
+
input: {schema: AnalyzeDeploymentLogsInputSchema},
|
| 33 |
+
output: {schema: AnalyzeDeploymentLogsOutputSchema},
|
| 34 |
+
prompt: `You are an AI expert in analyzing deployment logs for potential errors, warnings, and suggesting fixes.
|
| 35 |
+
|
| 36 |
+
Analyze the following deployment logs and provide a detailed analysis result including:
|
| 37 |
+
- Identified errors and warnings.
|
| 38 |
+
- Suggested fixes or optimizations to resolve deployment issues.
|
| 39 |
+
|
| 40 |
+
Deployment Logs:
|
| 41 |
+
{{{deploymentLogs}}}`,
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
const analyzeDeploymentLogsFlow = ai.defineFlow(
|
| 45 |
+
{
|
| 46 |
+
name: 'analyzeDeploymentLogsFlow',
|
| 47 |
+
inputSchema: AnalyzeDeploymentLogsInputSchema,
|
| 48 |
+
outputSchema: AnalyzeDeploymentLogsOutputSchema,
|
| 49 |
+
},
|
| 50 |
+
async input => {
|
| 51 |
+
const {output} = await prompt(input);
|
| 52 |
+
return output!;
|
| 53 |
+
}
|
| 54 |
+
);
|
src/ai/genkit.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {genkit} from 'genkit';
|
| 2 |
+
import {googleAI} from '@genkit-ai/googleai';
|
| 3 |
+
|
| 4 |
+
export const ai = genkit({
|
| 5 |
+
plugins: [googleAI()],
|
| 6 |
+
model: 'googleai/gemini-2.0-flash',
|
| 7 |
+
});
|
src/app/(auth)/login/page.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { LoginForm } from "@/components/auth/LoginForm";
|
| 3 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { Zap } from "lucide-react";
|
| 6 |
+
|
| 7 |
+
export default function LoginPage() {
|
| 8 |
+
return (
|
| 9 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-secondary via-background to-accent/20 p-4">
|
| 10 |
+
<Card className="w-full max-w-md shadow-xl hover:shadow-2xl">
|
| 11 |
+
<CardHeader className="text-center">
|
| 12 |
+
<Link href="/" className="inline-flex items-center justify-center mb-4">
|
| 13 |
+
<Zap className="h-8 w-8 text-primary" />
|
| 14 |
+
<span className="ml-2 text-2xl font-semibold text-foreground">Anita Deploy</span>
|
| 15 |
+
</Link>
|
| 16 |
+
<CardTitle className="text-2xl">Welcome Back!</CardTitle>
|
| 17 |
+
<CardDescription>Enter your credentials to access your dashboard.</CardDescription>
|
| 18 |
+
</CardHeader>
|
| 19 |
+
<CardContent>
|
| 20 |
+
<LoginForm />
|
| 21 |
+
<p className="mt-6 text-center text-sm text-muted-foreground">
|
| 22 |
+
Don't have an account?{" "}
|
| 23 |
+
<Link href="/register" className="font-medium text-primary hover:underline">
|
| 24 |
+
Sign up
|
| 25 |
+
</Link>
|
| 26 |
+
</p>
|
| 27 |
+
</CardContent>
|
| 28 |
+
</Card>
|
| 29 |
+
</div>
|
| 30 |
+
);
|
| 31 |
+
}
|
src/app/(auth)/register/page.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { RegisterForm } from "@/components/auth/RegisterForm";
|
| 3 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 4 |
+
import Link from "next/link";
|
| 5 |
+
import { Zap } from "lucide-react";
|
| 6 |
+
|
| 7 |
+
export default function RegisterPage() {
|
| 8 |
+
return (
|
| 9 |
+
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-secondary via-background to-accent/20 p-4">
|
| 10 |
+
<Card className="w-full max-w-md shadow-xl hover:shadow-2xl">
|
| 11 |
+
<CardHeader className="text-center">
|
| 12 |
+
<Link href="/" className="inline-flex items-center justify-center mb-4">
|
| 13 |
+
<Zap className="h-8 w-8 text-primary" />
|
| 14 |
+
<span className="ml-2 text-2xl font-semibold text-foreground">Anita Deploy</span>
|
| 15 |
+
</Link>
|
| 16 |
+
<CardTitle className="text-2xl">Create an Account</CardTitle>
|
| 17 |
+
<CardDescription>Join Anita Deploy to start deploying your bot.</CardDescription>
|
| 18 |
+
</CardHeader>
|
| 19 |
+
<CardContent>
|
| 20 |
+
<RegisterForm />
|
| 21 |
+
<p className="mt-6 text-center text-sm text-muted-foreground">
|
| 22 |
+
Already have an account?{" "}
|
| 23 |
+
<Link href="/login" className="font-medium text-primary hover:underline">
|
| 24 |
+
Log in
|
| 25 |
+
</Link>
|
| 26 |
+
</p>
|
| 27 |
+
</CardContent>
|
| 28 |
+
</Card>
|
| 29 |
+
</div>
|
| 30 |
+
);
|
| 31 |
+
}
|
src/app/(dashboard)/deploy/page.tsx
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { DeploymentForm } from "@/components/deployment/DeploymentForm";
|
| 2 |
+
|
| 3 |
+
export default function NewDeploymentPage() {
|
| 4 |
+
return (
|
| 5 |
+
<div>
|
| 6 |
+
<DeploymentForm />
|
| 7 |
+
</div>
|
| 8 |
+
);
|
| 9 |
+
}
|
src/app/(dashboard)/deployments/[id]/page.tsx
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"; // This page uses client-side hooks for state and effects
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react';
|
| 4 |
+
import { useParams, useRouter } from 'next/navigation';
|
| 5 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
| 6 |
+
import { Badge } from '@/components/ui/badge';
|
| 7 |
+
import { Button } from '@/components/ui/button';
|
| 8 |
+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
| 9 |
+
import { LogDisplay } from '@/components/deployment/LogDisplay';
|
| 10 |
+
import { AiLogAnalyzer } from '@/components/deployment/AiLogAnalyzer';
|
| 11 |
+
import { DeploymentControls } from '@/components/deployment/DeploymentControls';
|
| 12 |
+
import type { Deployment, DeploymentStatus } from '@/lib/types';
|
| 13 |
+
import { ArrowLeft, CheckCircle2, ExternalLink, Hourglass, AlertTriangle, Zap, Info, PowerOff, Settings2, FileText, Brain } from 'lucide-react';
|
| 14 |
+
import { getDeploymentLogs } from '@/lib/actions/deployment'; // Mocked action
|
| 15 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
| 16 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
// Mock function to fetch deployment details - replace with actual API call
|
| 20 |
+
async function fetchDeploymentDetails(id: string): Promise<Deployment | null> {
|
| 21 |
+
console.log("Fetching details for", id);
|
| 22 |
+
await new Promise(resolve => setTimeout(resolve, 500)); // Simulate API delay
|
| 23 |
+
const mockDeployments: Deployment[] = [
|
| 24 |
+
{ id: 'anita-bot-alpha', appName: 'Anita Bot Alpha', status: 'succeeded', createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), lastDeployedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), region: 'us-east', url: 'https://anita-bot-alpha.herokuapp.com' },
|
| 25 |
+
{ id: 'anita-staging-v4', appName: 'Anita Staging V4', status: 'deploying', createdAt: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(), region: 'eu-west' },
|
| 26 |
+
{ id: 'legacy-anita-bot', appName: 'Legacy Anita Bot', status: 'failed', createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), region: 'us-west' },
|
| 27 |
+
{ id: 'anita-experimental', appName: 'Anita Experimental', status: 'stopped', createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), region: 'eu-central', url: 'https://anita-experimental.herokuapp.com' },
|
| 28 |
+
];
|
| 29 |
+
return mockDeployments.find(d => d.id === id) || null;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function getStatusBadgeVariant(status: DeploymentStatus) {
|
| 33 |
+
switch (status) {
|
| 34 |
+
case 'succeeded': return 'default';
|
| 35 |
+
case 'deploying': return 'secondary';
|
| 36 |
+
case 'pending': return 'outline';
|
| 37 |
+
case 'failed': return 'destructive';
|
| 38 |
+
case 'stopped': return 'outline';
|
| 39 |
+
default: return 'outline';
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function getStatusIcon(status: DeploymentStatus) {
|
| 44 |
+
switch (status) {
|
| 45 |
+
case 'succeeded': return <CheckCircle2 className="h-5 w-5 text-green-500" />;
|
| 46 |
+
case 'deploying': return <Hourglass className="h-5 w-5 text-blue-500 animate-spin" />;
|
| 47 |
+
case 'pending': return <Hourglass className="h-5 w-5 text-yellow-500" />;
|
| 48 |
+
case 'failed': return <AlertTriangle className="h-5 w-5 text-red-500" />;
|
| 49 |
+
case 'stopped': return <PowerOff className="h-5 w-5 text-gray-500" />;
|
| 50 |
+
default: return <Zap className="h-5 w-5 text-muted-foreground" />;
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
export default function DeploymentDetailPage() {
|
| 56 |
+
const params = useParams();
|
| 57 |
+
const router = useRouter();
|
| 58 |
+
const id = params.id as string;
|
| 59 |
+
|
| 60 |
+
const [deployment, setDeployment] = useState<Deployment | null>(null);
|
| 61 |
+
const [logs, setLogs] = useState<string[]>([]);
|
| 62 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 63 |
+
const [logsLoading, setLogsLoading] = useState(true);
|
| 64 |
+
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
if (id) {
|
| 67 |
+
setIsLoading(true);
|
| 68 |
+
fetchDeploymentDetails(id)
|
| 69 |
+
.then(data => {
|
| 70 |
+
setDeployment(data);
|
| 71 |
+
if (data) { // Fetch logs only if deployment data is found
|
| 72 |
+
setLogsLoading(true);
|
| 73 |
+
getDeploymentLogs(id).then(logData => {
|
| 74 |
+
setLogs(logData);
|
| 75 |
+
setLogsLoading(false);
|
| 76 |
+
});
|
| 77 |
+
}
|
| 78 |
+
})
|
| 79 |
+
.catch(err => console.error("Failed to fetch deployment details:", err))
|
| 80 |
+
.finally(() => setIsLoading(false));
|
| 81 |
+
}
|
| 82 |
+
}, [id]);
|
| 83 |
+
|
| 84 |
+
const handleStatusChange = (newStatus: DeploymentStatus) => {
|
| 85 |
+
if (deployment) {
|
| 86 |
+
setDeployment({ ...deployment, status: newStatus });
|
| 87 |
+
}
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
if (isLoading) {
|
| 91 |
+
return (
|
| 92 |
+
<div className="space-y-6">
|
| 93 |
+
<Skeleton className="h-8 w-1/4" />
|
| 94 |
+
<Skeleton className="h-24 w-full" />
|
| 95 |
+
<Skeleton className="h-64 w-full" />
|
| 96 |
+
<Skeleton className="h-64 w-full" />
|
| 97 |
+
</div>
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if (!deployment) {
|
| 102 |
+
return (
|
| 103 |
+
<Alert variant="destructive">
|
| 104 |
+
<AlertTriangle className="h-4 w-4" />
|
| 105 |
+
<AlertTitle>Error</AlertTitle>
|
| 106 |
+
<AlertDescription>Deployment not found. It might have been deleted or the ID is incorrect.</AlertDescription>
|
| 107 |
+
<Button onClick={() => router.push('/dashboard')} variant="outline" className="mt-4">
|
| 108 |
+
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Dashboard
|
| 109 |
+
</Button>
|
| 110 |
+
</Alert>
|
| 111 |
+
);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
const combinedLogs = logs.join('\n');
|
| 115 |
+
|
| 116 |
+
return (
|
| 117 |
+
<div className="space-y-8">
|
| 118 |
+
<Button variant="outline" onClick={() => router.push('/dashboard')} className="mb-6">
|
| 119 |
+
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Dashboard
|
| 120 |
+
</Button>
|
| 121 |
+
|
| 122 |
+
<Card className="shadow-lg">
|
| 123 |
+
<CardHeader>
|
| 124 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
|
| 125 |
+
<div>
|
| 126 |
+
<CardTitle className="text-3xl text-primary">{deployment.appName}</CardTitle>
|
| 127 |
+
<CardDescription>Manage your "{deployment.appName}" deployment.</CardDescription>
|
| 128 |
+
</div>
|
| 129 |
+
<Badge variant={getStatusBadgeVariant(deployment.status)} className="text-md capitalize px-3 py-1.5 flex items-center gap-2">
|
| 130 |
+
{getStatusIcon(deployment.status)}
|
| 131 |
+
{deployment.status}
|
| 132 |
+
</Badge>
|
| 133 |
+
</div>
|
| 134 |
+
</CardHeader>
|
| 135 |
+
<CardContent className="space-y-4">
|
| 136 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
| 137 |
+
<p><strong className="text-foreground/80">ID:</strong> {deployment.id}</p>
|
| 138 |
+
<p><strong className="text-foreground/80">Created:</strong> {new Date(deployment.createdAt).toLocaleString()}</p>
|
| 139 |
+
<p><strong className="text-foreground/80">Region:</strong> {deployment.region || 'N/A'}</p>
|
| 140 |
+
{deployment.lastDeployedAt && <p><strong className="text-foreground/80">Last Deployed:</strong> {new Date(deployment.lastDeployedAt).toLocaleString()}</p>}
|
| 141 |
+
</div>
|
| 142 |
+
{deployment.url && (
|
| 143 |
+
<a
|
| 144 |
+
href={deployment.url}
|
| 145 |
+
target="_blank"
|
| 146 |
+
rel="noopener noreferrer"
|
| 147 |
+
className="inline-flex items-center text-accent hover:underline"
|
| 148 |
+
>
|
| 149 |
+
<ExternalLink className="mr-2 h-4 w-4" /> Visit Deployed App
|
| 150 |
+
</a>
|
| 151 |
+
)}
|
| 152 |
+
<DeploymentControls deploymentId={deployment.id} currentStatus={deployment.status} onStatusChange={handleStatusChange} />
|
| 153 |
+
</CardContent>
|
| 154 |
+
</Card>
|
| 155 |
+
|
| 156 |
+
<Tabs defaultValue="logs" className="w-full">
|
| 157 |
+
<TabsList className="grid w-full grid-cols-2 md:grid-cols-3 gap-2 h-auto">
|
| 158 |
+
<TabsTrigger value="logs" className="py-2.5 text-sm"><FileText className="mr-2 h-4 w-4"/>Logs</TabsTrigger>
|
| 159 |
+
<TabsTrigger value="ai-analyzer" className="py-2.5 text-sm"><Brain className="mr-2 h-4 w-4"/>AI Analyzer</TabsTrigger>
|
| 160 |
+
<TabsTrigger value="settings" className="py-2.5 text-sm md:hidden lg:inline-block" disabled><Settings2 className="mr-2 h-4 w-4"/>Settings (Soon)</TabsTrigger>
|
| 161 |
+
</TabsList>
|
| 162 |
+
<TabsContent value="logs" className="mt-6">
|
| 163 |
+
<LogDisplay logs={logs} isLoading={logsLoading} />
|
| 164 |
+
</TabsContent>
|
| 165 |
+
<TabsContent value="ai-analyzer" className="mt-6">
|
| 166 |
+
<AiLogAnalyzer initialLogs={logsLoading ? "Loading logs for analysis..." : combinedLogs} />
|
| 167 |
+
</TabsContent>
|
| 168 |
+
<TabsContent value="settings" className="mt-6">
|
| 169 |
+
<Card>
|
| 170 |
+
<CardHeader>
|
| 171 |
+
<CardTitle className="flex items-center"><Settings2 className="mr-2 h-5 w-5 text-primary"/>Deployment Settings</CardTitle>
|
| 172 |
+
<CardDescription>Configuration options for this deployment (feature coming soon).</CardDescription>
|
| 173 |
+
</CardHeader>
|
| 174 |
+
<CardContent>
|
| 175 |
+
<Alert>
|
| 176 |
+
<Info className="h-4 w-4" />
|
| 177 |
+
<AlertTitle>Coming Soon!</AlertTitle>
|
| 178 |
+
<AlertDescription>
|
| 179 |
+
Advanced settings and environment variable management for this deployment will be available here in a future update.
|
| 180 |
+
</AlertDescription>
|
| 181 |
+
</Alert>
|
| 182 |
+
</CardContent>
|
| 183 |
+
</Card>
|
| 184 |
+
</TabsContent>
|
| 185 |
+
</Tabs>
|
| 186 |
+
</div>
|
| 187 |
+
);
|
| 188 |
+
}
|
src/app/(dashboard)/layout.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Header } from "@/components/layout/Header";
|
| 2 |
+
import { ReactNode } from "react";
|
| 3 |
+
|
| 4 |
+
interface DashboardLayoutProps {
|
| 5 |
+
children: ReactNode;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
| 9 |
+
// Here you would typically add authentication checks.
|
| 10 |
+
// For this example, we assume the user is authenticated.
|
| 11 |
+
// e.g., by checking a session or token.
|
| 12 |
+
// If not authenticated, redirect to /login.
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div className="flex min-h-screen flex-col">
|
| 16 |
+
<Header />
|
| 17 |
+
<main className="flex-1 container py-8">
|
| 18 |
+
{children}
|
| 19 |
+
</main>
|
| 20 |
+
<footer className="py-6 border-t">
|
| 21 |
+
<div className="container text-center text-sm text-muted-foreground">
|
| 22 |
+
Anita Deploy © {new Date().getFullYear()}
|
| 23 |
+
</div>
|
| 24 |
+
</footer>
|
| 25 |
+
</div>
|
| 26 |
+
);
|
| 27 |
+
}
|
src/app/(dashboard)/page.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from 'next/link';
|
| 2 |
+
import { Button } from '@/components/ui/button';
|
| 3 |
+
import { PlusCircle, LayoutGrid } from 'lucide-react';
|
| 4 |
+
import { DeploymentCard } from '@/components/deployment/DeploymentCard';
|
| 5 |
+
import type { Deployment } from '@/lib/types';
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
// Mock data for deployments - in a real app, this would come from an API/database
|
| 9 |
+
const mockDeployments: Deployment[] = [
|
| 10 |
+
{
|
| 11 |
+
id: 'anita-bot-alpha',
|
| 12 |
+
appName: 'Anita Bot Alpha',
|
| 13 |
+
status: 'succeeded',
|
| 14 |
+
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), // 2 days ago
|
| 15 |
+
lastDeployedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), // 1 day ago
|
| 16 |
+
region: 'us-east',
|
| 17 |
+
url: 'https://anita-bot-alpha.herokuapp.com',
|
| 18 |
+
userId: ''
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
id: 'anita-staging-v4',
|
| 22 |
+
appName: 'Anita Staging V4',
|
| 23 |
+
status: 'deploying',
|
| 24 |
+
createdAt: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(), // 5 hours ago
|
| 25 |
+
region: 'eu-west',
|
| 26 |
+
userId: ''
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
id: 'legacy-anita-bot',
|
| 30 |
+
appName: 'Legacy Anita Bot',
|
| 31 |
+
status: 'failed',
|
| 32 |
+
createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), // 10 days ago
|
| 33 |
+
region: 'us-west',
|
| 34 |
+
userId: ''
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
id: 'anita-experimental',
|
| 38 |
+
appName: 'Anita Experimental',
|
| 39 |
+
status: 'stopped',
|
| 40 |
+
createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
| 41 |
+
region: 'eu-central',
|
| 42 |
+
url: 'https://anita-experimental.herokuapp.com',
|
| 43 |
+
userId: ''
|
| 44 |
+
},
|
| 45 |
+
];
|
| 46 |
+
|
| 47 |
+
export const dynamic = 'force-dynamic'
|
| 48 |
+
|
| 49 |
+
export default function DashboardPage() {
|
| 50 |
+
const deployments = mockDeployments; // In real app: fetchDeployments();
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div className="space-y-8">
|
| 54 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
| 55 |
+
<div>
|
| 56 |
+
<h1 className="text-3xl font-bold tracking-tight text-foreground">Your Deployments</h1>
|
| 57 |
+
<p className="text-muted-foreground">Manage your Anita-V4 bot deployments.</p>
|
| 58 |
+
</div>
|
| 59 |
+
<Button asChild size="lg">
|
| 60 |
+
<Link href="/dashboard/deploy">
|
| 61 |
+
<PlusCircle className="mr-2 h-5 w-5" /> New Deployment
|
| 62 |
+
</Link>
|
| 63 |
+
</Button>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
{deployments.length > 0 ? (
|
| 67 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 68 |
+
{deployments.map((deployment) => (
|
| 69 |
+
<DeploymentCard key={deployment.id} deployment={deployment} />
|
| 70 |
+
))}
|
| 71 |
+
</div>
|
| 72 |
+
) : (
|
| 73 |
+
<div className="text-center py-12 border-2 border-dashed rounded-lg">
|
| 74 |
+
<LayoutGrid className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
|
| 75 |
+
<h3 className="text-xl font-semibold text-foreground">No Deployments Yet</h3>
|
| 76 |
+
<p className="text-muted-foreground mt-1">
|
| 77 |
+
Get started by creating your first Anita-V4 bot deployment.
|
| 78 |
+
</p>
|
| 79 |
+
<Button asChild className="mt-6">
|
| 80 |
+
<Link href="/dashboard/deploy">
|
| 81 |
+
<PlusCircle className="mr-2 h-4 w-4" /> Create Deployment
|
| 82 |
+
</Link>
|
| 83 |
+
</Button>
|
| 84 |
+
</div>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
);
|
| 88 |
+
}
|
src/app/admin/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
"use client";
|
| 3 |
+
|
| 4 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Input } from "@/components/ui/input";
|
| 7 |
+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from "@/components/ui/form";
|
| 8 |
+
import { useForm } from "react-hook-form";
|
| 9 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
| 10 |
+
import { PlatformApiKeySchema, type PlatformApiKeyInput } from "@/lib/schemas";
|
| 11 |
+
import { getPlatformApiKey, updatePlatformApiKey, getAllUsersForAdmin } from "@/lib/actions/admin";
|
| 12 |
+
import { useToast } from "@/hooks/use-toast";
|
| 13 |
+
import { useEffect, useState, useCallback, useMemo } from "react";
|
| 14 |
+
import { Shield, KeyRound, Users, FileText, Loader2, Eye, EyeOff, RefreshCcw, Edit, Search } from "lucide-react";
|
| 15 |
+
import type { User } from "@/lib/types";
|
| 16 |
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
| 17 |
+
import { Badge } from "@/components/ui/badge";
|
| 18 |
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 19 |
+
import { EditUserDialog } from "@/components/admin/EditUserDialog";
|
| 20 |
+
|
| 21 |
+
export default function AdminDashboardPage() {
|
| 22 |
+
const { toast } = useToast();
|
| 23 |
+
const [currentApiKey, setCurrentApiKey] = useState("");
|
| 24 |
+
const [showApiKey, setShowApiKey] = useState(false);
|
| 25 |
+
const [isLoadingApiKey, setIsLoadingApiKey] = useState(true);
|
| 26 |
+
const [isUpdatingApiKey, setIsUpdatingApiKey] = useState(false);
|
| 27 |
+
|
| 28 |
+
const [users, setUsers] = useState<User[]>([]);
|
| 29 |
+
const [isLoadingUsers, setIsLoadingUsers] = useState(true);
|
| 30 |
+
const [isRefreshingUsers, setIsRefreshingUsers] = useState(false);
|
| 31 |
+
const [searchTerm, setSearchTerm] = useState("");
|
| 32 |
+
|
| 33 |
+
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
| 34 |
+
const [isEditUserDialogOpen, setIsEditUserDialogOpen] = useState(false);
|
| 35 |
+
|
| 36 |
+
const apiKeyForm = useForm<PlatformApiKeyInput>({
|
| 37 |
+
resolver: zodResolver(PlatformApiKeySchema),
|
| 38 |
+
defaultValues: { apiKey: "" },
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
const fetchAdminApiKey = useCallback(async () => {
|
| 42 |
+
setIsLoadingApiKey(true);
|
| 43 |
+
try {
|
| 44 |
+
const apiKeyResult = await getPlatformApiKey();
|
| 45 |
+
if (apiKeyResult.success && apiKeyResult.apiKey) {
|
| 46 |
+
setCurrentApiKey(apiKeyResult.apiKey);
|
| 47 |
+
apiKeyForm.setValue("apiKey", apiKeyResult.apiKey);
|
| 48 |
+
} else if (!apiKeyResult.success && apiKeyResult.message) {
|
| 49 |
+
toast({ title: "Error", description: `Failed to load API Key: ${apiKeyResult.message}`, variant: "destructive" });
|
| 50 |
+
}
|
| 51 |
+
} catch (error) {
|
| 52 |
+
toast({ title: "Error", description: "Failed to load API key.", variant: "destructive" });
|
| 53 |
+
} finally {
|
| 54 |
+
setIsLoadingApiKey(false);
|
| 55 |
+
}
|
| 56 |
+
}, [toast, apiKeyForm]);
|
| 57 |
+
|
| 58 |
+
const fetchAllUsers = useCallback(async () => {
|
| 59 |
+
setIsLoadingUsers(true);
|
| 60 |
+
try {
|
| 61 |
+
const usersResult = await getAllUsersForAdmin();
|
| 62 |
+
if (usersResult.success && usersResult.users) {
|
| 63 |
+
setUsers(usersResult.users);
|
| 64 |
+
} else if (!usersResult.success && usersResult.message) {
|
| 65 |
+
toast({ title: "Error", description: `Failed to load users: ${usersResult.message}`, variant: "destructive" });
|
| 66 |
+
}
|
| 67 |
+
} catch (error) {
|
| 68 |
+
toast({ title: "Error", description: "Failed to load users.", variant: "destructive" });
|
| 69 |
+
} finally {
|
| 70 |
+
setIsLoadingUsers(false);
|
| 71 |
+
}
|
| 72 |
+
}, [toast]);
|
| 73 |
+
|
| 74 |
+
const handleRefreshUsers = async () => {
|
| 75 |
+
setIsRefreshingUsers(true);
|
| 76 |
+
await fetchAllUsers();
|
| 77 |
+
setIsRefreshingUsers(false);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
useEffect(() => {
|
| 81 |
+
fetchAdminApiKey();
|
| 82 |
+
fetchAllUsers();
|
| 83 |
+
}, [fetchAdminApiKey, fetchAllUsers]);
|
| 84 |
+
|
| 85 |
+
const filteredUsers = useMemo(() => {
|
| 86 |
+
if (!searchTerm) return users;
|
| 87 |
+
const lowercasedSearchTerm = searchTerm.toLowerCase();
|
| 88 |
+
return users.filter(user =>
|
| 89 |
+
user.name.toLowerCase().includes(lowercasedSearchTerm) ||
|
| 90 |
+
user.email.toLowerCase().includes(lowercasedSearchTerm)
|
| 91 |
+
);
|
| 92 |
+
}, [users, searchTerm]);
|
| 93 |
+
|
| 94 |
+
async function onApiKeySubmit(values: PlatformApiKeyInput) {
|
| 95 |
+
setIsUpdatingApiKey(true);
|
| 96 |
+
try {
|
| 97 |
+
const result = await updatePlatformApiKey(values);
|
| 98 |
+
toast({
|
| 99 |
+
title: result.success ? "Success" : "Error",
|
| 100 |
+
description: result.message,
|
| 101 |
+
variant: result.success ? "default" : "destructive",
|
| 102 |
+
});
|
| 103 |
+
if (result.success) {
|
| 104 |
+
setCurrentApiKey(values.apiKey);
|
| 105 |
+
}
|
| 106 |
+
} catch (error) {
|
| 107 |
+
toast({ title: "Error", description: "An unexpected error occurred.", variant: "destructive" });
|
| 108 |
+
} finally {
|
| 109 |
+
setIsUpdatingApiKey(false);
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const handleOpenEditUserDialog = (user: User) => {
|
| 114 |
+
setSelectedUser(user);
|
| 115 |
+
setIsEditUserDialogOpen(true);
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
const handleUserUpdateSuccess = () => {
|
| 119 |
+
setIsEditUserDialogOpen(false);
|
| 120 |
+
setSelectedUser(null);
|
| 121 |
+
fetchAllUsers();
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
return (
|
| 125 |
+
<div className="space-y-8">
|
| 126 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
| 127 |
+
<div>
|
| 128 |
+
<h1 className="text-3xl font-bold tracking-tight text-foreground flex items-center">
|
| 129 |
+
<Shield className="mr-3 h-8 w-8 text-primary" /> Admin Dashboard
|
| 130 |
+
</h1>
|
| 131 |
+
<p className="text-muted-foreground">Welcome to the admin control panel.</p>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<Card className="shadow-xl hover:shadow-2xl">
|
| 136 |
+
<CardHeader>
|
| 137 |
+
<CardTitle className="text-lg flex items-center"><KeyRound className="mr-2 h-5 w-5 text-primary"/>Platform API Key Management</CardTitle>
|
| 138 |
+
<CardDescription className="text-xs">Configure the global Platform API Key used for deployments.</CardDescription>
|
| 139 |
+
</CardHeader>
|
| 140 |
+
<CardContent>
|
| 141 |
+
{isLoadingApiKey ? (
|
| 142 |
+
<div className="flex items-center justify-center p-4">
|
| 143 |
+
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
| 144 |
+
<span className="ml-2">Loading API Key...</span>
|
| 145 |
+
</div>
|
| 146 |
+
) : (
|
| 147 |
+
<Form {...apiKeyForm}>
|
| 148 |
+
<form onSubmit={apiKeyForm.handleSubmit(onApiKeySubmit)} className="space-y-4">
|
| 149 |
+
<FormField
|
| 150 |
+
control={apiKeyForm.control}
|
| 151 |
+
name="apiKey"
|
| 152 |
+
render={({ field }) => (
|
| 153 |
+
<FormItem>
|
| 154 |
+
<FormLabel>Platform API Key</FormLabel>
|
| 155 |
+
<div className="flex items-center gap-2">
|
| 156 |
+
<FormControl>
|
| 157 |
+
<Input type={showApiKey ? "text" : "password"} placeholder="Enter Platform API Key" {...field} />
|
| 158 |
+
</FormControl>
|
| 159 |
+
<Button type="button" variant="outline" size="icon" onClick={() => setShowApiKey(!showApiKey)} aria-label={showApiKey ? "Hide API key" : "Show API key"}>
|
| 160 |
+
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
| 161 |
+
</Button>
|
| 162 |
+
</div>
|
| 163 |
+
<FormDescription className="text-xs">This key is used by the system to interact with the deployment platform.</FormDescription>
|
| 164 |
+
<FormMessage />
|
| 165 |
+
</FormItem>
|
| 166 |
+
)}
|
| 167 |
+
/>
|
| 168 |
+
<Button type="submit" disabled={isUpdatingApiKey}>
|
| 169 |
+
{isUpdatingApiKey && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
| 170 |
+
Update API Key
|
| 171 |
+
</Button>
|
| 172 |
+
</form>
|
| 173 |
+
</Form>
|
| 174 |
+
)}
|
| 175 |
+
</CardContent>
|
| 176 |
+
</Card>
|
| 177 |
+
|
| 178 |
+
<Card className="shadow-xl hover:shadow-2xl">
|
| 179 |
+
<CardHeader>
|
| 180 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
| 181 |
+
<div>
|
| 182 |
+
<CardTitle className="text-lg flex items-center"><Users className="mr-2 h-5 w-5 text-primary"/>User Management</CardTitle>
|
| 183 |
+
<CardDescription className="text-xs">View and manage user accounts. Currently showing {filteredUsers.length} of {users.length} user(s).</CardDescription>
|
| 184 |
+
</div>
|
| 185 |
+
<div className="flex items-center gap-2 w-full sm:w-auto">
|
| 186 |
+
<div className="relative flex-grow sm:flex-grow-0">
|
| 187 |
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
| 188 |
+
<Input
|
| 189 |
+
type="search"
|
| 190 |
+
placeholder="Search name or email..."
|
| 191 |
+
value={searchTerm}
|
| 192 |
+
onChange={(e) => setSearchTerm(e.target.value)}
|
| 193 |
+
className="pl-8 w-full sm:w-[200px] lg:w-[250px]"
|
| 194 |
+
/>
|
| 195 |
+
</div>
|
| 196 |
+
<Button variant="outline" size="sm" onClick={handleRefreshUsers} disabled={isRefreshingUsers || isLoadingUsers} className="flex-shrink-0">
|
| 197 |
+
{isRefreshingUsers ? <Loader2 className="mr-2 h-4 w-4 animate-spin"/> : <RefreshCcw className="mr-2 h-4 w-4"/>}
|
| 198 |
+
Refresh
|
| 199 |
+
</Button>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
</CardHeader>
|
| 203 |
+
<CardContent>
|
| 204 |
+
{isLoadingUsers ? (
|
| 205 |
+
<div className="flex items-center justify-center p-4">
|
| 206 |
+
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
| 207 |
+
<span className="ml-2">Loading users...</span>
|
| 208 |
+
</div>
|
| 209 |
+
) : filteredUsers.length > 0 ? (
|
| 210 |
+
<ScrollArea className="h-[400px] w-full rounded-md border">
|
| 211 |
+
<Table>
|
| 212 |
+
<TableHeader className="sticky top-0 bg-card z-10">
|
| 213 |
+
<TableRow>
|
| 214 |
+
<TableHead>Name</TableHead>
|
| 215 |
+
<TableHead>Email</TableHead>
|
| 216 |
+
<TableHead>Role</TableHead>
|
| 217 |
+
<TableHead className="text-right">Coins</TableHead>
|
| 218 |
+
<TableHead>Referral Code</TableHead>
|
| 219 |
+
<TableHead>Joined</TableHead>
|
| 220 |
+
<TableHead className="text-center">Actions</TableHead>
|
| 221 |
+
</TableRow>
|
| 222 |
+
</TableHeader>
|
| 223 |
+
<TableBody>
|
| 224 |
+
{filteredUsers.map((user) => (
|
| 225 |
+
<TableRow key={user._id}>
|
| 226 |
+
<TableCell className="font-medium">{user.name}</TableCell>
|
| 227 |
+
<TableCell>{user.email}</TableCell>
|
| 228 |
+
<TableCell><Badge variant={user.role === 'admin' ? 'default' : 'secondary'} className="text-xs">{user.role}</Badge></TableCell>
|
| 229 |
+
<TableCell className="text-right">{user.coins?.toLocaleString() || 0}</TableCell>
|
| 230 |
+
<TableCell>{user.referralCode || 'N/A'}</TableCell>
|
| 231 |
+
<TableCell>{new Date(user.createdAt).toLocaleDateString()}</TableCell>
|
| 232 |
+
<TableCell className="text-center">
|
| 233 |
+
<Button variant="ghost" size="sm" onClick={() => handleOpenEditUserDialog(user)}>
|
| 234 |
+
<Edit className="mr-2 h-4 w-4" /> Edit
|
| 235 |
+
</Button>
|
| 236 |
+
</TableCell>
|
| 237 |
+
</TableRow>
|
| 238 |
+
))}
|
| 239 |
+
</TableBody>
|
| 240 |
+
</Table>
|
| 241 |
+
</ScrollArea>
|
| 242 |
+
) : (
|
| 243 |
+
<p className="text-muted-foreground text-center py-4">{searchTerm ? "No users match your search." : "No users found."}</p>
|
| 244 |
+
)}
|
| 245 |
+
</CardContent>
|
| 246 |
+
</Card>
|
| 247 |
+
|
| 248 |
+
{selectedUser && (
|
| 249 |
+
<EditUserDialog
|
| 250 |
+
user={selectedUser}
|
| 251 |
+
isOpen={isEditUserDialogOpen}
|
| 252 |
+
onOpenChange={setIsEditUserDialogOpen}
|
| 253 |
+
onSuccess={handleUserUpdateSuccess}
|
| 254 |
+
/>
|
| 255 |
+
)}
|
| 256 |
+
|
| 257 |
+
<Card className="shadow-xl hover:shadow-2xl">
|
| 258 |
+
<CardHeader>
|
| 259 |
+
<CardTitle className="text-lg flex items-center"><FileText className="mr-2 h-5 w-5 text-primary"/>Application Logs</CardTitle>
|
| 260 |
+
<CardDescription className="text-xs">View system-wide application logs (feature coming soon).</CardDescription>
|
| 261 |
+
</CardHeader>
|
| 262 |
+
<CardContent>
|
| 263 |
+
<p className="text-muted-foreground">
|
| 264 |
+
A centralized place to view important system logs and errors will be available here.
|
| 265 |
+
</p>
|
| 266 |
+
</CardContent>
|
| 267 |
+
</Card>
|
| 268 |
+
</div>
|
| 269 |
+
);
|
| 270 |
+
}
|
src/app/admin/layout.tsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { ReactNode } from "react";
|
| 3 |
+
import Link from "next/link";
|
| 4 |
+
import { Shield, LogOut, Settings, Users, LayoutDashboardIcon, BarChart3 } from "lucide-react";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { getLoggedInUser } from "@/lib/actions/auth";
|
| 7 |
+
import { redirect } from 'next/navigation';
|
| 8 |
+
|
| 9 |
+
interface AdminLayoutProps {
|
| 10 |
+
children: ReactNode;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default async function AdminLayout({ children }: AdminLayoutProps) {
|
| 14 |
+
const user = await getLoggedInUser();
|
| 15 |
+
|
| 16 |
+
if (!user || user.role !== 'admin') {
|
| 17 |
+
redirect('/admin/login');
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<div className="flex min-h-screen">
|
| 22 |
+
<aside className="w-64 bg-card border-r p-6 flex flex-col">
|
| 23 |
+
<Link href="/admin/dashboard" className="flex items-center mb-8">
|
| 24 |
+
<Shield className="h-8 w-8 text-primary" />
|
| 25 |
+
<span className="ml-2 text-xl font-semibold text-foreground">Admin Panel</span>
|
| 26 |
+
</Link>
|
| 27 |
+
<nav className="flex flex-col space-y-2 flex-1">
|
| 28 |
+
<Button variant="ghost" className="justify-start" asChild>
|
| 29 |
+
<Link href="/admin/dashboard"><LayoutDashboardIcon className="mr-2 h-4 w-4" /> Dashboard</Link>
|
| 30 |
+
</Button>
|
| 31 |
+
<Button variant="ghost" className="justify-start" asChild>
|
| 32 |
+
<Link href="/admin/stats"><BarChart3 className="mr-2 h-4 w-4" /> Statistics</Link>
|
| 33 |
+
</Button>
|
| 34 |
+
<Button variant="ghost" className="justify-start" asChild>
|
| 35 |
+
<Link href="/admin/dashboard"><Users className="mr-2 h-4 w-4" /> User Management</Link>
|
| 36 |
+
</Button>
|
| 37 |
+
<Button variant="ghost" className="justify-start text-muted-foreground" disabled>
|
| 38 |
+
<Settings className="mr-2 h-4 w-4" /> Site Settings
|
| 39 |
+
</Button>
|
| 40 |
+
{/* Add more admin navigation links here */}
|
| 41 |
+
</nav>
|
| 42 |
+
<div className="mt-auto">
|
| 43 |
+
<Button variant="outline" className="w-full justify-start" asChild>
|
| 44 |
+
{/* This should also call a logout function specific to admin if needed */}
|
| 45 |
+
<Link href="/"><LogOut className="mr-2 h-4 w-4" /> Exit Admin</Link>
|
| 46 |
+
</Button>
|
| 47 |
+
</div>
|
| 48 |
+
</aside>
|
| 49 |
+
<main className="flex-1 p-8 bg-secondary">
|
| 50 |
+
{children}
|
| 51 |
+
</main>
|
| 52 |
+
</div>
|
| 53 |
+
);
|
| 54 |
+
}
|
src/app/admin/login/page.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AdminLoginForm } from "@/components/auth/AdminLoginForm";
|
| 2 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 3 |
+
import Link from "next/link";
|
| 4 |
+
import { ShieldAlert } from "lucide-react";
|
| 5 |
+
|
| 6 |
+
export default function AdminLoginPage() {
|
| 7 |
+
return (
|
| 8 |
+
<div className="min-h-screen flex items-center justify-center bg-secondary p-4">
|
| 9 |
+
<Card className="w-full max-w-md shadow-xl">
|
| 10 |
+
<CardHeader className="text-center">
|
| 11 |
+
<Link href="/" className="inline-flex items-center justify-center mb-4">
|
| 12 |
+
<ShieldAlert className="h-8 w-8 text-primary" />
|
| 13 |
+
<span className="ml-2 text-2xl font-semibold text-foreground">Admin Panel</span>
|
| 14 |
+
</Link>
|
| 15 |
+
<CardTitle className="text-2xl">Administrator Access</CardTitle>
|
| 16 |
+
<CardDescription>Enter your admin credentials to manage the application.</CardDescription>
|
| 17 |
+
</CardHeader>
|
| 18 |
+
<CardContent>
|
| 19 |
+
<AdminLoginForm />
|
| 20 |
+
<p className="mt-6 text-center text-sm text-muted-foreground">
|
| 21 |
+
This area is for authorized personnel only.
|
| 22 |
+
</p>
|
| 23 |
+
</CardContent>
|
| 24 |
+
</Card>
|
| 25 |
+
</div>
|
| 26 |
+
);
|
| 27 |
+
}
|
src/app/admin/stats/page.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
export const dynamic = 'force-dynamic';
|
| 3 |
+
|
| 4 |
+
import { getPlatformStats } from "@/lib/actions/admin";
|
| 5 |
+
import { StatCard } from "@/components/admin/StatCard";
|
| 6 |
+
import { Users, Package, Coins as CoinsIcon, AlertTriangle, BarChart3 } from "lucide-react"; // Renamed Coins to CoinsIcon
|
| 7 |
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
| 8 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 9 |
+
import { Separator } from "@/components/ui/separator";
|
| 10 |
+
|
| 11 |
+
export default async function AdminStatsPage() {
|
| 12 |
+
const statsResult = await getPlatformStats();
|
| 13 |
+
|
| 14 |
+
if (!statsResult.success || !statsResult.stats) {
|
| 15 |
+
return (
|
| 16 |
+
<div className="space-y-8">
|
| 17 |
+
<div>
|
| 18 |
+
<h1 className="text-3xl font-bold tracking-tight text-foreground flex items-center">
|
| 19 |
+
<BarChart3 className="mr-3 h-8 w-8 text-primary" /> Platform Statistics
|
| 20 |
+
</h1>
|
| 21 |
+
</div>
|
| 22 |
+
<Alert variant="destructive">
|
| 23 |
+
<AlertTriangle className="h-4 w-4" />
|
| 24 |
+
<AlertTitle>Error Fetching Stats</AlertTitle>
|
| 25 |
+
<AlertDescription>
|
| 26 |
+
{statsResult.message || "Could not load platform statistics. Please try again later."}
|
| 27 |
+
</AlertDescription>
|
| 28 |
+
</Alert>
|
| 29 |
+
</div>
|
| 30 |
+
);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const { totalUsers, totalDeployments, totalCoinsInSystem } = statsResult.stats;
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="space-y-8">
|
| 37 |
+
<div>
|
| 38 |
+
<h1 className="text-3xl font-bold tracking-tight text-foreground flex items-center">
|
| 39 |
+
<BarChart3 className="mr-3 h-8 w-8 text-primary" /> Platform Statistics
|
| 40 |
+
</h1>
|
| 41 |
+
<p className="text-muted-foreground">Overview of platform activity and usage.</p>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
| 45 |
+
<StatCard title="Total Users" value={totalUsers.toLocaleString()} icon={Users} description="Total number of registered users." />
|
| 46 |
+
<StatCard title="Total Deployments" value={totalDeployments.toLocaleString()} icon={Package} description="Total number of deployments created." />
|
| 47 |
+
<StatCard title="Total Coins in System" value={totalCoinsInSystem.toLocaleString()} icon={CoinsIcon} description="Sum of all user coin balances." />
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<Separator />
|
| 51 |
+
|
| 52 |
+
{/* Placeholder for charts */}
|
| 53 |
+
<Card className="shadow-lg">
|
| 54 |
+
<CardHeader>
|
| 55 |
+
<CardTitle className="flex items-center text-xl">
|
| 56 |
+
<BarChart3 className="mr-2 h-5 w-5 text-accent" />
|
| 57 |
+
Usage Trends
|
| 58 |
+
</CardTitle>
|
| 59 |
+
<CardDescription>Visual charts showing platform growth (feature coming soon).</CardDescription>
|
| 60 |
+
</CardHeader>
|
| 61 |
+
<CardContent>
|
| 62 |
+
<Alert>
|
| 63 |
+
<BarChart3 className="h-4 w-4" />
|
| 64 |
+
<AlertTitle>Charts Coming Soon!</AlertTitle>
|
| 65 |
+
<AlertDescription>
|
| 66 |
+
Detailed charts for user registration trends, deployment activity, and more will be available here in a future update.
|
| 67 |
+
</AlertDescription>
|
| 68 |
+
</Alert>
|
| 69 |
+
</CardContent>
|
| 70 |
+
</Card>
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
}
|
src/app/api/genkit/[...slug]/route.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// src/app/api/genkit/[...slug]/route.ts
|
| 2 |
+
import { genkitNextHandler } from '@genkit-ai/next';
|
| 3 |
+
import '@/ai/flows/analyze-deployment-logs'; // Ensure flows are loaded
|
| 4 |
+
export { genkitNextHandler as GET, genkitNextHandler as POST };
|
src/app/dashboard/buy-coins/page.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { CoinPurchaseForm } from "@/components/billing/CoinPurchaseForm";
|
| 3 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 4 |
+
import { CreditCard, AlertTriangle } from "lucide-react";
|
| 5 |
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
| 6 |
+
|
| 7 |
+
export const dynamic = 'force-dynamic';
|
| 8 |
+
|
| 9 |
+
export default function BuyCoinsPage() {
|
| 10 |
+
return (
|
| 11 |
+
<div className="space-y-8">
|
| 12 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
| 13 |
+
<div>
|
| 14 |
+
<h1 className="text-3xl font-bold tracking-tight text-foreground flex items-center">
|
| 15 |
+
<CreditCard className="mr-3 h-8 w-8 text-primary" /> Buy Coins
|
| 16 |
+
</h1>
|
| 17 |
+
<p className="text-muted-foreground">Purchase coins to use for deployments and other features.</p>
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<Alert variant="destructive" className="shadow-md">
|
| 22 |
+
<AlertTriangle className="h-4 w-4" />
|
| 23 |
+
<AlertTitle>Important: Payment System Notice</AlertTitle>
|
| 24 |
+
<AlertDescription>
|
| 25 |
+
This coin purchase system is currently in a **simulated mode**.
|
| 26 |
+
Actual payment processing and coin crediting upon successful real payment via Paystack/Flutterwave webhooks are not yet fully implemented.
|
| 27 |
+
Do not use real payment details.
|
| 28 |
+
The backend verification of payments is a critical next step.
|
| 29 |
+
</AlertDescription>
|
| 30 |
+
</Alert>
|
| 31 |
+
|
| 32 |
+
<Card className="shadow-xl">
|
| 33 |
+
<CardHeader>
|
| 34 |
+
<CardTitle>Select a Coin Package</CardTitle>
|
| 35 |
+
<CardDescription>Choose the number of coins you'd like to purchase.</CardDescription>
|
| 36 |
+
</CardHeader>
|
| 37 |
+
<CardContent>
|
| 38 |
+
<CoinPurchaseForm />
|
| 39 |
+
</CardContent>
|
| 40 |
+
</Card>
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
}
|
src/app/dashboard/deploy/page.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { DeploymentForm } from "@/components/deployment/DeploymentForm";
|
| 2 |
+
|
| 3 |
+
export const dynamic = 'force-dynamic';
|
| 4 |
+
|
| 5 |
+
export default function NewDeploymentPage() {
|
| 6 |
+
return (
|
| 7 |
+
<div>
|
| 8 |
+
<DeploymentForm />
|
| 9 |
+
</div>
|
| 10 |
+
);
|
| 11 |
+
}
|
src/app/dashboard/deployments/[id]/page.tsx
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
"use client";
|
| 3 |
+
|
| 4 |
+
import { useEffect, useState, useCallback } from 'react';
|
| 5 |
+
import { useParams, useRouter } from 'next/navigation';
|
| 6 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
| 7 |
+
import { Badge } from '@/components/ui/badge';
|
| 8 |
+
import { Button } from '@/components/ui/button';
|
| 9 |
+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
| 10 |
+
import { LogDisplay } from '@/components/deployment/LogDisplay';
|
| 11 |
+
import { AiLogAnalyzer } from '@/components/deployment/AiLogAnalyzer';
|
| 12 |
+
import { DeploymentControls } from '@/components/deployment/DeploymentControls';
|
| 13 |
+
import type { Deployment, DeploymentStatus } from '@/lib/types';
|
| 14 |
+
import { ArrowLeft, CheckCircle2, ExternalLink, Hourglass, AlertTriangle, Zap, Info, PowerOff, Settings2, FileText, Brain, RefreshCcw, Edit } from 'lucide-react';
|
| 15 |
+
import { getDeploymentById, getDeploymentLogs } from '@/lib/actions/deployment';
|
| 16 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
| 17 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 18 |
+
import { EditEnvVariablesDialog } from '@/components/deployment/EditEnvVariablesDialog';
|
| 19 |
+
|
| 20 |
+
function getStatusBadgeVariant(status: DeploymentStatus) {
|
| 21 |
+
switch (status) {
|
| 22 |
+
case 'succeeded': return 'default';
|
| 23 |
+
case 'deploying': return 'secondary';
|
| 24 |
+
case 'pending': return 'outline';
|
| 25 |
+
case 'failed': return 'destructive';
|
| 26 |
+
case 'stopped': return 'outline';
|
| 27 |
+
default: return 'outline';
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function getStatusIcon(status: DeploymentStatus) {
|
| 32 |
+
switch (status) {
|
| 33 |
+
case 'succeeded': return <CheckCircle2 className="h-5 w-5 text-green-500" />;
|
| 34 |
+
case 'deploying': return <Hourglass className="h-5 w-5 text-blue-500 animate-spin" />;
|
| 35 |
+
case 'pending': return <Hourglass className="h-5 w-5 text-yellow-500" />;
|
| 36 |
+
case 'failed': return <AlertTriangle className="h-5 w-5 text-red-500" />;
|
| 37 |
+
case 'stopped': return <PowerOff className="h-5 w-5 text-gray-500" />;
|
| 38 |
+
default: return <Zap className="h-5 w-5 text-muted-foreground" />;
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const sensitiveKeywords = ['session', 'api', 'key', 'secret', 'token', 'pass', 'password', 'auth', 'credentials', 'mongodb_uri', 'database_url'];
|
| 43 |
+
|
| 44 |
+
const shouldHideValue = (key: string, value: any): boolean => {
|
| 45 |
+
if (typeof value !== 'string' || value.length <= 25) {
|
| 46 |
+
return false;
|
| 47 |
+
}
|
| 48 |
+
const keyLower = key.toLowerCase();
|
| 49 |
+
return sensitiveKeywords.some(keyword => keyLower.includes(keyword));
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const displayValueHelper = (key: string, value: any): string => {
|
| 53 |
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
| 54 |
+
const sValue = String(value);
|
| 55 |
+
if (shouldHideValue(key, sValue)) return `${sValue.substring(0,10)}... (hidden)`;
|
| 56 |
+
if (sValue.length > 100) return `${sValue.substring(0,97)}...`;
|
| 57 |
+
return sValue;
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
export default function DeploymentDetailPage() {
|
| 62 |
+
const params = useParams();
|
| 63 |
+
const router = useRouter();
|
| 64 |
+
const id = params.id as string;
|
| 65 |
+
|
| 66 |
+
const [deployment, setDeployment] = useState<Deployment | null>(null);
|
| 67 |
+
const [logs, setLogs] = useState<string[]>([]);
|
| 68 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 69 |
+
const [logsLoading, setLogsLoading] = useState(true);
|
| 70 |
+
const [error, setError] = useState<string | null>(null);
|
| 71 |
+
const [isEditEnvDialogOpen, setIsEditEnvDialogOpen] = useState(false);
|
| 72 |
+
|
| 73 |
+
const fetchDeploymentData = useCallback(async (forceRefresh = false) => {
|
| 74 |
+
if (!id) return;
|
| 75 |
+
if (!deployment || forceRefresh) setIsLoading(true);
|
| 76 |
+
setError(null);
|
| 77 |
+
try {
|
| 78 |
+
const deploymentData = await getDeploymentById(id);
|
| 79 |
+
if (deploymentData) {
|
| 80 |
+
setDeployment(deploymentData);
|
| 81 |
+
if (logsLoading || deploymentData.status !== deployment?.status || forceRefresh) {
|
| 82 |
+
setLogsLoading(true);
|
| 83 |
+
const logData = await getDeploymentLogs(deploymentData.id);
|
| 84 |
+
setLogs(logData || []);
|
| 85 |
+
setLogsLoading(false);
|
| 86 |
+
}
|
| 87 |
+
} else {
|
| 88 |
+
setError("Deployment not found.");
|
| 89 |
+
setDeployment(null);
|
| 90 |
+
}
|
| 91 |
+
} catch (err) {
|
| 92 |
+
console.error("Failed to fetch deployment details:", err);
|
| 93 |
+
setError(err instanceof Error ? err.message : "An unexpected error occurred.");
|
| 94 |
+
setDeployment(null);
|
| 95 |
+
} finally {
|
| 96 |
+
if (!deployment || forceRefresh) setIsLoading(false);
|
| 97 |
+
}
|
| 98 |
+
}, [id, deployment, logsLoading]);
|
| 99 |
+
|
| 100 |
+
useEffect(() => {
|
| 101 |
+
if (id) {
|
| 102 |
+
fetchDeploymentData(true); // Initial fetch with full loading indication
|
| 103 |
+
}
|
| 104 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 105 |
+
}, [id]); // Only re-run if ID changes
|
| 106 |
+
|
| 107 |
+
useEffect(() => {
|
| 108 |
+
let intervalId: NodeJS.Timeout | undefined = undefined;
|
| 109 |
+
if (deployment && (deployment.status === 'deploying' || deployment.status === 'pending')) {
|
| 110 |
+
intervalId = setInterval(() => {
|
| 111 |
+
fetchDeploymentData(); // No force refresh for interval, let it be gentle
|
| 112 |
+
}, 10000);
|
| 113 |
+
}
|
| 114 |
+
return () => {
|
| 115 |
+
if (intervalId) clearInterval(intervalId);
|
| 116 |
+
}
|
| 117 |
+
}, [deployment, fetchDeploymentData]);
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
const handleStatusChange = (newStatus: DeploymentStatus) => {
|
| 121 |
+
if (deployment) {
|
| 122 |
+
setDeployment({ ...deployment, status: newStatus });
|
| 123 |
+
if (newStatus !== 'deploying' && newStatus !== 'pending') {
|
| 124 |
+
fetchDeploymentData(true);
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
};
|
| 128 |
+
|
| 129 |
+
const handleEnvUpdateSuccess = () => {
|
| 130 |
+
setIsEditEnvDialogOpen(false);
|
| 131 |
+
fetchDeploymentData(true);
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
if (isLoading && !deployment) {
|
| 135 |
+
return (
|
| 136 |
+
<div className="space-y-6">
|
| 137 |
+
<Skeleton className="h-10 w-1/3" />
|
| 138 |
+
<Skeleton className="h-32 w-full" />
|
| 139 |
+
<Skeleton className="h-72 w-full" />
|
| 140 |
+
<Skeleton className="h-72 w-full" />
|
| 141 |
+
</div>
|
| 142 |
+
);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
if (error) {
|
| 146 |
+
return (
|
| 147 |
+
<Alert variant="destructive" className="shadow-xl">
|
| 148 |
+
<AlertTriangle className="h-4 w-4" />
|
| 149 |
+
<AlertTitle>Error</AlertTitle>
|
| 150 |
+
<AlertDescription>{error}</AlertDescription>
|
| 151 |
+
<Button onClick={() => router.push('/dashboard')} variant="outline" className="mt-4">
|
| 152 |
+
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Dashboard
|
| 153 |
+
</Button>
|
| 154 |
+
</Alert>
|
| 155 |
+
);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
if (!deployment) {
|
| 159 |
+
return (
|
| 160 |
+
<Alert variant="destructive" className="shadow-xl">
|
| 161 |
+
<AlertTriangle className="h-4 w-4" />
|
| 162 |
+
<AlertTitle>Error</AlertTitle>
|
| 163 |
+
<AlertDescription>Deployment not found. It might have been deleted or the ID is incorrect.</AlertDescription>
|
| 164 |
+
<Button onClick={() => router.push('/dashboard')} variant="outline" className="mt-4">
|
| 165 |
+
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Dashboard
|
| 166 |
+
</Button>
|
| 167 |
+
</Alert>
|
| 168 |
+
);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const combinedLogs = logs.join('\n');
|
| 172 |
+
|
| 173 |
+
return (
|
| 174 |
+
<div className="space-y-8">
|
| 175 |
+
<div className="flex justify-between items-center mb-6">
|
| 176 |
+
<Button variant="outline" onClick={() => router.push('/dashboard')} className="shadow hover:shadow-md">
|
| 177 |
+
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Dashboard
|
| 178 |
+
</Button>
|
| 179 |
+
<Button variant="ghost" onClick={() => fetchDeploymentData(true)} title="Refresh Data" className="text-muted-foreground hover:text-primary">
|
| 180 |
+
<RefreshCcw className="mr-2 h-4 w-4" /> Refresh
|
| 181 |
+
</Button>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
<Card className="shadow-xl hover:shadow-2xl">
|
| 185 |
+
<CardHeader>
|
| 186 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
|
| 187 |
+
<div>
|
| 188 |
+
<CardTitle className="text-3xl font-bold text-primary">{deployment.appName}</CardTitle>
|
| 189 |
+
<CardDescription className="text-sm text-muted-foreground">Manage your "{deployment.appName}" deployment.</CardDescription>
|
| 190 |
+
</div>
|
| 191 |
+
<Badge variant={getStatusBadgeVariant(deployment.status)} className="text-md capitalize px-4 py-2 flex items-center gap-2 shadow-md">
|
| 192 |
+
{getStatusIcon(deployment.status)}
|
| 193 |
+
{deployment.status}
|
| 194 |
+
</Badge>
|
| 195 |
+
</div>
|
| 196 |
+
</CardHeader>
|
| 197 |
+
<CardContent className="space-y-6">
|
| 198 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 text-sm">
|
| 199 |
+
<p><strong className="text-foreground/80">ID:</strong> <span className="font-mono text-muted-foreground">{deployment.id}</span></p>
|
| 200 |
+
<p><strong className="text-foreground/80">Created:</strong> {new Date(deployment.createdAt).toLocaleString()}</p>
|
| 201 |
+
<p><strong className="text-foreground/80">Region:</strong> {deployment.region || 'N/A'}</p>
|
| 202 |
+
{deployment.lastDeployedAt && <p><strong className="text-foreground/80">Last Deployed:</strong> {new Date(deployment.lastDeployedAt).toLocaleString()}</p>}
|
| 203 |
+
</div>
|
| 204 |
+
{deployment.url && (
|
| 205 |
+
<Button variant="link" asChild className="p-0 h-auto text-base">
|
| 206 |
+
<a
|
| 207 |
+
href={deployment.url}
|
| 208 |
+
target="_blank"
|
| 209 |
+
rel="noopener noreferrer"
|
| 210 |
+
className="inline-flex items-center text-accent hover:underline font-medium"
|
| 211 |
+
>
|
| 212 |
+
<ExternalLink className="mr-2 h-4 w-4" /> Visit Deployed App
|
| 213 |
+
</a>
|
| 214 |
+
</Button>
|
| 215 |
+
)}
|
| 216 |
+
<DeploymentControls deploymentId={deployment.id} currentStatus={deployment.status} onStatusChange={handleStatusChange} />
|
| 217 |
+
</CardContent>
|
| 218 |
+
</Card>
|
| 219 |
+
|
| 220 |
+
<Tabs defaultValue="logs" className="w-full">
|
| 221 |
+
<TabsList className="grid w-full grid-cols-2 md:grid-cols-3 gap-2 h-auto shadow-md bg-muted p-1 rounded-lg">
|
| 222 |
+
<TabsTrigger value="logs" className="py-2.5 text-sm data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-lg"><FileText className="mr-2 h-4 w-4"/>Logs</TabsTrigger>
|
| 223 |
+
<TabsTrigger value="ai-analyzer" className="py-2.5 text-sm data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-lg"><Brain className="mr-2 h-4 w-4"/>AI Analyzer</TabsTrigger>
|
| 224 |
+
<TabsTrigger value="env" className="py-2.5 text-sm data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-lg"><Settings2 className="mr-2 h-4 w-4"/>Environment</TabsTrigger>
|
| 225 |
+
</TabsList>
|
| 226 |
+
<TabsContent value="logs" className="mt-6">
|
| 227 |
+
<LogDisplay logs={logs} isLoading={logsLoading} />
|
| 228 |
+
</TabsContent>
|
| 229 |
+
<TabsContent value="ai-analyzer" className="mt-6">
|
| 230 |
+
<AiLogAnalyzer initialLogs={logsLoading ? "Loading logs for analysis..." : combinedLogs} />
|
| 231 |
+
</TabsContent>
|
| 232 |
+
<TabsContent value="env" className="mt-6">
|
| 233 |
+
<Card className="shadow-xl">
|
| 234 |
+
<CardHeader className="flex flex-row justify-between items-center">
|
| 235 |
+
<div>
|
| 236 |
+
<CardTitle className="text-xl flex items-center"><Settings2 className="mr-2 h-5 w-5 text-primary"/>Environment Variables</CardTitle>
|
| 237 |
+
<CardDescription className="text-xs text-muted-foreground">Current environment configuration for this deployment.</CardDescription>
|
| 238 |
+
</div>
|
| 239 |
+
<Button variant="outline" size="sm" onClick={() => setIsEditEnvDialogOpen(true)}>
|
| 240 |
+
<Edit className="mr-2 h-4 w-4" /> Edit Variables
|
| 241 |
+
</Button>
|
| 242 |
+
</CardHeader>
|
| 243 |
+
<CardContent>
|
| 244 |
+
{deployment.envVariables && Object.keys(deployment.envVariables).length > 0 ? (
|
| 245 |
+
<div className="space-y-2 text-sm max-h-96 overflow-y-auto bg-muted/50 p-4 rounded-lg shadow-inner border">
|
| 246 |
+
{Object.entries(deployment.envVariables).map(([key, value]) => (
|
| 247 |
+
<div key={key} className="grid grid-cols-3 gap-2 py-1.5 border-b border-border/50 last:border-b-0 font-mono">
|
| 248 |
+
<strong className="text-foreground/80 col-span-1 truncate" title={key}>{key}:</strong>
|
| 249 |
+
<span className="col-span-2 break-all text-foreground/90" title={typeof value === 'string' || typeof value === 'number' ? String(value) : undefined}>
|
| 250 |
+
{displayValueHelper(key, value)}
|
| 251 |
+
</span>
|
| 252 |
+
</div>
|
| 253 |
+
))}
|
| 254 |
+
</div>
|
| 255 |
+
) : (
|
| 256 |
+
<Alert className="shadow-sm">
|
| 257 |
+
<Info className="h-4 w-4" />
|
| 258 |
+
<AlertTitle>No Environment Variables</AlertTitle>
|
| 259 |
+
<AlertDescription>
|
| 260 |
+
No specific environment variables were found for this deployment, or they are not set to be displayed.
|
| 261 |
+
</AlertDescription>
|
| 262 |
+
</Alert>
|
| 263 |
+
)}
|
| 264 |
+
</CardContent>
|
| 265 |
+
</Card>
|
| 266 |
+
</TabsContent>
|
| 267 |
+
</Tabs>
|
| 268 |
+
{deployment && (
|
| 269 |
+
<EditEnvVariablesDialog
|
| 270 |
+
isOpen={isEditEnvDialogOpen}
|
| 271 |
+
onOpenChange={setIsEditEnvDialogOpen}
|
| 272 |
+
deployment={deployment}
|
| 273 |
+
onSuccess={handleEnvUpdateSuccess}
|
| 274 |
+
/>
|
| 275 |
+
)}
|
| 276 |
+
</div>
|
| 277 |
+
);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
|
src/app/dashboard/layout.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { Header } from "@/components/layout/Header";
|
| 3 |
+
import type { ReactNode } from "react";
|
| 4 |
+
import { getLoggedInUser } from "@/lib/actions/auth";
|
| 5 |
+
import { redirect } from 'next/navigation';
|
| 6 |
+
|
| 7 |
+
interface DashboardLayoutProps {
|
| 8 |
+
children: ReactNode;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default async function DashboardLayout({ children }: DashboardLayoutProps) {
|
| 12 |
+
const user = await getLoggedInUser();
|
| 13 |
+
|
| 14 |
+
if (!user) {
|
| 15 |
+
// If no user is logged in (cookie not present or invalid), redirect to login
|
| 16 |
+
// This is a basic check; more robust auth might involve checking session validity
|
| 17 |
+
redirect('/login');
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<div className="flex min-h-screen flex-col">
|
| 22 |
+
<Header user={user} />
|
| 23 |
+
<main className="flex-1 container py-8">
|
| 24 |
+
{children}
|
| 25 |
+
</main>
|
| 26 |
+
<footer className="py-6 border-t">
|
| 27 |
+
<div className="container text-center text-sm text-muted-foreground">
|
| 28 |
+
Anita Deploy © {new Date().getFullYear()}
|
| 29 |
+
</div>
|
| 30 |
+
</footer>
|
| 31 |
+
</div>
|
| 32 |
+
);
|
| 33 |
+
}
|
src/app/dashboard/page.tsx
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
"use client";
|
| 3 |
+
|
| 4 |
+
import Link from 'next/link';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { PlusCircle, LayoutGrid, Gift, Loader2, Coins, Send, RefreshCcw, Info } from 'lucide-react';
|
| 7 |
+
import { DeploymentCard } from '@/components/deployment/DeploymentCard';
|
| 8 |
+
import { getDeployments } from '@/lib/actions/deployment';
|
| 9 |
+
import { claimDailyCoins } from '@/lib/actions/user';
|
| 10 |
+
import { getLoggedInUser, type LoggedInUser } from '@/lib/actions/auth';
|
| 11 |
+
import type { Deployment } from '@/lib/types';
|
| 12 |
+
import { useEffect, useState, useCallback } from 'react';
|
| 13 |
+
import { useToast } from '@/hooks/use-toast';
|
| 14 |
+
import { TransferCoinsDialog } from '@/components/user/TransferCoinsDialog';
|
| 15 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
| 16 |
+
|
| 17 |
+
export default function DashboardPage() {
|
| 18 |
+
const [deployments, setDeployments] = useState<Deployment[]>([]);
|
| 19 |
+
const [isLoadingDeployments, setIsLoadingDeployments] = useState(true);
|
| 20 |
+
const [user, setUser] = useState<LoggedInUser | null>(null);
|
| 21 |
+
const [currentCoins, setCurrentCoins] = useState<number>(0);
|
| 22 |
+
const [isClaimingCoins, setIsClaimingCoins] = useState(false);
|
| 23 |
+
const [nextClaimInMs, setNextClaimInMs] = useState<number | null>(null);
|
| 24 |
+
const [countdown, setCountdown] = useState<string>("");
|
| 25 |
+
const [isTransferDialogOpen, setIsTransferDialogOpen] = useState(false);
|
| 26 |
+
|
| 27 |
+
const { toast } = useToast();
|
| 28 |
+
|
| 29 |
+
const fetchInitialData = useCallback(async () => {
|
| 30 |
+
setIsLoadingDeployments(true);
|
| 31 |
+
try {
|
| 32 |
+
const [fetchedUser, fetchedDeployments] = await Promise.all([
|
| 33 |
+
getLoggedInUser(),
|
| 34 |
+
getDeployments()
|
| 35 |
+
]);
|
| 36 |
+
setUser(fetchedUser);
|
| 37 |
+
setCurrentCoins(fetchedUser?.coins ?? 0);
|
| 38 |
+
setDeployments(fetchedDeployments);
|
| 39 |
+
|
| 40 |
+
if (fetchedUser?.lastCoinClaim) {
|
| 41 |
+
const lastClaimTime = new Date(fetchedUser.lastCoinClaim).getTime();
|
| 42 |
+
const cooldownEndTime = lastClaimTime + 24 * 60 * 60 * 1000;
|
| 43 |
+
const now = Date.now();
|
| 44 |
+
if (cooldownEndTime > now) {
|
| 45 |
+
setNextClaimInMs(cooldownEndTime - now);
|
| 46 |
+
} else {
|
| 47 |
+
setNextClaimInMs(0);
|
| 48 |
+
}
|
| 49 |
+
} else {
|
| 50 |
+
setNextClaimInMs(0);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
} catch (error) {
|
| 54 |
+
toast({ title: "Error", description: "Failed to load dashboard data.", variant: "destructive" });
|
| 55 |
+
} finally {
|
| 56 |
+
setIsLoadingDeployments(false);
|
| 57 |
+
}
|
| 58 |
+
}, [toast]);
|
| 59 |
+
|
| 60 |
+
useEffect(() => {
|
| 61 |
+
fetchInitialData();
|
| 62 |
+
}, [fetchInitialData]);
|
| 63 |
+
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
let intervalId: NodeJS.Timeout;
|
| 66 |
+
if (nextClaimInMs !== null && nextClaimInMs > 0) {
|
| 67 |
+
intervalId = setInterval(() => {
|
| 68 |
+
setNextClaimInMs(prev => {
|
| 69 |
+
if (prev === null || prev <= 1000) {
|
| 70 |
+
clearInterval(intervalId);
|
| 71 |
+
return 0;
|
| 72 |
+
}
|
| 73 |
+
return prev - 1000;
|
| 74 |
+
});
|
| 75 |
+
}, 1000);
|
| 76 |
+
}
|
| 77 |
+
return () => clearInterval(intervalId);
|
| 78 |
+
}, [nextClaimInMs]);
|
| 79 |
+
|
| 80 |
+
useEffect(() => {
|
| 81 |
+
if (nextClaimInMs !== null && nextClaimInMs > 0) {
|
| 82 |
+
const totalSeconds = Math.floor(nextClaimInMs / 1000);
|
| 83 |
+
const hours = Math.floor(totalSeconds / 3600);
|
| 84 |
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
| 85 |
+
const seconds = totalSeconds % 60;
|
| 86 |
+
|
| 87 |
+
const formattedHours = String(hours).padStart(2, '0');
|
| 88 |
+
const formattedMinutes = String(minutes).padStart(2, '0');
|
| 89 |
+
const formattedSeconds = String(seconds).padStart(2, '0');
|
| 90 |
+
|
| 91 |
+
setCountdown(`${formattedHours}:${formattedMinutes}:${formattedSeconds}`);
|
| 92 |
+
} else if (nextClaimInMs === 0) {
|
| 93 |
+
setCountdown("Ready to claim!");
|
| 94 |
+
} else {
|
| 95 |
+
setCountdown("");
|
| 96 |
+
}
|
| 97 |
+
}, [nextClaimInMs]);
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
const handleClaimCoins = async () => {
|
| 101 |
+
setIsClaimingCoins(true);
|
| 102 |
+
try {
|
| 103 |
+
const result = await claimDailyCoins();
|
| 104 |
+
toast({
|
| 105 |
+
title: result.success ? "Success!" : "Oops!",
|
| 106 |
+
description: result.message,
|
| 107 |
+
variant: result.success ? "default" : "destructive",
|
| 108 |
+
});
|
| 109 |
+
if (result.success && result.newBalance !== undefined) {
|
| 110 |
+
setCurrentCoins(result.newBalance);
|
| 111 |
+
fetchInitialData(); // Refetch to update lastCoinClaim and countdown
|
| 112 |
+
} else if (!result.success && result.nextClaimAvailableInMs) {
|
| 113 |
+
setNextClaimInMs(result.nextClaimAvailableInMs);
|
| 114 |
+
}
|
| 115 |
+
} catch (error) {
|
| 116 |
+
toast({ title: "Error", description: "An unexpected error occurred.", variant: "destructive" });
|
| 117 |
+
} finally {
|
| 118 |
+
setIsClaimingCoins(false);
|
| 119 |
+
}
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const handleTransferSuccess = (newBalance: number) => {
|
| 123 |
+
setCurrentCoins(newBalance);
|
| 124 |
+
fetchInitialData();
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
return (
|
| 128 |
+
<div className="space-y-8">
|
| 129 |
+
<Card className="shadow-xl hover:shadow-2xl border-primary/20">
|
| 130 |
+
<CardHeader className="p-4 sm:p-6 bg-card rounded-t-lg">
|
| 131 |
+
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
| 132 |
+
<div>
|
| 133 |
+
<CardTitle className="text-2xl sm:text-3xl font-bold tracking-tight text-primary">Your Deployments</CardTitle>
|
| 134 |
+
<CardDescription className="text-muted-foreground mt-1">Manage your Anita-V4 bot deployments.</CardDescription>
|
| 135 |
+
</div>
|
| 136 |
+
<Button asChild size="lg" className="w-full md:w-auto shadow-md hover:shadow-lg" disabled={!user} title="New Deployment">
|
| 137 |
+
<Link href="/dashboard/deploy">
|
| 138 |
+
<PlusCircle className="mr-2 h-5 w-5" /> New Deployment
|
| 139 |
+
</Link>
|
| 140 |
+
</Button>
|
| 141 |
+
</div>
|
| 142 |
+
</CardHeader>
|
| 143 |
+
{user && (
|
| 144 |
+
<CardContent className="p-4 sm:p-6 border-t">
|
| 145 |
+
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 flex-wrap">
|
| 146 |
+
<div className="flex items-center text-lg bg-secondary/50 p-3 rounded-lg shadow-sm border border-border">
|
| 147 |
+
<Coins className="mr-2.5 h-6 w-6 text-yellow-500" />
|
| 148 |
+
<span className="font-bold text-foreground">{currentCoins.toLocaleString()}</span>
|
| 149 |
+
<span className="ml-1.5 text-sm text-muted-foreground">Coins</span>
|
| 150 |
+
</div>
|
| 151 |
+
<div className="flex flex-col sm:flex-row items-center gap-3 flex-wrap">
|
| 152 |
+
<Button
|
| 153 |
+
variant="outline"
|
| 154 |
+
size="sm"
|
| 155 |
+
onClick={() => setIsTransferDialogOpen(true)}
|
| 156 |
+
disabled={!user}
|
| 157 |
+
className="min-w-[150px] shadow-sm hover:shadow-md"
|
| 158 |
+
title="Transfer Coins"
|
| 159 |
+
>
|
| 160 |
+
<Send className="mr-2 h-4 w-4" /> Transfer Coins
|
| 161 |
+
</Button>
|
| 162 |
+
{nextClaimInMs !== null && (
|
| 163 |
+
<div className="flex flex-col items-center sm:items-end">
|
| 164 |
+
<Button
|
| 165 |
+
onClick={handleClaimCoins}
|
| 166 |
+
disabled={isClaimingCoins || (nextClaimInMs !== null && nextClaimInMs > 0)}
|
| 167 |
+
variant={nextClaimInMs === 0 ? "default" : "outline"}
|
| 168 |
+
size="sm"
|
| 169 |
+
className="min-w-[170px] shadow-sm hover:shadow-md"
|
| 170 |
+
title="Claim Daily Coins"
|
| 171 |
+
>
|
| 172 |
+
{isClaimingCoins ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Gift className="mr-2 h-4 w-4" />}
|
| 173 |
+
{nextClaimInMs > 0 ? `Claim in ${countdown}` :
|
| 174 |
+
(countdown === "Ready to claim!" || nextClaimInMs === 0) ? "Claim Daily Coins" :
|
| 175 |
+
isClaimingCoins ? "Claiming..." : "Daily Coins"}
|
| 176 |
+
</Button>
|
| 177 |
+
{user.referralCode && user.referralCode !== 'N/A' && (
|
| 178 |
+
<p className="text-xs text-muted-foreground mt-1.5 text-center sm:text-right">
|
| 179 |
+
Your Code: <span className="font-semibold text-primary">{user.referralCode}</span>
|
| 180 |
+
</p>
|
| 181 |
+
)}
|
| 182 |
+
</div>
|
| 183 |
+
)}
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</CardContent>
|
| 187 |
+
)}
|
| 188 |
+
</Card>
|
| 189 |
+
|
| 190 |
+
{isLoadingDeployments ? (
|
| 191 |
+
<div className="text-center py-12">
|
| 192 |
+
<Loader2 className="mx-auto h-12 w-12 text-primary animate-spin mb-4" />
|
| 193 |
+
<p className="text-muted-foreground">Loading deployments...</p>
|
| 194 |
+
</div>
|
| 195 |
+
) : deployments.length > 0 ? (
|
| 196 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 197 |
+
{deployments.map((deployment) => (
|
| 198 |
+
<DeploymentCard key={deployment.id} deployment={deployment} />
|
| 199 |
+
))}
|
| 200 |
+
</div>
|
| 201 |
+
) : (
|
| 202 |
+
<Card className="text-center py-12 border-2 border-dashed rounded-lg shadow-sm hover:shadow-md">
|
| 203 |
+
<CardContent className="flex flex-col items-center">
|
| 204 |
+
<LayoutGrid className="mx-auto h-16 w-16 text-muted-foreground mb-6" />
|
| 205 |
+
<h3 className="text-2xl font-semibold text-foreground">No Deployments Yet</h3>
|
| 206 |
+
<p className="text-muted-foreground mt-2 max-w-md mx-auto">
|
| 207 |
+
Ready to launch your Anita-V4 bot? You'll need some coins to get started.
|
| 208 |
+
Don't forget to claim your daily coins!
|
| 209 |
+
</p>
|
| 210 |
+
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mt-8">
|
| 211 |
+
<Button asChild className="mt-0 shadow-md hover:shadow-lg" disabled={!user} title="Create Deployment">
|
| 212 |
+
<Link href="/dashboard/deploy">
|
| 213 |
+
<PlusCircle className="mr-2 h-4 w-4" /> Create Deployment
|
| 214 |
+
</Link>
|
| 215 |
+
</Button>
|
| 216 |
+
{user && nextClaimInMs !== null && (
|
| 217 |
+
<Button
|
| 218 |
+
onClick={handleClaimCoins}
|
| 219 |
+
disabled={isClaimingCoins || (nextClaimInMs !== null && nextClaimInMs > 0)}
|
| 220 |
+
variant={(nextClaimInMs === 0) ? "default" : "outline"}
|
| 221 |
+
className="shadow-sm hover:shadow-md"
|
| 222 |
+
>
|
| 223 |
+
{isClaimingCoins ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Coins className="mr-2 h-4 w-4" />}
|
| 224 |
+
{nextClaimInMs > 0 ? `Claim in ${countdown}` :
|
| 225 |
+
(countdown === "Ready to claim!" || nextClaimInMs === 0) ? "Claim Daily Coins" :
|
| 226 |
+
isClaimingCoins ? "Claiming..." : "Claim Coins"}
|
| 227 |
+
</Button>
|
| 228 |
+
)}
|
| 229 |
+
</div>
|
| 230 |
+
</CardContent>
|
| 231 |
+
</Card>
|
| 232 |
+
)}
|
| 233 |
+
|
| 234 |
+
{user && (
|
| 235 |
+
<TransferCoinsDialog
|
| 236 |
+
isOpen={isTransferDialogOpen}
|
| 237 |
+
onOpenChange={setIsTransferDialogOpen}
|
| 238 |
+
currentUserCoins={currentCoins}
|
| 239 |
+
onTransferSuccess={handleTransferSuccess}
|
| 240 |
+
/>
|
| 241 |
+
)}
|
| 242 |
+
</div>
|
| 243 |
+
);
|
| 244 |
+
}
|
src/app/dashboard/profile/page.tsx
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { getLoggedInUser } from "@/lib/actions/auth";
|
| 3 |
+
import { redirect } from 'next/navigation';
|
| 4 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 5 |
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
| 6 |
+
import { Badge } from "@/components/ui/badge";
|
| 7 |
+
import { CopyButton } from "@/components/ui/CopyButton";
|
| 8 |
+
import { UserCircle, Mail, CalendarDays, Coins, Gift, Shield, Users, BarChart3, KeyRound, UserCog, Trash2, AlertTriangle } from "lucide-react";
|
| 9 |
+
import { UpdateProfileForm } from "@/components/user/UpdateProfileForm";
|
| 10 |
+
import { ChangePasswordForm } from "@/components/user/ChangePasswordForm";
|
| 11 |
+
import { DeleteAccountDialog } from "@/components/user/DeleteAccountDialog";
|
| 12 |
+
import { Separator } from "@/components/ui/separator";
|
| 13 |
+
import { Button } from "@/components/ui/button";
|
| 14 |
+
|
| 15 |
+
export const dynamic = 'force-dynamic';
|
| 16 |
+
|
| 17 |
+
export default async function ProfilePage() {
|
| 18 |
+
const user = await getLoggedInUser();
|
| 19 |
+
|
| 20 |
+
if (!user) {
|
| 21 |
+
redirect("/login");
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const getAvatarFallback = () => {
|
| 25 |
+
if (user?.name) {
|
| 26 |
+
const nameParts = user.name.split(" ");
|
| 27 |
+
if (nameParts.length > 1 && nameParts[0] && nameParts[1]) {
|
| 28 |
+
return nameParts[0][0].toUpperCase() + nameParts[1][0].toUpperCase();
|
| 29 |
+
}
|
| 30 |
+
return user.name.substring(0, 2).toUpperCase();
|
| 31 |
+
}
|
| 32 |
+
return "U";
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="space-y-8">
|
| 37 |
+
<div>
|
| 38 |
+
<h1 className="text-3xl font-bold tracking-tight text-foreground flex items-center">
|
| 39 |
+
<UserCircle className="mr-3 h-8 w-8 text-primary" /> Your Profile
|
| 40 |
+
</h1>
|
| 41 |
+
<p className="text-muted-foreground">View and manage your account details and settings.</p>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<Card className="shadow-xl hover:shadow-2xl">
|
| 45 |
+
<CardHeader className="flex flex-col items-center text-center sm:flex-row sm:text-left sm:items-start gap-4">
|
| 46 |
+
<Avatar className="h-24 w-24 border-2 border-primary shadow-sm">
|
| 47 |
+
<AvatarImage
|
| 48 |
+
src={`https://placehold.co/100x100.png?text=${getAvatarFallback()}`}
|
| 49 |
+
alt={user.name}
|
| 50 |
+
data-ai-hint="user avatar"
|
| 51 |
+
/>
|
| 52 |
+
<AvatarFallback className="text-3xl">{getAvatarFallback()}</AvatarFallback>
|
| 53 |
+
</Avatar>
|
| 54 |
+
<div className="flex-1">
|
| 55 |
+
<CardTitle className="text-2xl">{user.name}</CardTitle>
|
| 56 |
+
<CardDescription className="flex items-center mt-1">
|
| 57 |
+
<Mail className="mr-2 h-4 w-4 text-muted-foreground" /> {user.email}
|
| 58 |
+
</CardDescription>
|
| 59 |
+
{user.role === 'admin' && (
|
| 60 |
+
<Badge variant="default" className="mt-2 text-xs">
|
| 61 |
+
<Shield className="mr-1.5 h-3.5 w-3.5" /> Admin
|
| 62 |
+
</Badge>
|
| 63 |
+
)}
|
| 64 |
+
</div>
|
| 65 |
+
</CardHeader>
|
| 66 |
+
<CardContent className="space-y-6 pt-6">
|
| 67 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm">
|
| 68 |
+
<div className="flex items-center p-4 bg-secondary/60 rounded-lg shadow">
|
| 69 |
+
<Coins className="mr-3 h-6 w-6 text-yellow-500" />
|
| 70 |
+
<div>
|
| 71 |
+
<p className="text-muted-foreground">Coin Balance</p>
|
| 72 |
+
<p className="font-semibold text-lg text-foreground">{user.coins.toLocaleString()} Coins</p>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
<div className="flex items-center p-4 bg-secondary/60 rounded-lg shadow">
|
| 76 |
+
<CalendarDays className="mr-3 h-6 w-6 text-blue-500" />
|
| 77 |
+
<div>
|
| 78 |
+
<p className="text-muted-foreground">Joined On</p>
|
| 79 |
+
<p className="font-semibold text-foreground">{new Date(user.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
{user.referralCode && user.referralCode !== 'N/A' && (
|
| 85 |
+
<Card className="bg-card border shadow-lg">
|
| 86 |
+
<CardHeader>
|
| 87 |
+
<CardTitle className="text-lg flex items-center">
|
| 88 |
+
<Gift className="mr-2 h-5 w-5 text-accent" />
|
| 89 |
+
Your Referral Code
|
| 90 |
+
</CardTitle>
|
| 91 |
+
<CardDescription className="text-xs">Share this code with friends. You earn 10 coins for each successful referral!</CardDescription>
|
| 92 |
+
</CardHeader>
|
| 93 |
+
<CardContent className="flex flex-col sm:flex-row items-center justify-between gap-2 p-4 bg-muted/40 rounded-md">
|
| 94 |
+
<p className="text-lg font-mono font-semibold text-primary break-all">{user.referralCode}</p>
|
| 95 |
+
<CopyButton textToCopy={user.referralCode} buttonText="Copy Code" />
|
| 96 |
+
</CardContent>
|
| 97 |
+
</Card>
|
| 98 |
+
)}
|
| 99 |
+
|
| 100 |
+
<Card className="bg-card border shadow-lg">
|
| 101 |
+
<CardHeader>
|
| 102 |
+
<CardTitle className="text-lg flex items-center">
|
| 103 |
+
<BarChart3 className="mr-2 h-5 w-5 text-green-500" />
|
| 104 |
+
Referral Statistics
|
| 105 |
+
</CardTitle>
|
| 106 |
+
</CardHeader>
|
| 107 |
+
<CardContent className="space-y-3">
|
| 108 |
+
<div className="flex items-center p-4 bg-secondary/60 rounded-lg shadow-sm">
|
| 109 |
+
<Users className="mr-3 h-5 w-5 text-indigo-500" />
|
| 110 |
+
<div>
|
| 111 |
+
<p className="text-muted-foreground">Users Referred</p>
|
| 112 |
+
<p className="font-semibold text-lg text-foreground">{user.referredUsersCount.toLocaleString()}</p>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
<div className="flex items-center p-4 bg-secondary/60 rounded-lg shadow-sm">
|
| 116 |
+
<Coins className="mr-3 h-5 w-5 text-amber-500" />
|
| 117 |
+
<div>
|
| 118 |
+
<p className="text-muted-foreground">Total Coins from Referrals</p>
|
| 119 |
+
<p className="font-semibold text-lg text-foreground">{user.referralCoinsEarned.toLocaleString()} Coins</p>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</CardContent>
|
| 123 |
+
</Card>
|
| 124 |
+
</CardContent>
|
| 125 |
+
</Card>
|
| 126 |
+
|
| 127 |
+
<Separator />
|
| 128 |
+
|
| 129 |
+
<Card className="shadow-xl hover:shadow-2xl">
|
| 130 |
+
<CardHeader>
|
| 131 |
+
<CardTitle className="text-lg flex items-center">
|
| 132 |
+
<UserCog className="mr-2 h-5 w-5 text-primary" />
|
| 133 |
+
Update Profile Information
|
| 134 |
+
</CardTitle>
|
| 135 |
+
<CardDescription className="text-xs">Change your display name.</CardDescription>
|
| 136 |
+
</CardHeader>
|
| 137 |
+
<CardContent>
|
| 138 |
+
<UpdateProfileForm user={user} />
|
| 139 |
+
</CardContent>
|
| 140 |
+
</Card>
|
| 141 |
+
|
| 142 |
+
<Separator />
|
| 143 |
+
|
| 144 |
+
<Card className="shadow-xl hover:shadow-2xl">
|
| 145 |
+
<CardHeader>
|
| 146 |
+
<CardTitle className="text-lg flex items-center">
|
| 147 |
+
<KeyRound className="mr-2 h-5 w-5 text-primary" />
|
| 148 |
+
Change Password
|
| 149 |
+
</CardTitle>
|
| 150 |
+
<CardDescription className="text-xs">Update your account password.</CardDescription>
|
| 151 |
+
</CardHeader>
|
| 152 |
+
<CardContent>
|
| 153 |
+
<ChangePasswordForm />
|
| 154 |
+
</CardContent>
|
| 155 |
+
</Card>
|
| 156 |
+
|
| 157 |
+
<Separator />
|
| 158 |
+
|
| 159 |
+
<Card className="shadow-xl border-destructive hover:shadow-2xl hover:border-destructive/70">
|
| 160 |
+
<CardHeader>
|
| 161 |
+
<CardTitle className="text-lg flex items-center text-destructive">
|
| 162 |
+
<AlertTriangle className="mr-2 h-5 w-5" />
|
| 163 |
+
Danger Zone
|
| 164 |
+
</CardTitle>
|
| 165 |
+
<CardDescription className="text-xs">Manage irreversible account actions.</CardDescription>
|
| 166 |
+
</CardHeader>
|
| 167 |
+
<CardContent>
|
| 168 |
+
<div className="flex flex-col items-start space-y-4">
|
| 169 |
+
<p className="text-sm text-muted-foreground">
|
| 170 |
+
Deleting your account is permanent. All your data, including deployments,
|
| 171 |
+
will be removed and cannot be recovered.
|
| 172 |
+
</p>
|
| 173 |
+
<DeleteAccountDialog>
|
| 174 |
+
<Button variant="destructive">
|
| 175 |
+
<Trash2 className="mr-2 h-4 w-4" />
|
| 176 |
+
Delete My Account
|
| 177 |
+
</Button>
|
| 178 |
+
</DeleteAccountDialog>
|
| 179 |
+
</div>
|
| 180 |
+
</CardContent>
|
| 181 |
+
</Card>
|
| 182 |
+
|
| 183 |
+
</div>
|
| 184 |
+
);
|
| 185 |
+
}
|
src/app/favicon.ico
ADDED
|
|
Git LFS Details
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
body {
|
| 6 |
+
font-family: var(--font-inter), Arial, Helvetica, sans-serif;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
@layer base {
|
| 10 |
+
:root {
|
| 11 |
+
--background: 210 40% 98%; /* Lighter, cleaner background */
|
| 12 |
+
--foreground: 222 47% 11%; /* Dark blue for strong contrast */
|
| 13 |
+
|
| 14 |
+
--card: 0 0% 100%; /* Pure White */
|
| 15 |
+
--card-foreground: 222 47% 11%;
|
| 16 |
+
|
| 17 |
+
--popover: 0 0% 100%;
|
| 18 |
+
--popover-foreground: 222 47% 11%;
|
| 19 |
+
|
| 20 |
+
--primary: 220 70% 50%; /* Vibrant, professional Blue */
|
| 21 |
+
--primary-foreground: 0 0% 100%; /* White */
|
| 22 |
+
|
| 23 |
+
--secondary: 210 40% 93%; /* Softer gray for secondary elements */
|
| 24 |
+
--secondary-foreground: 222 30% 25%;
|
| 25 |
+
|
| 26 |
+
--muted: 210 40% 90%; /* Muted gray */
|
| 27 |
+
--muted-foreground: 210 30% 50%;
|
| 28 |
+
|
| 29 |
+
--accent: 260 80% 60%; /* Rich Purple for accents */
|
| 30 |
+
--accent-foreground: 0 0% 100%; /* White */
|
| 31 |
+
|
| 32 |
+
--destructive: 0 75% 55%; /* Clear Red for destructive actions */
|
| 33 |
+
--destructive-foreground: 0 0% 100%;
|
| 34 |
+
|
| 35 |
+
--border: 210 30% 85%;
|
| 36 |
+
--input: 210 30% 95%;
|
| 37 |
+
--ring: 220 70% 55%; /* Primary blue for rings */
|
| 38 |
+
|
| 39 |
+
--radius: 0.75rem; /* Slightly larger radius for a softer look */
|
| 40 |
+
|
| 41 |
+
/* Chart colors */
|
| 42 |
+
--chart-1: 220 70% 50%;
|
| 43 |
+
--chart-2: 160 60% 45%;
|
| 44 |
+
--chart-3: 30 80% 55%;
|
| 45 |
+
--chart-4: 280 65% 60%;
|
| 46 |
+
--chart-5: 340 75% 55%;
|
| 47 |
+
|
| 48 |
+
--sidebar-background: 220 30% 97%;
|
| 49 |
+
--sidebar-foreground: 220 18% 12%;
|
| 50 |
+
--sidebar-primary: 225 75% 60%;
|
| 51 |
+
--sidebar-primary-foreground: 0 0% 100%;
|
| 52 |
+
--sidebar-accent: 250 70% 65%;
|
| 53 |
+
--sidebar-accent-foreground: 0 0% 100%;
|
| 54 |
+
--sidebar-border: 220 25% 90%;
|
| 55 |
+
--sidebar-ring: 225 75% 65%;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.dark {
|
| 59 |
+
--background: 222 47% 8%; /* Very dark blue for background */
|
| 60 |
+
--foreground: 210 40% 96%; /* Light gray/off-white for text */
|
| 61 |
+
|
| 62 |
+
--card: 222 40% 12%; /* Darker card for depth */
|
| 63 |
+
--card-foreground: 210 40% 96%;
|
| 64 |
+
|
| 65 |
+
--popover: 222 40% 10%;
|
| 66 |
+
--popover-foreground: 210 40% 96%;
|
| 67 |
+
|
| 68 |
+
--primary: 220 70% 65%; /* Lighter, vibrant Blue for dark mode */
|
| 69 |
+
--primary-foreground: 222 47% 11%; /* Dark text on light primary */
|
| 70 |
+
|
| 71 |
+
--secondary: 222 30% 18%; /* Darker secondary */
|
| 72 |
+
--secondary-foreground: 210 30% 85%;
|
| 73 |
+
|
| 74 |
+
--muted: 222 25% 22%;
|
| 75 |
+
--muted-foreground: 210 25% 70%;
|
| 76 |
+
|
| 77 |
+
--accent: 260 70% 70%; /* Lighter Purple for dark mode */
|
| 78 |
+
--accent-foreground: 0 0% 98%;
|
| 79 |
+
|
| 80 |
+
--destructive: 0 65% 60%; /* Adjusted red for dark */
|
| 81 |
+
--destructive-foreground: 0 0% 98%;
|
| 82 |
+
|
| 83 |
+
--border: 222 25% 28%;
|
| 84 |
+
--input: 222 25% 20%;
|
| 85 |
+
--ring: 220 70% 65%;
|
| 86 |
+
|
| 87 |
+
/* Chart colors for dark theme */
|
| 88 |
+
--chart-1: 220 70% 60%;
|
| 89 |
+
--chart-2: 160 60% 55%;
|
| 90 |
+
--chart-3: 30 80% 65%;
|
| 91 |
+
--chart-4: 280 65% 70%;
|
| 92 |
+
--chart-5: 340 75% 65%;
|
| 93 |
+
|
| 94 |
+
--sidebar-background: 222 40% 12%;
|
| 95 |
+
--sidebar-foreground: 210 40% 96%;
|
| 96 |
+
--sidebar-primary: 220 70% 65%;
|
| 97 |
+
--sidebar-primary-foreground: 222 47% 11%;
|
| 98 |
+
--sidebar-accent: 260 70% 70%;
|
| 99 |
+
--sidebar-accent-foreground: 0 0% 98%;
|
| 100 |
+
--sidebar-border: 222 25% 28%;
|
| 101 |
+
--sidebar-ring: 220 70% 65%;
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
@layer base {
|
| 106 |
+
* {
|
| 107 |
+
@apply border-border;
|
| 108 |
+
}
|
| 109 |
+
body {
|
| 110 |
+
@apply bg-background text-foreground;
|
| 111 |
+
min-height: 100vh;
|
| 112 |
+
display: flex;
|
| 113 |
+
flex-direction: column;
|
| 114 |
+
}
|
| 115 |
+
main {
|
| 116 |
+
flex-grow: 1;
|
| 117 |
+
}
|
| 118 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import type { Metadata } from 'next';
|
| 3 |
+
import { Inter } from 'next/font/google';
|
| 4 |
+
import './globals.css';
|
| 5 |
+
import { Toaster } from "@/components/ui/toaster";
|
| 6 |
+
import { cn } from "@/lib/utils";
|
| 7 |
+
import { ThemeProvider } from '@/components/layout/ThemeProvider';
|
| 8 |
+
|
| 9 |
+
const inter = Inter({
|
| 10 |
+
variable: '--font-inter',
|
| 11 |
+
subsets: ['latin'],
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
export const metadata: Metadata = {
|
| 15 |
+
title: 'Anita Deploy - Deploy Anita-V4 with Ease',
|
| 16 |
+
description: 'Effortlessly deploy your Anita-V4 WhatsApp bot to your chosen platform.',
|
| 17 |
+
icons: {
|
| 18 |
+
icon: '/favicon.ico', // Placeholder, ensure you have a favicon
|
| 19 |
+
},
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
export default function RootLayout({
|
| 23 |
+
children,
|
| 24 |
+
}: Readonly<{
|
| 25 |
+
children: React.ReactNode;
|
| 26 |
+
}>) {
|
| 27 |
+
return (
|
| 28 |
+
<html lang="en" suppressHydrationWarning>
|
| 29 |
+
<body className={cn(inter.variable, "font-sans antialiased")}>
|
| 30 |
+
<ThemeProvider
|
| 31 |
+
defaultTheme="light" // Or "dark", "system" needs more handling in ThemeProvider for OS pref
|
| 32 |
+
storageKey="anita-deploy-theme"
|
| 33 |
+
>
|
| 34 |
+
{children}
|
| 35 |
+
<Toaster />
|
| 36 |
+
</ThemeProvider>
|
| 37 |
+
</body>
|
| 38 |
+
</html>
|
| 39 |
+
);
|
| 40 |
+
}
|
src/app/page.tsx
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import Link from 'next/link';
|
| 3 |
+
import { Button } from '@/components/ui/button';
|
| 4 |
+
import { ArrowRight, Zap, Cog, Terminal, ShieldCheck } from 'lucide-react';
|
| 5 |
+
import Image from 'next/image';
|
| 6 |
+
|
| 7 |
+
export default function LandingPage() {
|
| 8 |
+
return (
|
| 9 |
+
<div className="flex flex-col min-h-screen">
|
| 10 |
+
<header className="px-4 lg:px-6 h-16 flex items-center border-b sticky top-0 bg-background/95 backdrop-blur-md z-50">
|
| 11 |
+
<Link href="/" className="flex items-center justify-center">
|
| 12 |
+
<Zap className="h-6 w-6 text-primary" />
|
| 13 |
+
<span className="ml-2 text-xl font-semibold text-foreground">Anita Deploy</span>
|
| 14 |
+
</Link>
|
| 15 |
+
<nav className="ml-auto flex gap-4 sm:gap-6">
|
| 16 |
+
<Button variant="outline" asChild>
|
| 17 |
+
<Link href="/login">Login</Link>
|
| 18 |
+
</Button>
|
| 19 |
+
<Button asChild>
|
| 20 |
+
<Link href="/register">Sign Up <ArrowRight className="ml-2 h-4 w-4" /></Link>
|
| 21 |
+
</Button>
|
| 22 |
+
</nav>
|
| 23 |
+
</header>
|
| 24 |
+
|
| 25 |
+
<main className="flex-1">
|
| 26 |
+
<section className="w-full py-20 md:py-28 lg:py-36 bg-gradient-to-br from-background via-secondary/70 to-background">
|
| 27 |
+
<div className="container px-4 md:px-6">
|
| 28 |
+
<div className="grid gap-10 lg:grid-cols-[1fr_500px] lg:gap-16 xl:grid-cols-[1fr_600px]">
|
| 29 |
+
<div className="flex flex-col justify-center space-y-8">
|
| 30 |
+
<div className="space-y-4">
|
| 31 |
+
<h1 className="text-5xl font-extrabold tracking-tight sm:text-6xl xl:text-7xl/none bg-clip-text text-transparent bg-gradient-to-r from-primary to-accent">
|
| 32 |
+
Deploy Anita-V4 Bot in Minutes
|
| 33 |
+
</h1>
|
| 34 |
+
<p className="max-w-[600px] text-muted-foreground md:text-xl">
|
| 35 |
+
Anita Deploy simplifies the process of deploying your own Anita-V4 WhatsApp bot.
|
| 36 |
+
Configure your environment, connect to your deployment platform, and go live effortlessly.
|
| 37 |
+
</p>
|
| 38 |
+
</div>
|
| 39 |
+
<div className="flex flex-col gap-3 min-[400px]:flex-row">
|
| 40 |
+
<Button size="lg" asChild className="shadow-lg hover:shadow-primary/30 transform hover:scale-105 transition-all duration-300">
|
| 41 |
+
<Link href="/dashboard">
|
| 42 |
+
Get Started
|
| 43 |
+
<ArrowRight className="ml-2 h-5 w-5" />
|
| 44 |
+
</Link>
|
| 45 |
+
</Button>
|
| 46 |
+
<Button variant="secondary" size="lg" asChild className="shadow-md hover:shadow-lg transform hover:scale-105 transition-all duration-300">
|
| 47 |
+
<Link href="https://github.com/DavidCyrilTech/Anita-V4" target="_blank" rel="noopener noreferrer">
|
| 48 |
+
View on GitHub
|
| 49 |
+
</Link>
|
| 50 |
+
</Button>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
<Image
|
| 54 |
+
src="https://files.catbox.moe/jd0s4p.jpg"
|
| 55 |
+
alt="Queen Anita V4 Bot"
|
| 56 |
+
data-ai-hint="chatbot ai"
|
| 57 |
+
width={600}
|
| 58 |
+
height={400}
|
| 59 |
+
className="mx-auto aspect-video overflow-hidden rounded-xl object-contain sm:w-full lg:order-last shadow-2xl border-2 border-primary/20"
|
| 60 |
+
priority
|
| 61 |
+
/>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
</section>
|
| 65 |
+
|
| 66 |
+
<section className="w-full py-16 md:py-24 lg:py-32 bg-background">
|
| 67 |
+
<div className="container px-4 md:px-6">
|
| 68 |
+
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
| 69 |
+
<div className="space-y-2">
|
| 70 |
+
<div className="inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-semibold text-primary shadow-sm">Key Features</div>
|
| 71 |
+
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl text-foreground">Everything You Need to Deploy</h2>
|
| 72 |
+
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
|
| 73 |
+
From easy configuration to AI-powered debugging, Anita Deploy provides a seamless experience.
|
| 74 |
+
</p>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
<div className="mx-auto grid max-w-5xl items-start gap-10 sm:grid-cols-2 md:gap-12 lg:grid-cols-3 lg:gap-16 mt-16">
|
| 78 |
+
<div className="flex flex-col items-center text-center p-8 rounded-xl border bg-card shadow-lg hover:shadow-xl transition-all duration-300 ease-in-out hover:border-primary/50 transform hover:-translate-y-2">
|
| 79 |
+
<div className="p-4 rounded-full bg-primary/10 mb-6 shadow-md">
|
| 80 |
+
<Cog className="h-12 w-12 text-primary" />
|
| 81 |
+
</div>
|
| 82 |
+
<h3 className="text-2xl font-semibold mb-3 text-foreground">Easy Configuration</h3>
|
| 83 |
+
<p className="text-sm text-muted-foreground">
|
| 84 |
+
Simple form to set up all your Anita-V4 environment variables.
|
| 85 |
+
</p>
|
| 86 |
+
</div>
|
| 87 |
+
<div className="flex flex-col items-center text-center p-8 rounded-xl border bg-card shadow-lg hover:shadow-xl transition-all duration-300 ease-in-out hover:border-primary/50 transform hover:-translate-y-2">
|
| 88 |
+
<div className="p-4 rounded-full bg-primary/10 mb-6 shadow-md">
|
| 89 |
+
<ShieldCheck className="h-12 w-12 text-primary" />
|
| 90 |
+
</div>
|
| 91 |
+
<h3 className="text-2xl font-semibold mb-3 text-foreground">Secure Platform</h3>
|
| 92 |
+
<p className="text-sm text-muted-foreground">
|
| 93 |
+
One-click deployment to your chosen platform, with security in mind.
|
| 94 |
+
</p>
|
| 95 |
+
</div>
|
| 96 |
+
<div className="flex flex-col items-center text-center p-8 rounded-xl border bg-card shadow-lg hover:shadow-xl transition-all duration-300 ease-in-out hover:border-primary/50 transform hover:-translate-y-2">
|
| 97 |
+
<div className="p-4 rounded-full bg-primary/10 mb-6 shadow-md">
|
| 98 |
+
<Terminal className="h-12 w-12 text-primary" />
|
| 99 |
+
</div>
|
| 100 |
+
<h3 className="text-2xl font-semibold mb-3 text-foreground">AI-Powered Debugging</h3>
|
| 101 |
+
<p className="text-sm text-muted-foreground">
|
| 102 |
+
Analyze deployment logs with AI to quickly identify and fix issues.
|
| 103 |
+
</p>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</section>
|
| 108 |
+
</main>
|
| 109 |
+
|
| 110 |
+
<footer className="flex flex-col gap-2 sm:flex-row py-8 w-full shrink-0 items-center px-4 md:px-6 border-t bg-secondary/50">
|
| 111 |
+
<p className="text-xs text-muted-foreground">© {new Date().getFullYear()} Anita Deploy. All rights reserved.</p>
|
| 112 |
+
<nav className="sm:ml-auto flex gap-4 sm:gap-6">
|
| 113 |
+
<Link href="/privacy" className="text-xs hover:underline underline-offset-4 text-muted-foreground hover:text-primary">
|
| 114 |
+
Privacy Policy
|
| 115 |
+
</Link>
|
| 116 |
+
<Link href="/terms" className="text-xs hover:underline underline-offset-4 text-muted-foreground hover:text-primary">
|
| 117 |
+
Terms of Service
|
| 118 |
+
</Link>
|
| 119 |
+
</nav>
|
| 120 |
+
</footer>
|
| 121 |
+
</div>
|
| 122 |
+
);
|
| 123 |
+
}
|
src/apphosting.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Settings to manage and configure a Firebase App Hosting backend.
|
| 2 |
+
# https://firebase.google.com/docs/app-hosting/configure
|
| 3 |
+
|
| 4 |
+
runConfig:
|
| 5 |
+
# Increase this value if you'd like to automatically spin up
|
| 6 |
+
# more instances in response to increased traffic.
|
| 7 |
+
maxInstances: 1
|
src/components.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "default",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "tailwind.config.ts",
|
| 8 |
+
"css": "src/app/globals.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"aliases": {
|
| 14 |
+
"components": "@/components",
|
| 15 |
+
"utils": "@/lib/utils",
|
| 16 |
+
"ui": "@/components/ui",
|
| 17 |
+
"lib": "@/lib",
|
| 18 |
+
"hooks": "@/hooks"
|
| 19 |
+
},
|
| 20 |
+
"iconLibrary": "lucide"
|
| 21 |
+
}
|
src/components/admin/EditUserDialog.tsx
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
"use client";
|
| 3 |
+
|
| 4 |
+
import {
|
| 5 |
+
Dialog,
|
| 6 |
+
DialogContent,
|
| 7 |
+
DialogHeader,
|
| 8 |
+
DialogTitle,
|
| 9 |
+
DialogDescription,
|
| 10 |
+
DialogFooter,
|
| 11 |
+
DialogClose,
|
| 12 |
+
} from "@/components/ui/dialog";
|
| 13 |
+
import { Button } from "@/components/ui/button";
|
| 14 |
+
import { Input } from "@/components/ui/input";
|
| 15 |
+
import { Label } from "@/components/ui/label";
|
| 16 |
+
import {
|
| 17 |
+
Select,
|
| 18 |
+
SelectContent,
|
| 19 |
+
SelectItem,
|
| 20 |
+
SelectTrigger,
|
| 21 |
+
SelectValue,
|
| 22 |
+
} from "@/components/ui/select";
|
| 23 |
+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
| 24 |
+
import { useForm } from "react-hook-form";
|
| 25 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
| 26 |
+
import { z } from "zod"; // Added missing import
|
| 27 |
+
import { UpdateUserRoleAdminSchema, UpdateUserCoinsAdminSchema, type UserRole } from "@/lib/schemas";
|
| 28 |
+
import { updateUserRoleAdmin, updateUserCoinsAdmin } from "@/lib/actions/admin";
|
| 29 |
+
import { useToast } from "@/hooks/use-toast";
|
| 30 |
+
import { useState, useEffect } from "react"; // useEffect was imported but not used, keeping it for now in case.
|
| 31 |
+
import { Loader2 } from "lucide-react";
|
| 32 |
+
import type { User } from "@/lib/types";
|
| 33 |
+
|
| 34 |
+
interface EditUserDialogProps {
|
| 35 |
+
user: User | null;
|
| 36 |
+
isOpen: boolean;
|
| 37 |
+
onOpenChange: (isOpen: boolean) => void;
|
| 38 |
+
onSuccess: () => void; // Callback after successful update
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
type EditUserFormData = {
|
| 42 |
+
newRole: UserRole;
|
| 43 |
+
coinAdjustment: number;
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
export function EditUserDialog({ user, isOpen, onOpenChange, onSuccess }: EditUserDialogProps) {
|
| 47 |
+
const { toast } = useToast();
|
| 48 |
+
const [isSubmittingRole, setIsSubmittingRole] = useState(false);
|
| 49 |
+
const [isSubmittingCoins, setIsSubmittingCoins] = useState(false);
|
| 50 |
+
|
| 51 |
+
const form = useForm<EditUserFormData>({
|
| 52 |
+
resolver: zodResolver(
|
| 53 |
+
z.object({ // This is where 'z' was needed
|
| 54 |
+
newRole: UpdateUserRoleAdminSchema.shape.newRole,
|
| 55 |
+
coinAdjustment: UpdateUserCoinsAdminSchema.shape.coinAdjustment,
|
| 56 |
+
})
|
| 57 |
+
),
|
| 58 |
+
defaultValues: {
|
| 59 |
+
newRole: user?.role || "user",
|
| 60 |
+
coinAdjustment: 0,
|
| 61 |
+
},
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
// Update form defaults when user prop changes
|
| 65 |
+
// Using useEffect to reset form when user prop changes
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
if (user) {
|
| 68 |
+
form.reset({
|
| 69 |
+
newRole: user.role || "user",
|
| 70 |
+
coinAdjustment: 0,
|
| 71 |
+
});
|
| 72 |
+
}
|
| 73 |
+
}, [user, form.reset]);
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
if (!user) return null;
|
| 77 |
+
|
| 78 |
+
const handleRoleSubmit = async (values: { newRole: UserRole }) => {
|
| 79 |
+
setIsSubmittingRole(true);
|
| 80 |
+
try {
|
| 81 |
+
const result = await updateUserRoleAdmin({ userId: user._id, newRole: values.newRole });
|
| 82 |
+
toast({
|
| 83 |
+
title: result.success ? "Success" : "Error",
|
| 84 |
+
description: result.message,
|
| 85 |
+
variant: result.success ? "default" : "destructive",
|
| 86 |
+
});
|
| 87 |
+
if (result.success) {
|
| 88 |
+
onSuccess();
|
| 89 |
+
}
|
| 90 |
+
} catch (error) {
|
| 91 |
+
toast({ title: "Error", description: "An unexpected error occurred.", variant: "destructive" });
|
| 92 |
+
} finally {
|
| 93 |
+
setIsSubmittingRole(false);
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
const handleCoinsSubmit = async (values: { coinAdjustment: number }) => {
|
| 98 |
+
setIsSubmittingCoins(true);
|
| 99 |
+
try {
|
| 100 |
+
const result = await updateUserCoinsAdmin({ userId: user._id, coinAdjustment: values.coinAdjustment });
|
| 101 |
+
toast({
|
| 102 |
+
title: result.success ? "Success" : "Error",
|
| 103 |
+
description: result.message,
|
| 104 |
+
variant: result.success ? "default" : "destructive",
|
| 105 |
+
});
|
| 106 |
+
if (result.success) {
|
| 107 |
+
form.setValue("coinAdjustment", 0); // Reset coin adjustment field
|
| 108 |
+
onSuccess();
|
| 109 |
+
}
|
| 110 |
+
} catch (error) {
|
| 111 |
+
toast({ title: "Error", description: "An unexpected error occurred.", variant: "destructive" });
|
| 112 |
+
} finally {
|
| 113 |
+
setIsSubmittingCoins(false);
|
| 114 |
+
}
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
return (
|
| 118 |
+
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
| 119 |
+
<DialogContent className="sm:max-w-[480px]">
|
| 120 |
+
<DialogHeader>
|
| 121 |
+
<DialogTitle>Edit User: {user.name}</DialogTitle>
|
| 122 |
+
<DialogDescription>
|
| 123 |
+
Modify user role and coin balance. Current email: {user.email} (cannot be changed).
|
| 124 |
+
</DialogDescription>
|
| 125 |
+
</DialogHeader>
|
| 126 |
+
|
| 127 |
+
<Form {...form}>
|
| 128 |
+
<div className="space-y-6 py-4">
|
| 129 |
+
{/* Role Management Form */}
|
| 130 |
+
<form onSubmit={form.handleSubmit(data => handleRoleSubmit({ newRole: data.newRole }))} className="space-y-4 p-4 border rounded-md">
|
| 131 |
+
<FormField
|
| 132 |
+
control={form.control}
|
| 133 |
+
name="newRole"
|
| 134 |
+
render={({ field }) => (
|
| 135 |
+
<FormItem>
|
| 136 |
+
<FormLabel>User Role</FormLabel>
|
| 137 |
+
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
| 138 |
+
<FormControl>
|
| 139 |
+
<SelectTrigger>
|
| 140 |
+
<SelectValue placeholder="Select a role" />
|
| 141 |
+
</SelectTrigger>
|
| 142 |
+
</FormControl>
|
| 143 |
+
<SelectContent>
|
| 144 |
+
<SelectItem value="user">User</SelectItem>
|
| 145 |
+
<SelectItem value="admin">Admin</SelectItem>
|
| 146 |
+
</SelectContent>
|
| 147 |
+
</Select>
|
| 148 |
+
<FormMessage />
|
| 149 |
+
</FormItem>
|
| 150 |
+
)}
|
| 151 |
+
/>
|
| 152 |
+
<Button type="submit" disabled={isSubmittingRole || user.role === form.getValues("newRole")}>
|
| 153 |
+
{isSubmittingRole && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
| 154 |
+
Update Role
|
| 155 |
+
</Button>
|
| 156 |
+
</form>
|
| 157 |
+
|
| 158 |
+
{/* Coin Management Form */}
|
| 159 |
+
<form onSubmit={form.handleSubmit(data => handleCoinsSubmit({ coinAdjustment: data.coinAdjustment }))} className="space-y-4 p-4 border rounded-md">
|
| 160 |
+
<FormField
|
| 161 |
+
control={form.control}
|
| 162 |
+
name="coinAdjustment"
|
| 163 |
+
render={({ field }) => (
|
| 164 |
+
<FormItem>
|
| 165 |
+
<FormLabel>Adjust Coins (Current: {user.coins.toLocaleString()})</FormLabel>
|
| 166 |
+
<FormControl>
|
| 167 |
+
<Input
|
| 168 |
+
type="number"
|
| 169 |
+
placeholder="e.g., 50 or -20"
|
| 170 |
+
{...field}
|
| 171 |
+
onChange={e => field.onChange(parseInt(e.target.value,10) || 0)}
|
| 172 |
+
/>
|
| 173 |
+
</FormControl>
|
| 174 |
+
<FormMessage />
|
| 175 |
+
</FormItem>
|
| 176 |
+
)}
|
| 177 |
+
/>
|
| 178 |
+
<Button type="submit" disabled={isSubmittingCoins || form.getValues("coinAdjustment") === 0}>
|
| 179 |
+
{isSubmittingCoins && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
| 180 |
+
Adjust Coins
|
| 181 |
+
</Button>
|
| 182 |
+
</form>
|
| 183 |
+
</div>
|
| 184 |
+
</Form>
|
| 185 |
+
|
| 186 |
+
<DialogFooter>
|
| 187 |
+
<DialogClose asChild>
|
| 188 |
+
<Button type="button" variant="outline">
|
| 189 |
+
Close
|
| 190 |
+
</Button>
|
| 191 |
+
</DialogClose>
|
| 192 |
+
</DialogFooter>
|
| 193 |
+
</DialogContent>
|
| 194 |
+
</Dialog>
|
| 195 |
+
);
|
| 196 |
+
}
|
src/components/admin/StatCard.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 3 |
+
import type { LucideIcon } from "lucide-react";
|
| 4 |
+
|
| 5 |
+
interface StatCardProps {
|
| 6 |
+
title: string;
|
| 7 |
+
value: string | number;
|
| 8 |
+
icon: LucideIcon;
|
| 9 |
+
description?: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function StatCard({ title, value, icon: Icon, description }: StatCardProps) {
|
| 13 |
+
return (
|
| 14 |
+
<Card className="shadow-md hover:shadow-lg transition-shadow">
|
| 15 |
+
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
| 16 |
+
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
| 17 |
+
<Icon className="h-5 w-5 text-primary" />
|
| 18 |
+
</CardHeader>
|
| 19 |
+
<CardContent>
|
| 20 |
+
<div className="text-3xl font-bold text-foreground">{value}</div>
|
| 21 |
+
{description && <p className="text-xs text-muted-foreground pt-1">{description}</p>}
|
| 22 |
+
</CardContent>
|
| 23 |
+
</Card>
|
| 24 |
+
);
|
| 25 |
+
}
|
src/components/auth/AdminLoginForm.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
"use client";
|
| 3 |
+
|
| 4 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
| 5 |
+
import { useForm } from "react-hook-form";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import {
|
| 8 |
+
Form,
|
| 9 |
+
FormControl,
|
| 10 |
+
FormField,
|
| 11 |
+
FormItem,
|
| 12 |
+
FormLabel,
|
| 13 |
+
FormMessage,
|
| 14 |
+
} from "@/components/ui/form";
|
| 15 |
+
import { Input } from "@/components/ui/input";
|
| 16 |
+
import { AdminLoginSchema, type AdminLoginInput } from "@/lib/schemas";
|
| 17 |
+
import { adminLoginUser } from "@/lib/actions/auth";
|
| 18 |
+
import { useToast } from "@/hooks/use-toast";
|
| 19 |
+
import { useState } from "react";
|
| 20 |
+
import { Loader2, ShieldCheck } from "lucide-react";
|
| 21 |
+
// No longer need useRouter for post-login navigation if server action redirects
|
| 22 |
+
|
| 23 |
+
export function AdminLoginForm() {
|
| 24 |
+
const { toast } = useToast();
|
| 25 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 26 |
+
|
| 27 |
+
const form = useForm<AdminLoginInput>({
|
| 28 |
+
resolver: zodResolver(AdminLoginSchema),
|
| 29 |
+
defaultValues: {
|
| 30 |
+
email: "",
|
| 31 |
+
password: "",
|
| 32 |
+
},
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
async function onSubmit(values: AdminLoginInput) {
|
| 36 |
+
setIsLoading(true);
|
| 37 |
+
try {
|
| 38 |
+
const result = await adminLoginUser(values); // This action will now redirect on success
|
| 39 |
+
|
| 40 |
+
// If the action returns (i.e., did not redirect), it means there was an error.
|
| 41 |
+
if (result && result.success === false) {
|
| 42 |
+
toast({
|
| 43 |
+
title: "Admin Login Failed",
|
| 44 |
+
description: result.message,
|
| 45 |
+
variant: "destructive",
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
// Successful login will result in a redirect handled by Next.js.
|
| 49 |
+
// The "Admin Login Successful" toast is removed as the page will change.
|
| 50 |
+
|
| 51 |
+
} catch (error: any) {
|
| 52 |
+
// Server actions that redirect throw a special error that Next.js catches.
|
| 53 |
+
if (error.message?.includes('NEXT_REDIRECT')) {
|
| 54 |
+
// This is an expected error during redirect.
|
| 55 |
+
} else {
|
| 56 |
+
toast({
|
| 57 |
+
title: "Error",
|
| 58 |
+
description: error.message || "An unexpected error occurred. Please try again.",
|
| 59 |
+
variant: "destructive",
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
} finally {
|
| 63 |
+
setIsLoading(false);
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
<Form {...form}>
|
| 69 |
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
| 70 |
+
<FormField
|
| 71 |
+
control={form.control}
|
| 72 |
+
name="email"
|
| 73 |
+
render={({ field }) => (
|
| 74 |
+
<FormItem>
|
| 75 |
+
<FormLabel>Admin Email</FormLabel>
|
| 76 |
+
<FormControl>
|
| 77 |
+
<Input placeholder="admin@example.com" {...field} type="email" />
|
| 78 |
+
</FormControl>
|
| 79 |
+
<FormMessage />
|
| 80 |
+
</FormItem>
|
| 81 |
+
)}
|
| 82 |
+
/>
|
| 83 |
+
<FormField
|
| 84 |
+
control={form.control}
|
| 85 |
+
name="password"
|
| 86 |
+
render={({ field }) => (
|
| 87 |
+
<FormItem>
|
| 88 |
+
<FormLabel>Admin Password</FormLabel>
|
| 89 |
+
<FormControl>
|
| 90 |
+
<Input type="password" placeholder="••••••••" {...field} />
|
| 91 |
+
</FormControl>
|
| 92 |
+
<FormMessage />
|
| 93 |
+
</FormItem>
|
| 94 |
+
)}
|
| 95 |
+
/>
|
| 96 |
+
<Button type="submit" className="w-full" disabled={isLoading}>
|
| 97 |
+
{isLoading ? (
|
| 98 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
| 99 |
+
) : (
|
| 100 |
+
<ShieldCheck className="mr-2 h-4 w-4" />
|
| 101 |
+
)}
|
| 102 |
+
Login as Admin
|
| 103 |
+
</Button>
|
| 104 |
+
</form>
|
| 105 |
+
</Form>
|
| 106 |
+
);
|
| 107 |
+
}
|
src/components/auth/LoginForm.tsx
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
"use client";
|
| 3 |
+
|
| 4 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
| 5 |
+
import { useForm } from "react-hook-form";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import {
|
| 8 |
+
Form,
|
| 9 |
+
FormControl,
|
| 10 |
+
FormField,
|
| 11 |
+
FormItem,
|
| 12 |
+
FormLabel,
|
| 13 |
+
FormMessage,
|
| 14 |
+
} from "@/components/ui/form";
|
| 15 |
+
import { Input } from "@/components/ui/input";
|
| 16 |
+
import { LoginSchema, type LoginInput } from "@/lib/schemas";
|
| 17 |
+
import { loginUser } from "@/lib/actions/auth";
|
| 18 |
+
import { useToast } from "@/hooks/use-toast";
|
| 19 |
+
import { useState } from "react";
|
| 20 |
+
import { Loader2 } from "lucide-react";
|
| 21 |
+
// No longer need useRouter for post-login navigation if server action redirects
|
| 22 |
+
|
| 23 |
+
export function LoginForm() {
|
| 24 |
+
const { toast } = useToast();
|
| 25 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 26 |
+
|
| 27 |
+
const form = useForm<LoginInput>({
|
| 28 |
+
resolver: zodResolver(LoginSchema),
|
| 29 |
+
defaultValues: {
|
| 30 |
+
email: "",
|
| 31 |
+
password: "",
|
| 32 |
+
},
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
async function onSubmit(values: LoginInput) {
|
| 36 |
+
setIsLoading(true);
|
| 37 |
+
try {
|
| 38 |
+
const result = await loginUser(values); // This action will now redirect on success
|
| 39 |
+
|
| 40 |
+
// If the action returns (i.e., did not redirect), it means there was an error.
|
| 41 |
+
if (result && result.success === false) {
|
| 42 |
+
toast({
|
| 43 |
+
title: "Login Failed",
|
| 44 |
+
description: result.message,
|
| 45 |
+
variant: "destructive",
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
// Successful login will result in a redirect handled by Next.js,
|
| 49 |
+
// so client-side navigation or refresh is no longer needed here.
|
| 50 |
+
// The "Login Successful" toast is removed as the page will change.
|
| 51 |
+
|
| 52 |
+
} catch (error: any) {
|
| 53 |
+
// Server actions that redirect throw a special error that Next.js catches.
|
| 54 |
+
// We should not show this as a user-facing toast.
|
| 55 |
+
if (error.message?.includes('NEXT_REDIRECT')) {
|
| 56 |
+
// This is an expected error during redirect, let Next.js handle it.
|
| 57 |
+
// console.log("Caught NEXT_REDIRECT, letting Next.js handle it.");
|
| 58 |
+
} else {
|
| 59 |
+
toast({
|
| 60 |
+
title: "Error",
|
| 61 |
+
description: error.message || "An unexpected error occurred. Please try again.",
|
| 62 |
+
variant: "destructive",
|
| 63 |
+
});
|
| 64 |
+
}
|
| 65 |
+
} finally {
|
| 66 |
+
setIsLoading(false);
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<Form {...form}>
|
| 72 |
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
| 73 |
+
<FormField
|
| 74 |
+
control={form.control}
|
| 75 |
+
name="email"
|
| 76 |
+
render={({ field }) => (
|
| 77 |
+
<FormItem>
|
| 78 |
+
<FormLabel>Email</FormLabel>
|
| 79 |
+
<FormControl>
|
| 80 |
+
<Input placeholder="you@example.com" {...field} type="email" />
|
| 81 |
+
</FormControl>
|
| 82 |
+
<FormMessage />
|
| 83 |
+
</FormItem>
|
| 84 |
+
)}
|
| 85 |
+
/>
|
| 86 |
+
<FormField
|
| 87 |
+
control={form.control}
|
| 88 |
+
name="password"
|
| 89 |
+
render={({ field }) => (
|
| 90 |
+
<FormItem>
|
| 91 |
+
<FormLabel>Password</FormLabel>
|
| 92 |
+
<FormControl>
|
| 93 |
+
<Input type="password" placeholder="••••••••" {...field} />
|
| 94 |
+
</FormControl>
|
| 95 |
+
<FormMessage />
|
| 96 |
+
</FormItem>
|
| 97 |
+
)}
|
| 98 |
+
/>
|
| 99 |
+
<Button type="submit" className="w-full" disabled={isLoading}>
|
| 100 |
+
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
| 101 |
+
Login
|
| 102 |
+
</Button>
|
| 103 |
+
</form>
|
| 104 |
+
</Form>
|
| 105 |
+
);
|
| 106 |
+
}
|
src/components/auth/RegisterForm.tsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
"use client";
|
| 3 |
+
|
| 4 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
| 5 |
+
import { useForm } from "react-hook-form";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import {
|
| 8 |
+
Form,
|
| 9 |
+
FormControl,
|
| 10 |
+
FormField,
|
| 11 |
+
FormItem,
|
| 12 |
+
FormLabel,
|
| 13 |
+
FormMessage,
|
| 14 |
+
FormDescription,
|
| 15 |
+
} from "@/components/ui/form";
|
| 16 |
+
import { Input } from "@/components/ui/input";
|
| 17 |
+
import { RegisterSchema, type RegisterInput } from "@/lib/schemas";
|
| 18 |
+
import { registerUser } from "@/lib/actions/auth";
|
| 19 |
+
import { useToast } from "@/hooks/use-toast";
|
| 20 |
+
import { useRouter } from "next/navigation";
|
| 21 |
+
import { useState } from "react";
|
| 22 |
+
import { Loader2 } from "lucide-react";
|
| 23 |
+
|
| 24 |
+
export function RegisterForm() {
|
| 25 |
+
const { toast } = useToast();
|
| 26 |
+
const router = useRouter();
|
| 27 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 28 |
+
|
| 29 |
+
const form = useForm<RegisterInput>({
|
| 30 |
+
resolver: zodResolver(RegisterSchema),
|
| 31 |
+
defaultValues: {
|
| 32 |
+
name: "",
|
| 33 |
+
email: "",
|
| 34 |
+
password: "",
|
| 35 |
+
confirmPassword: "",
|
| 36 |
+
referralCode: "",
|
| 37 |
+
},
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
async function onSubmit(values: RegisterInput) {
|
| 41 |
+
setIsLoading(true);
|
| 42 |
+
try {
|
| 43 |
+
const result = await registerUser(values);
|
| 44 |
+
if (result.success) {
|
| 45 |
+
toast({
|
| 46 |
+
title: "Registration Complete!",
|
| 47 |
+
description: result.message,
|
| 48 |
+
});
|
| 49 |
+
router.push('/login');
|
| 50 |
+
} else {
|
| 51 |
+
toast({
|
| 52 |
+
title: "Registration Failed",
|
| 53 |
+
description: result.message,
|
| 54 |
+
variant: "destructive",
|
| 55 |
+
});
|
| 56 |
+
}
|
| 57 |
+
} catch (error) {
|
| 58 |
+
toast({
|
| 59 |
+
title: "Error",
|
| 60 |
+
description: "An unexpected error occurred. Please try again.",
|
| 61 |
+
variant: "destructive",
|
| 62 |
+
});
|
| 63 |
+
} finally {
|
| 64 |
+
setIsLoading(false);
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
return (
|
| 69 |
+
<Form {...form}>
|
| 70 |
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
| 71 |
+
<FormField
|
| 72 |
+
control={form.control}
|
| 73 |
+
name="name"
|
| 74 |
+
render={({ field }) => (
|
| 75 |
+
<FormItem>
|
| 76 |
+
<FormLabel>Full Name</FormLabel>
|
| 77 |
+
<FormControl>
|
| 78 |
+
<Input placeholder="Your Name" {...field} />
|
| 79 |
+
</FormControl>
|
| 80 |
+
<FormMessage />
|
| 81 |
+
</FormItem>
|
| 82 |
+
)}
|
| 83 |
+
/>
|
| 84 |
+
<FormField
|
| 85 |
+
control={form.control}
|
| 86 |
+
name="email"
|
| 87 |
+
render={({ field }) => (
|
| 88 |
+
<FormItem>
|
| 89 |
+
<FormLabel>Email</FormLabel>
|
| 90 |
+
<FormControl>
|
| 91 |
+
<Input placeholder="you@example.com" {...field} type="email" />
|
| 92 |
+
</FormControl>
|
| 93 |
+
<FormMessage />
|
| 94 |
+
</FormItem>
|
| 95 |
+
)}
|
| 96 |
+
/>
|
| 97 |
+
<FormField
|
| 98 |
+
control={form.control}
|
| 99 |
+
name="password"
|
| 100 |
+
render={({ field }) => (
|
| 101 |
+
<FormItem>
|
| 102 |
+
<FormLabel>Password</FormLabel>
|
| 103 |
+
<FormControl>
|
| 104 |
+
<Input type="password" placeholder="••••••••" {...field} />
|
| 105 |
+
</FormControl>
|
| 106 |
+
<FormMessage />
|
| 107 |
+
</FormItem>
|
| 108 |
+
)}
|
| 109 |
+
/>
|
| 110 |
+
<FormField
|
| 111 |
+
control={form.control}
|
| 112 |
+
name="confirmPassword"
|
| 113 |
+
render={({ field }) => (
|
| 114 |
+
<FormItem>
|
| 115 |
+
<FormLabel>Confirm Password</FormLabel>
|
| 116 |
+
<FormControl>
|
| 117 |
+
<Input type="password" placeholder="••••••••" {...field} />
|
| 118 |
+
</FormControl>
|
| 119 |
+
<FormMessage />
|
| 120 |
+
</FormItem>
|
| 121 |
+
)}
|
| 122 |
+
/>
|
| 123 |
+
<FormField
|
| 124 |
+
control={form.control}
|
| 125 |
+
name="referralCode"
|
| 126 |
+
render={({ field }) => (
|
| 127 |
+
<FormItem>
|
| 128 |
+
<FormLabel>Referral Code (Optional)</FormLabel>
|
| 129 |
+
<FormControl>
|
| 130 |
+
<Input placeholder="Enter referral code" {...field} />
|
| 131 |
+
</FormControl>
|
| 132 |
+
<FormDescription>If you were referred by someone, enter their code here.</FormDescription>
|
| 133 |
+
<FormMessage />
|
| 134 |
+
</FormItem>
|
| 135 |
+
)}
|
| 136 |
+
/>
|
| 137 |
+
<Button type="submit" className="w-full" disabled={isLoading}>
|
| 138 |
+
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
| 139 |
+
Create Account
|
| 140 |
+
</Button>
|
| 141 |
+
</form>
|
| 142 |
+
</Form>
|
| 143 |
+
);
|
| 144 |
+
}
|
src/components/billing/CoinPurchaseForm.tsx
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
"use client";
|
| 3 |
+
|
| 4 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
| 5 |
+
import { useForm, Controller } from "react-hook-form";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import {
|
| 8 |
+
Form,
|
| 9 |
+
FormControl,
|
| 10 |
+
FormDescription,
|
| 11 |
+
FormField,
|
| 12 |
+
FormItem,
|
| 13 |
+
FormLabel,
|
| 14 |
+
FormMessage,
|
| 15 |
+
} from "@/components/ui/form";
|
| 16 |
+
import {
|
| 17 |
+
Select,
|
| 18 |
+
SelectContent,
|
| 19 |
+
SelectItem,
|
| 20 |
+
SelectTrigger,
|
| 21 |
+
SelectValue,
|
| 22 |
+
} from "@/components/ui/select";
|
| 23 |
+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
| 24 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
| 25 |
+
import { CoinPurchaseSchema, type CoinPurchaseInput, CoinPurchasePackageEnum, PaymentGatewayEnum, SupportedCurrencyEnum } from "@/lib/schemas";
|
| 26 |
+
import type { CoinPackageDetails, CurrencyInfo } from "@/lib/types";
|
| 27 |
+
import { useToast } from "@/hooks/use-toast";
|
| 28 |
+
import { useRouter } from "next/navigation";
|
| 29 |
+
import { useState, useEffect } from "react";
|
| 30 |
+
import { Loader2, Coins, CreditCard, ShoppingCart, Lock } from "lucide-react";
|
| 31 |
+
import { usePaystackPayment, PaystackButton, PaystackConsumer } from 'react-paystack';
|
| 32 |
+
import { useFlutterwave, closePaymentModal } from 'flutterwave-react-v3';
|
| 33 |
+
import { initiateCoinPurchase, verifyPaymentAndAwardCoins } from "@/lib/actions/billing";
|
| 34 |
+
import { getLoggedInUser, type LoggedInUser } from "@/lib/actions/auth";
|
| 35 |
+
|
| 36 |
+
const paystackPublicKey = process.env.NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY || '';
|
| 37 |
+
const flutterwavePublicKey = process.env.NEXT_PUBLIC_FLUTTERWAVE_PUBLIC_KEY || '';
|
| 38 |
+
|
| 39 |
+
const COIN_BASE_PRICE_NGN = 10; // 1 Coin = 10 NGN
|
| 40 |
+
|
| 41 |
+
const coinPackages: CoinPackageDetails[] = [
|
| 42 |
+
{ id: 'small_50', name: "Small Pack", coins: 50, priceNGN: 50 * COIN_BASE_PRICE_NGN, description: "Get started with 50 coins." },
|
| 43 |
+
{ id: 'medium_150', name: "Medium Pack", coins: 150, priceNGN: 150 * COIN_BASE_PRICE_NGN, description: "Most popular: 150 coins." },
|
| 44 |
+
{ id: 'large_300', name: "Large Pack", coins: 300, priceNGN: 300 * COIN_BASE_PRICE_NGN, description: "Best value: 300 coins." },
|
| 45 |
+
];
|
| 46 |
+
|
| 47 |
+
const currencyRatesList: CurrencyInfo[] = [
|
| 48 |
+
{ code: 'NGN', symbol: '₦', rate: 1, name: 'Nigerian Naira' },
|
| 49 |
+
{ code: 'USD', symbol: '$', rate: 0.00063, name: 'US Dollar' },
|
| 50 |
+
{ code: 'GBP', symbol: '£', rate: 0.00050, name: 'British Pound' },
|
| 51 |
+
{ code: 'EUR', symbol: '€', rate: 0.00058, name: 'Euro' },
|
| 52 |
+
{ code: 'GHS', symbol: 'GH₵', rate: 0.0094, name: 'Ghanaian Cedi' },
|
| 53 |
+
{ code: 'KES', symbol: 'KSh', rate: 0.093, name: 'Kenyan Shilling' },
|
| 54 |
+
{ code: 'ZAR', symbol: 'R', rate: 0.012, name: 'South African Rand' },
|
| 55 |
+
{ code: 'UGX', symbol: 'USh', rate: 2.5, name: 'Ugandan Shilling' },
|
| 56 |
+
{ code: 'TZS', symbol: 'TSh', rate: 1.6, name: 'Tanzanian Shilling' },
|
| 57 |
+
{ code: 'RWF', symbol: 'RF', rate: 0.82, name: 'Rwandan Franc' },
|
| 58 |
+
{ code: 'XOF', symbol: 'CFA', rate: 0.38, name: 'West African CFA franc' },
|
| 59 |
+
{ code: 'XAF', symbol: 'FCFA', rate: 0.38, name: 'Central African CFA franc' },
|
| 60 |
+
{ code: 'CAD', symbol: 'CA$', rate: 0.00086, name: 'Canadian Dollar' },
|
| 61 |
+
{ code: 'EGP', symbol: 'E£', rate: 0.030, name: 'Egyptian Pound' },
|
| 62 |
+
{ code: 'GNF', symbol: 'FG', rate: 5.4, name: 'Guinean Franc' },
|
| 63 |
+
{ code: 'MAD', symbol: 'MAD', rate: 0.0063, name: 'Moroccan Dirham' },
|
| 64 |
+
{ code: 'MWK', symbol: 'MK', rate: 1.1, name: 'Malawian Kwacha' },
|
| 65 |
+
{ code: 'SLL', symbol: 'Le', rate: 14.0, name: 'Sierra Leonean Leone (New)'},
|
| 66 |
+
{ code: 'STD', symbol: 'Db', rate: 14.0, name: 'São Tomé & Príncipe Dobra (New)' },
|
| 67 |
+
{ code: 'ZMW', symbol: 'ZK', rate: 0.017, name: 'Zambian Kwacha' },
|
| 68 |
+
{ code: 'CLP', symbol: 'CLP$', rate: 0.58, name: 'Chilean Peso' },
|
| 69 |
+
{ code: 'COP', symbol: 'COL$', rate: 2.5, name: 'Colombian Peso' },
|
| 70 |
+
];
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
export function CoinPurchaseForm() {
|
| 74 |
+
const { toast } = useToast();
|
| 75 |
+
const router = useRouter();
|
| 76 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 77 |
+
const [currentUser, setCurrentUser] = useState<LoggedInUser | null>(null);
|
| 78 |
+
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
getLoggedInUser().then(setCurrentUser);
|
| 81 |
+
}, []);
|
| 82 |
+
|
| 83 |
+
const form = useForm<CoinPurchaseInput>({
|
| 84 |
+
resolver: zodResolver(CoinPurchaseSchema),
|
| 85 |
+
defaultValues: {
|
| 86 |
+
package: "medium_150",
|
| 87 |
+
currency: "NGN",
|
| 88 |
+
paymentGateway: "paystack",
|
| 89 |
+
email: currentUser?.email || "",
|
| 90 |
+
name: currentUser?.name || "",
|
| 91 |
+
},
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
const selectedPackageId = form.watch("package");
|
| 95 |
+
const selectedCurrencyCode = form.watch("currency");
|
| 96 |
+
|
| 97 |
+
const selectedPkg = coinPackages.find(p => p.id === selectedPackageId) || coinPackages[0];
|
| 98 |
+
const selectedCurrInfo = currencyRatesList.find(c => c.code === selectedCurrencyCode) || currencyRatesList[0];
|
| 99 |
+
|
| 100 |
+
const priceInSelectedCurrency = parseFloat((selectedPkg.priceNGN * selectedCurrInfo.rate).toFixed(2));
|
| 101 |
+
|
| 102 |
+
useEffect(() => {
|
| 103 |
+
form.setValue("amountInSelectedCurrency", priceInSelectedCurrency);
|
| 104 |
+
form.setValue("amountInNGN", selectedPkg.priceNGN);
|
| 105 |
+
form.setValue("coinsToCredit", selectedPkg.coins);
|
| 106 |
+
if (currentUser) {
|
| 107 |
+
form.setValue("email", currentUser.email);
|
| 108 |
+
form.setValue("name", currentUser.name);
|
| 109 |
+
}
|
| 110 |
+
}, [selectedPkg, priceInSelectedCurrency, form, currentUser]);
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
const handlePaymentSuccess = async (response: any, gateway: 'paystack' | 'flutterwave') => {
|
| 114 |
+
console.log(`${gateway} success response:`, response);
|
| 115 |
+
toast({ title: `${gateway} Payment Submitted (Simulation)`, description: `Ref: ${response.reference || response.transaction_id}. Verifying...` });
|
| 116 |
+
setIsLoading(true);
|
| 117 |
+
// SIMULATE webhook verification for now
|
| 118 |
+
const verificationResult = await verifyPaymentAndAwardCoins(gateway, response.reference || response.transaction_id, response);
|
| 119 |
+
toast({
|
| 120 |
+
title: verificationResult.success ? "Purchase Complete!" : "Verification Issue",
|
| 121 |
+
description: verificationResult.message,
|
| 122 |
+
variant: verificationResult.success ? "default" : "destructive",
|
| 123 |
+
});
|
| 124 |
+
if (verificationResult.success) {
|
| 125 |
+
router.refresh();
|
| 126 |
+
}
|
| 127 |
+
setIsLoading(false);
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
const handlePaymentClose = (gateway: 'paystack' | 'flutterwave') => {
|
| 131 |
+
console.log(`${gateway} payment modal closed.`);
|
| 132 |
+
toast({ title: "Payment Cancelled", description: "The payment process was cancelled.", variant: "default" });
|
| 133 |
+
setIsLoading(false);
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
// Paystack Config
|
| 137 |
+
const paystackConfig = {
|
| 138 |
+
reference: new Date().getTime().toString(),
|
| 139 |
+
email: form.getValues("email"),
|
| 140 |
+
amount: priceInSelectedCurrency * 100, // Amount in kobo
|
| 141 |
+
currency: selectedCurrencyCode,
|
| 142 |
+
publicKey: paystackPublicKey,
|
| 143 |
+
metadata: {
|
| 144 |
+
userId: currentUser?._id,
|
| 145 |
+
packageName: selectedPkg.name,
|
| 146 |
+
coins: selectedPkg.coins,
|
| 147 |
+
custom_fields: [
|
| 148 |
+
{
|
| 149 |
+
display_name: "Package",
|
| 150 |
+
variable_name: "package",
|
| 151 |
+
value: selectedPkg.name
|
| 152 |
+
},
|
| 153 |
+
{
|
| 154 |
+
display_name: "Coins",
|
| 155 |
+
variable_name: "coins",
|
| 156 |
+
value: selectedPkg.coins
|
| 157 |
+
}
|
| 158 |
+
]
|
| 159 |
+
}
|
| 160 |
+
};
|
| 161 |
+
|
| 162 |
+
// Flutterwave Config
|
| 163 |
+
const flutterwaveConfig = {
|
| 164 |
+
public_key: flutterwavePublicKey,
|
| 165 |
+
tx_ref: new Date().getTime().toString(),
|
| 166 |
+
amount: priceInSelectedCurrency,
|
| 167 |
+
currency: selectedCurrencyCode,
|
| 168 |
+
payment_options: "card,mobilemoney,ussd",
|
| 169 |
+
customer: {
|
| 170 |
+
email: form.getValues("email"),
|
| 171 |
+
name: form.getValues("name") || "Anita Deploy User",
|
| 172 |
+
},
|
| 173 |
+
customizations: {
|
| 174 |
+
title: "Anita Deploy - Coin Purchase",
|
| 175 |
+
description: `Payment for ${selectedPkg.coins} coins`,
|
| 176 |
+
logo: "https://placehold.co/100x100.png?text=AD",
|
| 177 |
+
},
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
const initializePaystackPayment = usePaystackPayment(paystackConfig);
|
| 181 |
+
const handleFlutterwavePayment = useFlutterwave(flutterwaveConfig);
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
async function onSubmit(values: CoinPurchaseInput) {
|
| 185 |
+
setIsLoading(true);
|
| 186 |
+
|
| 187 |
+
if (!paystackPublicKey || !flutterwavePublicKey) {
|
| 188 |
+
toast({title: "Configuration Error", description: "Payment gateway keys are not set. Please contact support.", variant: "destructive"});
|
| 189 |
+
setIsLoading(false);
|
| 190 |
+
return;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
if (!currentUser) {
|
| 194 |
+
toast({title: "Error", description: "User not loaded. Please refresh.", variant: "destructive"});
|
| 195 |
+
setIsLoading(false);
|
| 196 |
+
return;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
const currentPaystackConfig = {
|
| 200 |
+
...paystackConfig,
|
| 201 |
+
reference: `anitad_${currentUser._id}_${new Date().getTime()}`,
|
| 202 |
+
email: values.email,
|
| 203 |
+
amount: values.amountInSelectedCurrency * 100,
|
| 204 |
+
currency: values.currency,
|
| 205 |
+
metadata: {
|
| 206 |
+
userId: currentUser._id,
|
| 207 |
+
packageName: selectedPkg.name,
|
| 208 |
+
coins: selectedPkg.coins,
|
| 209 |
+
transactionType: "coin_purchase",
|
| 210 |
+
custom_fields: [
|
| 211 |
+
{ display_name: "Package", variable_name: "package", value: selectedPkg.name },
|
| 212 |
+
{ display_name: "Coins", variable_name: "coins", value: selectedPkg.coins }
|
| 213 |
+
]
|
| 214 |
+
}
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
const currentFlutterwaveConfig = {
|
| 218 |
+
...flutterwaveConfig,
|
| 219 |
+
tx_ref: `anitad_${currentUser._id}_${new Date().getTime()}`,
|
| 220 |
+
amount: values.amountInSelectedCurrency,
|
| 221 |
+
currency: values.currency,
|
| 222 |
+
customer: {
|
| 223 |
+
email: values.email,
|
| 224 |
+
name: values.name || "Anita Deploy User",
|
| 225 |
+
},
|
| 226 |
+
meta: {
|
| 227 |
+
userId: currentUser._id,
|
| 228 |
+
packageName: selectedPkg.name,
|
| 229 |
+
coins: selectedPkg.coins,
|
| 230 |
+
transactionType: "coin_purchase"
|
| 231 |
+
}
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
const initResult = await initiateCoinPurchase({
|
| 235 |
+
...values,
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
if (!initResult.success || !initResult.transactionReference) {
|
| 239 |
+
toast({ title: "Initiation Failed", description: initResult.message, variant: "destructive" });
|
| 240 |
+
setIsLoading(false);
|
| 241 |
+
return;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
currentPaystackConfig.reference = initResult.transactionReference;
|
| 245 |
+
currentFlutterwaveConfig.tx_ref = initResult.transactionReference;
|
| 246 |
+
|
| 247 |
+
if (values.paymentGateway === "paystack") {
|
| 248 |
+
initializePaystackPayment({
|
| 249 |
+
onSuccess: (response) => handlePaymentSuccess(response, 'paystack'),
|
| 250 |
+
onClose: () => handlePaymentClose('paystack'),
|
| 251 |
+
config: currentPaystackConfig,
|
| 252 |
+
});
|
| 253 |
+
} else if (values.paymentGateway === "flutterwave") {
|
| 254 |
+
handleFlutterwavePayment({
|
| 255 |
+
callback: (response) => {
|
| 256 |
+
handlePaymentSuccess(response, 'flutterwave');
|
| 257 |
+
closePaymentModal();
|
| 258 |
+
},
|
| 259 |
+
onClose: () => handlePaymentClose('flutterwave'),
|
| 260 |
+
});
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
return (
|
| 266 |
+
<Form {...form}>
|
| 267 |
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
| 268 |
+
|
| 269 |
+
<FormField
|
| 270 |
+
control={form.control}
|
| 271 |
+
name="package"
|
| 272 |
+
render={({ field }) => (
|
| 273 |
+
<FormItem className="space-y-3">
|
| 274 |
+
<FormLabel className="text-lg font-semibold">1. Select Coin Package</FormLabel>
|
| 275 |
+
<FormControl>
|
| 276 |
+
<RadioGroup
|
| 277 |
+
onValueChange={field.onChange}
|
| 278 |
+
defaultValue={field.value}
|
| 279 |
+
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
| 280 |
+
>
|
| 281 |
+
{coinPackages.map((pkg) => (
|
| 282 |
+
<FormItem key={pkg.id} className="flex-1">
|
| 283 |
+
<FormControl>
|
| 284 |
+
<RadioGroupItem value={pkg.id} id={pkg.id} className="sr-only" />
|
| 285 |
+
</FormControl>
|
| 286 |
+
<Label
|
| 287 |
+
htmlFor={pkg.id}
|
| 288 |
+
className={`flex flex-col items-center justify-between rounded-lg border-2 bg-card p-4 hover:bg-accent hover:text-accent-foreground cursor-pointer transition-all
|
| 289 |
+
${field.value === pkg.id ? "border-primary ring-2 ring-primary shadow-lg" : "border-muted"}`}
|
| 290 |
+
>
|
| 291 |
+
<div className="flex items-center text-xl font-semibold mb-2">
|
| 292 |
+
<Coins className="mr-2 h-6 w-6 text-yellow-500" /> {pkg.coins} Coins
|
| 293 |
+
</div>
|
| 294 |
+
<p className="text-sm font-bold text-primary">{pkg.name}</p>
|
| 295 |
+
<p className="text-xs text-muted-foreground mt-1">{pkg.description}</p>
|
| 296 |
+
<p className="text-lg font-semibold mt-3 text-foreground">
|
| 297 |
+
{currencyRatesList.find(c => c.code === 'NGN')?.symbol}
|
| 298 |
+
{pkg.priceNGN.toLocaleString()}
|
| 299 |
+
</p>
|
| 300 |
+
</Label>
|
| 301 |
+
</FormItem>
|
| 302 |
+
))}
|
| 303 |
+
</RadioGroup>
|
| 304 |
+
</FormControl>
|
| 305 |
+
<FormMessage />
|
| 306 |
+
</FormItem>
|
| 307 |
+
)}
|
| 308 |
+
/>
|
| 309 |
+
|
| 310 |
+
<FormField
|
| 311 |
+
control={form.control}
|
| 312 |
+
name="currency"
|
| 313 |
+
render={({ field }) => (
|
| 314 |
+
<FormItem>
|
| 315 |
+
<FormLabel className="text-lg font-semibold">2. Select Currency</FormLabel>
|
| 316 |
+
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
| 317 |
+
<FormControl>
|
| 318 |
+
<SelectTrigger className="w-full md:w-[280px]">
|
| 319 |
+
<SelectValue placeholder="Select currency" />
|
| 320 |
+
</SelectTrigger>
|
| 321 |
+
</FormControl>
|
| 322 |
+
<SelectContent>
|
| 323 |
+
{currencyRatesList.map((currency) => (
|
| 324 |
+
<SelectItem key={currency.code} value={currency.code}>
|
| 325 |
+
{currency.name} ({currency.symbol})
|
| 326 |
+
</SelectItem>
|
| 327 |
+
))}
|
| 328 |
+
</SelectContent>
|
| 329 |
+
</Select>
|
| 330 |
+
<FormDescription>
|
| 331 |
+
The price will be converted to your selected currency.
|
| 332 |
+
</FormDescription>
|
| 333 |
+
<FormMessage />
|
| 334 |
+
</FormItem>
|
| 335 |
+
)}
|
| 336 |
+
/>
|
| 337 |
+
|
| 338 |
+
<Card className="bg-secondary/50 shadow-md">
|
| 339 |
+
<CardHeader>
|
| 340 |
+
<CardTitle className="text-xl">Order Summary</CardTitle>
|
| 341 |
+
</CardHeader>
|
| 342 |
+
<CardContent className="space-y-2">
|
| 343 |
+
<p className="text-lg">
|
| 344 |
+
<span className="font-medium text-muted-foreground">Package:</span> {selectedPkg.name} ({selectedPkg.coins} Coins)
|
| 345 |
+
</p>
|
| 346 |
+
<p className="text-2xl font-bold text-primary">
|
| 347 |
+
<span className="font-medium text-muted-foreground">Total:</span> {selectedCurrInfo.symbol}
|
| 348 |
+
{priceInSelectedCurrency.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
| 349 |
+
<span className="text-sm font-normal text-muted-foreground ml-1"> ({selectedCurrencyCode})</span>
|
| 350 |
+
</p>
|
| 351 |
+
{selectedCurrencyCode !== 'NGN' && (
|
| 352 |
+
<p className="text-sm text-muted-foreground">
|
| 353 |
+
(Approx. {currencyRatesList.find(c => c.code === 'NGN')?.symbol}
|
| 354 |
+
{selectedPkg.priceNGN.toLocaleString()} NGN)
|
| 355 |
+
</p>
|
| 356 |
+
)}
|
| 357 |
+
</CardContent>
|
| 358 |
+
</Card>
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
<FormField
|
| 362 |
+
control={form.control}
|
| 363 |
+
name="paymentGateway"
|
| 364 |
+
render={({ field }) => (
|
| 365 |
+
<FormItem className="space-y-3">
|
| 366 |
+
<FormLabel className="text-lg font-semibold">3. Select Payment Gateway</FormLabel>
|
| 367 |
+
<FormControl>
|
| 368 |
+
<RadioGroup
|
| 369 |
+
onValueChange={field.onChange}
|
| 370 |
+
defaultValue={field.value}
|
| 371 |
+
className="flex flex-col sm:flex-row gap-4"
|
| 372 |
+
>
|
| 373 |
+
<FormItem className="flex-1">
|
| 374 |
+
<FormControl>
|
| 375 |
+
<RadioGroupItem value="paystack" id="paystack" className="sr-only" />
|
| 376 |
+
</FormControl>
|
| 377 |
+
<Label htmlFor="paystack" className={`flex items-center justify-center rounded-lg border-2 p-4 hover:bg-accent hover:text-accent-foreground cursor-pointer ${field.value === "paystack" ? "border-primary ring-2 ring-primary" : "border-muted"}`}>
|
| 378 |
+
<img src="https://assets.paystack.com/assets/img/logos/paystack-logo-vector-deep-blue.svg" alt="Paystack" className="h-7" data-ai-hint="paystack logo"/>
|
| 379 |
+
</Label>
|
| 380 |
+
</FormItem>
|
| 381 |
+
<FormItem className="flex-1">
|
| 382 |
+
<FormControl>
|
| 383 |
+
<RadioGroupItem value="flutterwave" id="flutterwave" className="sr-only" />
|
| 384 |
+
</FormControl>
|
| 385 |
+
<Label htmlFor="flutterwave" className={`flex items-center justify-center rounded-lg border-2 p-4 hover:bg-accent hover:text-accent-foreground cursor-pointer ${field.value === "flutterwave" ? "border-primary ring-2 ring-primary" : "border-muted"}`}>
|
| 386 |
+
<img src="https://flutterwave.com/images/logo-colored.svg" alt="Flutterwave" className="h-7" data-ai-hint="flutterwave logo"/>
|
| 387 |
+
</Label>
|
| 388 |
+
</FormItem>
|
| 389 |
+
</RadioGroup>
|
| 390 |
+
</FormControl>
|
| 391 |
+
<FormMessage />
|
| 392 |
+
</FormItem>
|
| 393 |
+
)}
|
| 394 |
+
/>
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
<Button type="submit" size="lg" className="w-full text-base py-6" disabled={isLoading}>
|
| 398 |
+
{isLoading ? (
|
| 399 |
+
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
| 400 |
+
) : (
|
| 401 |
+
<ShoppingCart className="mr-2 h-5 w-5" />
|
| 402 |
+
)}
|
| 403 |
+
Proceed to Payment
|
| 404 |
+
</Button>
|
| 405 |
+
<p className="text-xs text-muted-foreground text-center flex items-center justify-center">
|
| 406 |
+
<Lock className="h-3 w-3 mr-1.5"/> Secure payment processing by Paystack & Flutterwave.
|
| 407 |
+
</p>
|
| 408 |
+
</form>
|
| 409 |
+
</Form>
|
| 410 |
+
);
|
| 411 |
+
}
|
| 412 |
+
|
src/components/deployment/AiLogAnalyzer.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 6 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 7 |
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
| 8 |
+
import { analyzeDeploymentLogs } from "@/lib/actions/deployment"; // Server Action
|
| 9 |
+
import { Loader2, Sparkles, Lightbulb } from "lucide-react";
|
| 10 |
+
import { ScrollArea } from "../ui/scroll-area";
|
| 11 |
+
|
| 12 |
+
interface AiLogAnalyzerProps {
|
| 13 |
+
initialLogs?: string;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function AiLogAnalyzer({ initialLogs = "" }: AiLogAnalyzerProps) {
|
| 17 |
+
const [logsToAnalyze, setLogsToAnalyze] = useState(initialLogs);
|
| 18 |
+
const [analysisResult, setAnalysisResult] = useState<string | null>(null);
|
| 19 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 20 |
+
const [error, setError] = useState<string | null>(null);
|
| 21 |
+
|
| 22 |
+
const handleAnalyze = async () => {
|
| 23 |
+
if (!logsToAnalyze.trim()) {
|
| 24 |
+
setError("Please paste some logs to analyze.");
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
setIsLoading(true);
|
| 28 |
+
setError(null);
|
| 29 |
+
setAnalysisResult(null);
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
const result = await analyzeDeploymentLogs(logsToAnalyze);
|
| 33 |
+
if (result.success && result.analysis) {
|
| 34 |
+
setAnalysisResult(result.analysis);
|
| 35 |
+
} else {
|
| 36 |
+
setError(result.error || "Failed to get analysis.");
|
| 37 |
+
}
|
| 38 |
+
} catch (e) {
|
| 39 |
+
setError("An unexpected error occurred during analysis.");
|
| 40 |
+
console.error(e);
|
| 41 |
+
} finally {
|
| 42 |
+
setIsLoading(false);
|
| 43 |
+
}
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<Card className="shadow-md">
|
| 48 |
+
<CardHeader>
|
| 49 |
+
<CardTitle className="flex items-center">
|
| 50 |
+
<Sparkles className="mr-2 h-5 w-5 text-accent" />
|
| 51 |
+
AI Log Analyzer
|
| 52 |
+
</CardTitle>
|
| 53 |
+
<CardDescription>
|
| 54 |
+
Paste your deployment logs below and let AI help you find issues and suggest fixes.
|
| 55 |
+
</CardDescription>
|
| 56 |
+
</CardHeader>
|
| 57 |
+
<CardContent className="space-y-4">
|
| 58 |
+
<Textarea
|
| 59 |
+
placeholder="Paste your deployment logs here..."
|
| 60 |
+
value={logsToAnalyze}
|
| 61 |
+
onChange={(e) => setLogsToAnalyze(e.target.value)}
|
| 62 |
+
rows={10}
|
| 63 |
+
className="font-mono text-xs"
|
| 64 |
+
/>
|
| 65 |
+
<Button onClick={handleAnalyze} disabled={isLoading || !logsToAnalyze.trim()} className="w-full sm:w-auto">
|
| 66 |
+
{isLoading ? (
|
| 67 |
+
<>
|
| 68 |
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Analyzing...
|
| 69 |
+
</>
|
| 70 |
+
) : (
|
| 71 |
+
<>
|
| 72 |
+
<Lightbulb className="mr-2 h-4 w-4" /> Analyze Logs
|
| 73 |
+
</>
|
| 74 |
+
)}
|
| 75 |
+
</Button>
|
| 76 |
+
|
| 77 |
+
{error && (
|
| 78 |
+
<Alert variant="destructive">
|
| 79 |
+
<AlertTitle>Error</AlertTitle>
|
| 80 |
+
<AlertDescription>{error}</AlertDescription>
|
| 81 |
+
</Alert>
|
| 82 |
+
)}
|
| 83 |
+
|
| 84 |
+
{analysisResult && (
|
| 85 |
+
<Card className="bg-secondary/50">
|
| 86 |
+
<CardHeader>
|
| 87 |
+
<CardTitle className="flex items-center text-lg">
|
| 88 |
+
<Lightbulb className="mr-2 h-5 w-5 text-primary" />
|
| 89 |
+
Analysis Result
|
| 90 |
+
</CardTitle>
|
| 91 |
+
</CardHeader>
|
| 92 |
+
<CardContent>
|
| 93 |
+
<ScrollArea className="h-[300px] w-full rounded-md p-1">
|
| 94 |
+
<pre className="whitespace-pre-wrap text-sm text-foreground/90">{analysisResult}</pre>
|
| 95 |
+
</ScrollArea>
|
| 96 |
+
</CardContent>
|
| 97 |
+
</Card>
|
| 98 |
+
)}
|
| 99 |
+
</CardContent>
|
| 100 |
+
</Card>
|
| 101 |
+
);
|
| 102 |
+
}
|
src/components/deployment/DeploymentCard.tsx
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from 'next/link';
|
| 2 |
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
| 3 |
+
import { Button } from '@/components/ui/button';
|
| 4 |
+
import { Badge } from '@/components/ui/badge';
|
| 5 |
+
import type { Deployment, DeploymentStatus } from '@/lib/types';
|
| 6 |
+
import { ExternalLink, Zap, AlertTriangle, CheckCircle2, Hourglass, PowerOff, Layers } from 'lucide-react';
|
| 7 |
+
import { cn } from '@/lib/utils';
|
| 8 |
+
|
| 9 |
+
interface DeploymentCardProps {
|
| 10 |
+
deployment: Deployment;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function getStatusBadgeVariant(status: DeploymentStatus) {
|
| 14 |
+
switch (status) {
|
| 15 |
+
case 'succeeded':
|
| 16 |
+
return 'default';
|
| 17 |
+
case 'deploying':
|
| 18 |
+
return 'secondary';
|
| 19 |
+
case 'pending':
|
| 20 |
+
return 'outline';
|
| 21 |
+
case 'failed':
|
| 22 |
+
return 'destructive';
|
| 23 |
+
case 'stopped':
|
| 24 |
+
return 'outline';
|
| 25 |
+
default:
|
| 26 |
+
return 'outline';
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function getStatusIcon(status: DeploymentStatus) {
|
| 31 |
+
switch (status) {
|
| 32 |
+
case 'succeeded':
|
| 33 |
+
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
|
| 34 |
+
case 'deploying':
|
| 35 |
+
return <Hourglass className="h-5 w-5 text-blue-500 animate-spin" />;
|
| 36 |
+
case 'pending':
|
| 37 |
+
return <Hourglass className="h-5 w-5 text-yellow-500" />;
|
| 38 |
+
case 'failed':
|
| 39 |
+
return <AlertTriangle className="h-5 w-5 text-red-500" />;
|
| 40 |
+
case 'stopped':
|
| 41 |
+
return <PowerOff className="h-5 w-5 text-gray-500" />;
|
| 42 |
+
default:
|
| 43 |
+
return <Zap className="h-5 w-5 text-muted-foreground" />;
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
export function DeploymentCard({ deployment }: DeploymentCardProps) {
|
| 49 |
+
return (
|
| 50 |
+
<Card className="flex flex-col h-full shadow-md hover:shadow-xl transition-shadow duration-300 ease-in-out">
|
| 51 |
+
<CardHeader className="pb-3">
|
| 52 |
+
<div className="flex justify-between items-start gap-2">
|
| 53 |
+
<div className="flex-1">
|
| 54 |
+
<CardTitle className="text-xl font-semibold text-primary flex items-center">
|
| 55 |
+
<Layers className="mr-2.5 h-5 w-5" />
|
| 56 |
+
{deployment.appName}
|
| 57 |
+
</CardTitle>
|
| 58 |
+
<CardDescription className="text-xs mt-1">Deployed: {new Date(deployment.createdAt).toLocaleDateString()}</CardDescription>
|
| 59 |
+
</div>
|
| 60 |
+
<Badge variant={getStatusBadgeVariant(deployment.status)} className="capitalize flex items-center gap-1.5 px-3 py-1 text-xs shadow-sm">
|
| 61 |
+
{getStatusIcon(deployment.status)}
|
| 62 |
+
<span>{deployment.status}</span>
|
| 63 |
+
</Badge>
|
| 64 |
+
</div>
|
| 65 |
+
</CardHeader>
|
| 66 |
+
<CardContent className="space-y-3 flex-grow">
|
| 67 |
+
<p className="text-sm text-muted-foreground">
|
| 68 |
+
Region: <span className="font-medium text-foreground">{deployment.region || 'N/A'}</span>
|
| 69 |
+
</p>
|
| 70 |
+
{deployment.lastDeployedAt && (
|
| 71 |
+
<p className="text-sm text-muted-foreground">
|
| 72 |
+
Last Activity: <span className="font-medium text-foreground">{new Date(deployment.lastDeployedAt).toLocaleDateString()}</span>
|
| 73 |
+
</p>
|
| 74 |
+
)}
|
| 75 |
+
{deployment.url && (
|
| 76 |
+
<div className="mt-2">
|
| 77 |
+
<a
|
| 78 |
+
href={deployment.url}
|
| 79 |
+
target="_blank"
|
| 80 |
+
rel="noopener noreferrer"
|
| 81 |
+
className="text-sm text-accent hover:underline flex items-center font-medium"
|
| 82 |
+
>
|
| 83 |
+
Visit App <ExternalLink className="ml-1.5 h-4 w-4" />
|
| 84 |
+
</a>
|
| 85 |
+
</div>
|
| 86 |
+
)}
|
| 87 |
+
</CardContent>
|
| 88 |
+
<CardFooter className="flex justify-end pt-4 border-t mt-auto">
|
| 89 |
+
<Button asChild variant="outline" size="sm" className="shadow-sm hover:shadow-md">
|
| 90 |
+
<Link href={`/dashboard/deployments/${deployment.id}`}>Manage</Link>
|
| 91 |
+
</Button>
|
| 92 |
+
</CardFooter>
|
| 93 |
+
</Card>
|
| 94 |
+
);
|
| 95 |
+
}
|