AppForge: migrate off Supabase to external Postgres + Better Auth + local storage
- New Express + TypeScript backend (server/) with pg, Better Auth, local file storage - De-Supabased Postgres schema (server/db) and TS reimplementations of DB functions - Frontend data layer rewired to REST (rest-client + backend-client compat shim) - Removed all Supabase references (code, config, deps, docs) - New brand assets: gradient favicon/app icons + dark/white wordmark logos Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -0,0 +1,68 @@
|
|||||||
|
# ============================================
|
||||||
|
# AppForge - Environment Variables
|
||||||
|
# Stack: External Postgres + Better Auth + Local Storage + Express backend
|
||||||
|
# Copy this file to .env and fill in your values. Never commit .env!
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Database (Required)
|
||||||
|
# ============================================
|
||||||
|
# Postgres connection string for your external database
|
||||||
|
DATABASE_URL=postgres://user:password@host:5432/postgres
|
||||||
|
# Set to true to allow self-signed / non-verified TLS on the DB connection
|
||||||
|
PGSSL_NO_VERIFY=true
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Backend Server (Required)
|
||||||
|
# ============================================
|
||||||
|
# Port the Express API server listens on
|
||||||
|
PORT=8787
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Better Auth (Required)
|
||||||
|
# ============================================
|
||||||
|
# Generate a strong random secret for production (e.g. `openssl rand -hex 32`)
|
||||||
|
BETTER_AUTH_SECRET=change-me-to-a-long-random-string
|
||||||
|
# Public origin the browser uses (dev: the Vite app origin)
|
||||||
|
BETTER_AUTH_URL=http://localhost:8080
|
||||||
|
# Optional Google OAuth (leave blank to disable Google sign-in)
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Local File Storage
|
||||||
|
# ============================================
|
||||||
|
# Directory where uploaded files are stored (relative to project root or absolute)
|
||||||
|
STORAGE_DIR=./server/storage
|
||||||
|
# Public URL prefix used to serve stored files
|
||||||
|
STORAGE_PUBLIC_PREFIX=/storage
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Self-Hosting / App Defaults
|
||||||
|
# ============================================
|
||||||
|
# Email that is auto-assigned the admin role on first signup (optional)
|
||||||
|
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Frontend (Vite)
|
||||||
|
# ============================================
|
||||||
|
# Base URL the frontend uses to reach the backend. Empty = same origin (Vite proxy).
|
||||||
|
VITE_API_URL=
|
||||||
|
# Demo mode: true | false | empty (defer to database setting)
|
||||||
|
VITE_DEMO_MODE=false
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Optional Integrations (server-side; fill when needed)
|
||||||
|
# ============================================
|
||||||
|
# STRIPE_SECRET_KEY=
|
||||||
|
# STRIPE_WEBHOOK_SECRET=
|
||||||
|
# PAYPAL_CLIENT_ID=
|
||||||
|
# PAYPAL_CLIENT_SECRET=
|
||||||
|
# COINBASE_API_KEY=
|
||||||
|
# COINBASE_WEBHOOK_SECRET=
|
||||||
|
# CODEMAGIC_API_TOKEN=
|
||||||
|
# CODEMAGIC_APP_ID=
|
||||||
|
# RESEND_API_KEY=
|
||||||
|
# OPENAI_API_KEY=
|
||||||
|
# APPETIZE_API_KEY=
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Environment files (contain secrets — never commit)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Local file storage (runtime uploads)
|
||||||
|
server/storage/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'com.app.appforge',
|
||||||
|
appName: 'AppForge',
|
||||||
|
webDir: 'dist',
|
||||||
|
server: {
|
||||||
|
url: 'https://61c63823-3472-48e3-a148-faed88715111.lovableproject.com?forceHideBadge=true',
|
||||||
|
cleartext: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
workflows:
|
||||||
|
android-build:
|
||||||
|
name: Android Build
|
||||||
|
instance_type: mac_mini_m2
|
||||||
|
max_build_duration: 30
|
||||||
|
environment:
|
||||||
|
java: 21
|
||||||
|
node: 22
|
||||||
|
|
||||||
|
# Variables are injected via the Codemagic API (cloud-build edge function)
|
||||||
|
scripts:
|
||||||
|
- name: Install dependencies
|
||||||
|
script: |
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Generate Capacitor config and build
|
||||||
|
script: |
|
||||||
|
rm -f capacitor.config.ts capacitor.config.js capacitor.config.json
|
||||||
|
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const sanitizeSegment = (segment, fallback) => {
|
||||||
|
const cleaned = String(segment || '').toLowerCase().replace(/[^a-z0-9_]/g, '');
|
||||||
|
if (!cleaned) return fallback;
|
||||||
|
return /^[a-z]/.test(cleaned) ? cleaned : 'app' + cleaned;
|
||||||
|
};
|
||||||
|
const sanitizeAppId = (value, appName) => {
|
||||||
|
const fallback = ['com', 'app', sanitizeSegment(appName, 'mobile')];
|
||||||
|
const source = String(value || '').split('.').filter(Boolean);
|
||||||
|
const segments = source.length
|
||||||
|
? source.map((segment, index) => sanitizeSegment(segment, fallback[index] || 'app'))
|
||||||
|
: [...fallback];
|
||||||
|
while (segments.length < 3) segments.push(fallback[segments.length] || 'app');
|
||||||
|
return segments.join('.');
|
||||||
|
};
|
||||||
|
const appName = process.env.APP_NAME || 'AppForge';
|
||||||
|
const appId = sanitizeAppId(process.env.PACKAGE_NAME || 'com.app.appforge', appName);
|
||||||
|
const url = process.env.WEBSITE_URL || '';
|
||||||
|
const cfg = { appId, appName, webDir: 'dist', server: { url, cleartext: true } };
|
||||||
|
fs.writeFileSync('capacitor.config.json', JSON.stringify(cfg, null, 2));
|
||||||
|
fs.writeFileSync(
|
||||||
|
'capacitor.config.ts',
|
||||||
|
'import type { CapacitorConfig } from \'@capacitor/cli\';\n\n' +
|
||||||
|
'const config: CapacitorConfig = ' + JSON.stringify(cfg, null, 2) + ';\n\n' +
|
||||||
|
'export default config;\n'
|
||||||
|
);
|
||||||
|
console.log('Generated Capacitor config:', JSON.stringify(cfg, null, 2));
|
||||||
|
"
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Add Android platform
|
||||||
|
script: |
|
||||||
|
node -e "
|
||||||
|
const cfg = require('./capacitor.config.json');
|
||||||
|
if (!/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(cfg.appId)) {
|
||||||
|
throw new Error('Invalid appId: ' + cfg.appId);
|
||||||
|
}
|
||||||
|
console.log('appId OK before init:', cfg.appId);
|
||||||
|
"
|
||||||
|
|
||||||
|
APP_ID="$(node -p "require('./capacitor.config.json').appId")"
|
||||||
|
APP_NAME_SAFE="$(node -p "require('./capacitor.config.json').appName")"
|
||||||
|
WEBSITE_URL_SAFE="$(node -p "require('./capacitor.config.json').server?.url || ''")"
|
||||||
|
|
||||||
|
rm -rf android
|
||||||
|
rm -f capacitor.config.ts capacitor.config.js capacitor.config.json
|
||||||
|
|
||||||
|
npx cap init "$APP_NAME_SAFE" "$APP_ID" --web-dir dist
|
||||||
|
# If cap init fails (e.g. TS config conflict), fall back to direct JSON write
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "cap init failed, writing config directly..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_ID="$APP_ID" APP_NAME_SAFE="$APP_NAME_SAFE" WEBSITE_URL_SAFE="$WEBSITE_URL_SAFE" node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const cfg = {
|
||||||
|
appId: process.env.APP_ID,
|
||||||
|
appName: process.env.APP_NAME_SAFE,
|
||||||
|
webDir: 'dist',
|
||||||
|
server: {
|
||||||
|
url: process.env.WEBSITE_URL_SAFE,
|
||||||
|
cleartext: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
fs.writeFileSync('capacitor.config.json', JSON.stringify(cfg, null, 2));
|
||||||
|
fs.writeFileSync(
|
||||||
|
'capacitor.config.ts',
|
||||||
|
'import type { CapacitorConfig } from \'@capacitor/cli\';\n\n' +
|
||||||
|
'const config: CapacitorConfig = ' + JSON.stringify(cfg, null, 2) + ';\n\n' +
|
||||||
|
'export default config;\n'
|
||||||
|
);
|
||||||
|
console.log('Rewrote Capacitor config after init:', JSON.stringify(cfg, null, 2));
|
||||||
|
"
|
||||||
|
|
||||||
|
node -e "
|
||||||
|
const cfg = require('./capacitor.config.json');
|
||||||
|
if (!/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(cfg.appId)) {
|
||||||
|
throw new Error('Invalid appId after init: ' + cfg.appId);
|
||||||
|
}
|
||||||
|
console.log('appId OK after init:', cfg.appId);
|
||||||
|
"
|
||||||
|
|
||||||
|
npx cap add android
|
||||||
|
npx cap sync android
|
||||||
|
|
||||||
|
- name: Set up JDK and build APK
|
||||||
|
script: |
|
||||||
|
export JAVA_HOME=$(/usr/libexec/java_home -v 21 2>/dev/null || echo "$JAVA_HOME")
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
java -version
|
||||||
|
|
||||||
|
cd android
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
artifacts:
|
||||||
|
- android/app/build/outputs/**/*.apk
|
||||||
|
|
||||||
|
publishing:
|
||||||
|
scripts:
|
||||||
|
- name: Notify webhook on completion
|
||||||
|
script: |
|
||||||
|
if [ -n "$CM_WEBHOOK_URL" ]; then
|
||||||
|
BUILD_STATUS="finished"
|
||||||
|
if [ "$CM_BUILD_STEP_STATUS" = "failure" ]; then
|
||||||
|
BUILD_STATUS="failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
APK_PATH=$(find android/app/build/outputs -name "*.apk" -type f | head -1)
|
||||||
|
APK_SIZE=0
|
||||||
|
if [ -n "$APK_PATH" ]; then
|
||||||
|
APK_SIZE=$(stat -f%z "$APK_PATH" 2>/dev/null || stat --printf="%s" "$APK_PATH" 2>/dev/null || echo 0)
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -X POST "$CM_WEBHOOK_URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"build\": {
|
||||||
|
\"_id\": \"$CM_BUILD_ID\",
|
||||||
|
\"status\": \"$BUILD_STATUS\",
|
||||||
|
\"artefacts\": [{
|
||||||
|
\"type\": \"apk\",
|
||||||
|
\"name\": \"$APP_NAME.apk\",
|
||||||
|
\"url\": \"${CM_ARTIFACT_LINKS:-}\",
|
||||||
|
\"size\": $APK_SIZE
|
||||||
|
}],
|
||||||
|
\"message\": \"Build $BUILD_STATUS for $APP_NAME\"
|
||||||
|
}
|
||||||
|
}" || echo "Webhook notification failed (non-fatal)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ios-build:
|
||||||
|
name: iOS Build
|
||||||
|
instance_type: mac_mini_m2
|
||||||
|
max_build_duration: 45
|
||||||
|
environment:
|
||||||
|
node: 22
|
||||||
|
xcode: latest
|
||||||
|
cocoapods: default
|
||||||
|
# Variables are injected via the Codemagic API (cloud-build edge function)
|
||||||
|
scripts:
|
||||||
|
- name: Install dependencies
|
||||||
|
script: |
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Generate Capacitor config and build
|
||||||
|
script: |
|
||||||
|
rm -f capacitor.config.ts capacitor.config.js capacitor.config.json
|
||||||
|
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const appName = process.env.APP_NAME || 'AppForge';
|
||||||
|
const bundleId = process.env.BUNDLE_ID || 'com.app.appforge';
|
||||||
|
const url = process.env.WEBSITE_URL || '';
|
||||||
|
const cfg = { appId: bundleId, appName, webDir: 'dist', server: { url, cleartext: true } };
|
||||||
|
fs.writeFileSync('capacitor.config.json', JSON.stringify(cfg, null, 2));
|
||||||
|
fs.writeFileSync(
|
||||||
|
'capacitor.config.ts',
|
||||||
|
'import type { CapacitorConfig } from \'@capacitor/cli\';\n\n' +
|
||||||
|
'const config: CapacitorConfig = ' + JSON.stringify(cfg, null, 2) + ';\n\n' +
|
||||||
|
'export default config;\n'
|
||||||
|
);
|
||||||
|
console.log('Generated Capacitor config:', JSON.stringify(cfg, null, 2));
|
||||||
|
"
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Add iOS platform
|
||||||
|
script: |
|
||||||
|
BUNDLE_ID_SAFE="$(node -p "require('./capacitor.config.json').appId")"
|
||||||
|
APP_NAME_SAFE="$(node -p "require('./capacitor.config.json').appName")"
|
||||||
|
WEBSITE_URL_SAFE="$(node -p "require('./capacitor.config.json').server?.url || ''")"
|
||||||
|
|
||||||
|
rm -rf ios
|
||||||
|
rm -f capacitor.config.ts capacitor.config.js capacitor.config.json
|
||||||
|
|
||||||
|
echo "Initializing Capacitor with appName=$APP_NAME_SAFE appId=$BUNDLE_ID_SAFE"
|
||||||
|
npx cap init "$APP_NAME_SAFE" "$BUNDLE_ID_SAFE" --web-dir dist || echo "cap init exited non-zero, writing config directly..."
|
||||||
|
|
||||||
|
BUNDLE_ID_SAFE="$BUNDLE_ID_SAFE" APP_NAME_SAFE="$APP_NAME_SAFE" WEBSITE_URL_SAFE="$WEBSITE_URL_SAFE" node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const cfg = {
|
||||||
|
appId: process.env.BUNDLE_ID_SAFE,
|
||||||
|
appName: process.env.APP_NAME_SAFE,
|
||||||
|
webDir: 'dist',
|
||||||
|
server: {
|
||||||
|
url: process.env.WEBSITE_URL_SAFE,
|
||||||
|
cleartext: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
fs.writeFileSync('capacitor.config.json', JSON.stringify(cfg, null, 2));
|
||||||
|
fs.writeFileSync(
|
||||||
|
'capacitor.config.ts',
|
||||||
|
'import type { CapacitorConfig } from \'@capacitor/cli\';\n\n' +
|
||||||
|
'const config: CapacitorConfig = ' + JSON.stringify(cfg, null, 2) + ';\n\n' +
|
||||||
|
'export default config;\n'
|
||||||
|
);
|
||||||
|
console.log('Rewrote Capacitor config after init:', JSON.stringify(cfg, null, 2));
|
||||||
|
"
|
||||||
|
|
||||||
|
npx cap add ios
|
||||||
|
npx cap sync ios
|
||||||
|
|
||||||
|
IOS_PROJECT_DIR="$(dirname "$(find ios \( -name '*.xcodeproj' -o -name '*.xcworkspace' \) | head -1)")"
|
||||||
|
if [ -z "$IOS_PROJECT_DIR" ] || [ "$IOS_PROJECT_DIR" = "." ]; then
|
||||||
|
echo "Could not find generated iOS project files under ios/"
|
||||||
|
ls -la ios || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$IOS_PROJECT_DIR"
|
||||||
|
if [ -f Podfile ]; then
|
||||||
|
pod install --repo-update
|
||||||
|
else
|
||||||
|
echo "No Podfile found in $IOS_PROJECT_DIR — continuing with SPM/default Capacitor setup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Disable code signing and build
|
||||||
|
script: |
|
||||||
|
IOS_PROJECT_DIR="$(dirname "$(find ios \( -name '*.xcodeproj' -o -name '*.xcworkspace' \) | head -1)")"
|
||||||
|
if [ -z "$IOS_PROJECT_DIR" ] || [ "$IOS_PROJECT_DIR" = "." ]; then
|
||||||
|
echo "Could not find generated iOS project files under ios/"
|
||||||
|
ls -la ios || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$IOS_PROJECT_DIR"
|
||||||
|
|
||||||
|
WORKSPACE_FILE="$(basename "$(find . -maxdepth 1 -name '*.xcworkspace' | head -1)")"
|
||||||
|
PROJECT_FILE="$(basename "$(find . -maxdepth 1 -name '*.xcodeproj' | head -1)")"
|
||||||
|
|
||||||
|
if [ -n "$WORKSPACE_FILE" ] && [ "$WORKSPACE_FILE" != "." ]; then
|
||||||
|
xcodebuild \
|
||||||
|
-workspace "$WORKSPACE_FILE" \
|
||||||
|
-scheme App \
|
||||||
|
-configuration Debug \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
DEVELOPMENT_TEAM="" \
|
||||||
|
-archivePath "$CM_BUILD_DIR/build/ios/App.xcarchive" \
|
||||||
|
archive
|
||||||
|
elif [ -n "$PROJECT_FILE" ] && [ "$PROJECT_FILE" != "." ]; then
|
||||||
|
xcodebuild \
|
||||||
|
-project "$PROJECT_FILE" \
|
||||||
|
-scheme App \
|
||||||
|
-configuration Debug \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
DEVELOPMENT_TEAM="" \
|
||||||
|
-archivePath "$CM_BUILD_DIR/build/ios/App.xcarchive" \
|
||||||
|
archive
|
||||||
|
else
|
||||||
|
echo "Neither .xcworkspace nor .xcodeproj found in $IOS_PROJECT_DIR"
|
||||||
|
ls -la
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Package IPA from archive
|
||||||
|
script: |
|
||||||
|
# Create IPA from the unsigned archive
|
||||||
|
mkdir -p "$CM_BUILD_DIR/build/ios/ipa"
|
||||||
|
cd "$CM_BUILD_DIR/build/ios/App.xcarchive/Products/Applications"
|
||||||
|
|
||||||
|
mkdir -p Payload
|
||||||
|
cp -r *.app Payload/
|
||||||
|
zip -r "$CM_BUILD_DIR/build/ios/ipa/$APP_NAME.ipa" Payload
|
||||||
|
|
||||||
|
echo "IPA created at $CM_BUILD_DIR/build/ios/ipa/$APP_NAME.ipa"
|
||||||
|
ls -la "$CM_BUILD_DIR/build/ios/ipa/"
|
||||||
|
|
||||||
|
artifacts:
|
||||||
|
- build/ios/ipa/*.ipa
|
||||||
|
- build/ios/App.xcarchive/
|
||||||
|
|
||||||
|
publishing:
|
||||||
|
scripts:
|
||||||
|
- name: Notify webhook on completion
|
||||||
|
script: |
|
||||||
|
if [ -n "$CM_WEBHOOK_URL" ]; then
|
||||||
|
BUILD_STATUS="finished"
|
||||||
|
if [ "$CM_BUILD_STEP_STATUS" = "failure" ]; then
|
||||||
|
BUILD_STATUS="failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
IPA_PATH=$(find build/ios/ipa -name "*.ipa" -type f | head -1)
|
||||||
|
IPA_SIZE=0
|
||||||
|
if [ -n "$IPA_PATH" ]; then
|
||||||
|
IPA_SIZE=$(stat -f%z "$IPA_PATH" 2>/dev/null || stat --printf="%s" "$IPA_PATH" 2>/dev/null || echo 0)
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -X POST "$CM_WEBHOOK_URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"build\": {
|
||||||
|
\"_id\": \"$CM_BUILD_ID\",
|
||||||
|
\"status\": \"$BUILD_STATUS\",
|
||||||
|
\"artefacts\": [{
|
||||||
|
\"type\": \"ipa\",
|
||||||
|
\"name\": \"$APP_NAME.ipa\",
|
||||||
|
\"url\": \"${CM_ARTIFACT_LINKS:-}\",
|
||||||
|
\"size\": $IPA_SIZE
|
||||||
|
}],
|
||||||
|
\"message\": \"iOS Build $BUILD_STATUS for $APP_NAME\"
|
||||||
|
}
|
||||||
|
}" || echo "Webhook notification failed (non-fatal)"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* AppForge Documentation v2.0.2
|
||||||
|
* Enhanced interactive documentation with smooth animations and modern UX
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// THEME MANAGEMENT
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function toggleTheme() {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme');
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('docs-theme', next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme() {
|
||||||
|
const stored = localStorage.getItem('docs-theme');
|
||||||
|
if (stored) {
|
||||||
|
document.documentElement.setAttribute('data-theme', stored);
|
||||||
|
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// SIDEBAR SEARCH / FILTER
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function filterSidebar() {
|
||||||
|
const input = document.getElementById('sidebarSearch');
|
||||||
|
if (!input) return;
|
||||||
|
const query = input.value.toLowerCase().trim();
|
||||||
|
const sections = document.querySelectorAll('.sidebar-section');
|
||||||
|
sections.forEach(section => {
|
||||||
|
const links = section.querySelectorAll('.sidebar-link');
|
||||||
|
let anyVisible = false;
|
||||||
|
links.forEach(link => {
|
||||||
|
const text = link.textContent.toLowerCase();
|
||||||
|
const match = !query || text.includes(query);
|
||||||
|
link.parentElement.style.display = match ? '' : 'none';
|
||||||
|
if (match) anyVisible = true;
|
||||||
|
});
|
||||||
|
const title = section.querySelector('.sidebar-title');
|
||||||
|
if (title) {
|
||||||
|
section.style.display = anyVisible || !query ? '' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// SIDEBAR MANAGEMENT
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function toggleSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const overlay = document.getElementById('sidebarOverlay');
|
||||||
|
if (sidebar) sidebar.classList.toggle('open');
|
||||||
|
if (overlay) overlay.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const overlay = document.getElementById('sidebarOverlay');
|
||||||
|
if (sidebar) sidebar.classList.remove('open');
|
||||||
|
if (overlay) overlay.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// COLLAPSIBLE SIDEBAR SECTIONS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function toggleSidebarSection(el) {
|
||||||
|
const section = el.closest('.sidebar-section');
|
||||||
|
if (section) section.classList.toggle('collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// FAQ ACCORDION
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function toggleFAQ(element) {
|
||||||
|
const answer = element.nextElementSibling;
|
||||||
|
const arrow = element.querySelector('.faq-arrow');
|
||||||
|
|
||||||
|
if (answer) {
|
||||||
|
answer.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arrow) {
|
||||||
|
arrow.style.transform = answer && answer.classList.contains('open')
|
||||||
|
? 'rotate(180deg)'
|
||||||
|
: 'rotate(0)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// FAQ SEARCH / FILTER
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function filterFAQs() {
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
if (!searchInput) return;
|
||||||
|
|
||||||
|
const query = searchInput.value.toLowerCase().trim();
|
||||||
|
const faqItems = document.querySelectorAll('.faq-item');
|
||||||
|
const faqSections = document.querySelectorAll('#help-center .card');
|
||||||
|
|
||||||
|
faqItems.forEach(item => {
|
||||||
|
const text = item.textContent.toLowerCase();
|
||||||
|
item.style.display = text.includes(query) ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide sections where all items are hidden
|
||||||
|
faqSections.forEach(section => {
|
||||||
|
const items = section.querySelectorAll('.faq-item');
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const allHidden = Array.from(items).every(i => i.style.display === 'none');
|
||||||
|
if (section.querySelector('.faq-list')) {
|
||||||
|
section.style.display = allHidden ? 'none' : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// SMOOTH SCROLL
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function scrollToSection(id) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// COPY CODE TO CLIPBOARD
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function copyCode(button) {
|
||||||
|
const codeBlock = button.closest('.code-block');
|
||||||
|
const code = codeBlock ? codeBlock.querySelector('code') : null;
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
navigator.clipboard.writeText(code.textContent).then(() => {
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.textContent = '✓ Copied!';
|
||||||
|
button.classList.add('copied');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.classList.remove('copied');
|
||||||
|
}, 2000);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// TAB NAVIGATION
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function switchTab(tabGroup, tabId) {
|
||||||
|
const group = document.querySelector(`[data-tab-group="${tabGroup}"]`);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
group.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.tab === tabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.querySelectorAll('.tab-content').forEach(content => {
|
||||||
|
content.classList.toggle('active', content.id === tabId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// READING PROGRESS BAR
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function updateReadingProgress() {
|
||||||
|
const bar = document.querySelector('.reading-progress');
|
||||||
|
if (!bar) return;
|
||||||
|
|
||||||
|
const scrollTop = window.scrollY;
|
||||||
|
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
|
||||||
|
bar.style.width = Math.min(progress, 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// ACTIVE SIDEBAR LINK (SCROLL SPY)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function updateActiveSidebarLink() {
|
||||||
|
const sections = document.querySelectorAll('section[id], div[id]');
|
||||||
|
const sidebarLinks = document.querySelectorAll('.sidebar-link, .sidebar-link-sub');
|
||||||
|
const navLinks = document.querySelectorAll('.nav-center a');
|
||||||
|
|
||||||
|
let currentSection = '';
|
||||||
|
const offset = 120;
|
||||||
|
|
||||||
|
sections.forEach(section => {
|
||||||
|
const rect = section.getBoundingClientRect();
|
||||||
|
if (rect.top <= offset) {
|
||||||
|
currentSection = section.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update sidebar links
|
||||||
|
sidebarLinks.forEach(link => {
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
if (href && href === '#' + currentSection) {
|
||||||
|
link.classList.add('active');
|
||||||
|
} else if (href && currentSection && href.includes('#')) {
|
||||||
|
// Check if this is a parent section
|
||||||
|
const section = document.getElementById(currentSection);
|
||||||
|
const linkTarget = href.substring(1);
|
||||||
|
if (section && section.closest('[id="' + linkTarget + '"]')) {
|
||||||
|
link.classList.add('active');
|
||||||
|
} else {
|
||||||
|
link.classList.remove('active');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
link.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update nav links
|
||||||
|
const mainSections = ['getting-started', 'api-reference', 'native-features', 'deployment'];
|
||||||
|
navLinks.forEach(link => {
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
if (!href) return;
|
||||||
|
const target = href.substring(1);
|
||||||
|
const isActive = mainSections.some(s => {
|
||||||
|
if (target === s) {
|
||||||
|
const el = document.getElementById(s);
|
||||||
|
if (!el) return false;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return rect.top <= offset && rect.bottom > 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
link.classList.toggle('active', currentSection === target || isActive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// BACK TO TOP BUTTON
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function updateBackToTop() {
|
||||||
|
const btn = document.querySelector('.back-to-top');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.classList.toggle('visible', window.scrollY > 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// NAV SCROLL EFFECT
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function updateNavEffect() {
|
||||||
|
const nav = document.querySelector('.nav');
|
||||||
|
if (!nav) return;
|
||||||
|
nav.classList.toggle('scrolled', window.scrollY > 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// SCROLL ANIMATIONS (Intersection Observer)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function initScrollAnimations() {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('animate-in');
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.1, rootMargin: '0px 0px -40px 0px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
document.querySelectorAll('.card, .callout, .table-wrapper').forEach(el => {
|
||||||
|
el.style.opacity = '0';
|
||||||
|
el.style.transform = 'translateY(20px)';
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// AUTO-WRAP CODE BLOCKS WITH COPY BUTTON
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function initCodeBlocks() {
|
||||||
|
document.querySelectorAll('pre').forEach(pre => {
|
||||||
|
// Skip if already wrapped
|
||||||
|
if (pre.parentElement.classList.contains('code-block')) return;
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'code-block';
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'copy-btn';
|
||||||
|
btn.textContent = 'Copy';
|
||||||
|
btn.onclick = function() { copyCode(this); };
|
||||||
|
|
||||||
|
pre.parentNode.insertBefore(wrapper, pre);
|
||||||
|
wrapper.appendChild(pre);
|
||||||
|
wrapper.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// DEBOUNCE UTILITY
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// INITIALIZATION
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Scroll handlers (debounced)
|
||||||
|
const onScroll = () => {
|
||||||
|
updateReadingProgress();
|
||||||
|
updateActiveSidebarLink();
|
||||||
|
updateBackToTop();
|
||||||
|
updateNavEffect();
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', debounce(onScroll, 16), { passive: true });
|
||||||
|
onScroll(); // Initial call
|
||||||
|
|
||||||
|
// Initialize code blocks with copy buttons
|
||||||
|
initCodeBlocks();
|
||||||
|
|
||||||
|
// Initialize scroll animations
|
||||||
|
initScrollAnimations();
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// Ctrl/Cmd + K to focus search
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
const searchInput = document.getElementById('searchInput') || document.querySelector('.nav-search-input');
|
||||||
|
if (searchInput) searchInput.focus();
|
||||||
|
}
|
||||||
|
// Escape to close sidebar
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeSidebar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close sidebar when clicking a link (mobile)
|
||||||
|
document.querySelectorAll('.sidebar-link, .sidebar-link-sub').forEach(link => {
|
||||||
|
link.addEventListener('click', () => {
|
||||||
|
if (window.innerWidth <= 900) closeSidebar();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run theme initialization immediately (before DOM ready)
|
||||||
|
initTheme();
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<title>AppForge - Convert Websites to Apps</title>
|
||||||
|
<meta name="description" content="Convert any website into a native mobile app with AppForge" />
|
||||||
|
<meta name="author" content="AppForge" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- PWA Meta Tags -->
|
||||||
|
<meta name="theme-color" content="#6366f1" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="AppForge" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="AppForge - Convert Websites to Apps" />
|
||||||
|
<meta property="og:description" content="Convert any website into a native mobile app with AppForge" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:image" content="/pwa-512x512.png" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:site" content="@AppForge" />
|
||||||
|
<meta name="twitter:image" content="/pwa-512x512.png" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
// Prevent flash of wrong theme on page load
|
||||||
|
(function(){try{var s=localStorage.getItem('appforge-theme');var t=s?JSON.parse(s).state?.theme:'dark';if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'}document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add('dark')}})();
|
||||||
|
</script>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
{
|
||||||
|
"name": "appforge",
|
||||||
|
"version": "2.0.2",
|
||||||
|
"private": true,
|
||||||
|
"description": "AppForge is a comprehensive platform for building and managing applications.",
|
||||||
|
"homepage": "https://appforge.wrapcoders.com",
|
||||||
|
"license": "ISC",
|
||||||
|
"author": "WRAPCODERS",
|
||||||
|
"type": "module",
|
||||||
|
"main": "eslint.config.js",
|
||||||
|
"directories": {
|
||||||
|
"doc": "docs"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -k -n server,web -c blue,green \"npm:dev:server\" \"npm:dev:web\"",
|
||||||
|
"dev:web": "vite",
|
||||||
|
"dev:server": "tsx watch server/src/index.ts",
|
||||||
|
"db:push": "tsx server/src/scripts/db-push.ts",
|
||||||
|
"build": "vite build",
|
||||||
|
"build:dev": "vite build --mode development",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aparajita/capacitor-biometric-auth": "^9.1.2",
|
||||||
|
"@capacitor/android": "^8.0.1",
|
||||||
|
"@capacitor/camera": "^8.0.0",
|
||||||
|
"@capacitor/cli": "^8.0.1",
|
||||||
|
"@capacitor/core": "^8.0.1",
|
||||||
|
"@capacitor/haptics": "^8.0.0",
|
||||||
|
"@capacitor/ios": "^8.0.1",
|
||||||
|
"@capacitor/push-notifications": "^8.0.0",
|
||||||
|
"@elevenlabs/react": "^0.12.3",
|
||||||
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.15",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.14",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-menubar": "^1.1.15",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slider": "^1.3.5",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-toast": "^1.2.14",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.9",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@tanstack/react-query": "^5.83.0",
|
||||||
|
"better-auth": "^1.2.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cookie": "^1.0.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"framer-motion": "^12.24.12",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"lucide-react": "^0.462.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"next-themes": "^0.3.0",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-day-picker": "^8.10.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.61.1",
|
||||||
|
"react-router-dom": "^6.30.1",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
|
"sonner": "^1.7.4",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vite-plugin-pwa": "^0.19.8",
|
||||||
|
"zod": "^3.25.76",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@resvg/resvg-wasm": "^2.6.2",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^22.16.5",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"@types/react": "^18.3.27",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
|
"eslint": "^9.32.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^15.15.0",
|
||||||
|
"lovable-tagger": "^1.1.13",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"typescript-eslint": "^8.38.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# AppForge - Static SPA Configuration for cPanel/Apache
|
||||||
|
# This file handles routing for a React Single Page Application
|
||||||
|
|
||||||
|
# Enable RewriteEngine
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
|
||||||
|
# Don't rewrite files or directories that exist
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
# Rewrite all other requests to index.html for SPA routing
|
||||||
|
RewriteRule ^ index.html [L]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Disable directory browsing
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
# Set default charset
|
||||||
|
AddDefaultCharset UTF-8
|
||||||
|
|
||||||
|
# Security Headers
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
# CORS headers
|
||||||
|
Header set Access-Control-Allow-Origin "*"
|
||||||
|
Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||||
|
Header set Access-Control-Allow-Headers "Content-Type, Authorization"
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
Header set X-Content-Type-Options "nosniff"
|
||||||
|
Header set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header set X-XSS-Protection "1; mode=block"
|
||||||
|
Header set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
|
# Content Security Policy (CSP) - XSS Protection
|
||||||
|
# Allows: self, Supabase APIs, Google Fonts, inline styles (required for React)
|
||||||
|
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.supabase.co https://cdn.gpteng.co; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https://*.supabase.co https://*.unsplash.com https://*.githubusercontent.com; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.openai.com https://generativelanguage.googleapis.com https://api.elevenlabs.io; frame-src 'self' https://appetize.io; media-src 'self' blob: https://*.supabase.co; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self';"
|
||||||
|
|
||||||
|
# Permissions Policy (formerly Feature Policy)
|
||||||
|
Header set Permissions-Policy "accelerometer=(), camera=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), payment=(), usb=()"
|
||||||
|
|
||||||
|
# Strict Transport Security (HTTPS only)
|
||||||
|
Header set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
|
||||||
|
# Prevent MIME type sniffing attacks
|
||||||
|
Header set X-Permitted-Cross-Domain-Policies "none"
|
||||||
|
|
||||||
|
# Control DNS prefetching
|
||||||
|
Header set X-DNS-Prefetch-Control "on"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Compression for better performance
|
||||||
|
<IfModule mod_deflate.c>
|
||||||
|
AddOutputFilterByType DEFLATE text/html text/plain text/css application/json application/javascript text/xml application/xml application/xhtml+xml text/javascript
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
<IfModule mod_expires.c>
|
||||||
|
ExpiresActive On
|
||||||
|
|
||||||
|
# HTML - no cache (always get fresh for SPA)
|
||||||
|
ExpiresByType text/html "access plus 0 seconds"
|
||||||
|
|
||||||
|
# Images - cache for 1 month
|
||||||
|
ExpiresByType image/png "access plus 1 month"
|
||||||
|
ExpiresByType image/jpg "access plus 1 month"
|
||||||
|
ExpiresByType image/jpeg "access plus 1 month"
|
||||||
|
ExpiresByType image/gif "access plus 1 month"
|
||||||
|
ExpiresByType image/svg+xml "access plus 1 month"
|
||||||
|
ExpiresByType image/webp "access plus 1 month"
|
||||||
|
ExpiresByType image/x-icon "access plus 1 month"
|
||||||
|
|
||||||
|
# CSS/JS - cache for 1 year (Vite adds hashes to filenames)
|
||||||
|
ExpiresByType text/css "access plus 1 year"
|
||||||
|
ExpiresByType application/javascript "access plus 1 year"
|
||||||
|
ExpiresByType text/javascript "access plus 1 year"
|
||||||
|
|
||||||
|
# Fonts
|
||||||
|
ExpiresByType font/woff "access plus 1 year"
|
||||||
|
ExpiresByType font/woff2 "access plus 1 year"
|
||||||
|
ExpiresByType application/font-woff "access plus 1 year"
|
||||||
|
ExpiresByType application/font-woff2 "access plus 1 year"
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# Handle 404 errors - redirect to index.html for SPA
|
||||||
|
ErrorDocument 404 /index.html
|
||||||
|
|
||||||
|
# MIME Types
|
||||||
|
<IfModule mod_mime.c>
|
||||||
|
AddType application/javascript .js
|
||||||
|
AddType text/css .css
|
||||||
|
AddType image/svg+xml .svg
|
||||||
|
AddType application/json .json
|
||||||
|
AddType font/woff .woff
|
||||||
|
AddType font/woff2 .woff2
|
||||||
|
</IfModule>
|
||||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 616 B |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="AppForge">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="afgm" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#8B5CF6"/>
|
||||||
|
<stop offset="0.55" stop-color="#6366F1"/>
|
||||||
|
<stop offset="1" stop-color="#0EA5E9"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- full-bleed background for Android maskable safe zone -->
|
||||||
|
<rect x="0" y="0" width="512" height="512" fill="url(#afgm)"/>
|
||||||
|
<path d="M300 136 L182 316 H256 L242 392 L334 248 H272 Z"
|
||||||
|
fill="#ffffff" stroke="#ffffff" stroke-width="5" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 641 B |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="AppForge">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="afg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#8B5CF6"/>
|
||||||
|
<stop offset="0.55" stop-color="#6366F1"/>
|
||||||
|
<stop offset="1" stop-color="#0EA5E9"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="32" y="32" width="448" height="448" rx="112" fill="url(#afg)"/>
|
||||||
|
<path d="M292 80 L150 296 H238 L220 432 L372 208 H280 Z"
|
||||||
|
fill="#ffffff" stroke="#ffffff" stroke-width="6" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 585 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 232 56" width="232" height="56" role="img" aria-label="AppForge">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="afl1" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#8B5CF6"/>
|
||||||
|
<stop offset="0.55" stop-color="#6366F1"/>
|
||||||
|
<stop offset="1" stop-color="#0EA5E9"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- icon mark -->
|
||||||
|
<g transform="translate(6 8)">
|
||||||
|
<rect x="0" y="0" width="40" height="40" rx="11" fill="url(#afl1)"/>
|
||||||
|
<path d="M23 7 L11.5 24 H18.7 L17 33.5 L29 15.5 H21.7 Z"
|
||||||
|
fill="#ffffff" stroke="#ffffff" stroke-width="0.8" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<!-- wordmark (dark — for light backgrounds) -->
|
||||||
|
<text x="58" y="37" font-family="'Segoe UI', Inter, system-ui, -apple-system, Roboto, Arial, sans-serif"
|
||||||
|
font-size="27" font-weight="700" letter-spacing="-0.5" fill="#0F172A">App<tspan fill="#6366F1">Forge</tspan></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 933 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 232 56" width="232" height="56" role="img" aria-label="AppForge">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="afl2" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#A78BFA"/>
|
||||||
|
<stop offset="0.55" stop-color="#818CF8"/>
|
||||||
|
<stop offset="1" stop-color="#38BDF8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- icon mark -->
|
||||||
|
<g transform="translate(6 8)">
|
||||||
|
<rect x="0" y="0" width="40" height="40" rx="11" fill="url(#afl2)"/>
|
||||||
|
<path d="M23 7 L11.5 24 H18.7 L17 33.5 L29 15.5 H21.7 Z"
|
||||||
|
fill="#0B1020" stroke="#0B1020" stroke-width="0.8" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<!-- wordmark (white — for dark backgrounds) -->
|
||||||
|
<text x="58" y="37" font-family="'Segoe UI', Inter, system-ui, -apple-system, Roboto, Arial, sans-serif"
|
||||||
|
font-size="27" font-weight="700" letter-spacing="-0.5" fill="#FFFFFF">App<tspan fill="#A5B4FC">Forge</tspan></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 933 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Bingbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Twitterbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: facebookexternalhit
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta http-equiv="refresh" content="1; url=https://nullphpscript.com">
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.location.href = "https://nullphpscript.com";
|
||||||
|
</script>
|
||||||
|
<title>Redirecting - Nullphpscript.com</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Redirecting! Thank you, please stay with us.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- Better Auth core schema (v1.x). Identifiers are camelCase and quoted to
|
||||||
|
-- match Better Auth's default field->column mapping.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "user" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"name" text NOT NULL DEFAULT '',
|
||||||
|
"email" text NOT NULL UNIQUE,
|
||||||
|
"emailVerified" boolean NOT NULL DEFAULT false,
|
||||||
|
"image" text,
|
||||||
|
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "session" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"expiresAt" timestamptz NOT NULL,
|
||||||
|
"token" text NOT NULL UNIQUE,
|
||||||
|
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"ipAddress" text,
|
||||||
|
"userAgent" text,
|
||||||
|
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "account" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"accountId" text NOT NULL,
|
||||||
|
"providerId" text NOT NULL,
|
||||||
|
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
|
||||||
|
"accessToken" text,
|
||||||
|
"refreshToken" text,
|
||||||
|
"idToken" text,
|
||||||
|
"accessTokenExpiresAt" timestamptz,
|
||||||
|
"refreshTokenExpiresAt" timestamptz,
|
||||||
|
"scope" text,
|
||||||
|
"password" text,
|
||||||
|
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "verification" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"expiresAt" timestamptz NOT NULL,
|
||||||
|
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_user ON "session" ("userId");
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_account_user ON "account" ("userId");
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_verification_identifier ON "verification" ("identifier");
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- AppForge — application schema (de-Supabased)
|
||||||
|
-- Target: plain PostgreSQL. No RLS, no auth.* schema.
|
||||||
|
-- User identity is owned by Better Auth ("user" table, text id).
|
||||||
|
-- All user references are TEXT referencing "user"(id).
|
||||||
|
-- This file is idempotent (safe to re-run).
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- gen_random_uuid() is built into PG13+ core; pgcrypto kept for safety.
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
-- ---------- Enums ----------
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE public.app_role AS ENUM ('admin', 'moderator', 'user');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE public.payment_method AS ENUM ('paypal', 'crypto', 'bank_transfer', 'stripe');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE public.subscription_tier AS ENUM ('free', 'pro', 'enterprise');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE public.transaction_status AS ENUM ('pending', 'completed', 'failed', 'refunded');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
-- ---------- updated_at trigger fn ----------
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_updated_at_column() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Tables
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.profiles (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
email text,
|
||||||
|
display_name text,
|
||||||
|
avatar_url text,
|
||||||
|
company_name text,
|
||||||
|
stripe_customer_id text UNIQUE,
|
||||||
|
marketing_consent boolean DEFAULT false,
|
||||||
|
marketing_consent_date timestamptz,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_roles (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id text NOT NULL,
|
||||||
|
role public.app_role NOT NULL,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT user_roles_user_id_role_key UNIQUE (user_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.subscription_plans (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
tier public.subscription_tier NOT NULL UNIQUE,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text,
|
||||||
|
price_monthly numeric(10,2) DEFAULT 0 NOT NULL,
|
||||||
|
price_yearly numeric(10,2) DEFAULT 0 NOT NULL,
|
||||||
|
monthly_credits integer DEFAULT 0 NOT NULL,
|
||||||
|
features jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
is_active boolean DEFAULT true NOT NULL,
|
||||||
|
stripe_price_id text,
|
||||||
|
stripe_yearly_price_id text,
|
||||||
|
paypal_plan_id text,
|
||||||
|
paypal_yearly_plan_id text,
|
||||||
|
paypal_product_id text,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_credits (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id text NOT NULL UNIQUE,
|
||||||
|
monthly_credits integer DEFAULT 0 NOT NULL,
|
||||||
|
bonus_credits integer DEFAULT 0 NOT NULL,
|
||||||
|
credits_reset_at timestamptz,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.user_subscriptions (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id text NOT NULL UNIQUE,
|
||||||
|
plan_id uuid NOT NULL REFERENCES public.subscription_plans(id),
|
||||||
|
status text DEFAULT 'active'::text NOT NULL,
|
||||||
|
billing_cycle text DEFAULT 'monthly'::text NOT NULL,
|
||||||
|
current_period_start timestamptz DEFAULT now() NOT NULL,
|
||||||
|
current_period_end timestamptz NOT NULL,
|
||||||
|
cancel_at_period_end boolean DEFAULT false NOT NULL,
|
||||||
|
payment_method public.payment_method,
|
||||||
|
external_subscription_id text,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.app_projects (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id text NOT NULL,
|
||||||
|
website_url text NOT NULL,
|
||||||
|
app_name text NOT NULL,
|
||||||
|
primary_color text DEFAULT '#22D3EE'::text,
|
||||||
|
accent_color text DEFAULT '#A855F7'::text,
|
||||||
|
navigation_style text DEFAULT 'bottom-nav'::text,
|
||||||
|
features text[] DEFAULT '{}'::text[],
|
||||||
|
app_category text,
|
||||||
|
description text,
|
||||||
|
icon_style text DEFAULT 'modern'::text,
|
||||||
|
splash_screen_style text DEFAULT 'centered-logo'::text,
|
||||||
|
build_status text DEFAULT 'draft'::text,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT app_projects_user_id_website_url_key UNIQUE (user_id, website_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.app_builds (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id text NOT NULL,
|
||||||
|
project_id uuid REFERENCES public.app_projects(id) ON DELETE SET NULL,
|
||||||
|
website_url text NOT NULL,
|
||||||
|
app_name text NOT NULL,
|
||||||
|
package_name text NOT NULL,
|
||||||
|
config jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
status text DEFAULT 'pending'::text NOT NULL,
|
||||||
|
progress integer DEFAULT 0 NOT NULL,
|
||||||
|
download_url text,
|
||||||
|
file_size_bytes bigint,
|
||||||
|
error_message text,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT app_builds_status_check CHECK (status = ANY (ARRAY['pending'::text,'building'::text,'complete'::text,'failed'::text]))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.app_templates (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text,
|
||||||
|
config jsonb NOT NULL,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.automation_configs (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES public.app_projects(id) ON DELETE CASCADE,
|
||||||
|
user_id text NOT NULL,
|
||||||
|
workflow_type text NOT NULL,
|
||||||
|
is_enabled boolean DEFAULT true NOT NULL,
|
||||||
|
config jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
last_run_at timestamptz,
|
||||||
|
next_run_at timestamptz,
|
||||||
|
run_count integer DEFAULT 0 NOT NULL,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT automation_configs_project_id_workflow_type_key UNIQUE (project_id, workflow_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.automation_logs (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
automation_id uuid NOT NULL REFERENCES public.automation_configs(id) ON DELETE CASCADE,
|
||||||
|
user_id text NOT NULL,
|
||||||
|
status text DEFAULT 'pending'::text NOT NULL,
|
||||||
|
message text,
|
||||||
|
metadata jsonb DEFAULT '{}'::jsonb,
|
||||||
|
started_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
completed_at timestamptz,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.credit_packs (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
credits integer NOT NULL,
|
||||||
|
price numeric(10,2) NOT NULL,
|
||||||
|
description text,
|
||||||
|
is_active boolean DEFAULT true NOT NULL,
|
||||||
|
stripe_price_id text,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.credit_usage_history (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id text NOT NULL,
|
||||||
|
amount integer NOT NULL,
|
||||||
|
action_type text NOT NULL,
|
||||||
|
description text,
|
||||||
|
metadata jsonb DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.payment_transactions (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id text NOT NULL,
|
||||||
|
amount numeric(10,2) NOT NULL,
|
||||||
|
currency text DEFAULT 'USD'::text NOT NULL,
|
||||||
|
payment_method public.payment_method NOT NULL,
|
||||||
|
status public.transaction_status DEFAULT 'pending'::public.transaction_status NOT NULL,
|
||||||
|
transaction_type text NOT NULL,
|
||||||
|
reference_id text,
|
||||||
|
external_transaction_id text,
|
||||||
|
paypal_order_id text,
|
||||||
|
paypal_subscription_id text,
|
||||||
|
metadata jsonb DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.bank_transfer_requests (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id text NOT NULL,
|
||||||
|
transaction_id uuid REFERENCES public.payment_transactions(id),
|
||||||
|
amount numeric(10,2) NOT NULL,
|
||||||
|
currency text DEFAULT 'USD'::text NOT NULL,
|
||||||
|
plan_id uuid REFERENCES public.subscription_plans(id),
|
||||||
|
credit_pack_id uuid REFERENCES public.credit_packs(id),
|
||||||
|
status text DEFAULT 'pending'::text NOT NULL,
|
||||||
|
proof_of_payment_url text,
|
||||||
|
admin_notes text,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.chat_messages (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id text NOT NULL,
|
||||||
|
role text NOT NULL,
|
||||||
|
content text NOT NULL,
|
||||||
|
project_id uuid REFERENCES public.app_projects(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT chat_messages_role_check CHECK (role = ANY (ARRAY['user'::text,'assistant'::text]))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.consent_records (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
user_id text,
|
||||||
|
email text,
|
||||||
|
consent_type text NOT NULL,
|
||||||
|
consented boolean DEFAULT false NOT NULL,
|
||||||
|
ip_address text,
|
||||||
|
user_agent text,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.email_templates (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
subject text NOT NULL,
|
||||||
|
html_content text NOT NULL,
|
||||||
|
text_content text,
|
||||||
|
variables jsonb DEFAULT '[]'::jsonb,
|
||||||
|
is_active boolean DEFAULT true NOT NULL,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.invoices (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
invoice_number text NOT NULL UNIQUE,
|
||||||
|
user_id text NOT NULL,
|
||||||
|
amount numeric NOT NULL,
|
||||||
|
currency text DEFAULT 'USD'::text NOT NULL,
|
||||||
|
status text DEFAULT 'draft'::text NOT NULL,
|
||||||
|
due_date timestamptz,
|
||||||
|
paid_at timestamptz,
|
||||||
|
items jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
notes text,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.api_configurations (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
provider text NOT NULL,
|
||||||
|
api_key_masked text,
|
||||||
|
is_active boolean DEFAULT false NOT NULL,
|
||||||
|
rate_limit integer DEFAULT 1000,
|
||||||
|
usage_count integer DEFAULT 0,
|
||||||
|
last_used_at timestamptz,
|
||||||
|
config jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.plugins (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
slug text NOT NULL UNIQUE,
|
||||||
|
type text NOT NULL,
|
||||||
|
description text,
|
||||||
|
config jsonb DEFAULT '{}'::jsonb,
|
||||||
|
is_active boolean DEFAULT false NOT NULL,
|
||||||
|
version text DEFAULT '1.0.0'::text,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.system_settings (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
key text NOT NULL UNIQUE,
|
||||||
|
value jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
category text DEFAULT 'general'::text NOT NULL,
|
||||||
|
description text,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_by text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.settings_audit_log (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
setting_id uuid REFERENCES public.system_settings(id) ON DELETE SET NULL,
|
||||||
|
setting_key text NOT NULL,
|
||||||
|
old_value jsonb,
|
||||||
|
new_value jsonb NOT NULL,
|
||||||
|
changed_by text,
|
||||||
|
changed_by_email text,
|
||||||
|
change_type text DEFAULT 'update'::text NOT NULL,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.payment_gateway_configs (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
gateway text NOT NULL UNIQUE,
|
||||||
|
is_enabled boolean DEFAULT false,
|
||||||
|
is_test_mode boolean DEFAULT true,
|
||||||
|
sandbox_config jsonb DEFAULT '{}'::jsonb,
|
||||||
|
live_config jsonb DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.webhook_event_logs (
|
||||||
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
gateway text NOT NULL,
|
||||||
|
event_type text NOT NULL,
|
||||||
|
event_id text,
|
||||||
|
status text NOT NULL DEFAULT 'received',
|
||||||
|
payload jsonb DEFAULT '{}'::jsonb,
|
||||||
|
response_status integer,
|
||||||
|
error_message text,
|
||||||
|
processing_time_ms integer,
|
||||||
|
created_at timestamptz DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- Indexes ----------
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_automation_configs_project ON public.automation_configs (project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_automation_configs_type ON public.automation_configs (workflow_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_automation_configs_user ON public.automation_configs (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_automation_logs_automation ON public.automation_logs (automation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_automation_logs_status ON public.automation_logs (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_messages_user_project ON public.chat_messages (user_id, project_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_consent_records_email ON public.consent_records (email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_consent_records_user_id ON public.consent_records (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credit_usage_action ON public.credit_usage_history (action_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credit_usage_user_date ON public.credit_usage_history (user_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_app_builds_user ON public.app_builds (user_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_app_projects_user ON public.app_projects (user_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_settings_audit_log_created_at ON public.settings_audit_log (created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_settings_audit_log_setting_id ON public.settings_audit_log (setting_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_event_logs_gateway ON public.webhook_event_logs (gateway);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_event_logs_created_at ON public.webhook_event_logs (created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhook_event_logs_status ON public.webhook_event_logs (status);
|
||||||
|
|
||||||
|
-- ---------- updated_at triggers ----------
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOR t IN
|
||||||
|
SELECT unnest(ARRAY[
|
||||||
|
'api_configurations','app_builds','app_projects','app_templates','automation_configs',
|
||||||
|
'bank_transfer_requests','consent_records','email_templates','invoices','payment_transactions',
|
||||||
|
'plugins','profiles','subscription_plans','system_settings','user_credits','user_subscriptions',
|
||||||
|
'payment_gateway_configs'
|
||||||
|
])
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP TRIGGER IF EXISTS update_%1$s_updated_at ON public.%1$s;', t);
|
||||||
|
EXECUTE format('CREATE TRIGGER update_%1$s_updated_at BEFORE UPDATE ON public.%1$s FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();', t);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Seed data
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO public.subscription_plans (tier, name, description, price_monthly, price_yearly, monthly_credits, features, is_active)
|
||||||
|
VALUES
|
||||||
|
('free', 'Free', 'Get started building apps', 0, 0, 5,
|
||||||
|
'["5 credits / month","Community support","Standard build queue"]'::jsonb, true),
|
||||||
|
('pro', 'Pro', 'For serious builders', 19, 190, 100,
|
||||||
|
'["100 credits / month","Priority builds","Email support","Custom branding"]'::jsonb, true),
|
||||||
|
('enterprise', 'Enterprise', 'For teams and agencies', 99, 990, 1000,
|
||||||
|
'["1000 credits / month","Fastest builds","Dedicated support","Team seats","White-label"]'::jsonb, true)
|
||||||
|
ON CONFLICT (tier) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO public.system_settings (key, value, category, description)
|
||||||
|
VALUES
|
||||||
|
('credits_per_build', '1'::jsonb, 'builds', 'Number of credits consumed per app build'),
|
||||||
|
('default_signup_credits', '5'::jsonb, 'app', 'Number of free credits given to new users on signup')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO public.payment_gateway_configs (gateway, is_enabled, is_test_mode)
|
||||||
|
VALUES ('paypal', false, true), ('stripe', false, true), ('coinbase', false, true), ('bank_transfer', false, false)
|
||||||
|
ON CONFLICT (gateway) DO NOTHING;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { toNodeHandler } from 'better-auth/node';
|
||||||
|
import { env } from './env.js';
|
||||||
|
import { auth } from './auth.js';
|
||||||
|
import { withSession } from './middleware/auth.js';
|
||||||
|
import { ensureBuckets } from './lib/storage.js';
|
||||||
|
|
||||||
|
import userRoutes from './routes/user.js';
|
||||||
|
import projectRoutes from './routes/projects.js';
|
||||||
|
import buildRoutes from './routes/builds.js';
|
||||||
|
import bankTransferRoutes from './routes/bankTransfer.js';
|
||||||
|
import adminRoutes from './routes/admin.js';
|
||||||
|
import storageRoutes from './routes/storage.js';
|
||||||
|
import dbRoutes from './routes/db.js';
|
||||||
|
import functionRoutes from './functions/index.js';
|
||||||
|
import {
|
||||||
|
automationRouter, templatesRouter, plansRouter,
|
||||||
|
creditPacksRouter, chatRouter, setupRouter,
|
||||||
|
} from './routes/misc.js';
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: [env.BETTER_AUTH_URL, 'http://localhost:8080', 'http://localhost:8787'],
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Better Auth must be mounted BEFORE express.json() (it reads the raw body).
|
||||||
|
app.all('/api/auth/*', toNodeHandler(auth));
|
||||||
|
|
||||||
|
// JSON body parsing for the rest of the API
|
||||||
|
app.use(express.json({ limit: '2mb' }));
|
||||||
|
|
||||||
|
// Attach session (if present) to every /api request
|
||||||
|
app.use('/api', withSession);
|
||||||
|
|
||||||
|
// Serve local file storage
|
||||||
|
ensureBuckets();
|
||||||
|
app.use(env.STORAGE_PUBLIC_PREFIX, express.static(env.STORAGE_DIR));
|
||||||
|
|
||||||
|
// Health
|
||||||
|
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/api/user', userRoutes);
|
||||||
|
app.use('/api/projects', projectRoutes);
|
||||||
|
app.use('/api/builds', buildRoutes);
|
||||||
|
app.use('/api/automation', automationRouter);
|
||||||
|
app.use('/api/templates', templatesRouter);
|
||||||
|
app.use('/api/plans', plansRouter);
|
||||||
|
app.use('/api/credit-packs', creditPacksRouter);
|
||||||
|
app.use('/api/chat', chatRouter);
|
||||||
|
app.use('/api/bank-transfers', bankTransferRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
app.use('/api/storage', storageRoutes);
|
||||||
|
app.use('/api/db', dbRoutes);
|
||||||
|
app.use('/api/functions', functionRoutes);
|
||||||
|
app.use('/api/setup', setupRouter);
|
||||||
|
|
||||||
|
// Fallback for unknown API routes
|
||||||
|
app.use('/api', (_req, res) => res.status(404).json({ error: 'Not found' }));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { betterAuth } from 'better-auth';
|
||||||
|
import { pool, one, query } from './db.js';
|
||||||
|
import { env } from './env.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Better Auth instance — email/password (+ optional Google), backed by the
|
||||||
|
* external Postgres pool.
|
||||||
|
*
|
||||||
|
* On user creation we bootstrap the app-specific rows (profiles, user_credits,
|
||||||
|
* free subscription, first-admin role) via a database hook.
|
||||||
|
*/
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: pool,
|
||||||
|
secret: env.BETTER_AUTH_SECRET,
|
||||||
|
baseURL: env.BETTER_AUTH_URL,
|
||||||
|
trustedOrigins: [
|
||||||
|
env.BETTER_AUTH_URL,
|
||||||
|
'http://localhost:8080',
|
||||||
|
'http://localhost:8787',
|
||||||
|
],
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
requireEmailVerification: false,
|
||||||
|
minPasswordLength: 6,
|
||||||
|
autoSignIn: true,
|
||||||
|
},
|
||||||
|
socialProviders:
|
||||||
|
env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
||||||
|
? {
|
||||||
|
google: {
|
||||||
|
clientId: env.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
user: {
|
||||||
|
additionalFields: {},
|
||||||
|
},
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
after: async (user: any) => {
|
||||||
|
try {
|
||||||
|
await bootstrapNewUser(user.id, user.email, user.name);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[auth] bootstrapNewUser failed:', (e as Error).message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function bootstrapNewUser(userId: string, email: string, name?: string | null) {
|
||||||
|
const displayName = name || (email ? email.split('@')[0] : 'User');
|
||||||
|
|
||||||
|
// profile
|
||||||
|
await query(
|
||||||
|
`INSERT INTO public.profiles (id, email, display_name)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email`,
|
||||||
|
[userId, email, displayName]
|
||||||
|
);
|
||||||
|
|
||||||
|
// default signup credits from system_settings (fallback 5)
|
||||||
|
const setting = await one<{ value: any }>(
|
||||||
|
`SELECT value FROM public.system_settings WHERE key = 'default_signup_credits' LIMIT 1`
|
||||||
|
);
|
||||||
|
let defaultCredits = 5;
|
||||||
|
if (setting && setting.value != null) {
|
||||||
|
const parsed = typeof setting.value === 'number' ? setting.value : parseInt(String(setting.value), 10);
|
||||||
|
if (!Number.isNaN(parsed)) defaultCredits = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO public.user_credits (user_id, monthly_credits, bonus_credits, credits_reset_at)
|
||||||
|
VALUES ($1, $2, 0, now() + interval '1 month')
|
||||||
|
ON CONFLICT (user_id) DO NOTHING`,
|
||||||
|
[userId, defaultCredits]
|
||||||
|
);
|
||||||
|
|
||||||
|
// free subscription
|
||||||
|
const freePlan = await one<{ id: string }>(
|
||||||
|
`SELECT id FROM public.subscription_plans WHERE tier = 'free' LIMIT 1`
|
||||||
|
);
|
||||||
|
if (freePlan) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO public.user_subscriptions (user_id, plan_id, current_period_end)
|
||||||
|
VALUES ($1, $2, now() + interval '1 month')
|
||||||
|
ON CONFLICT (user_id) DO NOTHING`,
|
||||||
|
[userId, freePlan.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// first-admin assignment
|
||||||
|
const adminCount = await one<{ count: string }>(
|
||||||
|
`SELECT count(*)::text AS count FROM public.user_roles WHERE role = 'admin'`
|
||||||
|
);
|
||||||
|
const noAdmins = !adminCount || adminCount.count === '0';
|
||||||
|
const isInitialAdmin =
|
||||||
|
env.INITIAL_ADMIN_EMAIL && email && email.toLowerCase() === env.INITIAL_ADMIN_EMAIL;
|
||||||
|
|
||||||
|
if (isInitialAdmin && noAdmins) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO public.user_roles (user_id, role) VALUES ($1, 'admin')
|
||||||
|
ON CONFLICT (user_id, role) DO NOTHING`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
import { env } from './env.js';
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
// Strip libpq-style sslmode from the URL so it doesn't force certificate
|
||||||
|
// verification; we control TLS via the `ssl` option below instead.
|
||||||
|
function stripSslMode(url: string): string {
|
||||||
|
return url.replace(/([?&])sslmode=[^&]*/i, '$1').replace(/[?&]$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: stripSslMode(env.DATABASE_URL),
|
||||||
|
ssl: env.PGSSL_NO_VERIFY ? { rejectUnauthorized: false } : undefined,
|
||||||
|
max: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error('[pg] unexpected pool error:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Run a query and return rows. */
|
||||||
|
export async function query<T = any>(text: string, params: any[] = []): Promise<T[]> {
|
||||||
|
const res = await pool.query(text, params);
|
||||||
|
return res.rows as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a query and return the first row (or null). */
|
||||||
|
export async function one<T = any>(text: string, params: any[] = []): Promise<T | null> {
|
||||||
|
const rows = await query<T>(text, params);
|
||||||
|
return rows.length ? rows[0] : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Load .env from the project root (one level above /server)
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const ROOT = path.resolve(__dirname, '../../');
|
||||||
|
dotenv.config({ path: path.join(ROOT, '.env') });
|
||||||
|
|
||||||
|
export const ROOT_DIR = ROOT;
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL || '',
|
||||||
|
PGSSL_NO_VERIFY: process.env.PGSSL_NO_VERIFY === 'true',
|
||||||
|
PORT: parseInt(process.env.PORT || '8787', 10),
|
||||||
|
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||||
|
|
||||||
|
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET || 'dev-insecure-secret',
|
||||||
|
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL || 'http://localhost:8080',
|
||||||
|
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || '',
|
||||||
|
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET || '',
|
||||||
|
|
||||||
|
STORAGE_DIR: path.isAbsolute(process.env.STORAGE_DIR || '')
|
||||||
|
? (process.env.STORAGE_DIR as string)
|
||||||
|
: path.join(ROOT, process.env.STORAGE_DIR || 'server/storage'),
|
||||||
|
STORAGE_PUBLIC_PREFIX: process.env.STORAGE_PUBLIC_PREFIX || '/storage',
|
||||||
|
|
||||||
|
INITIAL_ADMIN_EMAIL: (process.env.INITIAL_ADMIN_EMAIL || '').toLowerCase().trim(),
|
||||||
|
|
||||||
|
// Optional integration secrets
|
||||||
|
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY || '',
|
||||||
|
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||||
|
PAYPAL_CLIENT_ID: process.env.PAYPAL_CLIENT_ID || '',
|
||||||
|
PAYPAL_CLIENT_SECRET: process.env.PAYPAL_CLIENT_SECRET || '',
|
||||||
|
COINBASE_API_KEY: process.env.COINBASE_API_KEY || '',
|
||||||
|
CODEMAGIC_API_TOKEN: process.env.CODEMAGIC_API_TOKEN || '',
|
||||||
|
CODEMAGIC_APP_ID: process.env.CODEMAGIC_APP_ID || '',
|
||||||
|
RESEND_API_KEY: process.env.RESEND_API_KEY || '',
|
||||||
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!env.DATABASE_URL) {
|
||||||
|
console.error('[env] DATABASE_URL is not set. The server cannot start.');
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { env } from '../env.js';
|
||||||
|
import { one } from '../db.js';
|
||||||
|
|
||||||
|
const systemPrompt = `You are an expert mobile app designer. Analyze the given website URL and suggest optimal mobile app configuration settings. Respond ONLY with valid JSON (no markdown) with fields: app_name (max 20 chars), primary_color (hex), accent_color (hex), navigation_style (one of bottom-nav, drawer, tabs), features (array of 3-6 of offline_mode, push_notifications, dark_mode, share, favorites, search), app_category (news, shopping, social, business, education, entertainment, lifestyle, utility), description (max 100 chars), icon_style (modern, classic, minimal, rounded, gradient), splash_screen_style (centered-logo, full-bleed, minimal, animated).`;
|
||||||
|
|
||||||
|
function domainOf(websiteUrl: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(websiteUrl).hostname.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return websiteUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function heuristicConfig(websiteUrl: string) {
|
||||||
|
const domain = domainOf(websiteUrl);
|
||||||
|
const base = domain.split('.')[0] || 'app';
|
||||||
|
return {
|
||||||
|
app_name: base.charAt(0).toUpperCase() + base.slice(1),
|
||||||
|
primary_color: '#3B82F6',
|
||||||
|
accent_color: '#8B5CF6',
|
||||||
|
navigation_style: 'bottom-nav',
|
||||||
|
features: ['offline_mode', 'push_notifications', 'dark_mode'],
|
||||||
|
app_category: 'utility',
|
||||||
|
description: `Mobile app for ${domain}`,
|
||||||
|
icon_style: 'modern',
|
||||||
|
splash_screen_style: 'centered-logo',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callOpenAI(apiKey: string, websiteUrl: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: `Analyze this website: ${websiteUrl} (domain: ${domainOf(websiteUrl)}). Return JSON only.` },
|
||||||
|
],
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 600,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
let content = (data.choices?.[0]?.message?.content || '').trim();
|
||||||
|
if (content.startsWith('```json')) content = content.slice(7);
|
||||||
|
else if (content.startsWith('```')) content = content.slice(3);
|
||||||
|
if (content.endsWith('```')) content = content.slice(0, -3);
|
||||||
|
return JSON.parse(content.trim());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns { config } — AI-powered when OPENAI_API_KEY (or an admin-configured
|
||||||
|
* AI provider) is available, otherwise a sensible heuristic so the builder
|
||||||
|
* flow always works.
|
||||||
|
*/
|
||||||
|
export async function analyzeWebsite(websiteUrl: string): Promise<{ config: any }> {
|
||||||
|
// env key first
|
||||||
|
let apiKey = env.OPENAI_API_KEY;
|
||||||
|
|
||||||
|
// admin-configured AI provider (api_configurations) as fallback
|
||||||
|
if (!apiKey) {
|
||||||
|
const cfg = await one<{ config: any }>(
|
||||||
|
`SELECT config FROM public.api_configurations WHERE provider = 'ai' AND is_active = true LIMIT 1`
|
||||||
|
).catch(() => null);
|
||||||
|
apiKey = cfg?.config?.openai_api_key || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
const aiConfig = await callOpenAI(apiKey, websiteUrl);
|
||||||
|
if (aiConfig) return { config: aiConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: heuristicConfig(websiteUrl) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { query, one } from '../db.js';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
|
||||||
|
interface TriggerArgs {
|
||||||
|
buildId: string;
|
||||||
|
websiteUrl: string;
|
||||||
|
appName: string;
|
||||||
|
platform: string;
|
||||||
|
packageName: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a cloud build.
|
||||||
|
*
|
||||||
|
* If CODEMAGIC_API_TOKEN + CODEMAGIC_APP_ID are configured, this calls the
|
||||||
|
* Codemagic API for real. Otherwise it runs a local SIMULATION so the build
|
||||||
|
* flow is demonstrable in development (progress + placeholder artifact).
|
||||||
|
*/
|
||||||
|
export async function triggerCloudBuild(args: TriggerArgs): Promise<{ cloudBuildId?: string; message: string }> {
|
||||||
|
if (env.CODEMAGIC_API_TOKEN && env.CODEMAGIC_APP_ID) {
|
||||||
|
const res = await fetch('https://api.codemagic.io/builds', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-auth-token': env.CODEMAGIC_API_TOKEN },
|
||||||
|
body: JSON.stringify({
|
||||||
|
appId: env.CODEMAGIC_APP_ID,
|
||||||
|
workflowId: args.platform === 'ios' ? 'ios-workflow' : 'android-workflow',
|
||||||
|
branch: 'main',
|
||||||
|
environment: {
|
||||||
|
variables: {
|
||||||
|
APP_NAME: args.appName,
|
||||||
|
WEBSITE_URL: args.websiteUrl,
|
||||||
|
PACKAGE_NAME: args.packageName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json: any = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(json?.error || json?.message || 'Codemagic build trigger failed');
|
||||||
|
const cloudBuildId = json?.buildId || json?._id;
|
||||||
|
await query(`UPDATE public.app_builds SET status = 'building', progress = 5 WHERE id = $1`, [args.buildId]);
|
||||||
|
return { cloudBuildId, message: 'Cloud build started (Codemagic)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Simulation (no Codemagic configured) ----
|
||||||
|
await query(`UPDATE public.app_builds SET status = 'building', progress = 10 WHERE id = $1`, [args.buildId]);
|
||||||
|
simulateBuild(args.buildId).catch((e) => console.error('[cloud-build sim]', e.message));
|
||||||
|
return { message: 'Cloud build simulated (Codemagic not configured)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function simulateBuild(buildId: string) {
|
||||||
|
const steps = [25, 45, 65, 85];
|
||||||
|
for (const p of steps) {
|
||||||
|
await delay(2000);
|
||||||
|
await query(`UPDATE public.app_builds SET progress = $2 WHERE id = $1 AND status = 'building'`, [buildId, p]);
|
||||||
|
}
|
||||||
|
await delay(2000);
|
||||||
|
const build = await one<any>(`SELECT package_name FROM public.app_builds WHERE id = $1`, [buildId]);
|
||||||
|
const fileName = `${(build?.package_name || 'app').replace(/\./g, '-')}.apk`;
|
||||||
|
await query(
|
||||||
|
`UPDATE public.app_builds
|
||||||
|
SET status = 'complete', progress = 100,
|
||||||
|
download_url = $2, file_size_bytes = $3
|
||||||
|
WHERE id = $1 AND status = 'building'`,
|
||||||
|
[buildId, `/storage/apk-builds/simulated/${fileName}`, 12 * 1024 * 1024]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||||||
|
|
||||||
|
/** Returns the current build row (used by the status endpoint). */
|
||||||
|
export async function getCloudBuildStatus(buildId: string) {
|
||||||
|
return one(`SELECT * FROM public.app_builds WHERE id = $1`, [buildId]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
import { query, one } from '../db.js';
|
||||||
|
import { ensureBuckets, BUCKETS } from '../lib/storage.js';
|
||||||
|
import { analyzeWebsite } from './analyze-website.js';
|
||||||
|
import { triggerCloudBuild, getCloudBuildStatus } from './cloud-build.js';
|
||||||
|
import type { AuthedRequest } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side functions (payments, build, email, AI, storage helpers).
|
||||||
|
* Reachable at both POST /api/functions/:name (primary) and
|
||||||
|
* GET|POST /api/functions/v1/:name (alias used by some admin screens).
|
||||||
|
*/
|
||||||
|
const r = Router();
|
||||||
|
|
||||||
|
type Ctx = { body: any; query: any; req: AuthedRequest };
|
||||||
|
type Handler = (ctx: Ctx) => Promise<any>;
|
||||||
|
|
||||||
|
const NOT_CONFIGURED = (gateway: string) =>
|
||||||
|
new Error(`${gateway} is not configured. Add the API keys to your server .env to enable it.`);
|
||||||
|
|
||||||
|
const handlers: Record<string, Handler> = {
|
||||||
|
'analyze-website': async ({ body }) => {
|
||||||
|
const url = body?.websiteUrl || body?.url;
|
||||||
|
if (!url) throw new Error('websiteUrl is required');
|
||||||
|
return analyzeWebsite(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
'cloud-build': async ({ body }) => triggerCloudBuild(body),
|
||||||
|
|
||||||
|
'cloud-build-status': async ({ body }) => {
|
||||||
|
const status = await getCloudBuildStatus(body?.buildId);
|
||||||
|
if (!status) throw new Error('Build not found');
|
||||||
|
return status;
|
||||||
|
},
|
||||||
|
|
||||||
|
'send-email': async ({ body }) => {
|
||||||
|
if (!env.RESEND_API_KEY) {
|
||||||
|
console.log('[send-email] (stub) would send:', body?.to, body?.subject);
|
||||||
|
return { success: true, stubbed: true, message: 'Email logged (Resend not configured)' };
|
||||||
|
}
|
||||||
|
const res = await fetch('https://api.resend.com/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: body.from || 'AppForge <onboarding@resend.dev>',
|
||||||
|
to: body.to, subject: body.subject, html: body.html || body.htmlContent,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json?.message || 'Email send failed');
|
||||||
|
return { success: true, id: json.id };
|
||||||
|
},
|
||||||
|
|
||||||
|
'test-storage-connection': async () => {
|
||||||
|
ensureBuckets();
|
||||||
|
return { success: true, message: 'Local storage is available', provider: 'local-filesystem' };
|
||||||
|
},
|
||||||
|
|
||||||
|
'storage-admin': async ({ query: q }) => {
|
||||||
|
ensureBuckets();
|
||||||
|
const action = q?.action || 'list';
|
||||||
|
if (action === 'list') {
|
||||||
|
return BUCKETS.map((name) => ({
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
public: name !== 'project-assets',
|
||||||
|
file_size_limit: null,
|
||||||
|
created_at: new Date(0).toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// create/delete are no-ops for fixed local buckets
|
||||||
|
return { success: true, message: `Local storage uses fixed buckets; '${action}' is a no-op.` };
|
||||||
|
},
|
||||||
|
|
||||||
|
'ai-assistant': async ({ body }) => {
|
||||||
|
const messages = body?.messages || (body?.message ? [{ role: 'user', content: body.message }] : []);
|
||||||
|
const apiKey = env.OPENAI_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return { reply: 'The AI assistant is not configured. Add OPENAI_API_KEY to the server .env to enable it.', stubbed: true };
|
||||||
|
}
|
||||||
|
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: 'gpt-4o-mini', messages, temperature: 0.7 }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json?.error?.message || 'AI request failed');
|
||||||
|
return { reply: json.choices?.[0]?.message?.content || '' };
|
||||||
|
},
|
||||||
|
|
||||||
|
'reset-demo-data': async () => ({ success: true, message: 'Demo data reset not applicable in self-hosted mode' }),
|
||||||
|
|
||||||
|
'retry-webhook': async ({ body }) => {
|
||||||
|
if (!body?.id) throw new Error('Webhook log id required');
|
||||||
|
await query(`UPDATE public.webhook_event_logs SET status = 'retried' WHERE id = $1`, [body.id]);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
'stripe-checkout': async () => { throw NOT_CONFIGURED('Stripe'); },
|
||||||
|
'stripe-portal': async () => { throw NOT_CONFIGURED('Stripe'); },
|
||||||
|
'stripe-webhook': async () => ({ received: true }),
|
||||||
|
'paypal-checkout': async () => { throw NOT_CONFIGURED('PayPal'); },
|
||||||
|
'paypal-billing': async () => { throw NOT_CONFIGURED('PayPal'); },
|
||||||
|
'paypal-webhook': async () => ({ received: true }),
|
||||||
|
'coinbase-checkout': async () => { throw NOT_CONFIGURED('Coinbase'); },
|
||||||
|
'coinbase-webhook': async () => ({ received: true }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUBLIC = new Set(['cloud-build-status', 'test-storage-connection', 'storage-admin',
|
||||||
|
'stripe-webhook', 'paypal-webhook', 'coinbase-webhook']);
|
||||||
|
|
||||||
|
async function dispatch(req: AuthedRequest, res: any) {
|
||||||
|
const name = req.params.name;
|
||||||
|
|
||||||
|
// health probe used by edge-function-health.ts
|
||||||
|
if (req.query?.health) return res.json({ ok: true, function: name });
|
||||||
|
|
||||||
|
const handler = handlers[name];
|
||||||
|
if (!handler) return res.status(404).json({ error: `Unknown function: ${name}` });
|
||||||
|
if (!PUBLIC.has(name) && !req.userId) return res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await handler({ body: req.body || {}, query: req.query || {}, req });
|
||||||
|
res.json(result ?? null);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ error: err?.message || 'Function failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.post('/:name', dispatch);
|
||||||
|
r.get('/:name', dispatch);
|
||||||
|
r.post('/v1/:name', dispatch);
|
||||||
|
r.get('/v1/:name', dispatch);
|
||||||
|
|
||||||
|
export default r;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import './env.js';
|
||||||
|
import { env } from './env.js';
|
||||||
|
import { createApp } from './app.js';
|
||||||
|
import { pool } from './db.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// verify DB connectivity early
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
console.log('[server] connected to Postgres');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[server] FAILED to connect to Postgres:', (e as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
app.listen(env.PORT, () => {
|
||||||
|
console.log(`[server] AppForge API listening on http://localhost:${env.PORT}`);
|
||||||
|
console.log(`[server] storage dir: ${env.STORAGE_DIR}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { pool } from '../db.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reimplements public.use_credits(uuid, integer, text, text).
|
||||||
|
* Deducts from bonus first, then monthly, atomically, and logs usage.
|
||||||
|
* Returns true on success, false if insufficient credits / no record.
|
||||||
|
*/
|
||||||
|
export async function useCredits(
|
||||||
|
userId: string,
|
||||||
|
amount: number,
|
||||||
|
actionType = 'app_build',
|
||||||
|
description: string | null = null
|
||||||
|
): Promise<boolean> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const cur = await client.query(
|
||||||
|
`SELECT monthly_credits, bonus_credits FROM public.user_credits WHERE user_id = $1 FOR UPDATE`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
if (cur.rowCount === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const monthly = cur.rows[0].monthly_credits as number;
|
||||||
|
const bonus = cur.rows[0].bonus_credits as number;
|
||||||
|
if (monthly + bonus < amount) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = amount;
|
||||||
|
if (bonus >= remaining) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE public.user_credits SET bonus_credits = bonus_credits - $2, updated_at = now() WHERE user_id = $1`,
|
||||||
|
[userId, remaining]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
remaining -= bonus;
|
||||||
|
await client.query(
|
||||||
|
`UPDATE public.user_credits SET bonus_credits = 0, monthly_credits = monthly_credits - $2, updated_at = now() WHERE user_id = $1`,
|
||||||
|
[userId, remaining]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.credit_usage_history (user_id, amount, action_type, description) VALUES ($1, $2, $3, $4)`,
|
||||||
|
[userId, amount, actionType, description]
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reimplements public.add_credits(uuid, integer, text, text, text).
|
||||||
|
* credit_type 'monthly' adds to monthly_credits, otherwise bonus_credits.
|
||||||
|
*/
|
||||||
|
export async function addCredits(
|
||||||
|
userId: string,
|
||||||
|
amount: number,
|
||||||
|
creditType = 'bonus',
|
||||||
|
actionType = 'purchase',
|
||||||
|
description: string | null = null
|
||||||
|
): Promise<boolean> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const column = creditType === 'monthly' ? 'monthly_credits' : 'bonus_credits';
|
||||||
|
const res = await client.query(
|
||||||
|
`UPDATE public.user_credits SET ${column} = ${column} + $2, updated_at = now() WHERE user_id = $1`,
|
||||||
|
[userId, amount]
|
||||||
|
);
|
||||||
|
if (res.rowCount === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// negative amount indicates credit added (matches original convention)
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.credit_usage_history (user_id, amount, action_type, description) VALUES ($1, $2, $3, $4)`,
|
||||||
|
[userId, -amount, actionType, description]
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an async route handler so it returns its resolved value as the JSON body
|
||||||
|
* and converts thrown errors into a 400 `{ error }` shape that the frontend
|
||||||
|
* `{ data, error }` client understands.
|
||||||
|
*
|
||||||
|
* Return a plain value → 200 { ...value }.
|
||||||
|
* Throw an Error → 400 { error: message }.
|
||||||
|
*/
|
||||||
|
export function h(
|
||||||
|
fn: (req: Request, res: Response) => Promise<any>
|
||||||
|
) {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const result = await fn(req, res);
|
||||||
|
if (res.headersSent) return;
|
||||||
|
res.json(result ?? null);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (res.headersSent) return next(err);
|
||||||
|
const status = err?.status || 400;
|
||||||
|
res.status(status).json({ error: err?.message || 'Request failed' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(message: string, status = 400) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { one } from '../db.js';
|
||||||
|
|
||||||
|
export type AppRole = 'admin' | 'moderator' | 'user';
|
||||||
|
|
||||||
|
/** Reimplements public.has_role(uuid, app_role). */
|
||||||
|
export async function hasRole(userId: string, role: AppRole): Promise<boolean> {
|
||||||
|
const row = await one<{ exists: boolean }>(
|
||||||
|
`SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = $1 AND role = $2) AS exists`,
|
||||||
|
[userId, role]
|
||||||
|
);
|
||||||
|
return !!row?.exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reimplements public.get_user_role(uuid). Returns null if none. */
|
||||||
|
export async function getUserRole(userId: string): Promise<AppRole | null> {
|
||||||
|
const row = await one<{ role: AppRole }>(
|
||||||
|
`SELECT role FROM public.user_roles WHERE user_id = $1 LIMIT 1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return row?.role ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reimplements public.no_admin_exists(). */
|
||||||
|
export async function noAdminExists(): Promise<boolean> {
|
||||||
|
const row = await one<{ exists: boolean }>(
|
||||||
|
`SELECT NOT EXISTS (SELECT 1 FROM public.user_roles WHERE role = 'admin') AS exists`
|
||||||
|
);
|
||||||
|
return !!row?.exists;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { one } from '../db.js';
|
||||||
|
|
||||||
|
const JSON_COLUMNS = new Set([
|
||||||
|
'config', 'features', 'variables', 'value', 'metadata', 'items',
|
||||||
|
'sandbox_config', 'live_config', 'payload', 'old_value', 'new_value',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function encode(col: string, val: any): any {
|
||||||
|
if (val !== null && typeof val === 'object' && JSON_COLUMNS.has(col) && !Array.isArray(val)) {
|
||||||
|
return JSON.stringify(val);
|
||||||
|
}
|
||||||
|
// jsonb scalar columns (value) may receive primitives — let pg handle arrays via text[]
|
||||||
|
if (JSON_COLUMNS.has(col) && (typeof val === 'number' || typeof val === 'boolean')) {
|
||||||
|
return JSON.stringify(val);
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a row from a plain object and return the inserted row. */
|
||||||
|
export async function insertRow<T = any>(table: string, obj: Record<string, any>): Promise<T> {
|
||||||
|
const keys = Object.keys(obj).filter((k) => obj[k] !== undefined);
|
||||||
|
const cols = keys.map((k) => `"${k}"`).join(', ');
|
||||||
|
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
|
||||||
|
const params = keys.map((k) => encode(k, obj[k]));
|
||||||
|
const row = await one<T>(
|
||||||
|
`INSERT INTO public.${table} (${cols}) VALUES (${placeholders}) RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return row as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a row by id from a plain object and return the updated row. */
|
||||||
|
export async function updateById<T = any>(
|
||||||
|
table: string,
|
||||||
|
id: string,
|
||||||
|
updates: Record<string, any>,
|
||||||
|
idCol = 'id'
|
||||||
|
): Promise<T | null> {
|
||||||
|
const keys = Object.keys(updates).filter((k) => updates[k] !== undefined && k !== idCol);
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return one<T>(`SELECT * FROM public.${table} WHERE "${idCol}" = $1`, [id]);
|
||||||
|
}
|
||||||
|
const sets = keys.map((k, i) => `"${k}" = $${i + 2}`).join(', ');
|
||||||
|
const params = [id, ...keys.map((k) => encode(k, updates[k]))];
|
||||||
|
return one<T>(
|
||||||
|
`UPDATE public.${table} SET ${sets} WHERE "${idCol}" = $1 RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
|
||||||
|
export const BUCKETS = ['avatars', 'app-icons', 'splash-screens', 'apk-builds', 'project-assets'];
|
||||||
|
|
||||||
|
function safeJoin(base: string, target: string): string {
|
||||||
|
const resolved = path.resolve(base, target);
|
||||||
|
if (!resolved.startsWith(path.resolve(base))) {
|
||||||
|
throw new Error('Invalid path');
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bucketDir(bucket: string): string {
|
||||||
|
if (!/^[a-z0-9-]+$/.test(bucket)) throw new Error('Invalid bucket name');
|
||||||
|
return path.join(env.STORAGE_DIR, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureBuckets() {
|
||||||
|
for (const b of BUCKETS) {
|
||||||
|
fs.mkdirSync(path.join(env.STORAGE_DIR, b), { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeFile(bucket: string, relPath: string, buffer: Buffer): { path: string; url: string } {
|
||||||
|
const dir = bucketDir(bucket);
|
||||||
|
const dest = safeJoin(dir, relPath);
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||||
|
fs.writeFileSync(dest, buffer);
|
||||||
|
return { path: relPath, url: publicUrl(bucket, relPath) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteFile(bucket: string, relPath: string) {
|
||||||
|
const dir = bucketDir(bucket);
|
||||||
|
const dest = safeJoin(dir, relPath);
|
||||||
|
if (fs.existsSync(dest)) fs.unlinkSync(dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFiles(bucket: string, folder = ''): { name: string; id: string }[] {
|
||||||
|
const dir = bucketDir(bucket);
|
||||||
|
const target = safeJoin(dir, folder);
|
||||||
|
if (!fs.existsSync(target)) return [];
|
||||||
|
return fs.readdirSync(target, { withFileTypes: true }).map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
id: e.isDirectory() ? `${e.name}/` : e.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicUrl(bucket: string, relPath: string): string {
|
||||||
|
return `${env.STORAGE_PUBLIC_PREFIX}/${bucket}/${relPath}`.replace(/\/+/g, '/');
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
|
import { auth } from '../auth.js';
|
||||||
|
import { hasRole } from '../lib/roles.js';
|
||||||
|
|
||||||
|
export interface AuthedRequest extends Request {
|
||||||
|
userId?: string;
|
||||||
|
userEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach session user (if any) to the request. Never throws. */
|
||||||
|
export async function withSession(req: AuthedRequest, _res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) });
|
||||||
|
if (session?.user) {
|
||||||
|
req.userId = session.user.id;
|
||||||
|
req.userEmail = session.user.email;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Require an authenticated user. */
|
||||||
|
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||||
|
if (!req.userId) {
|
||||||
|
return res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Require an admin user. */
|
||||||
|
export async function requireAdmin(req: AuthedRequest, res: Response, next: NextFunction) {
|
||||||
|
if (!req.userId) return res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
const ok = await hasRole(req.userId, 'admin');
|
||||||
|
if (!ok) return res.status(403).json({ error: 'Forbidden: admin only' });
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query, one } from '../db.js';
|
||||||
|
import { insertRow, updateById } from '../lib/sql.js';
|
||||||
|
import { h } from '../lib/respond.js';
|
||||||
|
import { requireAuth, requireAdmin, type AuthedRequest } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const r = Router();
|
||||||
|
|
||||||
|
// checkAdminStatus is available to any authenticated user
|
||||||
|
r.get('/check-status', requireAuth, h(async (req: AuthedRequest) => {
|
||||||
|
const role = await one<{ role: string }>(`SELECT role FROM public.user_roles WHERE user_id = $1 LIMIT 1`, [req.userId]);
|
||||||
|
return { isAdmin: role?.role === 'admin', role: role?.role || 'user' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// everything below requires admin
|
||||||
|
r.use(requireAdmin);
|
||||||
|
|
||||||
|
r.get('/stats', h(async () => {
|
||||||
|
const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
|
||||||
|
const today = todayStart.toISOString();
|
||||||
|
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString();
|
||||||
|
|
||||||
|
const num = async (sql: string, p: any[] = []) =>
|
||||||
|
parseInt((await one<{ c: string }>(sql, p))?.c || '0', 10);
|
||||||
|
|
||||||
|
const [totalUsers, totalBuilds, totalProjects, activeSubscriptions, activeBuilds, newUsersToday, buildsToday] =
|
||||||
|
await Promise.all([
|
||||||
|
num(`SELECT count(*)::text c FROM public.profiles`),
|
||||||
|
num(`SELECT count(*)::text c FROM public.app_builds`),
|
||||||
|
num(`SELECT count(*)::text c FROM public.app_projects`),
|
||||||
|
num(`SELECT count(*)::text c FROM public.user_subscriptions WHERE status = 'active'`),
|
||||||
|
num(`SELECT count(*)::text c FROM public.app_builds WHERE status IN ('pending','building')`),
|
||||||
|
num(`SELECT count(*)::text c FROM public.profiles WHERE created_at >= $1`, [today]),
|
||||||
|
num(`SELECT count(*)::text c FROM public.app_builds WHERE created_at >= $1`, [today]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mrrRows = await query<any>(
|
||||||
|
`SELECT s.billing_cycle, p.price_monthly, p.price_yearly
|
||||||
|
FROM public.user_subscriptions s JOIN public.subscription_plans p ON p.id = s.plan_id
|
||||||
|
WHERE s.status = 'active'`
|
||||||
|
);
|
||||||
|
const mrr = mrrRows.reduce((sum, s) =>
|
||||||
|
sum + (s.billing_cycle === 'yearly' ? Number(s.price_yearly) / 12 : Number(s.price_monthly)), 0);
|
||||||
|
|
||||||
|
const totalRevenue = Number((await one<{ s: string }>(
|
||||||
|
`SELECT COALESCE(sum(amount),0)::text s FROM public.payment_transactions WHERE status = 'completed'`))?.s || 0);
|
||||||
|
const monthlyRevenue = Number((await one<{ s: string }>(
|
||||||
|
`SELECT COALESCE(sum(amount),0)::text s FROM public.payment_transactions WHERE status = 'completed' AND created_at >= $1`,
|
||||||
|
[monthStart]))?.s || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers, totalBuilds, totalProjects, totalRevenue,
|
||||||
|
monthlyRevenue: monthlyRevenue || mrr, activeBuilds, activeSubscriptions,
|
||||||
|
mrr, revenue: totalRevenue, newUsersToday, buildsToday,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.get('/users', h(async () => {
|
||||||
|
const profiles = await query<any>(`SELECT * FROM public.profiles ORDER BY created_at DESC LIMIT 100`);
|
||||||
|
if (!profiles.length) return [];
|
||||||
|
const ids = profiles.map((p) => p.id);
|
||||||
|
const roles = await query<any>(`SELECT user_id, role FROM public.user_roles WHERE user_id = ANY($1::text[])`, [ids]);
|
||||||
|
const credits = await query<any>(`SELECT user_id, monthly_credits, bonus_credits FROM public.user_credits WHERE user_id = ANY($1::text[])`, [ids]);
|
||||||
|
const roleMap = new Map<string, any[]>();
|
||||||
|
roles.forEach((r2) => { const a = roleMap.get(r2.user_id) || []; a.push({ role: r2.role }); roleMap.set(r2.user_id, a); });
|
||||||
|
const credMap = new Map(credits.map((c) => [c.user_id, c]));
|
||||||
|
return profiles.map((p) => ({
|
||||||
|
...p,
|
||||||
|
user_roles: roleMap.get(p.id) || [],
|
||||||
|
user_credits: credMap.get(p.id) ? [credMap.get(p.id)] : [],
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.patch('/users/:userId/role', h(async (req) => {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const role = req.body?.role;
|
||||||
|
if (role === null) {
|
||||||
|
await query(`DELETE FROM public.user_roles WHERE user_id = $1`, [userId]);
|
||||||
|
return { message: 'Role removed' };
|
||||||
|
}
|
||||||
|
const existing = await one(`SELECT id FROM public.user_roles WHERE user_id = $1 LIMIT 1`, [userId]);
|
||||||
|
if (existing) {
|
||||||
|
await query(`UPDATE public.user_roles SET role = $2 WHERE user_id = $1`, [userId, role]);
|
||||||
|
return { message: 'Role updated' };
|
||||||
|
}
|
||||||
|
await query(`INSERT INTO public.user_roles (user_id, role) VALUES ($1, $2)`, [userId, role]);
|
||||||
|
return { message: 'Role created' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.get('/transactions', h(async () =>
|
||||||
|
query(`SELECT * FROM public.payment_transactions ORDER BY created_at DESC`)));
|
||||||
|
|
||||||
|
r.get('/builds', h(async () =>
|
||||||
|
query(`SELECT * FROM public.app_builds ORDER BY created_at DESC LIMIT 100`)));
|
||||||
|
|
||||||
|
// plans
|
||||||
|
r.get('/plans', h(async () =>
|
||||||
|
query(`SELECT * FROM public.subscription_plans ORDER BY price_monthly ASC`)));
|
||||||
|
r.post('/plans', h(async (req) => insertRow('subscription_plans', req.body)));
|
||||||
|
r.patch('/plans/:id', h(async (req) => updateById('subscription_plans', req.params.id, req.body)));
|
||||||
|
|
||||||
|
// credit packs
|
||||||
|
r.get('/credit-packs', h(async () =>
|
||||||
|
query(`SELECT * FROM public.credit_packs ORDER BY price ASC`)));
|
||||||
|
r.post('/credit-packs', h(async (req) => insertRow('credit_packs', req.body)));
|
||||||
|
r.patch('/credit-packs/:id', h(async (req) => updateById('credit_packs', req.params.id, req.body)));
|
||||||
|
r.delete('/credit-packs/:id', h(async (req) => {
|
||||||
|
await query(`DELETE FROM public.credit_packs WHERE id = $1`, [req.params.id]);
|
||||||
|
return { message: 'Credit pack deleted' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// system settings
|
||||||
|
r.get('/settings', h(async () => query(`SELECT * FROM public.system_settings ORDER BY key`)));
|
||||||
|
r.patch('/settings/:id', h(async (req) => updateById('system_settings', req.params.id, { value: req.body?.value })));
|
||||||
|
r.put('/settings', h(async (req) => {
|
||||||
|
const { key, value, category = 'general', description = null } = req.body || {};
|
||||||
|
return one(
|
||||||
|
`INSERT INTO public.system_settings (key, value, category, description)
|
||||||
|
VALUES ($1, $2::jsonb, $3, $4)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, category = EXCLUDED.category, description = EXCLUDED.description, updated_at = now()
|
||||||
|
RETURNING *`,
|
||||||
|
[key, JSON.stringify(value ?? null), category, description]
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// email templates
|
||||||
|
r.get('/email-templates', h(async () => query(`SELECT * FROM public.email_templates ORDER BY name`)));
|
||||||
|
r.patch('/email-templates/:id', h(async (req) => updateById('email_templates', req.params.id, req.body)));
|
||||||
|
|
||||||
|
// plugins
|
||||||
|
r.get('/plugins', h(async () => query(`SELECT * FROM public.plugins ORDER BY name`)));
|
||||||
|
r.patch('/plugins/:id', h(async (req) => updateById('plugins', req.params.id, req.body)));
|
||||||
|
|
||||||
|
// api configs
|
||||||
|
r.get('/api-configs', h(async () => query(`SELECT * FROM public.api_configurations ORDER BY name`)));
|
||||||
|
r.post('/api-configs', h(async (req) => insertRow('api_configurations', req.body)));
|
||||||
|
r.patch('/api-configs/:id', h(async (req) => updateById('api_configurations', req.params.id, req.body)));
|
||||||
|
r.delete('/api-configs/:id', h(async (req) => {
|
||||||
|
await query(`DELETE FROM public.api_configurations WHERE id = $1`, [req.params.id]);
|
||||||
|
return { message: 'API config deleted' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// invoices
|
||||||
|
r.get('/invoices', h(async () => query(`SELECT * FROM public.invoices ORDER BY created_at DESC`)));
|
||||||
|
r.post('/invoices', h(async (req) => insertRow('invoices', req.body)));
|
||||||
|
r.patch('/invoices/:id', h(async (req) => updateById('invoices', req.params.id, req.body)));
|
||||||
|
|
||||||
|
// audit log
|
||||||
|
r.get('/settings-audit-log', h(async () =>
|
||||||
|
query(`SELECT * FROM public.settings_audit_log ORDER BY created_at DESC LIMIT 100`)));
|
||||||
|
|
||||||
|
// payment gateway configs (used by admin payment config screens)
|
||||||
|
r.get('/payment-gateways', h(async () => query(`SELECT * FROM public.payment_gateway_configs ORDER BY gateway`)));
|
||||||
|
r.put('/payment-gateways/:gateway', h(async (req) => {
|
||||||
|
const { gateway } = req.params;
|
||||||
|
const { is_enabled, is_test_mode, sandbox_config, live_config } = req.body || {};
|
||||||
|
return one(
|
||||||
|
`INSERT INTO public.payment_gateway_configs (gateway, is_enabled, is_test_mode, sandbox_config, live_config)
|
||||||
|
VALUES ($1,$2,$3,$4::jsonb,$5::jsonb)
|
||||||
|
ON CONFLICT (gateway) DO UPDATE SET
|
||||||
|
is_enabled = COALESCE(EXCLUDED.is_enabled, payment_gateway_configs.is_enabled),
|
||||||
|
is_test_mode = COALESCE(EXCLUDED.is_test_mode, payment_gateway_configs.is_test_mode),
|
||||||
|
sandbox_config = EXCLUDED.sandbox_config, live_config = EXCLUDED.live_config, updated_at = now()
|
||||||
|
RETURNING *`,
|
||||||
|
[gateway, is_enabled ?? null, is_test_mode ?? null, JSON.stringify(sandbox_config ?? {}), JSON.stringify(live_config ?? {})]
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// webhook event logs
|
||||||
|
r.get('/webhook-logs', h(async () =>
|
||||||
|
query(`SELECT * FROM public.webhook_event_logs ORDER BY created_at DESC LIMIT 200`)));
|
||||||
|
|
||||||
|
export default r;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query, one } from '../db.js';
|
||||||
|
import { insertRow } from '../lib/sql.js';
|
||||||
|
import { addCredits } from '../lib/credits.js';
|
||||||
|
import { h } from '../lib/respond.js';
|
||||||
|
import { requireAuth, requireAdmin, type AuthedRequest } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const r = Router();
|
||||||
|
const uid = (req: AuthedRequest) => req.userId as string;
|
||||||
|
|
||||||
|
// ---- user-facing ----
|
||||||
|
r.post('/', requireAuth, h(async (req) => {
|
||||||
|
const { amount, currency, plan_id, credit_pack_id, proof_of_payment_url } = req.body || {};
|
||||||
|
const row = await insertRow<any>('bank_transfer_requests', {
|
||||||
|
user_id: uid(req as any), amount, currency, plan_id, credit_pack_id, proof_of_payment_url,
|
||||||
|
});
|
||||||
|
return { id: row.id };
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.get('/:id/status', requireAuth, h(async (req) =>
|
||||||
|
one(`SELECT status, admin_notes FROM public.bank_transfer_requests WHERE id = $1 AND user_id = $2`,
|
||||||
|
[req.params.id, uid(req as any)])
|
||||||
|
));
|
||||||
|
|
||||||
|
// ---- admin ----
|
||||||
|
r.get('/', requireAdmin, h(async () => {
|
||||||
|
const transfers = await query<any>(`SELECT * FROM public.bank_transfer_requests ORDER BY created_at DESC`);
|
||||||
|
if (!transfers.length) return [];
|
||||||
|
const userIds = [...new Set(transfers.map((t) => t.user_id))];
|
||||||
|
const profiles = await query<any>(
|
||||||
|
`SELECT id, email, display_name FROM public.profiles WHERE id = ANY($1::text[])`, [userIds]
|
||||||
|
);
|
||||||
|
const planIds = [...new Set(transfers.map((t) => t.plan_id).filter(Boolean))];
|
||||||
|
const packIds = [...new Set(transfers.map((t) => t.credit_pack_id).filter(Boolean))];
|
||||||
|
const plans = planIds.length ? await query<any>(`SELECT id, name, monthly_credits FROM public.subscription_plans WHERE id = ANY($1::uuid[])`, [planIds]) : [];
|
||||||
|
const packs = packIds.length ? await query<any>(`SELECT id, name, credits FROM public.credit_packs WHERE id = ANY($1::uuid[])`, [packIds]) : [];
|
||||||
|
const pMap = new Map(profiles.map((p) => [p.id, p]));
|
||||||
|
const planMap = new Map(plans.map((p) => [p.id, p]));
|
||||||
|
const packMap = new Map(packs.map((p) => [p.id, p]));
|
||||||
|
return transfers.map((t) => ({
|
||||||
|
...t,
|
||||||
|
profiles: pMap.get(t.user_id) || null,
|
||||||
|
subscription_plans: t.plan_id ? planMap.get(t.plan_id) || null : null,
|
||||||
|
credit_packs: t.credit_pack_id ? packMap.get(t.credit_pack_id) || null : null,
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.post('/:id/approve', requireAdmin, h(async (req) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const adminNotes = req.body?.adminNotes;
|
||||||
|
const transfer = await one<any>(`SELECT * FROM public.bank_transfer_requests WHERE id = $1`, [id]);
|
||||||
|
if (!transfer) throw new Error('Transfer not found');
|
||||||
|
|
||||||
|
await query(`UPDATE public.bank_transfer_requests SET status = 'approved', admin_notes = $2 WHERE id = $1`,
|
||||||
|
[id, adminNotes || null]);
|
||||||
|
|
||||||
|
if (transfer.credit_pack_id) {
|
||||||
|
const pack = await one<any>(`SELECT credits FROM public.credit_packs WHERE id = $1`, [transfer.credit_pack_id]);
|
||||||
|
if (pack?.credits) {
|
||||||
|
await addCredits(transfer.user_id, pack.credits, 'bonus', 'bank_transfer_purchase',
|
||||||
|
`Bank transfer approved - ${pack.credits} credits`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transfer.plan_id) {
|
||||||
|
const updated = await query(
|
||||||
|
`UPDATE public.user_subscriptions
|
||||||
|
SET plan_id = $2, status = 'active', payment_method = 'bank_transfer',
|
||||||
|
current_period_start = now(), current_period_end = now() + interval '30 days'
|
||||||
|
WHERE user_id = $1`,
|
||||||
|
[transfer.user_id, transfer.plan_id]
|
||||||
|
);
|
||||||
|
// pg doesn't return rowCount via our helper; check existence
|
||||||
|
const exists = await one(`SELECT id FROM public.user_subscriptions WHERE user_id = $1`, [transfer.user_id]);
|
||||||
|
if (!exists) {
|
||||||
|
await insertRow('user_subscriptions', {
|
||||||
|
user_id: transfer.user_id, plan_id: transfer.plan_id, status: 'active',
|
||||||
|
payment_method: 'bank_transfer', current_period_end: new Date(Date.now() + 30 * 864e5).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const plan = await one<any>(`SELECT monthly_credits FROM public.subscription_plans WHERE id = $1`, [transfer.plan_id]);
|
||||||
|
if (plan?.monthly_credits > 0) {
|
||||||
|
await addCredits(transfer.user_id, plan.monthly_credits, 'monthly', 'subscription_activation',
|
||||||
|
`Subscription activated via bank transfer - ${plan.monthly_credits} monthly credits`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await insertRow('payment_transactions', {
|
||||||
|
user_id: transfer.user_id, amount: transfer.amount, currency: transfer.currency,
|
||||||
|
payment_method: 'bank_transfer', transaction_type: transfer.credit_pack_id ? 'credit_purchase' : 'subscription',
|
||||||
|
status: 'completed', reference_id: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.post('/:id/reject', requireAdmin, h(async (req) => {
|
||||||
|
await query(`UPDATE public.bank_transfer_requests SET status = 'rejected', admin_notes = $2 WHERE id = $1`,
|
||||||
|
[req.params.id, req.body?.adminNotes]);
|
||||||
|
return { success: true };
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default r;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query, one } from '../db.js';
|
||||||
|
import { insertRow } from '../lib/sql.js';
|
||||||
|
import { h } from '../lib/respond.js';
|
||||||
|
import { requireAuth, type AuthedRequest } from '../middleware/auth.js';
|
||||||
|
import { triggerCloudBuild, getCloudBuildStatus } from '../functions/cloud-build.js';
|
||||||
|
|
||||||
|
const r = Router();
|
||||||
|
r.use(requireAuth);
|
||||||
|
const uid = (req: AuthedRequest) => req.userId as string;
|
||||||
|
|
||||||
|
function sanitizeSegment(segment: string, fallback: string) {
|
||||||
|
const cleaned = segment.toLowerCase().replace(/[^a-z0-9_]/g, '');
|
||||||
|
if (!cleaned) return fallback;
|
||||||
|
return /^[a-z]/.test(cleaned) ? cleaned : `app${cleaned}`;
|
||||||
|
}
|
||||||
|
function sanitizePackageName(raw: string | undefined, appName: string) {
|
||||||
|
const fallback = ['com', 'app', sanitizeSegment(appName, 'mobile')];
|
||||||
|
const src = (raw || '').split('.').filter(Boolean);
|
||||||
|
const segs = src.length ? src.map((s, i) => sanitizeSegment(s, fallback[i] || 'app')) : fallback;
|
||||||
|
while (segs.length < 3) segs.push(fallback[segs.length] || 'app');
|
||||||
|
return segs.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /builds -> start a build
|
||||||
|
r.post('/', h(async (req) => {
|
||||||
|
const config = req.body || {};
|
||||||
|
const platform = config.platform || 'android';
|
||||||
|
const appName = config.appName || 'My App';
|
||||||
|
const packageName = sanitizePackageName(config.packageName, appName);
|
||||||
|
const normalizedConfig = { ...config, packageName };
|
||||||
|
|
||||||
|
const build = await insertRow<any>('app_builds', {
|
||||||
|
user_id: uid(req as any),
|
||||||
|
app_name: appName,
|
||||||
|
package_name: packageName,
|
||||||
|
website_url: config.websiteUrl,
|
||||||
|
config: normalizedConfig,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await triggerCloudBuild({
|
||||||
|
buildId: build.id,
|
||||||
|
websiteUrl: config.websiteUrl,
|
||||||
|
appName,
|
||||||
|
platform,
|
||||||
|
packageName,
|
||||||
|
config: normalizedConfig,
|
||||||
|
});
|
||||||
|
return { buildId: build.id, cloudBuildId: result.cloudBuildId, message: result.message };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { buildId: build.id, message: 'Build created but cloud trigger failed: ' + e.message };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /builds?limit=
|
||||||
|
r.get('/', h(async (req) => {
|
||||||
|
const limit = Math.min(parseInt(String(req.query.limit ?? '20'), 10) || 20, 200);
|
||||||
|
return query(
|
||||||
|
`SELECT * FROM public.app_builds WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2`,
|
||||||
|
[uid(req as any), limit]
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /builds/:id/status
|
||||||
|
r.get('/:id/status', h(async (req) => {
|
||||||
|
const status = await getCloudBuildStatus(req.params.id);
|
||||||
|
if (status) return status;
|
||||||
|
return one(`SELECT * FROM public.app_builds WHERE id = $1 AND user_id = $2`, [req.params.id, uid(req as any)]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default r;
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query, one } from '../db.js';
|
||||||
|
import { hasRole, noAdminExists } from '../lib/roles.js';
|
||||||
|
import type { AuthedRequest } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic, guarded query endpoint backing the frontend backend-client.
|
||||||
|
* Supports the bounded subset of the query-builder the app actually uses.
|
||||||
|
*
|
||||||
|
* Access rules (RLS replacement):
|
||||||
|
* - PUBLIC_READ tables: readable without auth (were public via RLS).
|
||||||
|
* - ADMIN tables: read + write require admin.
|
||||||
|
* - other tables: read requires auth; write requires admin.
|
||||||
|
* - user_roles: write allowed during the first-admin "setup window"
|
||||||
|
* (no admin exists yet) — mirrors the original first-admin RLS policy.
|
||||||
|
*/
|
||||||
|
const r = Router();
|
||||||
|
|
||||||
|
const ALL_TABLES = new Set([
|
||||||
|
'profiles', 'user_roles', 'user_credits', 'user_subscriptions', 'subscription_plans',
|
||||||
|
'app_projects', 'app_builds', 'app_templates', 'automation_configs', 'automation_logs',
|
||||||
|
'credit_packs', 'credit_usage_history', 'payment_transactions', 'bank_transfer_requests',
|
||||||
|
'chat_messages', 'consent_records', 'email_templates', 'invoices', 'api_configurations',
|
||||||
|
'plugins', 'system_settings', 'settings_audit_log', 'payment_gateway_configs', 'webhook_event_logs',
|
||||||
|
]);
|
||||||
|
const PUBLIC_READ = new Set(['subscription_plans', 'credit_packs', 'system_settings']);
|
||||||
|
const ADMIN_TABLES = new Set([
|
||||||
|
'api_configurations', 'payment_gateway_configs', 'webhook_event_logs',
|
||||||
|
'settings_audit_log', 'plugins', 'email_templates',
|
||||||
|
]);
|
||||||
|
const JSON_COLUMNS = new Set([
|
||||||
|
'config', 'features', 'variables', 'value', 'metadata', 'items',
|
||||||
|
'sandbox_config', 'live_config', 'payload', 'old_value', 'new_value',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OPS: Record<string, string> = { eq: '=', neq: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' };
|
||||||
|
|
||||||
|
function placeholder(col: string, idx: number) {
|
||||||
|
return JSON_COLUMNS.has(col) ? `$${idx}::jsonb` : `$${idx}`;
|
||||||
|
}
|
||||||
|
function enc(col: string, val: any) {
|
||||||
|
if (JSON_COLUMNS.has(col) && val !== null && typeof val !== 'string') return JSON.stringify(val);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWhere(filters: any[], startIdx: number): { clause: string; params: any[] } {
|
||||||
|
if (!filters?.length) return { clause: '', params: [] };
|
||||||
|
const parts: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let i = startIdx;
|
||||||
|
for (const f of filters) {
|
||||||
|
if (f.op === 'in') {
|
||||||
|
params.push(f.val);
|
||||||
|
parts.push(`"${f.col}" = ANY($${i}::text[])`);
|
||||||
|
i++;
|
||||||
|
} else if (OPS[f.op]) {
|
||||||
|
params.push(f.val);
|
||||||
|
parts.push(`"${f.col}" ${OPS[f.op]} $${i}`);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { clause: parts.length ? 'WHERE ' + parts.join(' AND ') : '', params };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachProfiles(rows: any[]) {
|
||||||
|
const ids = [...new Set(rows.map((x) => x.user_id).filter(Boolean))];
|
||||||
|
if (!ids.length) return rows;
|
||||||
|
const profs = await query<any>(
|
||||||
|
`SELECT id, email, display_name FROM public.profiles WHERE id = ANY($1::text[])`, [ids]
|
||||||
|
);
|
||||||
|
const map = new Map(profs.map((p) => [p.id, p]));
|
||||||
|
return rows.map((x) => ({ ...x, profiles: map.get(x.user_id) || null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
r.post('/query', async (req: AuthedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { table, action = 'select', columns, filters = [], order, limit, single, values, onConflict, head, count } = req.body || {};
|
||||||
|
if (!ALL_TABLES.has(table)) return res.status(400).json({ error: `Table not allowed: ${table}` });
|
||||||
|
|
||||||
|
const isAdmin = req.userId ? await hasRole(req.userId, 'admin') : false;
|
||||||
|
const isRead = action === 'select';
|
||||||
|
|
||||||
|
// ---- authorization ----
|
||||||
|
if (isRead) {
|
||||||
|
if (!PUBLIC_READ.has(table)) {
|
||||||
|
if (!req.userId) return res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
if (ADMIN_TABLES.has(table) && !isAdmin) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// writes
|
||||||
|
let allowed = isAdmin;
|
||||||
|
if (!allowed && table === 'user_roles' && req.userId) {
|
||||||
|
// first-admin setup window
|
||||||
|
allowed = await noAdminExists();
|
||||||
|
}
|
||||||
|
if (!allowed && table === 'profiles' && req.userId) {
|
||||||
|
// allow self profile insert/update
|
||||||
|
const selfFilter = (filters || []).find((f: any) => (f.col === 'id') && f.val === req.userId);
|
||||||
|
const selfValue = values && (Array.isArray(values) ? values : [values]).every((v: any) => !v.id || v.id === req.userId);
|
||||||
|
allowed = !!selfFilter || !!selfValue;
|
||||||
|
}
|
||||||
|
if (!allowed) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- execute ----
|
||||||
|
if (action === 'select') {
|
||||||
|
if (head && count === 'exact') {
|
||||||
|
const { clause, params } = buildWhere(filters, 1);
|
||||||
|
const row = await one<{ c: string }>(`SELECT count(*)::text c FROM public.${table} ${clause}`, params);
|
||||||
|
return res.json({ data: null, count: parseInt(row?.c || '0', 10) });
|
||||||
|
}
|
||||||
|
const { clause, params } = buildWhere(filters, 1);
|
||||||
|
let sql = `SELECT * FROM public.${table} ${clause}`;
|
||||||
|
if (order?.col) sql += ` ORDER BY "${order.col}" ${order.ascending === false ? 'DESC' : 'ASC'}`;
|
||||||
|
if (limit) sql += ` LIMIT ${parseInt(String(limit), 10)}`;
|
||||||
|
let rows = await query<any>(sql, params);
|
||||||
|
if (typeof columns === 'string' && columns.includes('profiles(')) rows = await attachProfiles(rows);
|
||||||
|
if (single) return res.json({ data: rows[0] ?? null, count: rows.length });
|
||||||
|
return res.json({ data: rows, count: rows.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'insert' || action === 'upsert') {
|
||||||
|
const list = Array.isArray(values) ? values : [values];
|
||||||
|
const out: any[] = [];
|
||||||
|
for (const obj of list) {
|
||||||
|
const keys = Object.keys(obj).filter((k) => obj[k] !== undefined);
|
||||||
|
const cols = keys.map((k) => `"${k}"`).join(', ');
|
||||||
|
const ph = keys.map((k, idx) => placeholder(k, idx + 1)).join(', ');
|
||||||
|
const params = keys.map((k) => enc(k, obj[k]));
|
||||||
|
let sql = `INSERT INTO public.${table} (${cols}) VALUES (${ph})`;
|
||||||
|
if (action === 'upsert' && onConflict) {
|
||||||
|
const updates = keys.filter((k) => k !== onConflict).map((k) => `"${k}" = EXCLUDED."${k}"`).join(', ');
|
||||||
|
sql += ` ON CONFLICT ("${onConflict}") DO UPDATE SET ${updates || `"${onConflict}" = EXCLUDED."${onConflict}"`}`;
|
||||||
|
}
|
||||||
|
sql += ' RETURNING *';
|
||||||
|
const row = await one(sql, params);
|
||||||
|
out.push(row);
|
||||||
|
}
|
||||||
|
return res.json({ data: single ? out[0] ?? null : out });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'update') {
|
||||||
|
const obj = values || {};
|
||||||
|
const keys = Object.keys(obj).filter((k) => obj[k] !== undefined);
|
||||||
|
const sets = keys.map((k, idx) => `"${k}" = ${placeholder(k, idx + 1)}`).join(', ');
|
||||||
|
const setParams = keys.map((k) => enc(k, obj[k]));
|
||||||
|
const { clause, params } = buildWhere(filters, keys.length + 1);
|
||||||
|
const sql = `UPDATE public.${table} SET ${sets} ${clause} RETURNING *`;
|
||||||
|
const rows = await query<any>(sql, [...setParams, ...params]);
|
||||||
|
return res.json({ data: single ? rows[0] ?? null : rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete') {
|
||||||
|
const { clause, params } = buildWhere(filters, 1);
|
||||||
|
if (!clause) return res.status(400).json({ error: 'Refusing unfiltered delete' });
|
||||||
|
await query(`DELETE FROM public.${table} ${clause}`, params);
|
||||||
|
return res.json({ data: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(400).json({ error: `Unknown action: ${action}` });
|
||||||
|
} catch (err: any) {
|
||||||
|
return res.status(400).json({ error: err?.message || 'Query failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default r;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query, one } from '../db.js';
|
||||||
|
import { insertRow, updateById } from '../lib/sql.js';
|
||||||
|
import { h } from '../lib/respond.js';
|
||||||
|
import { requireAuth, type AuthedRequest } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const uid = (req: AuthedRequest) => req.userId as string;
|
||||||
|
|
||||||
|
// ---------- Automation ----------
|
||||||
|
export const automationRouter = Router();
|
||||||
|
automationRouter.use(requireAuth);
|
||||||
|
|
||||||
|
automationRouter.get('/', h(async (req) => {
|
||||||
|
const projectId = req.query.projectId as string | undefined;
|
||||||
|
const rows = projectId
|
||||||
|
? await query(`SELECT * FROM public.automation_configs WHERE user_id = $1 AND project_id = $2 ORDER BY created_at DESC`, [uid(req as any), projectId])
|
||||||
|
: await query(`SELECT * FROM public.automation_configs WHERE user_id = $1 ORDER BY created_at DESC`, [uid(req as any)]);
|
||||||
|
return { automations: rows };
|
||||||
|
}));
|
||||||
|
automationRouter.post('/', h(async (req) => {
|
||||||
|
const { projectId, workflowType, config } = req.body || {};
|
||||||
|
return insertRow('automation_configs', { user_id: uid(req as any), project_id: projectId, workflow_type: workflowType, config });
|
||||||
|
}));
|
||||||
|
automationRouter.patch('/:id/toggle', h(async (req) => {
|
||||||
|
return updateById('automation_configs', req.params.id, { is_enabled: req.body?.enabled });
|
||||||
|
}));
|
||||||
|
automationRouter.patch('/:id/config', h(async (req) => {
|
||||||
|
return updateById('automation_configs', req.params.id, { config: req.body?.config });
|
||||||
|
}));
|
||||||
|
automationRouter.get('/:id/logs', h(async (req) => {
|
||||||
|
const logs = await query(`SELECT * FROM public.automation_logs WHERE automation_id = $1 ORDER BY created_at DESC`, [req.params.id]);
|
||||||
|
return { logs };
|
||||||
|
}));
|
||||||
|
automationRouter.post('/:id/execute', h(async () => ({ message: 'Automation execution triggered' })));
|
||||||
|
automationRouter.delete('/:id', h(async (req) => {
|
||||||
|
await query(`DELETE FROM public.automation_configs WHERE id = $1 AND user_id = $2`, [req.params.id, uid(req as any)]);
|
||||||
|
return { message: 'Automation deleted' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------- Templates ----------
|
||||||
|
export const templatesRouter = Router();
|
||||||
|
templatesRouter.use(requireAuth);
|
||||||
|
templatesRouter.get('/', h(async (req) =>
|
||||||
|
query(`SELECT * FROM public.app_templates WHERE user_id = $1 ORDER BY created_at DESC`, [uid(req as any)])
|
||||||
|
));
|
||||||
|
templatesRouter.post('/', h(async (req) => {
|
||||||
|
const { name, description, config } = req.body || {};
|
||||||
|
return insertRow('app_templates', { name, description, config, user_id: uid(req as any) });
|
||||||
|
}));
|
||||||
|
templatesRouter.delete('/:id', h(async (req) => {
|
||||||
|
await query(`DELETE FROM public.app_templates WHERE id = $1 AND user_id = $2`, [req.params.id, uid(req as any)]);
|
||||||
|
return { message: 'Template deleted' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------- Plans (public) ----------
|
||||||
|
export const plansRouter = Router();
|
||||||
|
plansRouter.get('/', h(async () =>
|
||||||
|
query(`SELECT * FROM public.subscription_plans WHERE is_active = true ORDER BY price_monthly ASC`)
|
||||||
|
));
|
||||||
|
plansRouter.get('/:id', h(async (req) =>
|
||||||
|
one(`SELECT * FROM public.subscription_plans WHERE id = $1`, [req.params.id])
|
||||||
|
));
|
||||||
|
|
||||||
|
// ---------- Credit packs (public) ----------
|
||||||
|
export const creditPacksRouter = Router();
|
||||||
|
creditPacksRouter.get('/', h(async () =>
|
||||||
|
query(`SELECT * FROM public.credit_packs WHERE is_active = true ORDER BY price ASC`)
|
||||||
|
));
|
||||||
|
creditPacksRouter.get('/:id', h(async (req) =>
|
||||||
|
one(`SELECT * FROM public.credit_packs WHERE id = $1`, [req.params.id])
|
||||||
|
));
|
||||||
|
|
||||||
|
// ---------- Chat ----------
|
||||||
|
export const chatRouter = Router();
|
||||||
|
chatRouter.use(requireAuth);
|
||||||
|
chatRouter.get('/', h(async (req) => {
|
||||||
|
const projectId = req.query.projectId as string;
|
||||||
|
const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 500);
|
||||||
|
return query(
|
||||||
|
`SELECT * FROM public.chat_messages WHERE user_id = $1 AND project_id = $2 ORDER BY created_at ASC LIMIT $3`,
|
||||||
|
[uid(req as any), projectId, limit]
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
chatRouter.post('/', h(async (req) => {
|
||||||
|
const { projectId, role, content } = req.body || {};
|
||||||
|
const row = await insertRow<any>('chat_messages', { user_id: uid(req as any), project_id: projectId, role, content });
|
||||||
|
return { id: row.id };
|
||||||
|
}));
|
||||||
|
chatRouter.delete('/', h(async (req) => {
|
||||||
|
await query(`DELETE FROM public.chat_messages WHERE user_id = $1 AND project_id = $2`, [uid(req as any), req.query.projectId]);
|
||||||
|
return { message: 'Chat history cleared' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------- Setup / public role checks ----------
|
||||||
|
export const setupRouter = Router();
|
||||||
|
import { noAdminExists } from '../lib/roles.js';
|
||||||
|
setupRouter.get('/no-admin-exists', h(async () => ({ result: await noAdminExists() })));
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query, one } from '../db.js';
|
||||||
|
import { insertRow, updateById } from '../lib/sql.js';
|
||||||
|
import { h } from '../lib/respond.js';
|
||||||
|
import { requireAuth, type AuthedRequest } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const r = Router();
|
||||||
|
r.use(requireAuth);
|
||||||
|
const uid = (req: AuthedRequest) => req.userId as string;
|
||||||
|
|
||||||
|
r.get('/', h(async (req) => {
|
||||||
|
return query(
|
||||||
|
`SELECT * FROM public.app_projects WHERE user_id = $1 ORDER BY updated_at DESC`,
|
||||||
|
[uid(req as any)]
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// builds for current user (optionally filtered by project) — must be before /:id
|
||||||
|
r.get('/builds', h(async (req) => {
|
||||||
|
const projectId = req.query.projectId as string | undefined;
|
||||||
|
if (projectId) {
|
||||||
|
return query(
|
||||||
|
`SELECT * FROM public.app_builds WHERE user_id = $1 AND project_id = $2 ORDER BY created_at DESC`,
|
||||||
|
[uid(req as any), projectId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return query(
|
||||||
|
`SELECT * FROM public.app_builds WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||||
|
[uid(req as any)]
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.get('/:id', h(async (req) => {
|
||||||
|
return one(`SELECT * FROM public.app_projects WHERE id = $1 AND user_id = $2`, [req.params.id, uid(req as any)]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.post('/', h(async (req) => {
|
||||||
|
return insertRow('app_projects', { ...req.body, user_id: uid(req as any) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.patch('/:id', h(async (req) => {
|
||||||
|
// ownership check
|
||||||
|
const owned = await one(`SELECT id FROM public.app_projects WHERE id = $1 AND user_id = $2`, [req.params.id, uid(req as any)]);
|
||||||
|
if (!owned) return null;
|
||||||
|
return updateById('app_projects', req.params.id, req.body || {});
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.delete('/:id', h(async (req) => {
|
||||||
|
await query(`DELETE FROM public.app_projects WHERE id = $1 AND user_id = $2`, [req.params.id, uid(req as any)]);
|
||||||
|
return { message: 'Project deleted' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default r;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { h } from '../lib/respond.js';
|
||||||
|
import { requireAuth, type AuthedRequest } from '../middleware/auth.js';
|
||||||
|
import { writeFile, deleteFile, listFiles, publicUrl } from '../lib/storage.js';
|
||||||
|
|
||||||
|
const r = Router();
|
||||||
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 600 * 1024 * 1024 } });
|
||||||
|
|
||||||
|
// POST /storage/upload (multipart: bucket, path, file)
|
||||||
|
r.post('/upload', requireAuth, upload.single('file'), h(async (req: AuthedRequest) => {
|
||||||
|
const bucket = req.body.bucket as string;
|
||||||
|
const relPath = (req.body.path as string) || (req as any).file?.originalname;
|
||||||
|
const file = (req as any).file;
|
||||||
|
if (!file) throw new Error('No file provided');
|
||||||
|
if (!bucket) throw new Error('No bucket provided');
|
||||||
|
const result = writeFile(bucket, relPath, file.buffer);
|
||||||
|
return result;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// DELETE /storage (body: bucket, path)
|
||||||
|
r.delete('/', requireAuth, h(async (req) => {
|
||||||
|
const { bucket, path: relPath } = req.body || {};
|
||||||
|
deleteFile(bucket, relPath);
|
||||||
|
return { message: 'File deleted successfully' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /storage/list?bucket=&folder=
|
||||||
|
r.get('/list', requireAuth, h(async (req) => {
|
||||||
|
return listFiles(req.query.bucket as string, (req.query.folder as string) || '');
|
||||||
|
}));
|
||||||
|
|
||||||
|
// GET /storage/public-url?bucket=&path=
|
||||||
|
r.get('/public-url', h(async (req) => {
|
||||||
|
return { url: publicUrl(req.query.bucket as string, req.query.path as string) };
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default r;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query, one } from '../db.js';
|
||||||
|
import { updateById } from '../lib/sql.js';
|
||||||
|
import { useCredits } from '../lib/credits.js';
|
||||||
|
import { getUserRole } from '../lib/roles.js';
|
||||||
|
import { h } from '../lib/respond.js';
|
||||||
|
import { requireAuth, type AuthedRequest } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const r = Router();
|
||||||
|
r.use(requireAuth);
|
||||||
|
|
||||||
|
const uid = (req: AuthedRequest) => req.userId as string;
|
||||||
|
|
||||||
|
r.get('/profile', h(async (req) => {
|
||||||
|
return one(`SELECT * FROM public.profiles WHERE id = $1`, [uid(req as any)]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.patch('/profile', h(async (req) => {
|
||||||
|
return updateById('profiles', uid(req as any), req.body || {});
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.get('/credits', h(async (req) => {
|
||||||
|
return one(`SELECT * FROM public.user_credits WHERE user_id = $1`, [uid(req as any)]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.post('/credits/use', h(async (req) => {
|
||||||
|
const { amount, actionType, description } = req.body || {};
|
||||||
|
const success = await useCredits(uid(req as any), Number(amount), actionType, description ?? null);
|
||||||
|
return { success };
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.get('/subscription', h(async (req) => {
|
||||||
|
const sub = await one<any>(
|
||||||
|
`SELECT * FROM public.user_subscriptions WHERE user_id = $1`,
|
||||||
|
[uid(req as any)]
|
||||||
|
);
|
||||||
|
if (!sub) return null;
|
||||||
|
const plan = await one(`SELECT * FROM public.subscription_plans WHERE id = $1`, [sub.plan_id]);
|
||||||
|
return { ...sub, plan };
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.get('/credit-history', h(async (req) => {
|
||||||
|
const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 500);
|
||||||
|
return query(
|
||||||
|
`SELECT * FROM public.credit_usage_history WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2`,
|
||||||
|
[uid(req as any), limit]
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.get('/transactions', h(async (req) => {
|
||||||
|
return query(
|
||||||
|
`SELECT * FROM public.payment_transactions WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||||
|
[uid(req as any)]
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.get('/invoices', h(async (req) => {
|
||||||
|
return query(
|
||||||
|
`SELECT * FROM public.invoices WHERE user_id = $1 ORDER BY created_at DESC`,
|
||||||
|
[uid(req as any)]
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.get('/role', h(async (req) => {
|
||||||
|
return { role: await getUserRole(uid(req as any)) };
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.get('/export', h(async (req) => {
|
||||||
|
const id = uid(req as any);
|
||||||
|
const [profile, credits, projects, builds, creditHistory] = await Promise.all([
|
||||||
|
one(`SELECT * FROM public.profiles WHERE id = $1`, [id]),
|
||||||
|
one(`SELECT * FROM public.user_credits WHERE user_id = $1`, [id]),
|
||||||
|
query(`SELECT * FROM public.app_projects WHERE user_id = $1`, [id]),
|
||||||
|
query(`SELECT * FROM public.app_builds WHERE user_id = $1`, [id]),
|
||||||
|
query(`SELECT * FROM public.credit_usage_history WHERE user_id = $1`, [id]),
|
||||||
|
]);
|
||||||
|
return { profile, credits, projects, builds, creditHistory, exportedAt: new Date().toISOString() };
|
||||||
|
}));
|
||||||
|
|
||||||
|
r.delete('/account', h(async (req) => {
|
||||||
|
const id = uid(req as any);
|
||||||
|
await query(`DELETE FROM public.credit_usage_history WHERE user_id = $1`, [id]);
|
||||||
|
await query(`DELETE FROM public.user_credits WHERE user_id = $1`, [id]);
|
||||||
|
await query(`DELETE FROM public.user_subscriptions WHERE user_id = $1`, [id]);
|
||||||
|
await query(`DELETE FROM public.app_builds WHERE user_id = $1`, [id]);
|
||||||
|
await query(`DELETE FROM public.app_projects WHERE user_id = $1`, [id]);
|
||||||
|
await query(`DELETE FROM public.user_roles WHERE user_id = $1`, [id]);
|
||||||
|
await query(`DELETE FROM public.profiles WHERE id = $1`, [id]);
|
||||||
|
// Better Auth user + sessions
|
||||||
|
await query(`DELETE FROM "session" WHERE "userId" = $1`, [id]).catch(() => {});
|
||||||
|
await query(`DELETE FROM "account" WHERE "userId" = $1`, [id]).catch(() => {});
|
||||||
|
await query(`DELETE FROM "user" WHERE id = $1`, [id]).catch(() => {});
|
||||||
|
return { message: 'Account deleted' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default r;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { lazy, Suspense, useState, useEffect, type ReactNode } from "react";
|
||||||
|
import { backend } from "@/lib/backend-client";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||||
|
import CookieConsent from "@/components/CookieConsent";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import { useSetupCheck } from "@/hooks/useSetupCheck";
|
||||||
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
import Maintenance from "@/pages/Maintenance";
|
||||||
|
import CustomCSSInjector from "@/components/CustomCSSInjector";
|
||||||
|
|
||||||
|
|
||||||
|
// Lazy load all pages for code splitting
|
||||||
|
const Index = lazy(() => import("./pages/Index"));
|
||||||
|
const AppBuilder = lazy(() => import("./pages/AppBuilder"));
|
||||||
|
const Auth = lazy(() => import("./pages/Auth"));
|
||||||
|
const Dashboard = lazy(() => import("./pages/Dashboard"));
|
||||||
|
const Settings = lazy(() => import("./pages/Settings"));
|
||||||
|
const Subscription = lazy(() => import("./pages/Subscription"));
|
||||||
|
const Privacy = lazy(() => import("./pages/Privacy"));
|
||||||
|
const Terms = lazy(() => import("./pages/Terms"));
|
||||||
|
const Install = lazy(() => import("./pages/Install"));
|
||||||
|
const Admin = lazy(() => import("./pages/Admin"));
|
||||||
|
const AdminSetup = lazy(() => import("./pages/AdminSetup"));
|
||||||
|
const StyleGuide = lazy(() => import("./pages/StyleGuide"));
|
||||||
|
const BuildHistory = lazy(() => import("./pages/BuildHistory"));
|
||||||
|
const PaymentHistory = lazy(() => import("./pages/PaymentHistory"));
|
||||||
|
const Help = lazy(() => import("./pages/Help"));
|
||||||
|
const NotFound = lazy(() => import("./pages/NotFound"));
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RouteGuardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
needsSetup: boolean | null;
|
||||||
|
setupLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProtectedRoute = ({ children, needsSetup, setupLoading }: RouteGuardProps) => {
|
||||||
|
const { user, session, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading || setupLoading) {
|
||||||
|
return <LoadingSpinner fullScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsSetup) {
|
||||||
|
return <Navigate to="/setup" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !session) {
|
||||||
|
return <Navigate to="/auth" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PublicRoute = ({ children, needsSetup, setupLoading }: RouteGuardProps) => {
|
||||||
|
if (setupLoading) {
|
||||||
|
return <LoadingSpinner fullScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsSetup) {
|
||||||
|
return <Navigate to="/setup" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SetupRoute = ({ children, needsSetup, setupLoading }: RouteGuardProps) => {
|
||||||
|
if (setupLoading) {
|
||||||
|
return <LoadingSpinner fullScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsSetup === false) {
|
||||||
|
return <Navigate to="/auth" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppRoutes = () => {
|
||||||
|
const { needsSetup, loading: setupLoading } = useSetupCheck();
|
||||||
|
const { settings, loaded: settingsLoaded } = useSystemSettings();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
setIsAdmin(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.rpc("get_user_role", { _user_id: user.id }).then(({ data }) => {
|
||||||
|
setIsAdmin(data === "admin");
|
||||||
|
});
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
if (settingsLoaded && settings.maintenance_mode && !isAdmin) {
|
||||||
|
return <Maintenance />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingSpinner fullScreen />}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<PublicRoute needsSetup={needsSetup} setupLoading={setupLoading}>
|
||||||
|
<Index />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/auth"
|
||||||
|
element={
|
||||||
|
<PublicRoute needsSetup={needsSetup} setupLoading={setupLoading}>
|
||||||
|
<Auth />
|
||||||
|
</PublicRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="/privacy" element={<Privacy />} />
|
||||||
|
<Route path="/terms" element={<Terms />} />
|
||||||
|
<Route path="/install" element={<Install />} />
|
||||||
|
<Route
|
||||||
|
path="/setup"
|
||||||
|
element={
|
||||||
|
<SetupRoute needsSetup={needsSetup} setupLoading={setupLoading}>
|
||||||
|
<AdminSetup />
|
||||||
|
</SetupRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute needsSetup={needsSetup} setupLoading={setupLoading}>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/builder"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute needsSetup={needsSetup} setupLoading={setupLoading}>
|
||||||
|
<AppBuilder />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute needsSetup={needsSetup} setupLoading={setupLoading}>
|
||||||
|
<Settings />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/subscription"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute needsSetup={needsSetup} setupLoading={setupLoading}>
|
||||||
|
<Subscription />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute needsSetup={needsSetup} setupLoading={setupLoading}>
|
||||||
|
<Admin />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/style-guide" element={<StyleGuide />} />
|
||||||
|
<Route
|
||||||
|
path="/build-history"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute needsSetup={needsSetup} setupLoading={setupLoading}>
|
||||||
|
<BuildHistory />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/payment-history"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute needsSetup={needsSetup} setupLoading={setupLoading}>
|
||||||
|
<PaymentHistory />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/help" element={<Help />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = () => (
|
||||||
|
<ThemeProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<CustomCSSInjector />
|
||||||
|
<Toaster />
|
||||||
|
<Sonner />
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
|
||||||
|
<CookieConsent />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</TooltipProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 232 56" width="232" height="56" role="img" aria-label="AppForge">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="afl1" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#8B5CF6"/>
|
||||||
|
<stop offset="0.55" stop-color="#6366F1"/>
|
||||||
|
<stop offset="1" stop-color="#0EA5E9"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- icon mark -->
|
||||||
|
<g transform="translate(6 8)">
|
||||||
|
<rect x="0" y="0" width="40" height="40" rx="11" fill="url(#afl1)"/>
|
||||||
|
<path d="M23 7 L11.5 24 H18.7 L17 33.5 L29 15.5 H21.7 Z"
|
||||||
|
fill="#ffffff" stroke="#ffffff" stroke-width="0.8" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<!-- wordmark (dark — for light backgrounds) -->
|
||||||
|
<text x="58" y="37" font-family="'Segoe UI', Inter, system-ui, -apple-system, Roboto, Arial, sans-serif"
|
||||||
|
font-size="27" font-weight="700" letter-spacing="-0.5" fill="#0F172A">App<tspan fill="#6366F1">Forge</tspan></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 933 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 232 56" width="232" height="56" role="img" aria-label="AppForge">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="afl2" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#A78BFA"/>
|
||||||
|
<stop offset="0.55" stop-color="#818CF8"/>
|
||||||
|
<stop offset="1" stop-color="#38BDF8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- icon mark -->
|
||||||
|
<g transform="translate(6 8)">
|
||||||
|
<rect x="0" y="0" width="40" height="40" rx="11" fill="url(#afl2)"/>
|
||||||
|
<path d="M23 7 L11.5 24 H18.7 L17 33.5 L29 15.5 H21.7 Z"
|
||||||
|
fill="#0B1020" stroke="#0B1020" stroke-width="0.8" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<!-- wordmark (white — for dark backgrounds) -->
|
||||||
|
<text x="58" y="37" font-family="'Segoe UI', Inter, system-ui, -apple-system, Roboto, Arial, sans-serif"
|
||||||
|
font-size="27" font-weight="700" letter-spacing="-0.5" fill="#FFFFFF">App<tspan fill="#A5B4FC">Forge</tspan></text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 933 B |
@@ -0,0 +1,106 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface AnimatedSectionProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnimatedSection = ({ children, className = "", delay = 0 }: AnimatedSectionProps) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-100px" }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.6,
|
||||||
|
delay,
|
||||||
|
ease: [0.25, 0.1, 0.25, 1],
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnimatedItem = ({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
delay = 0,
|
||||||
|
direction = "up"
|
||||||
|
}: AnimatedSectionProps & { direction?: "up" | "left" | "right" }) => {
|
||||||
|
const directionVariants = {
|
||||||
|
up: { y: 30, x: 0 },
|
||||||
|
left: { x: 30, y: 0 },
|
||||||
|
right: { x: -30, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, ...directionVariants[direction] }}
|
||||||
|
whileInView={{ opacity: 1, x: 0, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.5,
|
||||||
|
delay,
|
||||||
|
ease: [0.25, 0.1, 0.25, 1],
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StaggerContainer = ({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
staggerDelay = 0.1
|
||||||
|
}: { children: ReactNode; className?: string; staggerDelay?: number }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
variants={{
|
||||||
|
hidden: {},
|
||||||
|
visible: {
|
||||||
|
transition: {
|
||||||
|
staggerChildren: staggerDelay,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StaggerItem = ({
|
||||||
|
children,
|
||||||
|
className = ""
|
||||||
|
}: { children: ReactNode; className?: string }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
ease: [0.25, 0.1, 0.25, 1],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnimatedSection;
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Smartphone, RotateCcw, ExternalLink, TabletSmartphone, AlertCircle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface AppPreviewDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
websiteUrl: string;
|
||||||
|
appName: string;
|
||||||
|
primaryColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceType = 'iphone' | 'android' | 'tablet';
|
||||||
|
|
||||||
|
const DEVICES = {
|
||||||
|
iphone: { name: 'iPhone 15', width: 393, height: 852, frame: 'rounded-[44px]' },
|
||||||
|
android: { name: 'Pixel 7', width: 412, height: 915, frame: 'rounded-[32px]' },
|
||||||
|
tablet: { name: 'iPad', width: 820, height: 580, frame: 'rounded-[20px]' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppPreviewDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
websiteUrl,
|
||||||
|
appName,
|
||||||
|
}: AppPreviewDialogProps) => {
|
||||||
|
const [device, setDevice] = useState<DeviceType>('android');
|
||||||
|
const [key, setKey] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
const currentDevice = DEVICES[device];
|
||||||
|
const scale = device === 'tablet' ? 0.7 : 0.5;
|
||||||
|
const containerWidth = currentDevice.width * scale;
|
||||||
|
const containerHeight = currentDevice.height * scale;
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setHasError(false);
|
||||||
|
setKey(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoad = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setHasError(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh] p-0 overflow-hidden">
|
||||||
|
<DialogHeader className="p-4 pb-2 border-b border-border">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||||
|
<Smartphone className="w-5 h-5 text-accent" />
|
||||||
|
{appName} - App Preview
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Device Switcher */}
|
||||||
|
<div className="flex bg-muted rounded-lg p-1">
|
||||||
|
<Button
|
||||||
|
variant={device === 'android' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => { setDevice('android'); setHasError(false); }}
|
||||||
|
>
|
||||||
|
<Smartphone className="w-3 h-3 mr-1" />
|
||||||
|
Android
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={device === 'iphone' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => { setDevice('iphone'); setHasError(false); }}
|
||||||
|
>
|
||||||
|
<Smartphone className="w-3 h-3 mr-1" />
|
||||||
|
iOS
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={device === 'tablet' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => { setDevice('tablet'); setHasError(false); }}
|
||||||
|
>
|
||||||
|
<TabletSmartphone className="w-3 h-3 mr-1" />
|
||||||
|
Tablet
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 bg-gradient-to-br from-background to-muted/30 flex flex-col items-center justify-center p-6">
|
||||||
|
{/* Device Frame */}
|
||||||
|
<div
|
||||||
|
className={`relative bg-gray-900 ${currentDevice.frame} p-2 shadow-2xl border-4 border-gray-800`}
|
||||||
|
style={{ width: containerWidth + 16, height: containerHeight + 16 }}
|
||||||
|
>
|
||||||
|
{/* Screen */}
|
||||||
|
<div
|
||||||
|
className={`bg-white ${currentDevice.frame} overflow-hidden relative`}
|
||||||
|
style={{ width: containerWidth, height: containerHeight }}
|
||||||
|
>
|
||||||
|
{isLoading && !hasError && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-background z-10">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-xs text-muted-foreground">Loading preview...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasError ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-muted/50 z-10 p-4">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<AlertCircle className="w-10 h-10 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground mb-1">Preview Blocked</p>
|
||||||
|
<p className="text-xs text-muted-foreground max-w-[180px]">
|
||||||
|
This website doesn't allow iframe previews. Open it directly instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open(websiteUrl, '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3 mr-1" />
|
||||||
|
Open Website
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<iframe
|
||||||
|
key={`${device}-${key}`}
|
||||||
|
src={websiteUrl}
|
||||||
|
style={{
|
||||||
|
width: currentDevice.width,
|
||||||
|
height: currentDevice.height,
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
}}
|
||||||
|
frameBorder="0"
|
||||||
|
onLoad={handleLoad}
|
||||||
|
onError={handleError}
|
||||||
|
className="bg-white"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notch for iPhone */}
|
||||||
|
{device === 'iphone' && (
|
||||||
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 w-24 h-6 bg-gray-900 rounded-full z-20" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device Name */}
|
||||||
|
<p className="text-sm text-muted-foreground mt-4">
|
||||||
|
{currentDevice.name} Preview
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 mt-4">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||||
|
<RotateCcw className="w-3 h-3 mr-1" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => window.open(websiteUrl, '_blank')}>
|
||||||
|
<ExternalLink className="w-3 h-3 mr-1" />
|
||||||
|
Open Website
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info text */}
|
||||||
|
<p className="text-xs text-muted-foreground mt-3 text-center max-w-md">
|
||||||
|
💡 This simulates how your app will look on mobile devices. The actual APK will display the website in a native WebView.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppPreviewDialog;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { ArrowUp } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
const BackToTop = () => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const toggleVisibility = () => {
|
||||||
|
setIsVisible(window.scrollY > 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", toggleVisibility);
|
||||||
|
return () => window.removeEventListener("scroll", toggleVisibility);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isVisible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed bottom-6 right-6 z-50"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={scrollToTop}
|
||||||
|
size="icon"
|
||||||
|
className="h-12 w-12 rounded-full shadow-lg bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||||
|
aria-label="Back to top"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BackToTop;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import logoDark from "@/assets/logo-dark.svg";
|
||||||
|
import logoLight from "@/assets/logo-light.svg";
|
||||||
|
import { useThemeStore } from "@/stores/useThemeStore";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface BrandWordmarkProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full AppForge wordmark logo that swaps between the dark variant (for light
|
||||||
|
* backgrounds) and the white variant (for dark backgrounds) based on theme.
|
||||||
|
* Use this in fixed product-branding areas (e.g. the setup installer).
|
||||||
|
*/
|
||||||
|
const BrandWordmark = ({ className = "h-12 w-auto" }: BrandWordmarkProps) => {
|
||||||
|
const { theme } = useThemeStore();
|
||||||
|
const isDark =
|
||||||
|
theme === "dark" ||
|
||||||
|
(theme === "system" && typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={isDark ? logoLight : logoDark}
|
||||||
|
alt="AppForge"
|
||||||
|
className={cn("object-contain", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BrandWordmark;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowRight, Sparkles } from "lucide-react";
|
||||||
|
import AnimatedSection from "@/components/AnimatedSection";
|
||||||
|
|
||||||
|
const CTASection = () => {
|
||||||
|
return (
|
||||||
|
<section className="py-32 relative overflow-hidden">
|
||||||
|
{/* Background */}
|
||||||
|
<div className="absolute inset-0 bg-muted/30" />
|
||||||
|
<div className="hero-glow top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-pulse-glow" />
|
||||||
|
|
||||||
|
<div className="container mx-auto px-6 relative z-10">
|
||||||
|
<AnimatedSection className="max-w-4xl mx-auto text-center">
|
||||||
|
{/* Badge */}
|
||||||
|
<div className="inline-flex items-center gap-2 glass-card px-4 py-2 rounded-full mb-8">
|
||||||
|
<Sparkles className="w-4 h-4 text-accent" />
|
||||||
|
<span className="text-sm text-muted-foreground">Ready to Start?</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heading */}
|
||||||
|
<h2 className="font-display text-4xl md:text-5xl lg:text-6xl font-bold mb-6">
|
||||||
|
<span className="text-foreground">Start Building Your</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-primary">Mobile App Today</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Subheading */}
|
||||||
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-10">
|
||||||
|
Join thousands of creators who have already turned their websites into native mobile apps.
|
||||||
|
No coding required. Get started in minutes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CTA Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<Button variant="accent" size="xl" className="group">
|
||||||
|
Start Building Free
|
||||||
|
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="glass" size="xl">
|
||||||
|
Schedule a Demo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trust Note */}
|
||||||
|
<p className="text-muted-foreground text-sm mt-8">
|
||||||
|
No credit card required • Free plan available • Cancel anytime
|
||||||
|
</p>
|
||||||
|
</AnimatedSection>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CTASection;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
interface ConfettiPiece {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
delay: number;
|
||||||
|
duration: number;
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
rotation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfettiProps {
|
||||||
|
isActive: boolean;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'hsl(var(--primary))',
|
||||||
|
'hsl(var(--primary) / 0.8)',
|
||||||
|
'hsl(var(--primary) / 0.6)',
|
||||||
|
'hsl(var(--muted-foreground))',
|
||||||
|
'hsl(var(--foreground) / 0.3)',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Confetti = ({ isActive, duration = 3000 }: ConfettiProps) => {
|
||||||
|
const [pieces, setPieces] = useState<ConfettiPiece[]>([]);
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) {
|
||||||
|
// Generate confetti pieces
|
||||||
|
const newPieces: ConfettiPiece[] = Array.from({ length: 50 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
x: Math.random() * 100,
|
||||||
|
delay: Math.random() * 0.5,
|
||||||
|
duration: 2 + Math.random() * 2,
|
||||||
|
color: COLORS[Math.floor(Math.random() * COLORS.length)],
|
||||||
|
size: 6 + Math.random() * 8,
|
||||||
|
rotation: Math.random() * 360,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setPieces(newPieces);
|
||||||
|
setShow(true);
|
||||||
|
|
||||||
|
// Hide confetti after duration
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShow(false);
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isActive, duration]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
|
||||||
|
{pieces.map((piece) => (
|
||||||
|
<motion.div
|
||||||
|
key={piece.id}
|
||||||
|
initial={{
|
||||||
|
opacity: 1,
|
||||||
|
x: `${piece.x}vw`,
|
||||||
|
y: -20,
|
||||||
|
rotate: 0,
|
||||||
|
scale: 1,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: [1, 1, 0],
|
||||||
|
y: '100vh',
|
||||||
|
rotate: piece.rotation + 720,
|
||||||
|
scale: [1, 1, 0.5],
|
||||||
|
}}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: piece.duration,
|
||||||
|
delay: piece.delay,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: piece.size,
|
||||||
|
height: piece.size,
|
||||||
|
backgroundColor: piece.color,
|
||||||
|
borderRadius: Math.random() > 0.5 ? '50%' : '2px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Confetti;
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Cookie, X, Settings, Shield } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface CookiePreferences {
|
||||||
|
necessary: boolean;
|
||||||
|
analytics: boolean;
|
||||||
|
marketing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COOKIE_CONSENT_KEY = "cookie-consent";
|
||||||
|
const COOKIE_PREFERENCES_KEY = "cookie-preferences";
|
||||||
|
|
||||||
|
const CookieConsent = () => {
|
||||||
|
const [showBanner, setShowBanner] = useState(false);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [preferences, setPreferences] = useState<CookiePreferences>({
|
||||||
|
necessary: true, // Always required
|
||||||
|
analytics: false,
|
||||||
|
marketing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const consent = localStorage.getItem(COOKIE_CONSENT_KEY);
|
||||||
|
if (!consent) {
|
||||||
|
// Small delay to avoid flash on page load
|
||||||
|
const timer = setTimeout(() => setShowBanner(true), 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else {
|
||||||
|
const savedPreferences = localStorage.getItem(COOKIE_PREFERENCES_KEY);
|
||||||
|
if (savedPreferences) {
|
||||||
|
setPreferences(JSON.parse(savedPreferences));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAcceptAll = () => {
|
||||||
|
const allAccepted: CookiePreferences = {
|
||||||
|
necessary: true,
|
||||||
|
analytics: true,
|
||||||
|
marketing: true,
|
||||||
|
};
|
||||||
|
saveConsent(allAccepted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectAll = () => {
|
||||||
|
const onlyNecessary: CookiePreferences = {
|
||||||
|
necessary: true,
|
||||||
|
analytics: false,
|
||||||
|
marketing: false,
|
||||||
|
};
|
||||||
|
saveConsent(onlyNecessary);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePreferences = () => {
|
||||||
|
saveConsent(preferences);
|
||||||
|
setShowSettings(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveConsent = (prefs: CookiePreferences) => {
|
||||||
|
localStorage.setItem(COOKIE_CONSENT_KEY, "true");
|
||||||
|
localStorage.setItem(COOKIE_PREFERENCES_KEY, JSON.stringify(prefs));
|
||||||
|
setPreferences(prefs);
|
||||||
|
setShowBanner(false);
|
||||||
|
|
||||||
|
// Dispatch custom event for analytics scripts to listen to
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("cookie-consent-updated", { detail: prefs })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!showBanner) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Cookie Banner */}
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-50 p-4 animate-fade-in">
|
||||||
|
<Card className="max-w-4xl mx-auto glass-card border-border/50 shadow-2xl">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col md:flex-row items-start md:items-center gap-4">
|
||||||
|
<div className="flex items-start gap-3 flex-1">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10">
|
||||||
|
<Cookie className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold text-foreground">
|
||||||
|
We value your privacy
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
We use cookies to enhance your browsing experience, serve
|
||||||
|
personalized content, and analyze our traffic. By clicking
|
||||||
|
"Accept All", you consent to our use of cookies.{" "}
|
||||||
|
<Link
|
||||||
|
to="/privacy"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Read our Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 w-full md:w-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
Customize
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRejectAll}>
|
||||||
|
Reject All
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={handleAcceptAll}>
|
||||||
|
Accept All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cookie Settings Dialog */}
|
||||||
|
<Dialog open={showSettings} onOpenChange={setShowSettings}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5 text-primary" />
|
||||||
|
Cookie Preferences
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Manage your cookie preferences. You can enable or disable
|
||||||
|
different types of cookies below.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Necessary Cookies */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-base font-medium">
|
||||||
|
Necessary Cookies
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Essential for the website to function properly. Cannot be
|
||||||
|
disabled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={true} disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analytics Cookies */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-base font-medium">
|
||||||
|
Analytics Cookies
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Help us understand how visitors interact with our website to
|
||||||
|
improve user experience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.analytics}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setPreferences((prev) => ({ ...prev, analytics: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marketing Cookies */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-base font-medium">
|
||||||
|
Marketing Cookies
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Used to track visitors across websites to display relevant
|
||||||
|
advertisements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={preferences.marketing}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setPreferences((prev) => ({ ...prev, marketing: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={handleRejectAll}>
|
||||||
|
Reject All
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSavePreferences}>Save Preferences</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CookieConsent;
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
|
||||||
|
function hexToHSL(hex: string): string | null {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
let r = parseInt(result[1], 16) / 255;
|
||||||
|
let g = parseInt(result[2], 16) / 255;
|
||||||
|
let b = parseInt(result[3], 16) / 255;
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||||
|
let h = 0, s = 0;
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
|
||||||
|
if (max !== min) {
|
||||||
|
const d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
switch (max) {
|
||||||
|
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||||
|
case g: h = ((b - r) / d + 2) / 6; break;
|
||||||
|
case b: h = ((r - g) / d + 4) / 6; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an HSL string "H S% L%" and return [h, s, l] numbers.
|
||||||
|
*/
|
||||||
|
function parseHSL(hsl: string): [number, number, number] | null {
|
||||||
|
const m = hsl.match(/^(\d+)\s+(\d+)%\s+(\d+)%$/);
|
||||||
|
if (!m) return null;
|
||||||
|
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a contrasting foreground HSL for a given background HSL.
|
||||||
|
* Returns white foreground for dark colors, black foreground for light colors.
|
||||||
|
*/
|
||||||
|
function contrastingForeground(hsl: string): string {
|
||||||
|
const parsed = parseHSL(hsl);
|
||||||
|
if (!parsed) return "0 0% 100%";
|
||||||
|
const [h, s, l] = parsed;
|
||||||
|
// Use lightness threshold: if bg is light (>55%), use dark fg; otherwise white
|
||||||
|
return l > 55 ? `${h} ${s}% 7%` : `${h} ${Math.min(s, 10)}% 100%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invert the lightness of an HSL color for dark mode.
|
||||||
|
* E.g. a dark primary (#000000 = "0 0% 0%") becomes "0 0% 100%" in dark mode.
|
||||||
|
*/
|
||||||
|
function invertForDarkMode(hsl: string): string {
|
||||||
|
const parsed = parseHSL(hsl);
|
||||||
|
if (!parsed) return hsl;
|
||||||
|
const [h, s, l] = parsed;
|
||||||
|
// Invert lightness: 0% → 100%, 100% → 0%, 30% → 70%
|
||||||
|
return `${h} ${s}% ${100 - l}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColorOverrides(primary: string, accent: string): string {
|
||||||
|
const primaryHSL = hexToHSL(primary);
|
||||||
|
const accentHSL = hexToHSL(accent);
|
||||||
|
if (!primaryHSL && !accentHSL) return "";
|
||||||
|
|
||||||
|
// --- Light mode overrides ---
|
||||||
|
let css = ":root {\n";
|
||||||
|
if (primaryHSL) {
|
||||||
|
css += ` --primary: ${primaryHSL};\n`;
|
||||||
|
css += ` --primary-foreground: ${contrastingForeground(primaryHSL)};\n`;
|
||||||
|
css += ` --ring: ${primaryHSL};\n`;
|
||||||
|
css += ` --sidebar-primary: ${primaryHSL};\n`;
|
||||||
|
css += ` --sidebar-primary-foreground: ${contrastingForeground(primaryHSL)};\n`;
|
||||||
|
}
|
||||||
|
if (accentHSL) {
|
||||||
|
css += ` --accent: ${accentHSL};\n`;
|
||||||
|
css += ` --accent-foreground: ${contrastingForeground(accentHSL)};\n`;
|
||||||
|
css += ` --sidebar-accent: ${accentHSL};\n`;
|
||||||
|
}
|
||||||
|
css += "}\n";
|
||||||
|
|
||||||
|
// --- Dark mode overrides (invert lightness so dark colors become light) ---
|
||||||
|
const darkPrimaryHSL = primaryHSL ? invertForDarkMode(primaryHSL) : null;
|
||||||
|
const darkAccentHSL = accentHSL ? invertForDarkMode(accentHSL) : null;
|
||||||
|
|
||||||
|
css += ".dark {\n";
|
||||||
|
if (darkPrimaryHSL) {
|
||||||
|
css += ` --primary: ${darkPrimaryHSL};\n`;
|
||||||
|
css += ` --primary-foreground: ${contrastingForeground(darkPrimaryHSL)};\n`;
|
||||||
|
css += ` --ring: ${darkPrimaryHSL};\n`;
|
||||||
|
css += ` --sidebar-primary: ${darkPrimaryHSL};\n`;
|
||||||
|
css += ` --sidebar-primary-foreground: ${contrastingForeground(darkPrimaryHSL)};\n`;
|
||||||
|
}
|
||||||
|
if (darkAccentHSL) {
|
||||||
|
css += ` --accent: ${darkAccentHSL};\n`;
|
||||||
|
css += ` --accent-foreground: ${contrastingForeground(darkAccentHSL)};\n`;
|
||||||
|
css += ` --sidebar-accent: ${darkAccentHSL};\n`;
|
||||||
|
}
|
||||||
|
css += "}\n";
|
||||||
|
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomCSSInjector = () => {
|
||||||
|
const { settings, loaded } = useSystemSettings();
|
||||||
|
const styleRef = useRef<HTMLStyleElement | null>(null);
|
||||||
|
|
||||||
|
// Inject custom CSS and color overrides
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded) return;
|
||||||
|
|
||||||
|
if (!styleRef.current) {
|
||||||
|
styleRef.current = document.createElement("style");
|
||||||
|
styleRef.current.id = "custom-css-injector";
|
||||||
|
document.head.appendChild(styleRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorCSS = buildColorOverrides(settings.primary_color, settings.accent_color);
|
||||||
|
const customCSS = settings.custom_css || "";
|
||||||
|
styleRef.current.textContent = colorCSS + customCSS;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (styleRef.current) {
|
||||||
|
styleRef.current.remove();
|
||||||
|
styleRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [settings.custom_css, settings.primary_color, settings.accent_color, loaded]);
|
||||||
|
|
||||||
|
// Dynamically update favicon from settings
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded || !settings.favicon_url) return;
|
||||||
|
|
||||||
|
const existingLink = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
|
||||||
|
if (existingLink) {
|
||||||
|
existingLink.href = settings.favicon_url;
|
||||||
|
} else {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "icon";
|
||||||
|
link.type = "image/png";
|
||||||
|
link.href = settings.favicon_url;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
}, [settings.favicon_url, loaded]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomCSSInjector;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { FlaskConical, UserCog, User, Sparkles } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
import { isDemoAccount as checkDemoAccount, isAdminDemoAccount } from "@/lib/demo-mode";
|
||||||
|
|
||||||
|
const TOUR_STORAGE_KEY = "demo_tour_completed";
|
||||||
|
|
||||||
|
export function DemoModeBanner() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { settings } = useSystemSettings();
|
||||||
|
|
||||||
|
const userEmail = user?.email?.toLowerCase();
|
||||||
|
const isDemoUser = checkDemoAccount(userEmail, settings.demo_mode);
|
||||||
|
const isAdminDemo = isAdminDemoAccount(userEmail, settings.demo_mode);
|
||||||
|
|
||||||
|
if (!isDemoUser) return null;
|
||||||
|
|
||||||
|
const handleRestartTour = () => {
|
||||||
|
localStorage.removeItem(TOUR_STORAGE_KEY);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-r from-amber-500/10 via-orange-500/10 to-amber-500/10 border border-amber-500/20 rounded-xl p-4 mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center shrink-0">
|
||||||
|
<FlaskConical className="w-5 h-5 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-foreground">Demo Mode Active</h3>
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
isAdminDemo
|
||||||
|
? "bg-purple-500/20 text-purple-500"
|
||||||
|
: "bg-blue-500/20 text-blue-500"
|
||||||
|
}`}>
|
||||||
|
{isAdminDemo ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<UserCog className="w-3 h-3" />
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
User
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You're using a demo account ({userEmail}). <strong>Read-only mode</strong> — changes are disabled.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRestartTour}
|
||||||
|
className="gap-2 text-amber-600 hover:text-amber-700 hover:bg-amber-500/10"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Replay Tour</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isAdminDemo && (
|
||||||
|
<Button variant="outline" size="sm" asChild className="shrink-0">
|
||||||
|
<Link to="/admin">
|
||||||
|
<UserCog className="w-4 h-4 mr-2" />
|
||||||
|
Admin Panel
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Rocket,
|
||||||
|
Smartphone,
|
||||||
|
Palette,
|
||||||
|
Zap,
|
||||||
|
Download,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
X,
|
||||||
|
Sparkles,
|
||||||
|
CheckCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { isDemoAccount as checkDemoAccount, isAdminDemoAccount } from "@/lib/demo-mode";
|
||||||
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
|
||||||
|
const TOUR_STORAGE_KEY = "demo_tour_completed";
|
||||||
|
|
||||||
|
interface TourStep {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
features?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTourSteps = (appName: string): TourStep[] => [
|
||||||
|
{
|
||||||
|
id: "welcome",
|
||||||
|
title: `Welcome to ${appName}!`,
|
||||||
|
description: "Transform any website into a native mobile app in minutes. Let's take a quick tour of the key features.",
|
||||||
|
icon: <Rocket className="w-8 h-8" />,
|
||||||
|
color: "from-accent to-accent/60",
|
||||||
|
features: [
|
||||||
|
"AI-powered app configuration",
|
||||||
|
"Cross-platform builds (iOS & Android)",
|
||||||
|
"No coding required",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "builder",
|
||||||
|
title: "App Builder",
|
||||||
|
description: "Enter any website URL and our AI will automatically analyze it and configure your mobile app settings.",
|
||||||
|
icon: <Smartphone className="w-8 h-8" />,
|
||||||
|
color: "from-blue-500 to-blue-400",
|
||||||
|
features: [
|
||||||
|
"Paste your website URL",
|
||||||
|
"AI analyzes colors, icons & structure",
|
||||||
|
"Customize app name & branding",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "customize",
|
||||||
|
title: "Customization",
|
||||||
|
description: "Fine-tune every aspect of your app with our visual configuration tools and preset templates.",
|
||||||
|
icon: <Palette className="w-8 h-8" />,
|
||||||
|
color: "from-purple-500 to-purple-400",
|
||||||
|
features: [
|
||||||
|
"Choose navigation styles",
|
||||||
|
"Customize splash screens",
|
||||||
|
"Apply preset templates",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "automations",
|
||||||
|
title: "Automations",
|
||||||
|
description: "Set up powerful workflows to automate push notifications, data sync, and scheduled tasks.",
|
||||||
|
icon: <Zap className="w-8 h-8" />,
|
||||||
|
color: "from-amber-500 to-amber-400",
|
||||||
|
features: [
|
||||||
|
"Push notification workflows",
|
||||||
|
"Scheduled data syncing",
|
||||||
|
"Event-triggered actions",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "builds",
|
||||||
|
title: "Build & Download",
|
||||||
|
description: "Generate production-ready APK and IPA files. Track build progress in real-time with notifications.",
|
||||||
|
icon: <Download className="w-8 h-8" />,
|
||||||
|
color: "from-green-500 to-green-400",
|
||||||
|
features: [
|
||||||
|
"Real-time build progress",
|
||||||
|
"Download APK/IPA files",
|
||||||
|
"View build history",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "admin",
|
||||||
|
title: "Admin Panel",
|
||||||
|
description: "As a demo admin, you have access to the full admin panel to manage users, plans, and system settings.",
|
||||||
|
icon: <Shield className="w-8 h-8" />,
|
||||||
|
color: "from-red-500 to-red-400",
|
||||||
|
features: [
|
||||||
|
"User management",
|
||||||
|
"Payment tracking",
|
||||||
|
"System configuration",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DemoTour() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { settings } = useSystemSettings();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [direction, setDirection] = useState(0);
|
||||||
|
|
||||||
|
const userEmail = user?.email?.toLowerCase();
|
||||||
|
const isDemoAccount = checkDemoAccount(userEmail, settings.demo_mode);
|
||||||
|
const isAdminDemo = isAdminDemoAccount(userEmail, settings.demo_mode);
|
||||||
|
|
||||||
|
const tourSteps = getTourSteps(settings.app_name);
|
||||||
|
|
||||||
|
// Filter steps based on user type (only show admin step to admin demo)
|
||||||
|
const filteredSteps = tourSteps.filter(
|
||||||
|
(step) => step.id !== "admin" || isAdminDemo
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDemoAccount) {
|
||||||
|
// Check if tour has been completed
|
||||||
|
const tourCompleted = localStorage.getItem(TOUR_STORAGE_KEY);
|
||||||
|
if (!tourCompleted) {
|
||||||
|
// Small delay to let the page render first
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isDemoAccount]);
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentStep < filteredSteps.length - 1) {
|
||||||
|
setDirection(1);
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (currentStep > 0) {
|
||||||
|
setDirection(-1);
|
||||||
|
setCurrentStep((prev) => prev - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
localStorage.setItem(TOUR_STORAGE_KEY, "true");
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
localStorage.setItem(TOUR_STORAGE_KEY, "true");
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestart = () => {
|
||||||
|
setCurrentStep(0);
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isDemoAccount) return null;
|
||||||
|
|
||||||
|
const step = filteredSteps[currentStep];
|
||||||
|
const isLastStep = currentStep === filteredSteps.length - 1;
|
||||||
|
const isFirstStep = currentStep === 0;
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
enter: (direction: number) => ({
|
||||||
|
x: direction > 0 ? 100 : -100,
|
||||||
|
opacity: 0,
|
||||||
|
}),
|
||||||
|
center: {
|
||||||
|
x: 0,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
exit: (direction: number) => ({
|
||||||
|
x: direction < 0 ? 100 : -100,
|
||||||
|
opacity: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg p-0 overflow-hidden bg-card border-border/50">
|
||||||
|
{/* Header with gradient */}
|
||||||
|
<div className={cn("p-6 bg-gradient-to-br text-white", step.color)}>
|
||||||
|
<DialogHeader className="text-left">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-white/20 backdrop-blur flex items-center justify-center mb-4">
|
||||||
|
{step.icon}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-white/80 hover:text-white hover:bg-white/20 -mt-2 -mr-2"
|
||||||
|
onClick={handleSkip}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-2xl font-bold text-white">
|
||||||
|
{step.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-white/90 text-base mt-2">
|
||||||
|
{step.description}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
<AnimatePresence mode="wait" custom={direction}>
|
||||||
|
<motion.div
|
||||||
|
key={step.id}
|
||||||
|
custom={direction}
|
||||||
|
variants={variants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
{step.features && (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{step.features.map((feature, index) => (
|
||||||
|
<motion.li
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
className="flex items-center gap-3 text-foreground"
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"w-6 h-6 rounded-full flex items-center justify-center bg-gradient-to-br",
|
||||||
|
step.color
|
||||||
|
)}>
|
||||||
|
<CheckCircle className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span>{feature}</span>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-6 mb-4">
|
||||||
|
{filteredSteps.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => {
|
||||||
|
setDirection(index > currentStep ? 1 : -1);
|
||||||
|
setCurrentStep(index);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-2 h-2 rounded-full transition-all duration-300",
|
||||||
|
index === currentStep
|
||||||
|
? "w-6 bg-accent"
|
||||||
|
: "bg-muted-foreground/30 hover:bg-muted-foreground/50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-border/50">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={isFirstStep}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{currentStep + 1} of {filteredSteps.length}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isLastStep ? (
|
||||||
|
<Button onClick={handleComplete} className="gap-2 bg-accent hover:bg-accent/90">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleNext} className="gap-2">
|
||||||
|
Next
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a button to manually trigger the tour
|
||||||
|
export function DemoTourTrigger() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { settings } = useSystemSettings();
|
||||||
|
const userEmail = user?.email?.toLowerCase();
|
||||||
|
const isDemoAccount = checkDemoAccount(userEmail, settings.demo_mode);
|
||||||
|
|
||||||
|
const handleRestartTour = () => {
|
||||||
|
localStorage.removeItem(TOUR_STORAGE_KEY);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isDemoAccount) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRestartTour}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Replay Tour
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Palette,
|
||||||
|
Bell,
|
||||||
|
QrCode,
|
||||||
|
Shield,
|
||||||
|
Zap,
|
||||||
|
Smartphone,
|
||||||
|
Globe,
|
||||||
|
CreditCard,
|
||||||
|
FileText
|
||||||
|
} from "lucide-react";
|
||||||
|
import AnimatedSection, { StaggerContainer, StaggerItem } from "@/components/AnimatedSection";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Smartphone,
|
||||||
|
title: "Look great on any device",
|
||||||
|
description: "Your app looks and works great on any device, automatically. Responsive design built-in."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Globe,
|
||||||
|
title: "Design with pre-built components",
|
||||||
|
description: "Select from a growing library of components, including forms, calendars, and charts."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Palette,
|
||||||
|
title: "Apply themes and layouts",
|
||||||
|
description: "Quickly customize your app with color themes and layout presets. Make it truly yours."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Bot,
|
||||||
|
title: "AI-Powered Builder",
|
||||||
|
description: "Built-in AI assistant that automatically configures your app, suggests optimal settings, and guides you through the entire process."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Bell,
|
||||||
|
title: "Push Notifications",
|
||||||
|
description: "Send messages directly to app users. Keep them engaged with instant updates and announcements."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: QrCode,
|
||||||
|
title: "Instant Distribution",
|
||||||
|
description: "Download app files or scan QR codes for instant testing. No app store approval needed for testing."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
title: "GDPR Compliant",
|
||||||
|
description: "Built with privacy in mind. Full GDPR compliance and privacy features for worldwide customers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: CreditCard,
|
||||||
|
title: "Subscription Plans",
|
||||||
|
description: "Offer Free, Pro, and Enterprise pricing tiers. Accept PayPal and Bank Transfer payments."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
title: "Auto Invoicing",
|
||||||
|
description: "Professional PDF invoices generated automatically for every transaction."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const FeaturesSection = () => {
|
||||||
|
return (
|
||||||
|
<section id="features" className="py-32 relative">
|
||||||
|
<div className="absolute inset-0 bg-muted/20" />
|
||||||
|
|
||||||
|
<div className="container mx-auto px-6 relative z-10">
|
||||||
|
{/* Section Header */}
|
||||||
|
<AnimatedSection className="text-center max-w-3xl mx-auto mb-20">
|
||||||
|
<div className="inline-flex items-center gap-2 glass-card px-4 py-2 rounded-full mb-6">
|
||||||
|
<Zap className="w-4 h-4 text-accent" />
|
||||||
|
<span className="text-sm text-muted-foreground">Powerful Features</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-4xl md:text-5xl lg:text-6xl font-bold mb-6">
|
||||||
|
<span className="text-foreground">Everything You Need to</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-primary">Build & Monetize</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-muted-foreground">
|
||||||
|
A complete SaaS platform with all the tools to create, customize, and sell mobile apps
|
||||||
|
</p>
|
||||||
|
</AnimatedSection>
|
||||||
|
|
||||||
|
{/* Features Grid */}
|
||||||
|
<StaggerContainer className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" staggerDelay={0.1}>
|
||||||
|
{features.map((feature) => (
|
||||||
|
<StaggerItem key={feature.title} className="h-full">
|
||||||
|
<div className="group bg-card border border-border/50 rounded-2xl p-8 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/5 transition-all duration-500 hover:-translate-y-2 h-full flex flex-col">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-secondary/80 border border-border/50 flex items-center justify-center mb-8 group-hover:bg-primary/10 group-hover:border-primary/30 transition-all duration-300">
|
||||||
|
<feature.icon className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<h3 className="font-display text-xl font-semibold text-foreground mb-4">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground leading-relaxed flex-1">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</StaggerContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeaturesSection;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Github, Twitter, Linkedin } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
import ThemeAwareLogo from "@/components/ThemeAwareLogo";
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
const { settings } = useSystemSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-border py-16 pb-28 md:pb-16">
|
||||||
|
<div className="container mx-auto px-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-12">
|
||||||
|
{/* Logo & Description */}
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<ThemeAwareLogo className="w-10 h-10 rounded-xl" />
|
||||||
|
<span className="font-display text-xl font-bold text-foreground">{settings.app_name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm mb-6">
|
||||||
|
AI-powered no-code platform to convert any website into a native mobile app.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a href="#" className="text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
<Twitter className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
<Github className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
<Linkedin className="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-display font-semibold text-foreground mb-4">Product</h4>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
<li><a href="/#features" className="text-muted-foreground hover:text-foreground transition-colors text-sm">Features</a></li>
|
||||||
|
<li><a href="/#pricing" className="text-muted-foreground hover:text-foreground transition-colors text-sm">Pricing</a></li>
|
||||||
|
<li><a href="/#platforms" className="text-muted-foreground hover:text-foreground transition-colors text-sm">Platforms</a></li>
|
||||||
|
<li><Link to="/help" className="text-muted-foreground hover:text-foreground transition-colors text-sm">Help Center</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-display font-semibold text-foreground mb-4">Company</h4>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
<li><a href="#" className="text-muted-foreground hover:text-foreground transition-colors text-sm">About</a></li>
|
||||||
|
<li><a href="#" className="text-muted-foreground hover:text-foreground transition-colors text-sm">Blog</a></li>
|
||||||
|
<li><a href="#" className="text-muted-foreground hover:text-foreground transition-colors text-sm">Careers</a></li>
|
||||||
|
<li><a href="#" className="text-muted-foreground hover:text-foreground transition-colors text-sm">Contact</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legal */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-display font-semibold text-foreground mb-4">Legal</h4>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
<li><Link to="/privacy" className="text-muted-foreground hover:text-foreground transition-colors text-sm">Privacy Policy</Link></li>
|
||||||
|
<li><Link to="/terms" className="text-muted-foreground hover:text-foreground transition-colors text-sm">Terms of Service</Link></li>
|
||||||
|
<li><Link to="/privacy" className="text-muted-foreground hover:text-foreground transition-colors text-sm">GDPR</Link></li>
|
||||||
|
<li><Link to="/privacy#cookies" className="text-muted-foreground hover:text-foreground transition-colors text-sm">Cookie Policy</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom */}
|
||||||
|
<div className="border-t border-border pt-8 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
© {new Date().getFullYear()} {settings.app_name}. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Made with ❤️ by <a href="https://wrapcoders.com" target="_blank" rel="noopener noreferrer" className="font-semibold text-foreground hover:text-primary transition-colors">WRAPCODERS</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowRight, Play, Sparkles, Globe, Zap } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { motion, useScroll, useTransform, useMotionValue, useSpring } from "framer-motion";
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
const HeroSection = () => {
|
||||||
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
// Mouse position tracking
|
||||||
|
const mouseX = useMotionValue(0);
|
||||||
|
const mouseY = useMotionValue(0);
|
||||||
|
|
||||||
|
// Smooth spring physics for mouse follow
|
||||||
|
const springConfig = { damping: 25, stiffness: 150 };
|
||||||
|
const smoothMouseX = useSpring(mouseX, springConfig);
|
||||||
|
const smoothMouseY = useSpring(mouseY, springConfig);
|
||||||
|
|
||||||
|
// Different movement intensities for each orb (parallax depth)
|
||||||
|
const orb1X = useTransform(smoothMouseX, [-0.5, 0.5], [-40, 40]);
|
||||||
|
const orb1Y = useTransform(smoothMouseY, [-0.5, 0.5], [-40, 40]);
|
||||||
|
const orb2X = useTransform(smoothMouseX, [-0.5, 0.5], [30, -30]);
|
||||||
|
const orb2Y = useTransform(smoothMouseY, [-0.5, 0.5], [30, -30]);
|
||||||
|
const orb3X = useTransform(smoothMouseX, [-0.5, 0.5], [-20, 20]);
|
||||||
|
const orb3Y = useTransform(smoothMouseY, [-0.5, 0.5], [-25, 25]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const { clientX, clientY } = e;
|
||||||
|
const { innerWidth, innerHeight } = window;
|
||||||
|
// Normalize to -0.5 to 0.5 range
|
||||||
|
mouseX.set((clientX / innerWidth) - 0.5);
|
||||||
|
mouseY.set((clientY / innerHeight) - 0.5);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
return () => window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
}, [mouseX, mouseY]);
|
||||||
|
|
||||||
|
const { scrollYProgress } = useScroll({
|
||||||
|
target: sectionRef,
|
||||||
|
offset: ["start start", "end start"]
|
||||||
|
});
|
||||||
|
|
||||||
|
const backgroundY = useTransform(scrollYProgress, [0, 1], ["0%", "50%"]);
|
||||||
|
const gradientScale = useTransform(scrollYProgress, [0, 1], [1, 1.5]);
|
||||||
|
const contentY = useTransform(scrollYProgress, [0, 1], ["0%", "25%"]);
|
||||||
|
const floatingY = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={sectionRef} className="relative min-h-screen flex items-center justify-center overflow-hidden pt-20">
|
||||||
|
{/* Radial Gradient Background with Parallax */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{ y: backgroundY }}
|
||||||
|
>
|
||||||
|
{/* Primary radial gradient with mouse follow */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] md:w-[1200px] md:h-[1200px]"
|
||||||
|
style={{ scale: gradientScale, x: orb1X, y: orb1Y }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle,_hsl(var(--primary)_/_0.15)_0%,_transparent_70%)]" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Secondary radial gradient with mouse follow */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-1/4 right-1/4 w-[600px] h-[600px] md:w-[900px] md:h-[900px]"
|
||||||
|
style={{ scale: gradientScale, x: orb2X, y: orb2Y }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle,_hsl(var(--primary)_/_0.08)_0%,_transparent_60%)]" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Accent gradient glow with mouse follow */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-1/4 left-1/3 w-[400px] h-[400px] md:w-[600px] md:h-[600px]"
|
||||||
|
style={{ scale: gradientScale, x: orb3X, y: orb3Y }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle,_hsl(var(--muted-foreground)_/_0.05)_0%,_transparent_50%)]" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Background Base */}
|
||||||
|
<div className="absolute inset-0 bg-muted/20" />
|
||||||
|
|
||||||
|
{/* Grid Pattern with Parallax */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-[linear-gradient(to_right,_hsl(var(--border)_/_0.3)_1px,_transparent_1px),_linear-gradient(to_bottom,_hsl(var(--border)_/_0.3)_1px,_transparent_1px)] bg-[size:60px_60px]"
|
||||||
|
style={{ y: backgroundY }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Animated gradient orbs with mouse follow */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-20 right-1/4 w-32 h-32 rounded-full bg-primary/10 blur-3xl pointer-events-none"
|
||||||
|
style={{ x: orb1X, y: orb1Y }}
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.2, 1],
|
||||||
|
opacity: [0.3, 0.5, 0.3]
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 4,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-40 left-1/4 w-48 h-48 rounded-full bg-primary/5 blur-3xl pointer-events-none"
|
||||||
|
style={{ x: orb2X, y: orb2Y }}
|
||||||
|
animate={{
|
||||||
|
scale: [1.2, 1, 1.2],
|
||||||
|
opacity: [0.2, 0.4, 0.2]
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
delay: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-1/2 right-1/3 w-24 h-24 rounded-full bg-primary/8 blur-2xl pointer-events-none"
|
||||||
|
style={{ x: orb3X, y: orb3Y }}
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.3, 1],
|
||||||
|
opacity: [0.15, 0.3, 0.15]
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 6,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
delay: 2
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="container mx-auto px-6 relative z-10"
|
||||||
|
style={{ y: contentY }}
|
||||||
|
>
|
||||||
|
<div className="max-w-5xl mx-auto text-center">
|
||||||
|
{/* Badge */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
className="inline-flex items-center gap-2 glass-card px-4 py-2 rounded-full mb-8"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 text-accent" />
|
||||||
|
<span className="text-sm text-muted-foreground">AI-Powered No-Code App Builder</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Heading */}
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
className="font-display text-5xl md:text-7xl lg:text-8xl font-bold mb-6"
|
||||||
|
>
|
||||||
|
<span className="text-foreground">Convert Websites to</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-primary">Native Mobile Apps</span>
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
{/* Subheading */}
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.3 }}
|
||||||
|
className="text-xl md:text-2xl text-muted-foreground max-w-3xl mx-auto mb-10"
|
||||||
|
>
|
||||||
|
Enter your website URL and create professional native apps in 5 minutes.
|
||||||
|
No coding needed. AI handles everything automatically.
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{/* CTA Buttons */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.4 }}
|
||||||
|
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16"
|
||||||
|
>
|
||||||
|
<Button variant="accent" size="xl" className="group" asChild>
|
||||||
|
<Link to="/builder">
|
||||||
|
Start Building Free
|
||||||
|
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="glass" size="xl" className="group">
|
||||||
|
<Play className="w-5 h-5" />
|
||||||
|
Watch Demo
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.5 }}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-3xl mx-auto"
|
||||||
|
>
|
||||||
|
<div className="glass-card rounded-2xl p-6 text-center">
|
||||||
|
<div className="text-3xl font-display font-bold text-primary mb-1">50K+</div>
|
||||||
|
<div className="text-muted-foreground text-sm">Apps Created</div>
|
||||||
|
</div>
|
||||||
|
<div className="glass-card rounded-2xl p-6 text-center">
|
||||||
|
<div className="text-3xl font-display font-bold text-primary mb-1">5 Min</div>
|
||||||
|
<div className="text-muted-foreground text-sm">Average Build Time</div>
|
||||||
|
</div>
|
||||||
|
<div className="glass-card rounded-2xl p-6 text-center">
|
||||||
|
<div className="text-3xl font-display font-bold text-primary mb-1">99.9%</div>
|
||||||
|
<div className="text-muted-foreground text-sm">Uptime SLA</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Elements with Parallax */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -50 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.7 }}
|
||||||
|
style={{ y: floatingY }}
|
||||||
|
className="absolute top-1/3 left-10 hidden lg:block"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="glass-card p-4 rounded-2xl"
|
||||||
|
animate={{ y: [0, -10, 0] }}
|
||||||
|
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<Globe className="w-8 h-8 text-primary" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 50 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.8, delay: 0.9 }}
|
||||||
|
style={{ y: floatingY }}
|
||||||
|
className="absolute top-1/2 right-10 hidden lg:block"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="glass-card p-4 rounded-2xl"
|
||||||
|
animate={{ y: [0, -15, 0] }}
|
||||||
|
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut", delay: 1 }}
|
||||||
|
>
|
||||||
|
<Zap className="w-8 h-8 text-accent" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroSection;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Globe, Wand2, Download, Rocket } from "lucide-react";
|
||||||
|
import AnimatedSection, { StaggerContainer, StaggerItem } from "@/components/AnimatedSection";
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
number: "01",
|
||||||
|
icon: Globe,
|
||||||
|
title: "Enter Your Website URL",
|
||||||
|
description: "Simply paste your website URL. Our AI analyzes your site structure and content automatically."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "02",
|
||||||
|
icon: Wand2,
|
||||||
|
title: "AI Configures Everything",
|
||||||
|
description: "The AI assistant sets up navigation, colors, icons, and settings based on your website. You can customize anything."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "03",
|
||||||
|
icon: Download,
|
||||||
|
title: "Build Your App",
|
||||||
|
description: "One-click build generates your native app file. Preview it instantly in your browser or download for testing."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "04",
|
||||||
|
icon: Rocket,
|
||||||
|
title: "Publish to App Stores",
|
||||||
|
description: "Get signed app files ready for Google Play and App Store submission. QR codes for easy sharing."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const HowItWorksSection = () => {
|
||||||
|
return (
|
||||||
|
<section id="how-it-works" className="py-32 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-muted/20" />
|
||||||
|
|
||||||
|
<div className="container mx-auto px-6 relative z-10">
|
||||||
|
{/* Section Header */}
|
||||||
|
<AnimatedSection className="text-center max-w-3xl mx-auto mb-20">
|
||||||
|
<div className="inline-flex items-center gap-2 glass-card px-4 py-2 rounded-full mb-6">
|
||||||
|
<Wand2 className="w-4 h-4 text-primary" />
|
||||||
|
<span className="text-sm text-muted-foreground">Simple Process</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-4xl md:text-5xl lg:text-6xl font-bold mb-6">
|
||||||
|
<span className="text-foreground">From Website to App in</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-primary">4 Simple Steps</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-muted-foreground">
|
||||||
|
No coding, no complexity. Just enter your URL and let AI do the rest
|
||||||
|
</p>
|
||||||
|
</AnimatedSection>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Connection Line */}
|
||||||
|
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-border to-transparent hidden lg:block" />
|
||||||
|
|
||||||
|
<StaggerContainer className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8" staggerDelay={0.15}>
|
||||||
|
{steps.map((step) => (
|
||||||
|
<StaggerItem key={step.number} className="relative group h-full">
|
||||||
|
{/* Card */}
|
||||||
|
<div className="glass-card rounded-2xl p-8 text-center hover:border-primary/30 transition-all duration-500 hover:-translate-y-2 h-full flex flex-col">
|
||||||
|
{/* Number */}
|
||||||
|
<div className="absolute -top-4 left-1/2 -translate-x-1/2 w-8 h-8 rounded-full bg-primary flex items-center justify-center text-xs font-bold text-primary-foreground">
|
||||||
|
{step.number}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-secondary/50 flex items-center justify-center mx-auto mb-6 mt-4 group-hover:bg-primary/20 transition-colors">
|
||||||
|
<step.icon className="w-10 h-10 text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<h3 className="font-display text-xl font-semibold text-foreground mb-3">
|
||||||
|
{step.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm leading-relaxed flex-1">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</StaggerContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HowItWorksSection;
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Loader2, Image as ImageIcon, Maximize2, FileDown, Sparkles } from "lucide-react";
|
||||||
|
import {
|
||||||
|
OptimizationOptions,
|
||||||
|
createPreview,
|
||||||
|
formatBytes,
|
||||||
|
OPTIMIZATION_PRESETS,
|
||||||
|
loadImage,
|
||||||
|
} from "@/lib/image-optimization";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ImageOptimizationDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
files: File[];
|
||||||
|
onConfirm: (files: File[], options: OptimizationOptions | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresetKey = keyof typeof OPTIMIZATION_PRESETS;
|
||||||
|
|
||||||
|
export function ImageOptimizationDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
files,
|
||||||
|
onConfirm,
|
||||||
|
}: ImageOptimizationDialogProps) {
|
||||||
|
const [preset, setPreset] = useState<PresetKey>("balanced");
|
||||||
|
const [customMode, setCustomMode] = useState(false);
|
||||||
|
const [options, setOptions] = useState<OptimizationOptions>(
|
||||||
|
OPTIMIZATION_PRESETS.balanced.options
|
||||||
|
);
|
||||||
|
const [preview, setPreview] = useState<{
|
||||||
|
dataUrl: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
estimatedSize: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [originalInfo, setOriginalInfo] = useState<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
size: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [skipOptimization, setSkipOptimization] = useState(false);
|
||||||
|
|
||||||
|
const firstImageFile = files.find((f) => f.type.startsWith("image/"));
|
||||||
|
const imageCount = files.filter((f) => f.type.startsWith("image/")).length;
|
||||||
|
const nonImageCount = files.length - imageCount;
|
||||||
|
|
||||||
|
// Load original image info
|
||||||
|
useEffect(() => {
|
||||||
|
if (!firstImageFile || !open) return;
|
||||||
|
|
||||||
|
const loadOriginal = async () => {
|
||||||
|
try {
|
||||||
|
const img = await loadImage(firstImageFile);
|
||||||
|
setOriginalInfo({
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
size: firstImageFile.size,
|
||||||
|
});
|
||||||
|
URL.revokeObjectURL(img.src);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading image:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadOriginal();
|
||||||
|
}, [firstImageFile, open]);
|
||||||
|
|
||||||
|
// Generate preview when options change
|
||||||
|
const updatePreview = useCallback(async () => {
|
||||||
|
if (!firstImageFile || skipOptimization) {
|
||||||
|
setPreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const previewResult = await createPreview(firstImageFile, options);
|
||||||
|
setPreview(previewResult);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating preview:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [firstImageFile, options, skipOptimization]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && firstImageFile) {
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
}, [open, updatePreview, firstImageFile]);
|
||||||
|
|
||||||
|
const handlePresetChange = (newPreset: PresetKey) => {
|
||||||
|
setPreset(newPreset);
|
||||||
|
setOptions(OPTIMIZATION_PRESETS[newPreset].options);
|
||||||
|
setCustomMode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOptionChange = (key: keyof OptimizationOptions, value: number | string | boolean) => {
|
||||||
|
setCustomMode(true);
|
||||||
|
setOptions((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm(files, skipOptimization ? null : options);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savings = preview && originalInfo
|
||||||
|
? Math.round((1 - preview.estimatedSize / originalInfo.size) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (!firstImageFile && imageCount === 0) {
|
||||||
|
// No images to optimize, just confirm
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Upload Files</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{files.length} file(s) ready to upload. No images to optimize.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => { onConfirm(files, null); onOpenChange(false); }}>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-accent" />
|
||||||
|
Optimize Images
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* File info */}
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-secondary/50">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
|
||||||
|
<ImageIcon className="w-5 h-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{imageCount} image{imageCount > 1 ? "s" : ""} selected
|
||||||
|
{nonImageCount > 0 && ` + ${nonImageCount} other file(s)`}
|
||||||
|
</p>
|
||||||
|
{originalInfo && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Original: {originalInfo.width} × {originalInfo.height} • {formatBytes(originalInfo.size)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skip optimization toggle */}
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg border border-border">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Upload without optimization</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Keep original file size and quality</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={skipOptimization}
|
||||||
|
onCheckedChange={setSkipOptimization}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!skipOptimization && (
|
||||||
|
<>
|
||||||
|
{/* Preset Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Optimization Preset</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={customMode ? "" : preset}
|
||||||
|
onValueChange={(v) => handlePresetChange(v as PresetKey)}
|
||||||
|
className="grid grid-cols-2 sm:grid-cols-3 gap-2"
|
||||||
|
>
|
||||||
|
{(Object.entries(OPTIMIZATION_PRESETS) as [PresetKey, typeof OPTIMIZATION_PRESETS.balanced][]).map(
|
||||||
|
([key, { label, description }]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<RadioGroupItem value={key} id={key} className="peer sr-only" />
|
||||||
|
<Label
|
||||||
|
htmlFor={key}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col p-3 rounded-lg border-2 cursor-pointer transition-all",
|
||||||
|
"hover:border-primary/50 hover:bg-secondary/50",
|
||||||
|
preset === key && !customMode
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{description}</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Options */}
|
||||||
|
<div className="space-y-4 p-4 rounded-lg border border-border bg-secondary/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">Fine-tune Settings</Label>
|
||||||
|
{customMode && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-accent/20 text-accent">
|
||||||
|
Custom
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Width */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">Max Width</Label>
|
||||||
|
<span className="text-xs font-mono">{options.maxWidth}px</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[options.maxWidth || 1920]}
|
||||||
|
min={200}
|
||||||
|
max={4000}
|
||||||
|
step={100}
|
||||||
|
onValueChange={([v]) => handleOptionChange("maxWidth", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Height */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">Max Height</Label>
|
||||||
|
<span className="text-xs font-mono">{options.maxHeight}px</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[options.maxHeight || 1920]}
|
||||||
|
min={200}
|
||||||
|
max={4000}
|
||||||
|
step={100}
|
||||||
|
onValueChange={([v]) => handleOptionChange("maxHeight", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quality */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">Quality</Label>
|
||||||
|
<span className="text-xs font-mono">{options.quality}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[options.quality || 80]}
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
onValueChange={([v]) => handleOptionChange("quality", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Format */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">Format</Label>
|
||||||
|
<Select
|
||||||
|
value={options.format || "webp"}
|
||||||
|
onValueChange={(v) => handleOptionChange("format", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-24 h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="webp">WebP</SelectItem>
|
||||||
|
<SelectItem value="jpeg">JPEG</SelectItem>
|
||||||
|
<SelectItem value="png">PNG</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{firstImageFile && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">Preview</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Original */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground text-center">Original</p>
|
||||||
|
<div className="aspect-video rounded-lg bg-muted overflow-hidden flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(firstImageFile)}
|
||||||
|
alt="Original"
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{originalInfo && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs font-medium">{formatBytes(originalInfo.size)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{originalInfo.width} × {originalInfo.height}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optimized */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground text-center">Optimized</p>
|
||||||
|
<div className="aspect-video rounded-lg bg-muted overflow-hidden flex items-center justify-center">
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
) : preview ? (
|
||||||
|
<img
|
||||||
|
src={preview.dataUrl}
|
||||||
|
alt="Optimized"
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No preview</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{preview && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs font-medium">
|
||||||
|
~{formatBytes(preview.estimatedSize)}
|
||||||
|
{savings > 0 && (
|
||||||
|
<span className="text-accent ml-1">(-{savings}%)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{preview.width} × {preview.height}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm} disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
) : skipOptimization ? (
|
||||||
|
<FileDown className="w-4 h-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{skipOptimization ? "Upload Original" : `Optimize & Upload`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { Home, Plus, Settings, CreditCard, HelpCircle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useNavBadges } from "@/hooks/useNavBadges";
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
icon: React.ElementType;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
badgeKey?: "dashboard";
|
||||||
|
isCenter?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ icon: Home, label: "Home", path: "/dashboard", badgeKey: "dashboard" },
|
||||||
|
{ icon: CreditCard, label: "Credits", path: "/subscription" },
|
||||||
|
{ icon: Plus, label: "Build", path: "/builder", isCenter: true },
|
||||||
|
{ icon: HelpCircle, label: "Help", path: "/help" },
|
||||||
|
{ icon: Settings, label: "Settings", path: "/settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MobileBottomNav = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const { badges } = useNavBadges();
|
||||||
|
|
||||||
|
const getBadgeCount = (badgeKey?: "dashboard") => {
|
||||||
|
if (!badgeKey) return 0;
|
||||||
|
return badges[badgeKey] || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-background/80 backdrop-blur-xl border-t border-border/50 safe-area-bottom">
|
||||||
|
<div className="flex items-center justify-around h-16 px-1 max-w-md mx-auto">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = location.pathname === item.path;
|
||||||
|
const Icon = item.icon;
|
||||||
|
const badgeCount = getBadgeCount(item.badgeKey);
|
||||||
|
|
||||||
|
if (item.isCenter) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className="relative -mt-5 flex flex-col items-center"
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"w-13 h-13 rounded-full flex items-center justify-center shadow-lg transition-all duration-300",
|
||||||
|
isActive
|
||||||
|
? "bg-primary scale-110 shadow-primary/30"
|
||||||
|
: "bg-primary/90 hover:bg-primary"
|
||||||
|
)}>
|
||||||
|
<Icon className="w-6 h-6 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] font-medium mt-1 transition-colors",
|
||||||
|
isActive ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center flex-1 h-full gap-0.5 transition-all duration-200 relative",
|
||||||
|
isActive ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Active indicator */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-8 h-0.5 bg-primary rounded-full" />
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"w-5 h-5 transition-all duration-200",
|
||||||
|
isActive && "scale-110"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{badgeCount > 0 && (
|
||||||
|
<span className="absolute -top-1.5 -right-1.5 min-w-[16px] h-4 px-1 flex items-center justify-center text-[10px] font-bold bg-primary text-primary-foreground rounded-full animate-in zoom-in-50 duration-200">
|
||||||
|
{badgeCount > 9 ? "9+" : badgeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-medium transition-colors",
|
||||||
|
isActive ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileBottomNav;
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Smartphone, Menu, X, LogOut, Settings, Coins, Shield, Plus, ChevronDown, ChevronLeft, ChevronRight, FolderOpen, Trash2, Wrench, HelpCircle } from "lucide-react";
|
||||||
|
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { useAdminAuth } from "@/hooks/useAdminAuth";
|
||||||
|
import { useAdminExists } from "@/hooks/useAdminExists";
|
||||||
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
|
import { userApi, projectsApi } from "@/lib/api";
|
||||||
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
import ThemeAwareLogo from "@/components/ThemeAwareLogo";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const navSections = ['features', 'how-it-works', 'pricing', 'platforms'] as const;
|
||||||
|
|
||||||
|
const PROJECTS_PER_PAGE = 5;
|
||||||
|
|
||||||
|
const Navbar = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [activeSection, setActiveSection] = useState<string | null>(null);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [projectToDelete, setProjectToDelete] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const [projectPage, setProjectPage] = useState(0);
|
||||||
|
const { user, signOut } = useAuth();
|
||||||
|
const { isAdmin } = useAdminAuth();
|
||||||
|
const { hasAdmin } = useAdminExists();
|
||||||
|
const { settings } = useSystemSettings();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Track active section with IntersectionObserver
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname !== '/') {
|
||||||
|
setActiveSection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observers: IntersectionObserver[] = [];
|
||||||
|
|
||||||
|
navSections.forEach((sectionId) => {
|
||||||
|
const element = document.getElementById(sectionId);
|
||||||
|
if (element) {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setActiveSection(sectionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ rootMargin: '-20% 0px -60% 0px', threshold: 0 }
|
||||||
|
);
|
||||||
|
observer.observe(element);
|
||||||
|
observers.push(observer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observers.forEach((observer) => observer.disconnect());
|
||||||
|
};
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const handleSmoothScroll = useCallback((e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const scrollToElement = () => {
|
||||||
|
const element = document.getElementById(targetId);
|
||||||
|
if (element) {
|
||||||
|
const navbarHeight = 80;
|
||||||
|
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
|
||||||
|
window.scrollTo({
|
||||||
|
top: elementPosition - navbarHeight,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we're not on the home page, navigate first then scroll
|
||||||
|
if (location.pathname !== '/') {
|
||||||
|
navigate('/');
|
||||||
|
setTimeout(scrollToElement, 100);
|
||||||
|
} else {
|
||||||
|
scrollToElement();
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [location.pathname, navigate]);
|
||||||
|
|
||||||
|
const getNavLinkClass = (sectionId: string) =>
|
||||||
|
cn(
|
||||||
|
"transition-colors cursor-pointer relative",
|
||||||
|
activeSection === sectionId
|
||||||
|
? "text-primary font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch user profile for avatar
|
||||||
|
const { data: profile } = useQuery({
|
||||||
|
queryKey: ["navbar-profile", user?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!user?.id) return null;
|
||||||
|
const { data, error } = await userApi.getProfile();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all projects for builder dropdown
|
||||||
|
const { data: allProjects } = useQuery({
|
||||||
|
queryKey: ["recent-projects", user?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!user?.id) return [];
|
||||||
|
const { data, error } = await projectsApi.list();
|
||||||
|
if (error) throw error;
|
||||||
|
return data || [];
|
||||||
|
},
|
||||||
|
enabled: !!user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalProjectPages = Math.max(1, Math.ceil((allProjects?.length || 0) / PROJECTS_PER_PAGE));
|
||||||
|
const paginatedProjects = useMemo(() => {
|
||||||
|
if (!allProjects) return [];
|
||||||
|
const start = projectPage * PROJECTS_PER_PAGE;
|
||||||
|
return allProjects.slice(start, start + PROJECTS_PER_PAGE);
|
||||||
|
}, [allProjects, projectPage]);
|
||||||
|
|
||||||
|
// Delete project mutation
|
||||||
|
const deleteProjectMutation = useMutation({
|
||||||
|
mutationFn: async (projectId: string) => {
|
||||||
|
const { error } = await projectsApi.delete(projectId);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["recent-projects"] });
|
||||||
|
toast.success("Project deleted successfully");
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setProjectToDelete(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Delete error:", error);
|
||||||
|
toast.error("Failed to delete project");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDeleteClick = (e: React.MouseEvent, project: { id: string; app_name: string }) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setProjectToDelete({ id: project.id, name: project.app_name });
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (projectToDelete) {
|
||||||
|
deleteProjectMutation.mutate(projectToDelete.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (name?: string | null) => {
|
||||||
|
if (!name) return user?.email?.[0]?.toUpperCase() || "U";
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayName = profile?.display_name || user?.email?.split("@")[0] || "User";
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
await signOut();
|
||||||
|
navigate("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed top-0 left-0 right-0 z-50 glass-card border-b border-border/50">
|
||||||
|
<div className="container mx-auto px-3 sm:px-6 py-3 sm:py-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link to="/" className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||||
|
<ThemeAwareLogo />
|
||||||
|
<span className="font-display text-base sm:text-xl font-bold text-foreground truncate">{settings.app_name}</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<div className="hidden lg:flex items-center gap-6 xl:gap-8">
|
||||||
|
<a href="/#features" onClick={(e) => handleSmoothScroll(e, 'features')} className={getNavLinkClass('features')}>Features</a>
|
||||||
|
<a href="/#how-it-works" onClick={(e) => handleSmoothScroll(e, 'how-it-works')} className={getNavLinkClass('how-it-works')}>How It Works</a>
|
||||||
|
<a href="/#pricing" onClick={(e) => handleSmoothScroll(e, 'pricing')} className={getNavLinkClass('pricing')}>Pricing</a>
|
||||||
|
<a href="/#platforms" onClick={(e) => handleSmoothScroll(e, 'platforms')} className={getNavLinkClass('platforms')}>Platforms</a>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||||
|
location.pathname === '/builder'
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-primary/10 text-primary hover:bg-primary/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Try to Build
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/builder" className="cursor-pointer flex items-center gap-2 font-medium">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New App
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{allProjects && allProjects.length > 0 && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal flex items-center justify-between">
|
||||||
|
<span>Recent Projects ({allProjects.length})</span>
|
||||||
|
{totalProjectPages > 1 && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setProjectPage(p => Math.max(0, p - 1)); }}
|
||||||
|
disabled={projectPage === 0}
|
||||||
|
className="p-0.5 rounded hover:bg-muted disabled:opacity-30 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] tabular-nums">{projectPage + 1}/{totalProjectPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setProjectPage(p => Math.min(totalProjectPages - 1, p + 1)); }}
|
||||||
|
disabled={projectPage >= totalProjectPages - 1}
|
||||||
|
className="p-0.5 rounded hover:bg-muted disabled:opacity-30 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{paginatedProjects.map((project) => (
|
||||||
|
<DropdownMenuItem key={project.id} className="p-0">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<Link
|
||||||
|
to={`/builder`}
|
||||||
|
className="flex-1 px-2 py-1.5 cursor-pointer flex flex-col items-start gap-0.5"
|
||||||
|
>
|
||||||
|
<span className="font-medium truncate max-w-[160px]">{project.app_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(project.updated_at), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDeleteClick(e, project)}
|
||||||
|
className="p-1.5 mr-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
title="Delete project"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/dashboard" className="cursor-pointer flex items-center gap-2 text-muted-foreground">
|
||||||
|
<FolderOpen className="w-4 h-4" />
|
||||||
|
View All Projects
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA Buttons */}
|
||||||
|
<div className="hidden lg:flex items-center gap-2 xl:gap-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
{user ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="glass" className="gap-2">
|
||||||
|
<Avatar className="w-7 h-7 border border-primary/20">
|
||||||
|
<AvatarImage src={profile?.avatar_url || undefined} alt="Avatar" />
|
||||||
|
<AvatarFallback className="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs">
|
||||||
|
{getInitials(profile?.display_name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="max-w-[100px] truncate">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/dashboard" className="cursor-pointer">
|
||||||
|
<Smartphone className="w-4 h-4 mr-2" />
|
||||||
|
My Projects
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/subscription" className="cursor-pointer">
|
||||||
|
<Coins className="w-4 h-4 mr-2" />
|
||||||
|
Plans & Credits
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/settings" className="cursor-pointer">
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/help" className="cursor-pointer">
|
||||||
|
<HelpCircle className="w-4 h-4 mr-2" />
|
||||||
|
Help Center
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{isAdmin && (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/admin" className="cursor-pointer flex items-center justify-between w-full">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
Admin Panel
|
||||||
|
</span>
|
||||||
|
<Badge variant="destructive" className="ml-2 text-[10px] px-1.5 py-0 h-4">
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleSignOut} className="cursor-pointer text-destructive">
|
||||||
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
Sign Out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{hasAdmin === false && (
|
||||||
|
<Button variant="outline" size="sm" asChild className="border-destructive/50 text-destructive hover:bg-destructive/10">
|
||||||
|
<Link to="/setup">
|
||||||
|
<Wrench className="w-4 h-4 mr-2" />
|
||||||
|
Setup Admin
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<Link to="/auth">Sign In</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="hero" size="lg" asChild>
|
||||||
|
<Link to="/auth">Start Free</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Toggle */}
|
||||||
|
<button
|
||||||
|
className="lg:hidden text-foreground"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
{isOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="lg:hidden pt-4 pb-2 space-y-4 animate-fade-in">
|
||||||
|
<a href="/#features" onClick={(e) => handleSmoothScroll(e, 'features')} className={cn("block py-2 cursor-pointer", getNavLinkClass('features'))}>Features</a>
|
||||||
|
<a href="/#how-it-works" onClick={(e) => handleSmoothScroll(e, 'how-it-works')} className={cn("block py-2 cursor-pointer", getNavLinkClass('how-it-works'))}>How It Works</a>
|
||||||
|
<a href="/#pricing" onClick={(e) => handleSmoothScroll(e, 'pricing')} className={cn("block py-2 cursor-pointer", getNavLinkClass('pricing'))}>Pricing</a>
|
||||||
|
<a href="/#platforms" onClick={(e) => handleSmoothScroll(e, 'platforms')} className={cn("block py-2 cursor-pointer", getNavLinkClass('platforms'))}>Platforms</a>
|
||||||
|
<div className="py-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/builder"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 py-2 cursor-pointer",
|
||||||
|
location.pathname === '/builder'
|
||||||
|
? "text-primary font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New App
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="flex items-center gap-2 py-2 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-4 h-4" />
|
||||||
|
My Projects
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-col gap-2 pt-4">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<Button variant="glass" className="w-full" asChild>
|
||||||
|
<Link to="/dashboard">My Projects</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="glass" className="w-full" asChild>
|
||||||
|
<Link to="/subscription">Plans & Credits</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="glass" className="w-full" asChild>
|
||||||
|
<Link to="/settings">Settings</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="glass" className="w-full" asChild>
|
||||||
|
<Link to="/help">
|
||||||
|
<HelpCircle className="w-4 h-4 mr-2" />
|
||||||
|
Help Center
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button variant="glass" className="w-full justify-between" asChild>
|
||||||
|
<Link to="/admin">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
Admin Panel
|
||||||
|
</span>
|
||||||
|
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4">
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" className="w-full" onClick={handleSignOut}>
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{hasAdmin === false && (
|
||||||
|
<Button variant="outline" className="w-full border-destructive/50 text-destructive hover:bg-destructive/10" asChild>
|
||||||
|
<Link to="/setup">
|
||||||
|
<Wrench className="w-4 h-4 mr-2" />
|
||||||
|
Setup Admin
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" className="w-full" asChild>
|
||||||
|
<Link to="/auth">Sign In</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="hero" className="w-full" asChild>
|
||||||
|
<Link to="/auth">Start Free</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Project Confirmation Dialog */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Project</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{projectToDelete?.name}"? This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
import ThemeAwareLogo from "@/components/ThemeAwareLogo";
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
backLink?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
titleClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageHeader = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
backLink,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
titleClassName,
|
||||||
|
}: PageHeaderProps) => {
|
||||||
|
const { settings } = useSystemSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-4 mb-8 sm:mb-10", className)}>
|
||||||
|
<div className="flex items-start gap-3 sm:gap-4">
|
||||||
|
{backLink && (
|
||||||
|
<Button variant="ghost" size="icon" className="shrink-0 mt-1" asChild>
|
||||||
|
<Link to={backLink}>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<ThemeAwareLogo className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className={cn(
|
||||||
|
"font-display text-2xl sm:text-3xl md:text-4xl font-bold text-foreground",
|
||||||
|
titleClassName
|
||||||
|
)}>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground mt-1">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children && (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageHeader;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Smartphone, Monitor, Laptop, Tablet, ArrowRight } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import AnimatedSection, { StaggerContainer, StaggerItem } from "@/components/AnimatedSection";
|
||||||
|
|
||||||
|
const platforms = [
|
||||||
|
{
|
||||||
|
icon: Smartphone,
|
||||||
|
name: "Android",
|
||||||
|
status: "Available Now",
|
||||||
|
description: "Generate native Android apps ready for Google Play Store",
|
||||||
|
available: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Tablet,
|
||||||
|
name: "WordPress",
|
||||||
|
status: "Plugin Available",
|
||||||
|
description: "Dedicated plugin for WordPress blogs and websites",
|
||||||
|
available: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Monitor,
|
||||||
|
name: "iOS",
|
||||||
|
status: "Available Now",
|
||||||
|
description: "Native iPhone and iPad apps for the App Store",
|
||||||
|
available: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Laptop,
|
||||||
|
name: "Windows",
|
||||||
|
status: "Available Now",
|
||||||
|
description: "Desktop apps for Windows PCs",
|
||||||
|
available: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const PlatformsSection = () => {
|
||||||
|
return (
|
||||||
|
<section id="platforms" className="py-32 relative">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,_hsl(var(--primary)_/_0.05)_0%,_transparent_50%)]" />
|
||||||
|
|
||||||
|
<div className="container mx-auto px-6 relative z-10">
|
||||||
|
{/* Section Header */}
|
||||||
|
<AnimatedSection className="text-center max-w-3xl mx-auto mb-20">
|
||||||
|
<div className="inline-flex items-center gap-2 glass-card px-4 py-2 rounded-full mb-6">
|
||||||
|
<Smartphone className="w-4 h-4 text-primary" />
|
||||||
|
<span className="text-sm text-muted-foreground">Multi-Platform</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-4xl md:text-5xl lg:text-6xl font-bold mb-6">
|
||||||
|
<span className="text-foreground">Build for</span>
|
||||||
|
<br />
|
||||||
|
<span className="gradient-text">Every Platform</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-muted-foreground">
|
||||||
|
Build native apps for Android, iOS, Windows, and WordPress with our modular architecture
|
||||||
|
</p>
|
||||||
|
</AnimatedSection>
|
||||||
|
|
||||||
|
{/* Platforms Grid */}
|
||||||
|
<StaggerContainer className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-5xl mx-auto" staggerDelay={0.1}>
|
||||||
|
{platforms.map((platform) => (
|
||||||
|
<StaggerItem key={platform.name} className="h-full">
|
||||||
|
<div className="glass-card rounded-2xl p-8 text-center transition-all duration-500 hover:-translate-y-2 hover:border-primary/30 h-full flex flex-col">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-accent flex items-center justify-center mx-auto mb-6">
|
||||||
|
<platform.icon className="w-8 h-8 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium mb-4 bg-primary/20 text-primary mx-auto">
|
||||||
|
{platform.status}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<h3 className="font-display text-xl font-semibold text-foreground mb-2">
|
||||||
|
{platform.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground text-sm flex-1 mb-6">
|
||||||
|
{platform.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<Button asChild variant="outline" size="sm" className="w-full group">
|
||||||
|
<Link to="/builder">
|
||||||
|
Get Started
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</StaggerContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlatformsSection;
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Check, Sparkles } from "lucide-react";
|
||||||
|
import { plansApi } from "@/lib/api";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import AnimatedSection, { StaggerContainer, StaggerItem } from "@/components/AnimatedSection";
|
||||||
|
|
||||||
|
interface DisplayPlan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tier: string;
|
||||||
|
price_monthly: number;
|
||||||
|
price_yearly: number;
|
||||||
|
description: string | null;
|
||||||
|
features: string[] | Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback plans if API is unavailable
|
||||||
|
const fallbackPlans: DisplayPlan[] = [
|
||||||
|
{
|
||||||
|
id: "free",
|
||||||
|
name: "Free",
|
||||||
|
tier: "free",
|
||||||
|
price_monthly: 0,
|
||||||
|
price_yearly: 0,
|
||||||
|
description: "Perfect for trying out the platform",
|
||||||
|
features: [
|
||||||
|
"1 App Build per Month",
|
||||||
|
"Basic Customization",
|
||||||
|
"Browser Preview",
|
||||||
|
"Community Support",
|
||||||
|
"Watermark on App"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: "Pro",
|
||||||
|
tier: "pro",
|
||||||
|
price_monthly: 19,
|
||||||
|
price_yearly: 182,
|
||||||
|
description: "For serious app creators",
|
||||||
|
features: [
|
||||||
|
"10 App Builds per Month",
|
||||||
|
"Full Customization",
|
||||||
|
"Push Notifications",
|
||||||
|
"No Watermark",
|
||||||
|
"Priority Support",
|
||||||
|
"App Store Ready Builds",
|
||||||
|
"Analytics Dashboard"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enterprise",
|
||||||
|
name: "Enterprise",
|
||||||
|
tier: "enterprise",
|
||||||
|
price_monthly: 199,
|
||||||
|
price_yearly: 1910,
|
||||||
|
description: "For agencies and teams",
|
||||||
|
features: [
|
||||||
|
"Unlimited App Builds",
|
||||||
|
"White-Label Solution",
|
||||||
|
"Custom Branding",
|
||||||
|
"Dedicated Support",
|
||||||
|
"API Access",
|
||||||
|
"Team Management",
|
||||||
|
"Custom Integrations",
|
||||||
|
"SLA Guarantee"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const PricingSection = () => {
|
||||||
|
const [plans, setPlans] = useState<DisplayPlan[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await plansApi.list();
|
||||||
|
if (error) throw error;
|
||||||
|
const activePlans = (data || []).filter(p => p.is_active).map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
tier: p.tier,
|
||||||
|
price_monthly: p.price_monthly,
|
||||||
|
price_yearly: p.price_yearly,
|
||||||
|
description: p.description,
|
||||||
|
features: p.features as string[] | Record<string, boolean>,
|
||||||
|
}));
|
||||||
|
setPlans(activePlans);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching plans:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transform features to array
|
||||||
|
const getFeatures = (features: unknown): string[] => {
|
||||||
|
if (Array.isArray(features)) {
|
||||||
|
return features as string[];
|
||||||
|
}
|
||||||
|
if (typeof features === 'object' && features !== null) {
|
||||||
|
return Object.keys(features).filter(key => (features as Record<string, boolean>)[key]);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get CTA text based on tier
|
||||||
|
const getCTA = (tier: string): string => {
|
||||||
|
switch (tier) {
|
||||||
|
case "free": return "Get Started";
|
||||||
|
case "pro": return "Start Pro Trial";
|
||||||
|
case "enterprise": return "Contact Sales";
|
||||||
|
default: return "Get Started";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if plan is popular (pro tier)
|
||||||
|
const isPopular = (tier: string): boolean => tier === "pro";
|
||||||
|
|
||||||
|
// Use API plans or fallback
|
||||||
|
const displayPlans = plans.length > 0 ? plans : (loading ? [] : fallbackPlans);
|
||||||
|
|
||||||
|
// Skeleton for plan cards
|
||||||
|
const PlanCardSkeleton = () => (
|
||||||
|
<div className="glass-card rounded-3xl p-8 space-y-6">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<Skeleton className="h-7 w-20 mx-auto" />
|
||||||
|
<div className="flex items-baseline justify-center gap-1">
|
||||||
|
<Skeleton className="h-12 w-20" />
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-4 w-3/4 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<Skeleton className="w-5 h-5 rounded-full" />
|
||||||
|
<Skeleton className="h-4 flex-1" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-11 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="pricing" className="py-32 relative">
|
||||||
|
<div className="absolute inset-0 bg-muted/20" />
|
||||||
|
|
||||||
|
<div className="container mx-auto px-6 relative z-10">
|
||||||
|
{/* Section Header */}
|
||||||
|
<AnimatedSection className="text-center max-w-3xl mx-auto mb-20">
|
||||||
|
<div className="inline-flex items-center gap-2 glass-card px-4 py-2 rounded-full mb-6">
|
||||||
|
<Sparkles className="w-4 h-4 text-accent" />
|
||||||
|
<span className="text-sm text-muted-foreground">Pricing Plans</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-4xl md:text-5xl lg:text-6xl font-bold mb-6">
|
||||||
|
<span className="text-foreground">Simple, Transparent</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-primary">Pricing</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-muted-foreground">
|
||||||
|
Start free, upgrade when you need. Cancel anytime
|
||||||
|
</p>
|
||||||
|
</AnimatedSection>
|
||||||
|
|
||||||
|
{/* Pricing Cards */}
|
||||||
|
<StaggerContainer className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto" staggerDelay={0.15}>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 3 }).map((_, i) => <PlanCardSkeleton key={i} />)
|
||||||
|
) : (
|
||||||
|
displayPlans.map((plan) => {
|
||||||
|
const features = getFeatures(plan.features);
|
||||||
|
const popular = isPopular(plan.tier);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StaggerItem key={plan.id} className="h-full">
|
||||||
|
<div
|
||||||
|
className={`relative glass-card rounded-3xl p-8 h-full ${popular ? 'border-primary/50 scale-105' : ''} transition-all duration-500 hover:-translate-y-2`}
|
||||||
|
>
|
||||||
|
{/* Popular Badge */}
|
||||||
|
{popular && (
|
||||||
|
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
|
||||||
|
<div className="bg-accent text-accent-foreground text-sm font-semibold px-4 py-1 rounded-full">
|
||||||
|
Most Popular
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plan Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h3 className="font-display text-2xl font-bold text-foreground mb-2">{plan.name}</h3>
|
||||||
|
<div className="flex items-baseline justify-center gap-1">
|
||||||
|
<span className="font-display text-5xl font-bold text-primary">${plan.price_monthly}</span>
|
||||||
|
<span className="text-muted-foreground">/month</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2">{plan.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<ul className="space-y-4 mb-8">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<li key={feature} className="flex items-center gap-3">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Check className="w-3 h-3 text-accent" />
|
||||||
|
</div>
|
||||||
|
<span className="text-foreground">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<Button
|
||||||
|
variant={popular ? "accent" : "glass"}
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link to="/subscription">
|
||||||
|
{getCTA(plan.tier)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</StaggerItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</StaggerContainer>
|
||||||
|
|
||||||
|
{/* Trust Badges */}
|
||||||
|
<AnimatedSection delay={0.3} className="flex flex-wrap items-center justify-center gap-8 mt-16 text-muted-foreground text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="w-4 h-4 text-accent" />
|
||||||
|
<span>14-day money-back guarantee</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="w-4 h-4 text-accent" />
|
||||||
|
<span>No credit card required</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="w-4 h-4 text-accent" />
|
||||||
|
<span>Cancel anytime</span>
|
||||||
|
</div>
|
||||||
|
</AnimatedSection>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PricingSection;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Download, Smartphone, Copy, Check } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface QRDownloadDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
downloadUrl: string;
|
||||||
|
appName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QRDownloadDialog = ({ open, onOpenChange, downloadUrl, appName }: QRDownloadDialogProps) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleCopyLink = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(downloadUrl);
|
||||||
|
setCopied(true);
|
||||||
|
toast({
|
||||||
|
title: "Link Copied",
|
||||||
|
description: "Download link copied to clipboard",
|
||||||
|
});
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: "Failed to copy",
|
||||||
|
description: "Please copy the link manually",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Smartphone className="w-5 h-5 text-accent" />
|
||||||
|
Download {appName}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Scan the QR code with your phone to download the app, or use the link below.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-6 py-4">
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="bg-white p-4 rounded-2xl shadow-lg">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={downloadUrl}
|
||||||
|
size={200}
|
||||||
|
level="H"
|
||||||
|
includeMargin
|
||||||
|
bgColor="#ffffff"
|
||||||
|
fgColor="#000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
<p className="mb-2">📱 Open your phone camera and point it at the QR code</p>
|
||||||
|
<p>The download will start automatically</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download Link */}
|
||||||
|
<div className="w-full space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={downloadUrl}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-3 py-2 text-xs bg-muted rounded-lg border border-border truncate"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="w-4 h-4 text-accent" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Direct Download */}
|
||||||
|
<Button variant="accent" className="w-full" asChild>
|
||||||
|
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Download APK
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QRDownloadDialog;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import AnimatedSection, { StaggerContainer, StaggerItem } from "./AnimatedSection";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Star, Quote } from "lucide-react";
|
||||||
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
|
||||||
|
const getTestimonials = (appName: string) => [
|
||||||
|
{
|
||||||
|
name: "Sarah Chen",
|
||||||
|
role: "Startup Founder",
|
||||||
|
company: "TechFlow",
|
||||||
|
avatar: "",
|
||||||
|
content: `${appName} transformed our web app into a native mobile experience in just minutes. The quality exceeded our expectations.`,
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Marcus Johnson",
|
||||||
|
role: "Product Manager",
|
||||||
|
company: "ScaleUp Inc",
|
||||||
|
avatar: "",
|
||||||
|
content: "We saved months of development time and thousands in costs. Our app is now live on both iOS and Android stores.",
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Elena Rodriguez",
|
||||||
|
role: "CTO",
|
||||||
|
company: "DigitalFirst",
|
||||||
|
avatar: "",
|
||||||
|
content: "The automation features are incredible. Push notifications, offline mode—everything just works out of the box.",
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "David Kim",
|
||||||
|
role: "Solo Developer",
|
||||||
|
company: "Indie Apps",
|
||||||
|
avatar: "",
|
||||||
|
content: `As a solo developer, ${appName} lets me compete with bigger teams. I've launched 3 apps this year alone.`,
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Amanda Foster",
|
||||||
|
role: "Marketing Director",
|
||||||
|
company: "BrandBoost",
|
||||||
|
avatar: "",
|
||||||
|
content: "Our client engagement increased 40% after launching the mobile app. The ROI has been phenomenal.",
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "James Wright",
|
||||||
|
role: "Agency Owner",
|
||||||
|
company: "WebCraft Studio",
|
||||||
|
avatar: "",
|
||||||
|
content: `We now offer mobile app development as a service to our clients. ${appName} is a game-changer for agencies.`,
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TestimonialsSection = () => {
|
||||||
|
const { settings } = useSystemSettings();
|
||||||
|
const testimonials = getTestimonials(settings.app_name);
|
||||||
|
return (
|
||||||
|
<section id="testimonials" className="py-24 bg-muted/30">
|
||||||
|
<div className="container mx-auto px-6">
|
||||||
|
<AnimatedSection className="text-center mb-16">
|
||||||
|
<span className="inline-block px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium mb-4">
|
||||||
|
Testimonials
|
||||||
|
</span>
|
||||||
|
<h2 className="font-display text-4xl md:text-5xl font-bold text-foreground mb-4">
|
||||||
|
Loved by Thousands
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||||
|
See what our customers are saying about their experience with {settings.app_name}
|
||||||
|
</p>
|
||||||
|
</AnimatedSection>
|
||||||
|
|
||||||
|
<StaggerContainer className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{testimonials.map((testimonial, index) => (
|
||||||
|
<StaggerItem key={index}>
|
||||||
|
<div className="glass-card rounded-2xl p-6 h-full flex flex-col border border-border/50 hover:border-primary/20 transition-colors">
|
||||||
|
<Quote className="w-8 h-8 text-primary/20 mb-4" />
|
||||||
|
|
||||||
|
<p className="text-foreground/90 mb-6 flex-grow leading-relaxed">
|
||||||
|
"{testimonial.content}"
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 mb-4">
|
||||||
|
{[...Array(testimonial.rating)].map((_, i) => (
|
||||||
|
<Star key={i} className="w-4 h-4 fill-primary text-primary" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="w-10 h-10 border border-border">
|
||||||
|
<AvatarImage src={testimonial.avatar} alt={testimonial.name} />
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary text-sm font-medium">
|
||||||
|
{testimonial.name.split(' ').map(n => n[0]).join('')}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">{testimonial.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{testimonial.role} at {testimonial.company}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</StaggerContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TestimonialsSection;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
import { useThemeStore } from "@/stores/useThemeStore";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ThemeAwareLogoProps {
|
||||||
|
className?: string;
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the appropriate logo based on the current theme (light/dark).
|
||||||
|
* Falls back to default logo or /favicon.png if no logo is configured.
|
||||||
|
*/
|
||||||
|
const ThemeAwareLogo = ({ className = "w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl", alt }: ThemeAwareLogoProps) => {
|
||||||
|
const { settings } = useSystemSettings();
|
||||||
|
const { theme } = useThemeStore();
|
||||||
|
|
||||||
|
const isDark = theme === "dark" ||
|
||||||
|
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
|
||||||
|
const hasDarkLogo = !!settings.logo_url_dark;
|
||||||
|
const hasLightLogo = !!settings.logo_url;
|
||||||
|
|
||||||
|
// Prefer admin-configured logos; otherwise fall back to the bundled brand icon.
|
||||||
|
let logoSrc = "/favicon.svg";
|
||||||
|
let shouldAutoInvert = false;
|
||||||
|
if (isDark && hasDarkLogo) {
|
||||||
|
logoSrc = settings.logo_url_dark;
|
||||||
|
} else if (hasLightLogo) {
|
||||||
|
logoSrc = settings.logo_url;
|
||||||
|
// Auto-invert a single (light-mode) custom logo when shown in dark mode
|
||||||
|
shouldAutoInvert = isDark && !hasDarkLogo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={logoSrc}
|
||||||
|
alt={alt || `${settings.app_name} Logo`}
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 object-contain transition-[filter] duration-200",
|
||||||
|
shouldAutoInvert && "invert",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeAwareLogo;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useThemeStore } from "@/stores/useThemeStore";
|
||||||
|
|
||||||
|
type ThemeProviderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||||
|
const { theme } = useThemeStore();
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== "system") return;
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
root.classList.remove("light", "dark");
|
||||||
|
root.classList.add(e.matches ? "dark" : "light");
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export useTheme hook for backwards compatibility
|
||||||
|
export const useTheme = () => {
|
||||||
|
const { theme, setTheme } = useThemeStore();
|
||||||
|
return { theme, setTheme };
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useThemeStore } from "@/stores/useThemeStore";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { setTheme } = useThemeStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||||
|
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||||
|
Light
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import { TrendingUp, Users, DollarSign, Hammer } from "lucide-react";
|
||||||
|
|
||||||
|
interface Transaction {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Build {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminAnalyticsProps {
|
||||||
|
transactions: Transaction[];
|
||||||
|
users: User[];
|
||||||
|
builds: Build[];
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ["hsl(var(--primary))", "hsl(var(--accent))", "hsl(var(--muted))", "hsl(var(--secondary))"];
|
||||||
|
|
||||||
|
export const AdminAnalytics = ({ transactions, users, builds, loading }: AdminAnalyticsProps) => {
|
||||||
|
// Process revenue data by month
|
||||||
|
const getRevenueByMonth = () => {
|
||||||
|
const monthlyRevenue: Record<string, number> = {};
|
||||||
|
const completedTransactions = transactions.filter((t) => t.status === "completed");
|
||||||
|
|
||||||
|
completedTransactions.forEach((t) => {
|
||||||
|
const date = new Date(t.created_at);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
monthlyRevenue[monthKey] = (monthlyRevenue[monthKey] || 0) + Number(t.amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
const months = Object.keys(monthlyRevenue).sort().slice(-6);
|
||||||
|
return months.map((month) => ({
|
||||||
|
month: new Date(month + "-01").toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
|
||||||
|
revenue: monthlyRevenue[month],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process user growth by month
|
||||||
|
const getUserGrowthByMonth = () => {
|
||||||
|
const monthlyUsers: Record<string, number> = {};
|
||||||
|
|
||||||
|
users.forEach((u) => {
|
||||||
|
const date = new Date(u.created_at);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
monthlyUsers[monthKey] = (monthlyUsers[monthKey] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const months = Object.keys(monthlyUsers).sort().slice(-6);
|
||||||
|
let cumulative = 0;
|
||||||
|
return months.map((month) => {
|
||||||
|
cumulative += monthlyUsers[month];
|
||||||
|
return {
|
||||||
|
month: new Date(month + "-01").toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
|
||||||
|
users: cumulative,
|
||||||
|
newUsers: monthlyUsers[month],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process build statistics
|
||||||
|
const getBuildStats = () => {
|
||||||
|
const statusCounts: Record<string, number> = {};
|
||||||
|
builds.forEach((b) => {
|
||||||
|
statusCounts[b.status] = (statusCounts[b.status] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(statusCounts).map(([status, count]) => ({
|
||||||
|
name: status.charAt(0).toUpperCase() + status.slice(1),
|
||||||
|
value: count,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process builds by month
|
||||||
|
const getBuildsByMonth = () => {
|
||||||
|
const monthlyBuilds: Record<string, { total: number; completed: number; failed: number }> = {};
|
||||||
|
|
||||||
|
builds.forEach((b) => {
|
||||||
|
const date = new Date(b.created_at);
|
||||||
|
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
if (!monthlyBuilds[monthKey]) {
|
||||||
|
monthlyBuilds[monthKey] = { total: 0, completed: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
monthlyBuilds[monthKey].total += 1;
|
||||||
|
if (b.status === "completed") monthlyBuilds[monthKey].completed += 1;
|
||||||
|
if (b.status === "failed") monthlyBuilds[monthKey].failed += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const months = Object.keys(monthlyBuilds).sort().slice(-6);
|
||||||
|
return months.map((month) => ({
|
||||||
|
month: new Date(month + "-01").toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
|
||||||
|
...monthlyBuilds[month],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const revenueData = getRevenueByMonth();
|
||||||
|
const userGrowthData = getUserGrowthByMonth();
|
||||||
|
const buildStats = getBuildStats();
|
||||||
|
const buildsTrend = getBuildsByMonth();
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
const totalRevenue = transactions
|
||||||
|
.filter((t) => t.status === "completed")
|
||||||
|
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||||
|
const totalUsers = users.length;
|
||||||
|
const totalBuilds = builds.length;
|
||||||
|
const successRate = builds.length > 0
|
||||||
|
? ((builds.filter((b) => b.status === "completed").length / builds.length) * 100).toFixed(1)
|
||||||
|
: "0";
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-8 w-48 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[300px]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Analytics Dashboard</h1>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground">Revenue trends, user growth, and build statistics</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Revenue</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">${totalRevenue.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<DollarSign className="h-8 w-8 text-primary opacity-80" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Users</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">{totalUsers.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="h-8 w-8 text-accent opacity-80" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Builds</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">{totalBuilds.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<Hammer className="h-8 w-8 text-secondary-foreground opacity-80" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Build Success Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">{successRate}%</p>
|
||||||
|
</div>
|
||||||
|
<TrendingUp className="h-8 w-8 text-primary opacity-80" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Revenue Trend */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Revenue Trend</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[250px]">
|
||||||
|
{revenueData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={revenueData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(value) => `$${value}`}
|
||||||
|
width={60}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [`$${value.toLocaleString()}`, "Revenue"]}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="hsl(var(--primary))"
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorRevenue)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
No revenue data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* User Growth */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">User Growth</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[250px]">
|
||||||
|
{userGrowthData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={userGrowthData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={40}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="users"
|
||||||
|
name="Total Users"
|
||||||
|
stroke="hsl(var(--primary))"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: "hsl(var(--primary))", r: 4 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="newUsers"
|
||||||
|
name="New Users"
|
||||||
|
stroke="hsl(var(--accent))"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: "hsl(var(--accent))", r: 4 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
No user data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Build Status Distribution */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Build Status Distribution</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[250px]">
|
||||||
|
{buildStats.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={buildStats}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{buildStats.map((_, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
formatter={(value) => <span style={{ color: "hsl(var(--foreground))" }}>{value}</span>}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
No build data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Builds Trend */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Builds Over Time</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[250px]">
|
||||||
|
{buildsTrend.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={buildsTrend} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
width={30}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "hsl(var(--card))",
|
||||||
|
border: "1px solid hsl(var(--border))",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="completed" name="Completed" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="failed" name="Failed" fill="hsl(var(--destructive))" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
No build data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Users, DollarSign, Hammer, TrendingUp, RotateCcw, Loader2, CreditCard, UserPlus, Activity, BarChart3 } from "lucide-react";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { backend } from "@/lib/backend-client";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface AdminOverviewProps {
|
||||||
|
stats: {
|
||||||
|
totalUsers: number;
|
||||||
|
totalBuilds: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
activeBuilds: number;
|
||||||
|
monthlyRevenue: number;
|
||||||
|
activeSubscriptions?: number;
|
||||||
|
mrr?: number;
|
||||||
|
newUsersToday?: number;
|
||||||
|
buildsToday?: number;
|
||||||
|
};
|
||||||
|
loading?: boolean;
|
||||||
|
isDemo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminOverview = ({ stats, loading = false, isDemo = false }: AdminOverviewProps) => {
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
|
||||||
|
const handleResetDemoData = async () => {
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await backend.functions.invoke("reset-demo-data");
|
||||||
|
if (error) throw error;
|
||||||
|
toast({
|
||||||
|
title: "Demo data reset",
|
||||||
|
description: data.message || "Demo accounts have been reset successfully.",
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("Reset demo data error:", error);
|
||||||
|
toast({
|
||||||
|
title: "Reset failed",
|
||||||
|
description: error instanceof Error ? error.message : "Failed to reset demo data.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryCards = [
|
||||||
|
{
|
||||||
|
title: "Total Users",
|
||||||
|
value: stats.totalUsers.toLocaleString(),
|
||||||
|
icon: Users,
|
||||||
|
description: "Registered accounts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Active Subscribers",
|
||||||
|
value: (stats.activeSubscriptions || 0).toLocaleString(),
|
||||||
|
icon: CreditCard,
|
||||||
|
description: "Active subscriptions",
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Monthly Recurring Revenue",
|
||||||
|
value: `$${(stats.mrr || stats.monthlyRevenue || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
|
||||||
|
icon: TrendingUp,
|
||||||
|
description: "MRR from subscriptions",
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Active Builds",
|
||||||
|
value: stats.activeBuilds.toString(),
|
||||||
|
icon: Hammer,
|
||||||
|
description: "Currently processing",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const analyticsCards = [
|
||||||
|
{
|
||||||
|
title: "Total Revenue",
|
||||||
|
value: `$${stats.totalRevenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
|
||||||
|
icon: DollarSign,
|
||||||
|
description: "All-time earnings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Total Builds",
|
||||||
|
value: stats.totalBuilds.toLocaleString(),
|
||||||
|
icon: BarChart3,
|
||||||
|
description: "All-time app builds",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "New Users Today",
|
||||||
|
value: (stats.newUsersToday || 0).toString(),
|
||||||
|
icon: UserPlus,
|
||||||
|
description: "Signed up today",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Builds Today",
|
||||||
|
value: (stats.buildsToday || 0).toString(),
|
||||||
|
icon: Activity,
|
||||||
|
description: "Started today",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderCards = (cards: typeof primaryCards) =>
|
||||||
|
cards.map((stat) => (
|
||||||
|
<Card key={stat.title} className={(stat as any).highlight ? "border-primary/50 bg-primary/5" : ""}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">{stat.title}</CardTitle>
|
||||||
|
<div className={`p-2 rounded-lg ${(stat as any).highlight ? "bg-primary/20" : "bg-accent/10"}`}>
|
||||||
|
<stat.icon className={`w-4 h-4 ${(stat as any).highlight ? "text-primary" : "text-accent"}`} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className={`text-2xl font-bold ${(stat as any).highlight ? "text-primary" : "text-foreground"}`}>{stat.value}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{stat.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
));
|
||||||
|
|
||||||
|
const renderSkeletons = (count: number) =>
|
||||||
|
Array.from({ length: count }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="w-8 h-8 rounded-lg" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-20 mb-1" />
|
||||||
|
<Skeleton className="h-3 w-28" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Dashboard Overview</h1>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground">Monitor your platform's performance</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isDemo && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="border-amber-500/50 text-amber-600 hover:bg-amber-500/10">
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Reset Demo Data
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Reset Demo Data</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will reset all data for demo accounts (admin@demo.com and user@demo.com):
|
||||||
|
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||||
|
<li>Delete all projects and builds</li>
|
||||||
|
<li>Reset credits to default values</li>
|
||||||
|
<li>Clear automation configs and logs</li>
|
||||||
|
<li>Create sample demo projects</li>
|
||||||
|
<li>Reset user roles to defaults</li>
|
||||||
|
</ul>
|
||||||
|
<span className="block mt-2 font-medium">This action cannot be undone.</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleResetDemoData}
|
||||||
|
disabled={resetting}
|
||||||
|
className="bg-amber-600 hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
{resetting ? (
|
||||||
|
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Resetting...</>
|
||||||
|
) : (
|
||||||
|
"Reset Demo Data"
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{loading ? renderSkeletons(4) : renderCards(primaryCards)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analytics Summary */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-3">Quick Analytics</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{loading ? renderSkeletons(4) : renderCards(analyticsCards)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Hammer,
|
||||||
|
Settings,
|
||||||
|
LayoutDashboard,
|
||||||
|
Menu,
|
||||||
|
Home,
|
||||||
|
Coins,
|
||||||
|
Plug,
|
||||||
|
Banknote,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export type AdminSection =
|
||||||
|
| "overview"
|
||||||
|
| "users"
|
||||||
|
| "payments"
|
||||||
|
| "pricing"
|
||||||
|
| "builds"
|
||||||
|
| "integrations"
|
||||||
|
| "settings";
|
||||||
|
|
||||||
|
const menuItems: { id: AdminSection; label: string; icon: React.ElementType }[] = [
|
||||||
|
{ id: "overview", label: "Overview", icon: LayoutDashboard },
|
||||||
|
{ id: "users", label: "Users", icon: Users },
|
||||||
|
{ id: "payments", label: "Payments", icon: Banknote },
|
||||||
|
{ id: "pricing", label: "Pricing", icon: Coins },
|
||||||
|
{ id: "builds", label: "Builds", icon: Hammer },
|
||||||
|
{ id: "integrations", label: "Integrations", icon: Plug },
|
||||||
|
{ id: "settings", label: "Settings", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AdminSidebarProps {
|
||||||
|
activeSection: AdminSection;
|
||||||
|
onSectionChange: (section: AdminSection) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const SidebarContent = ({
|
||||||
|
activeSection,
|
||||||
|
onSectionChange,
|
||||||
|
onClose
|
||||||
|
}: AdminSidebarProps & { onClose?: () => void }) => {
|
||||||
|
|
||||||
|
const renderItem = (item: { id: AdminSection; label: string; icon: React.ElementType }) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
onSectionChange(item.id);
|
||||||
|
onClose?.();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
|
||||||
|
activeSection === item.id
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="p-4 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src="/favicon.png"
|
||||||
|
alt="Admin Logo"
|
||||||
|
className="w-8 h-8 rounded-lg"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-foreground">Admin Panel</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">Manage your platform</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 p-2">
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{menuItems.map(renderItem)}
|
||||||
|
</nav>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-border">
|
||||||
|
<Button variant="outline" size="sm" className="w-full" asChild>
|
||||||
|
<Link to="/dashboard">
|
||||||
|
<Home className="w-4 h-4 mr-2" />
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminSidebar = ({ activeSection, onSectionChange }: AdminSidebarProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile Header with Menu Button */}
|
||||||
|
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 bg-background border-b border-border px-4 py-3 flex items-center gap-3">
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="p-0 w-64">
|
||||||
|
<SheetTitle className="sr-only">Admin Navigation</SheetTitle>
|
||||||
|
<SidebarContent
|
||||||
|
activeSection={activeSection}
|
||||||
|
onSectionChange={onSectionChange}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
<img
|
||||||
|
src="/favicon.png"
|
||||||
|
alt="Logo"
|
||||||
|
className="w-7 h-7 rounded-lg"
|
||||||
|
/>
|
||||||
|
<h1 className="font-semibold text-foreground">Admin Panel</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<aside className="hidden lg:flex w-56 xl:w-64 h-full bg-card border-r border-border flex-col shrink-0">
|
||||||
|
<SidebarContent activeSection={activeSection} onSectionChange={onSectionChange} />
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { RefreshCw, Plus, Key, Activity, Settings2, Trash2 } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface ApiConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
api_key_masked: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
rate_limit?: number | null;
|
||||||
|
usage_count?: number | null;
|
||||||
|
last_used_at?: string | null;
|
||||||
|
config: unknown;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiConfigurationProps {
|
||||||
|
configs: ApiConfig[];
|
||||||
|
onAdd: (config: Omit<ApiConfig, "id" | "created_at" | "usage_count" | "last_used_at">) => Promise<boolean>;
|
||||||
|
onUpdate: (id: string, updates: Partial<ApiConfig>) => Promise<boolean>;
|
||||||
|
onDelete: (id: string) => Promise<boolean>;
|
||||||
|
onRefresh: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
{ value: "openai", label: "OpenAI" },
|
||||||
|
{ value: "anthropic", label: "Anthropic" },
|
||||||
|
{ value: "google", label: "Google AI" },
|
||||||
|
{ value: "stripe", label: "Stripe" },
|
||||||
|
{ value: "sendgrid", label: "SendGrid" },
|
||||||
|
{ value: "twilio", label: "Twilio" },
|
||||||
|
{ value: "appetize", label: "Appetize.io" },
|
||||||
|
{ value: "custom", label: "Custom" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ApiConfiguration = ({
|
||||||
|
configs,
|
||||||
|
onAdd,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
onRefresh,
|
||||||
|
loading,
|
||||||
|
}: ApiConfigurationProps) => {
|
||||||
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
|
const [newConfig, setNewConfig] = useState({
|
||||||
|
name: "",
|
||||||
|
provider: "",
|
||||||
|
api_key: "",
|
||||||
|
rate_limit: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newConfig.name || !newConfig.provider) {
|
||||||
|
toast.error("Please fill in all required fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the actual API key in the config JSON for edge function access
|
||||||
|
const configPayload: Record<string, unknown> = {};
|
||||||
|
if (newConfig.api_key) {
|
||||||
|
configPayload.api_key = newConfig.api_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await onAdd({
|
||||||
|
name: newConfig.name,
|
||||||
|
provider: newConfig.provider,
|
||||||
|
api_key_masked: newConfig.api_key ? `${"*".repeat(20)}${newConfig.api_key.slice(-4)}` : null,
|
||||||
|
is_active: false,
|
||||||
|
rate_limit: newConfig.rate_limit,
|
||||||
|
config: configPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success("API configuration added successfully");
|
||||||
|
setIsAddOpen(false);
|
||||||
|
setNewConfig({ name: "", provider: "", api_key: "", rate_limit: 1000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (config: ApiConfig) => {
|
||||||
|
const success = await onUpdate(config.id, { is_active: !config.is_active });
|
||||||
|
if (success) {
|
||||||
|
toast.success(`${config.name} ${!config.is_active ? "enabled" : "disabled"}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (config: ApiConfig) => {
|
||||||
|
if (confirm(`Are you sure you want to delete ${config.name}?`)) {
|
||||||
|
const success = await onDelete(config.id);
|
||||||
|
if (success) {
|
||||||
|
toast.success("API configuration deleted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeCount = configs.filter((c) => c.is_active).length;
|
||||||
|
const totalUsage = configs.reduce((sum, c) => sum + (c.usage_count ?? 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">API Configuration</h1>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground">Manage external API integrations</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onRefresh} className="w-full sm:w-auto">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" className="w-full sm:w-auto">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add API
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add API Configuration</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., Production OpenAI"
|
||||||
|
value={newConfig.name}
|
||||||
|
onChange={(e) => setNewConfig({ ...newConfig, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Provider</Label>
|
||||||
|
<Select
|
||||||
|
value={newConfig.provider}
|
||||||
|
onValueChange={(value) => setNewConfig({ ...newConfig, provider: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<SelectItem key={p.value} value={p.value}>
|
||||||
|
{p.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>API Key</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter API key"
|
||||||
|
value={newConfig.api_key}
|
||||||
|
onChange={(e) => setNewConfig({ ...newConfig, api_key: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Rate Limit (requests/hour)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={newConfig.rate_limit}
|
||||||
|
onChange={(e) => setNewConfig({ ...newConfig, rate_limit: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAdd} className="w-full">
|
||||||
|
Add Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total APIs
|
||||||
|
</CardTitle>
|
||||||
|
<Key className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold text-foreground">{configs.length}</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Active APIs
|
||||||
|
</CardTitle>
|
||||||
|
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold text-primary">{activeCount}</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Usage
|
||||||
|
</CardTitle>
|
||||||
|
<Activity className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
) : (
|
||||||
|
<div className="text-2xl font-bold text-foreground">
|
||||||
|
{totalUsage.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>API Integrations</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Provider</TableHead>
|
||||||
|
<TableHead>API Key</TableHead>
|
||||||
|
<TableHead>Rate Limit</TableHead>
|
||||||
|
<TableHead>Usage</TableHead>
|
||||||
|
<TableHead>Last Used</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
{Array.from({ length: 8 }).map((_, j) => (
|
||||||
|
<TableCell key={j}>
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : configs.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||||
|
No API configurations found. Add one to get started.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
configs.map((config) => (
|
||||||
|
<TableRow key={config.id}>
|
||||||
|
<TableCell className="font-medium">{config.name}</TableCell>
|
||||||
|
<TableCell className="capitalize">{config.provider}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{config.api_key_masked || "Not set"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{(config.rate_limit ?? 0).toLocaleString()}/hr</TableCell>
|
||||||
|
<TableCell>{(config.usage_count ?? 0).toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{config.last_used_at
|
||||||
|
? format(new Date(config.last_used_at), "MMM d, HH:mm")
|
||||||
|
: "Never"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch
|
||||||
|
checked={config.is_active}
|
||||||
|
onCheckedChange={() => handleToggle(config)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(config)}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { adminApi } from "@/lib/api";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Eye,
|
||||||
|
RefreshCw,
|
||||||
|
ExternalLink,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface BankTransferRequest {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
plan_id: string | null;
|
||||||
|
credit_pack_id: string | null;
|
||||||
|
proof_of_payment_url: string | null;
|
||||||
|
admin_notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
profiles?: { email: string; display_name: string | null } | null;
|
||||||
|
subscription_plans?: { name: string } | null;
|
||||||
|
credit_packs?: { name: string; credits: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BankTransferManagementProps {
|
||||||
|
transfers: BankTransferRequest[];
|
||||||
|
onRefresh: () => void;
|
||||||
|
onApprove: (id: string, notes: string) => Promise<void>;
|
||||||
|
onReject: (id: string, notes: string) => Promise<void>;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
pending: { label: "Pending", icon: Clock, color: "bg-amber-500/10 text-amber-500 border-amber-500/20" },
|
||||||
|
approved: { label: "Approved", icon: CheckCircle, color: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20" },
|
||||||
|
rejected: { label: "Rejected", icon: XCircle, color: "bg-red-500/10 text-red-500 border-red-500/20" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BankTransferManagement = ({
|
||||||
|
transfers,
|
||||||
|
onRefresh,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
loading = false,
|
||||||
|
}: BankTransferManagementProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [selectedTransfer, setSelectedTransfer] = useState<BankTransferRequest | null>(null);
|
||||||
|
const [actionType, setActionType] = useState<"approve" | "reject" | "view" | null>(null);
|
||||||
|
const [adminNotes, setAdminNotes] = useState("");
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
|
const handleAction = async () => {
|
||||||
|
if (!selectedTransfer || !actionType || actionType === "view") return;
|
||||||
|
|
||||||
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
if (actionType === "approve") {
|
||||||
|
await onApprove(selectedTransfer.id, adminNotes);
|
||||||
|
toast({
|
||||||
|
title: "Transfer Approved",
|
||||||
|
description: "Credits have been added to the user's account.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await onReject(selectedTransfer.id, adminNotes);
|
||||||
|
toast({
|
||||||
|
title: "Transfer Rejected",
|
||||||
|
description: "The user has been notified.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSelectedTransfer(null);
|
||||||
|
setActionType(null);
|
||||||
|
setAdminNotes("");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing transfer:", error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to process the transfer request.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDialog = (transfer: BankTransferRequest, type: "approve" | "reject" | "view") => {
|
||||||
|
setSelectedTransfer(transfer);
|
||||||
|
setActionType(type);
|
||||||
|
setAdminNotes(transfer.admin_notes || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getItemName = (transfer: BankTransferRequest) => {
|
||||||
|
if (transfer.subscription_plans?.name) return transfer.subscription_plans.name;
|
||||||
|
if (transfer.credit_packs?.name) return `${transfer.credit_packs.name} (${transfer.credit_packs.credits} credits)`;
|
||||||
|
return "Unknown item";
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingCount = transfers.filter(t => t.status === "pending").length;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10">
|
||||||
|
<Building2 className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
Bank Transfer Requests
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<Badge variant="destructive" className="ml-2">
|
||||||
|
{pendingCount} pending
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Review and process bank transfer payment requests
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{transfers.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<Building2 className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>No bank transfer requests found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Item</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{transfers.map((transfer) => {
|
||||||
|
const status = statusConfig[transfer.status as keyof typeof statusConfig] || statusConfig.pending;
|
||||||
|
const StatusIcon = status.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={transfer.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{transfer.profiles?.display_name || "Unknown"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{transfer.profiles?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm">{getItemName(transfer)}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
${transfer.amount.toFixed(2)} {transfer.currency}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className={status.color}>
|
||||||
|
<StatusIcon className="w-3 h-3 mr-1" />
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{format(new Date(transfer.created_at), "MMM d, yyyy")}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => openDialog(transfer, "view")}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{transfer.status === "pending" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-emerald-500 hover:text-emerald-600 hover:bg-emerald-500/10"
|
||||||
|
onClick={() => openDialog(transfer, "approve")}
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-500/10"
|
||||||
|
onClick={() => openDialog(transfer, "reject")}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Action Dialog */}
|
||||||
|
<Dialog open={!!selectedTransfer} onOpenChange={(open) => !open && setSelectedTransfer(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{actionType === "approve" && <CheckCircle className="w-5 h-5 text-emerald-500" />}
|
||||||
|
{actionType === "reject" && <XCircle className="w-5 h-5 text-red-500" />}
|
||||||
|
{actionType === "view" && <Eye className="w-5 h-5 text-primary" />}
|
||||||
|
{actionType === "approve" && "Approve Transfer"}
|
||||||
|
{actionType === "reject" && "Reject Transfer"}
|
||||||
|
{actionType === "view" && "Transfer Details"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{actionType === "approve" && "Approve this transfer and add credits to the user's account."}
|
||||||
|
{actionType === "reject" && "Reject this transfer request with a reason."}
|
||||||
|
{actionType === "view" && "View details of this bank transfer request."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{selectedTransfer && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">User</p>
|
||||||
|
<p className="font-medium">{selectedTransfer.profiles?.email}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Amount</p>
|
||||||
|
<p className="font-medium font-mono">
|
||||||
|
${selectedTransfer.amount.toFixed(2)} {selectedTransfer.currency}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Item</p>
|
||||||
|
<p className="font-medium">{getItemName(selectedTransfer)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Date</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{format(new Date(selectedTransfer.created_at), "PPP")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTransfer.proof_of_payment_url && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Proof of Payment</p>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a
|
||||||
|
href={selectedTransfer.proof_of_payment_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
View Proof
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionType !== "view" && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">
|
||||||
|
Admin Notes {actionType === "reject" && "(required)"}
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
value={adminNotes}
|
||||||
|
onChange={(e) => setAdminNotes(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
actionType === "approve"
|
||||||
|
? "Optional notes about this approval..."
|
||||||
|
: "Reason for rejection..."
|
||||||
|
}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionType === "view" && selectedTransfer.admin_notes && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Admin Notes</p>
|
||||||
|
<p className="text-sm bg-muted p-3 rounded-lg">{selectedTransfer.admin_notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{actionType === "view" ? (
|
||||||
|
<Button variant="outline" onClick={() => setSelectedTransfer(null)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setSelectedTransfer(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={actionType === "approve" ? "default" : "destructive"}
|
||||||
|
onClick={handleAction}
|
||||||
|
disabled={processing || (actionType === "reject" && !adminNotes.trim())}
|
||||||
|
>
|
||||||
|
{processing && <RefreshCw className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
{!processing && actionType === "approve" && <Check className="w-4 h-4 mr-2" />}
|
||||||
|
{!processing && actionType === "reject" && <X className="w-4 h-4 mr-2" />}
|
||||||
|
{actionType === "approve" ? "Approve & Add Credits" : "Reject Request"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { PaginationControls } from "@/components/ui/pagination-controls";
|
||||||
|
import { Search, RefreshCw, Hammer, Clock, CheckCircle, XCircle } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
interface Build {
|
||||||
|
id: string;
|
||||||
|
app_name: string;
|
||||||
|
website_url?: string;
|
||||||
|
status: string;
|
||||||
|
progress?: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildMonitoringProps {
|
||||||
|
builds: Build[];
|
||||||
|
activeBuilds: number;
|
||||||
|
onRefresh: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export const BuildMonitoring = ({ builds, activeBuilds, onRefresh, loading }: BuildMonitoringProps) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const filteredBuilds = builds.filter((b) => {
|
||||||
|
const matchesSearch =
|
||||||
|
b.app_name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(b.website_url?.toLowerCase().includes(search.toLowerCase()) ?? false);
|
||||||
|
const matchesStatus = statusFilter === "all" || b.status === statusFilter;
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredBuilds.length / PAGE_SIZE));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const paginatedBuilds = filteredBuilds.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => { setSearch(value); setPage(1); };
|
||||||
|
const handleStatusFilter = (value: string) => { setStatusFilter(value); setPage(1); };
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "complete": return <CheckCircle className="w-4 h-4 text-primary" />;
|
||||||
|
case "failed": return <XCircle className="w-4 h-4 text-destructive" />;
|
||||||
|
case "building": return <Hammer className="w-4 h-4 text-accent animate-pulse" />;
|
||||||
|
default: return <Clock className="w-4 h-4 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "complete": return "bg-primary/10 text-primary";
|
||||||
|
case "failed": return "bg-destructive/10 text-destructive";
|
||||||
|
case "building": return "bg-accent/10 text-accent";
|
||||||
|
default: return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusCounts = builds.reduce((acc, b) => { acc[b.status] = (acc[b.status] || 0) + 1; return acc; }, {} as Record<string, number>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Build Monitoring</h1>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground">Track all app builds and their status</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={onRefresh} className="w-full sm:w-auto">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: "Active Builds", value: activeBuilds, color: "text-primary" },
|
||||||
|
{ label: "Completed", value: statusCounts["complete"] || 0, color: "text-primary" },
|
||||||
|
{ label: "Failed", value: statusCounts["failed"] || 0, color: "text-destructive" },
|
||||||
|
{ label: "Pending", value: statusCounts["pending"] || 0, color: "text-accent" },
|
||||||
|
].map((stat) => (
|
||||||
|
<Card key={stat.label}>
|
||||||
|
<CardHeader className="pb-2"><CardTitle className="text-sm font-medium text-muted-foreground">{stat.label}</CardTitle></CardHeader>
|
||||||
|
<CardContent>{loading ? <Skeleton className="h-8 w-12" /> : <div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>}</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
|
||||||
|
<div className="relative flex-1 sm:max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search builds..." value={search} onChange={(e) => handleSearch(e.target.value)} className="pl-9" />
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={handleStatusFilter}>
|
||||||
|
<SelectTrigger className="w-full sm:w-40"><SelectValue placeholder="Status" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="building">Building</SelectItem>
|
||||||
|
<SelectItem value="complete">Complete</SelectItem>
|
||||||
|
<SelectItem value="failed">Failed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>App Name</TableHead><TableHead>Website</TableHead><TableHead>Status</TableHead>
|
||||||
|
<TableHead>Progress</TableHead><TableHead>Started</TableHead><TableHead>Updated</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-20 rounded-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-2 w-20" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-20" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-4 w-20" /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : filteredBuilds.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No builds found</TableCell></TableRow>
|
||||||
|
) : (
|
||||||
|
paginatedBuilds.map((build) => (
|
||||||
|
<TableRow key={build.id}>
|
||||||
|
<TableCell className="font-medium">{build.app_name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground max-w-[200px] truncate">{build.website_url}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusIcon(build.status)}
|
||||||
|
<Badge className={getStatusColor(build.status)}>{build.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress value={build.progress ?? 0} className="w-20 h-2" />
|
||||||
|
<span className="text-sm text-muted-foreground">{build.progress ?? 0}%</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{format(new Date(build.created_at), "MMM d, HH:mm")}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{build.updated_at ? format(new Date(build.updated_at), "MMM d, HH:mm") : "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<PaginationControls
|
||||||
|
currentPage={safePage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={filteredBuilds.length}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { backend } from "@/lib/backend-client";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Server, CheckCircle, AlertCircle, ArrowRight, ArrowLeft,
|
||||||
|
ExternalLink, Loader2, Eye, EyeOff, Rocket, GitBranch,
|
||||||
|
Smartphone, Copy, RefreshCw, Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
type WizardStep = "intro" | "account" | "connect" | "configure" | "verify" | "done";
|
||||||
|
|
||||||
|
interface CodemagicApp {
|
||||||
|
_id: string;
|
||||||
|
appName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS: { key: WizardStep; label: string; number: number }[] = [
|
||||||
|
{ key: "intro", label: "Overview", number: 1 },
|
||||||
|
{ key: "account", label: "Codemagic Account", number: 2 },
|
||||||
|
{ key: "connect", label: "API Token", number: 3 },
|
||||||
|
{ key: "configure", label: "Select App", number: 4 },
|
||||||
|
{ key: "verify", label: "Verify Pipeline", number: 5 },
|
||||||
|
{ key: "done", label: "Complete", number: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CodemagicSetupWizard = ({ onClose }: { onClose?: () => void }) => {
|
||||||
|
const [step, setStep] = useState<WizardStep>("intro");
|
||||||
|
const [apiToken, setApiToken] = useState("");
|
||||||
|
const [showToken, setShowToken] = useState(false);
|
||||||
|
const [appId, setAppId] = useState("");
|
||||||
|
const [workflowId, setWorkflowId] = useState("android-build");
|
||||||
|
const [apps, setApps] = useState<CodemagicApp[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [verifyStatus, setVerifyStatus] = useState<"idle" | "testing" | "pass" | "fail">("idle");
|
||||||
|
const [verifyMessage, setVerifyMessage] = useState("");
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const currentIndex = STEPS.findIndex((s) => s.key === step);
|
||||||
|
const progress = ((currentIndex + 1) / STEPS.length) * 100;
|
||||||
|
|
||||||
|
// Fetch existing config on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadExisting = async () => {
|
||||||
|
const { data } = await backend
|
||||||
|
.from("api_configurations")
|
||||||
|
.select("config")
|
||||||
|
.eq("provider", "codemagic")
|
||||||
|
.eq("is_active", true)
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (data?.config) {
|
||||||
|
const cfg = data.config as Record<string, string>;
|
||||||
|
if (cfg.api_token) setApiToken(cfg.api_token);
|
||||||
|
if (cfg.app_id) setAppId(cfg.app_id);
|
||||||
|
if (cfg.workflow_id) setWorkflowId(cfg.workflow_id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadExisting();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchApps = async () => {
|
||||||
|
if (!apiToken) {
|
||||||
|
toast.error("Enter your API token first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://api.codemagic.io/apps", {
|
||||||
|
headers: { "x-auth-token": apiToken },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Invalid token (${res.status})`);
|
||||||
|
const data = await res.json();
|
||||||
|
const appsList = data.applications || data || [];
|
||||||
|
setApps(Array.isArray(appsList) ? appsList : []);
|
||||||
|
if (appsList.length > 0 && !appId) {
|
||||||
|
setAppId(appsList[0]._id || appsList[0].id);
|
||||||
|
}
|
||||||
|
toast.success(`Found ${appsList.length} app(s)`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to fetch apps");
|
||||||
|
setApps([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
// Input validation
|
||||||
|
const trimmedToken = apiToken.trim();
|
||||||
|
const trimmedAppId = appId.trim();
|
||||||
|
const trimmedWorkflow = workflowId.trim();
|
||||||
|
|
||||||
|
if (!trimmedToken) {
|
||||||
|
toast.error("API token is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmedToken.length < 10) {
|
||||||
|
toast.error("API token seems too short — please verify it");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trimmedAppId) {
|
||||||
|
toast.error("App ID is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trimmedWorkflow) {
|
||||||
|
toast.error("Workflow ID is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmedWorkflow.startsWith("http")) {
|
||||||
|
toast.error("Workflow ID should not be a URL — use a workflow name like 'android-build'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const configPayload = {
|
||||||
|
api_token: trimmedToken,
|
||||||
|
app_id: trimmedAppId,
|
||||||
|
workflow_id: trimmedWorkflow,
|
||||||
|
};
|
||||||
|
const masked = trimmedToken
|
||||||
|
? `${"*".repeat(Math.max(0, trimmedToken.length - 4))}${trimmedToken.slice(-4)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Fetch ALL existing codemagic configs to deduplicate
|
||||||
|
const { data: allConfigs } = await backend
|
||||||
|
.from("api_configurations")
|
||||||
|
.select("id")
|
||||||
|
.eq("provider", "codemagic")
|
||||||
|
.order("updated_at", { ascending: false });
|
||||||
|
|
||||||
|
if (allConfigs && allConfigs.length > 0) {
|
||||||
|
// Keep the first (most recent), delete the rest
|
||||||
|
const keepId = allConfigs[0].id;
|
||||||
|
if (allConfigs.length > 1) {
|
||||||
|
const duplicateIds = allConfigs.slice(1).map((c) => c.id);
|
||||||
|
await backend.from("api_configurations").delete().in("id", duplicateIds);
|
||||||
|
console.log(`Cleaned up ${duplicateIds.length} duplicate Codemagic config(s)`);
|
||||||
|
}
|
||||||
|
// Update the surviving record
|
||||||
|
await backend.from("api_configurations").update({
|
||||||
|
config: configPayload,
|
||||||
|
api_key_masked: masked,
|
||||||
|
is_active: true,
|
||||||
|
}).eq("id", keepId);
|
||||||
|
} else {
|
||||||
|
// No existing config — insert new
|
||||||
|
await backend.from("api_configurations").insert({
|
||||||
|
name: "Codemagic",
|
||||||
|
provider: "codemagic",
|
||||||
|
config: configPayload,
|
||||||
|
api_key_masked: masked,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaved(true);
|
||||||
|
toast.success("Codemagic configuration saved");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save configuration");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyPipeline = async () => {
|
||||||
|
setVerifyStatus("testing");
|
||||||
|
setVerifyMessage("Checking Codemagic connection...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Verify token by listing apps
|
||||||
|
const res = await fetch("https://api.codemagic.io/apps", {
|
||||||
|
headers: { "x-auth-token": apiToken },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("API token is invalid");
|
||||||
|
|
||||||
|
// Step 2: Check if the app ID exists
|
||||||
|
const data = await res.json();
|
||||||
|
const appsList = data.applications || data || [];
|
||||||
|
const found = appsList.find(
|
||||||
|
(a: Record<string, unknown>) => (a._id || a.id) === appId
|
||||||
|
);
|
||||||
|
if (!found && appId) {
|
||||||
|
setVerifyMessage("Warning: App ID not found in your Codemagic apps, but token is valid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Check if the edge function is deployed
|
||||||
|
setVerifyMessage("Checking cloud-build edge function...");
|
||||||
|
const { data: { session } } = await backend.auth.getSession();
|
||||||
|
if (session?.access_token) {
|
||||||
|
const apiBase = ((import.meta.env.VITE_API_URL || '') + '/api');
|
||||||
|
const edgeRes = await fetch(`${apiBase}/functions/v1/cloud-build`, {
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
if (!edgeRes.ok && edgeRes.status !== 204) {
|
||||||
|
setVerifyMessage("Cloud-build edge function may not be deployed yet — builds will fail until deployed.");
|
||||||
|
setVerifyStatus("pass"); // Token works, just edge function pending
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerifyStatus("pass");
|
||||||
|
setVerifyMessage("All checks passed! Your build pipeline is ready.");
|
||||||
|
} catch (err) {
|
||||||
|
setVerifyStatus("fail");
|
||||||
|
setVerifyMessage(err instanceof Error ? err.message : "Verification failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (step === "connect") {
|
||||||
|
await fetchApps();
|
||||||
|
}
|
||||||
|
if (step === "configure") {
|
||||||
|
await saveConfig();
|
||||||
|
}
|
||||||
|
if (step === "verify") {
|
||||||
|
// Already verified or skip
|
||||||
|
}
|
||||||
|
const nextStep = STEPS[currentIndex + 1];
|
||||||
|
if (nextStep) setStep(nextStep.key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
const prevStep = STEPS[currentIndex - 1];
|
||||||
|
if (prevStep) setStep(prevStep.key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Step {currentIndex + 1} of {STEPS.length}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground">{STEPS[currentIndex]?.label}</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progress} className="h-2" />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={s.key}
|
||||||
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||||
|
i <= currentIndex
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i < currentIndex ? <CheckCircle className="w-4 h-4" /> : s.number}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-accent/20">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
{/* Step: Intro */}
|
||||||
|
{step === "intro" && (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-2xl bg-accent/10 flex items-center justify-center">
|
||||||
|
<Rocket className="w-8 h-8 text-accent" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">Set Up Real APK Builds</h2>
|
||||||
|
<p className="text-muted-foreground max-w-md mx-auto">
|
||||||
|
Connect Codemagic CI/CD to compile real, installable Android APKs from your web apps.
|
||||||
|
This wizard will guide you through the setup in 5 minutes.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 pt-4">
|
||||||
|
<div className="p-3 rounded-xl bg-secondary/50 text-center">
|
||||||
|
<Server className="w-5 h-5 mx-auto mb-1 text-accent" />
|
||||||
|
<span className="text-xs text-muted-foreground">Cloud Build</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-secondary/50 text-center">
|
||||||
|
<Smartphone className="w-5 h-5 mx-auto mb-1 text-accent" />
|
||||||
|
<span className="text-xs text-muted-foreground">Real APK</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl bg-secondary/50 text-center">
|
||||||
|
<Zap className="w-5 h-5 mx-auto mb-1 text-accent" />
|
||||||
|
<span className="text-xs text-muted-foreground">Automated</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Account */}
|
||||||
|
{step === "account" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold text-foreground">Create a Codemagic Account</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
If you don't already have a Codemagic account, create one for free.
|
||||||
|
Then connect your GitHub repository containing this project.
|
||||||
|
</p>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-secondary/30">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-accent text-accent-foreground flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">1</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Sign up at Codemagic</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Free tier includes 500 build minutes/month</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-2" asChild>
|
||||||
|
<a href="https://codemagic.io/signup" target="_blank" rel="noopener noreferrer">
|
||||||
|
Open Codemagic <ExternalLink className="ml-1 w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-secondary/30">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-accent text-accent-foreground flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">2</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Connect your GitHub repository</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
In Codemagic, click "Add application" and select the GitHub repo for this project.
|
||||||
|
Make sure the repo contains the <code className="text-accent">codemagic.yaml</code> file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-secondary/30">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-accent text-accent-foreground flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">3</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Export to GitHub first</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use the "Export to GitHub" button to push your project code.
|
||||||
|
Codemagic clones from GitHub to build.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: API Token */}
|
||||||
|
{step === "connect" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold text-foreground">Enter Your API Token</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Find your API token in Codemagic → Settings → Integrations → API keys.
|
||||||
|
</p>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="cm-token">Codemagic API Token</Label>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<Input
|
||||||
|
id="cm-token"
|
||||||
|
type={showToken ? "text" : "password"}
|
||||||
|
value={apiToken}
|
||||||
|
onChange={(e) => setApiToken(e.target.value)}
|
||||||
|
placeholder="Paste your API token here"
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowToken(!showToken)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-accent/5 border border-accent/10">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<strong className="text-foreground">Where to find it:</strong> Log in to{" "}
|
||||||
|
<a href="https://codemagic.io/apps" target="_blank" rel="noopener noreferrer" className="text-accent underline">
|
||||||
|
codemagic.io
|
||||||
|
</a>
|
||||||
|
{" "}→ Click your avatar → "User settings" → "Integrations" → "Codemagic API" → "Show".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Select App */}
|
||||||
|
{step === "configure" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold text-foreground">Select Your App</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Choose which Codemagic application to use for builds.
|
||||||
|
</p>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{apps.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Your Codemagic Apps</Label>
|
||||||
|
{apps.map((app) => (
|
||||||
|
<button
|
||||||
|
key={app._id}
|
||||||
|
onClick={() => setAppId(app._id)}
|
||||||
|
className={`w-full p-3 rounded-lg border text-left transition-colors ${
|
||||||
|
appId === app._id
|
||||||
|
? "border-accent bg-accent/5"
|
||||||
|
: "border-border hover:border-accent/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium text-foreground">{app.appName}</span>
|
||||||
|
</div>
|
||||||
|
{appId === app._id && <CheckCircle className="w-4 h-4 text-accent" />}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 font-mono">{app._id}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<Button variant="ghost" size="sm" onClick={fetchApps} disabled={loading}>
|
||||||
|
<RefreshCw className={`w-3 h-3 mr-1 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-4 rounded-lg bg-secondary/30 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
{loading ? "Fetching apps..." : "No apps loaded yet. Click below to fetch."}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchApps} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-1" />}
|
||||||
|
Fetch Apps
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="cm-appid">Or paste App ID manually</Label>
|
||||||
|
<Input
|
||||||
|
id="cm-appid"
|
||||||
|
value={appId}
|
||||||
|
onChange={(e) => setAppId(e.target.value)}
|
||||||
|
placeholder="e.g. 64a1b2c3d4e5f6..."
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="cm-workflow">Workflow ID</Label>
|
||||||
|
<Input
|
||||||
|
id="cm-workflow"
|
||||||
|
value={workflowId}
|
||||||
|
onChange={(e) => setWorkflowId(e.target.value)}
|
||||||
|
placeholder="android-build"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Must match a workflow defined in your <code className="text-accent">codemagic.yaml</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Verify */}
|
||||||
|
{step === "verify" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold text-foreground">Verify Build Pipeline</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Let's make sure everything is connected and working.
|
||||||
|
</p>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className={`p-4 rounded-lg border ${
|
||||||
|
verifyStatus === "pass" ? "border-green-500/30 bg-green-500/5" :
|
||||||
|
verifyStatus === "fail" ? "border-destructive/30 bg-destructive/5" :
|
||||||
|
"border-border"
|
||||||
|
}`}>
|
||||||
|
{verifyStatus === "idle" && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Click below to verify your Codemagic connection and build pipeline.
|
||||||
|
</p>
|
||||||
|
<Button onClick={verifyPipeline}>
|
||||||
|
<Zap className="w-4 h-4 mr-2" />
|
||||||
|
Run Verification
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{verifyStatus === "testing" && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-accent" />
|
||||||
|
<span className="text-sm text-foreground">{verifyMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{verifyStatus === "pass" && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Pipeline Ready</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{verifyMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{verifyStatus === "fail" && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Verification Failed</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{verifyMessage}</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-2" onClick={verifyPipeline}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-lg bg-secondary/30">
|
||||||
|
<h4 className="text-xs font-semibold text-foreground mb-2">Configuration Summary</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<span className="text-muted-foreground">API Token:</span>
|
||||||
|
<span className="text-foreground font-mono">
|
||||||
|
{"*".repeat(Math.max(0, apiToken.length - 4))}{apiToken.slice(-4)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">App ID:</span>
|
||||||
|
<span className="text-foreground font-mono truncate">{appId || "—"}</span>
|
||||||
|
<span className="text-muted-foreground">Workflow:</span>
|
||||||
|
<span className="text-foreground">{workflowId}</span>
|
||||||
|
<span className="text-muted-foreground">Saved:</span>
|
||||||
|
<Badge variant={saved ? "default" : "secondary"} className="w-fit">
|
||||||
|
{saved ? "Yes" : "Not yet"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: Done */}
|
||||||
|
{step === "done" && (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto rounded-2xl bg-green-500/10 flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">Build Pipeline Ready!</h2>
|
||||||
|
<p className="text-muted-foreground max-w-md mx-auto">
|
||||||
|
Your Codemagic CI/CD pipeline is configured. When users build an Android app,
|
||||||
|
it will trigger a real Gradle build on Codemagic's servers and produce an installable APK.
|
||||||
|
</p>
|
||||||
|
<Separator />
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-left">
|
||||||
|
<div className="p-3 rounded-lg bg-secondary/30">
|
||||||
|
<p className="text-xs font-semibold text-foreground">What happens next</p>
|
||||||
|
<ul className="text-xs text-muted-foreground mt-1 space-y-1">
|
||||||
|
<li>• User clicks "Build" in App Builder</li>
|
||||||
|
<li>• Edge function triggers Codemagic</li>
|
||||||
|
<li>• Gradle compiles a real Android APK</li>
|
||||||
|
<li>• APK download link appears when done</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-secondary/30">
|
||||||
|
<p className="text-xs font-semibold text-foreground">Requirements</p>
|
||||||
|
<ul className="text-xs text-muted-foreground mt-1 space-y-1">
|
||||||
|
<li>• Project exported to GitHub</li>
|
||||||
|
<li>• <code className="text-accent">codemagic.yaml</code> in repo</li>
|
||||||
|
<li>• Codemagic connected to repo</li>
|
||||||
|
<li>• Edge functions deployed</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex gap-2 pt-6">
|
||||||
|
{step !== "intro" && step !== "done" && (
|
||||||
|
<Button variant="outline" onClick={handleBack} className="flex-1">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" /> Back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === "done" ? (
|
||||||
|
<Button onClick={onClose} className="flex-1">
|
||||||
|
Close Wizard
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={
|
||||||
|
loading ||
|
||||||
|
(step === "connect" && !apiToken) ||
|
||||||
|
(step === "configure" && !appId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Processing...</>
|
||||||
|
) : (
|
||||||
|
<>{step === "intro" ? "Get Started" : "Continue"}<ArrowRight className="ml-2 h-4 w-4" /></>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CodemagicSetupWizard;
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Bitcoin,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
Shield,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Copy,
|
||||||
|
RefreshCw,
|
||||||
|
ExternalLink,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { backend } from "@/lib/backend-client";
|
||||||
|
|
||||||
|
interface CoinbaseConfig {
|
||||||
|
api_key: string;
|
||||||
|
webhook_secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GatewayConfig {
|
||||||
|
id: string;
|
||||||
|
is_enabled: boolean;
|
||||||
|
is_test_mode: boolean;
|
||||||
|
sandbox_config: CoinbaseConfig;
|
||||||
|
live_config: CoinbaseConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CoinbaseConfiguration = () => {
|
||||||
|
const [config, setConfig] = useState<GatewayConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||||
|
const [healthStatus, setHealthStatus] = useState<{
|
||||||
|
canConnect: boolean;
|
||||||
|
lastChecked: Date | null;
|
||||||
|
error?: string;
|
||||||
|
}>({ canConnect: false, lastChecked: null });
|
||||||
|
|
||||||
|
const [sandboxConfig, setSandboxConfig] = useState<CoinbaseConfig>({
|
||||||
|
api_key: "",
|
||||||
|
webhook_secret: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [liveConfig, setLiveConfig] = useState<CoinbaseConfig>({
|
||||||
|
api_key: "",
|
||||||
|
webhook_secret: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await backend
|
||||||
|
.from("payment_gateway_configs")
|
||||||
|
.select("*")
|
||||||
|
.eq("gateway", "coinbase")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error && error.code !== "PGRST116") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setConfig(data as unknown as GatewayConfig);
|
||||||
|
const sandboxData = data.sandbox_config as unknown as CoinbaseConfig | null;
|
||||||
|
const liveData = data.live_config as unknown as CoinbaseConfig | null;
|
||||||
|
setSandboxConfig(sandboxData || { api_key: "", webhook_secret: "" });
|
||||||
|
setLiveConfig(liveData || { api_key: "", webhook_secret: "" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading Coinbase config:", error);
|
||||||
|
toast.error("Failed to load Coinbase configuration");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const { error } = await backend
|
||||||
|
.from("payment_gateway_configs")
|
||||||
|
.update({
|
||||||
|
is_enabled: config?.is_enabled ?? false,
|
||||||
|
is_test_mode: config?.is_test_mode ?? true,
|
||||||
|
sandbox_config: JSON.parse(JSON.stringify(sandboxConfig)),
|
||||||
|
live_config: JSON.parse(JSON.stringify(liveConfig)),
|
||||||
|
})
|
||||||
|
.eq("gateway", "coinbase");
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
toast.success("Coinbase configuration saved");
|
||||||
|
await loadConfig();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving config:", error);
|
||||||
|
toast.error("Failed to save configuration");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEnabled = async (enabled: boolean) => {
|
||||||
|
setConfig((prev) => prev ? { ...prev, is_enabled: enabled } : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTestMode = async (testMode: boolean) => {
|
||||||
|
setConfig((prev) => prev ? { ...prev, is_test_mode: testMode } : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConnection = async () => {
|
||||||
|
const currentConfig = config?.is_test_mode ? sandboxConfig : liveConfig;
|
||||||
|
|
||||||
|
if (!currentConfig.api_key) {
|
||||||
|
setHealthStatus({
|
||||||
|
canConnect: false,
|
||||||
|
lastChecked: new Date(),
|
||||||
|
error: "Missing API key",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test by fetching charges (empty list is fine)
|
||||||
|
const response = await fetch("https://api.commerce.coinbase.com/charges", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"X-CC-Api-Key": currentConfig.api_key,
|
||||||
|
"X-CC-Version": "2018-03-22",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setHealthStatus({
|
||||||
|
canConnect: true,
|
||||||
|
lastChecked: new Date(),
|
||||||
|
});
|
||||||
|
toast.success("Coinbase Commerce connection successful");
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
setHealthStatus({
|
||||||
|
canConnect: false,
|
||||||
|
lastChecked: new Date(),
|
||||||
|
error: "Invalid API key",
|
||||||
|
});
|
||||||
|
toast.error("Coinbase Commerce connection failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setHealthStatus({
|
||||||
|
canConnect: false,
|
||||||
|
lastChecked: new Date(),
|
||||||
|
error: "Network error",
|
||||||
|
});
|
||||||
|
toast.error("Failed to connect to Coinbase Commerce");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string, label: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
toast.success(`${label} copied`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSecretVisibility = (key: string) => {
|
||||||
|
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const webhookUrl = `${((import.meta.env.VITE_API_URL || '') + '/api')}/functions/v1/coinbase-webhook`;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentConfig = config?.is_test_mode ? sandboxConfig : liveConfig;
|
||||||
|
const isConfigured = !!currentConfig.api_key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-[#0052FF]/10">
|
||||||
|
<Bitcoin className="w-5 h-5 text-[#0052FF]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Coinbase Commerce</CardTitle>
|
||||||
|
<CardDescription>Accept cryptocurrency payments</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="coinbase-enabled" className="text-sm">Enable</Label>
|
||||||
|
<Switch
|
||||||
|
id="coinbase-enabled"
|
||||||
|
checked={config?.is_enabled ?? false}
|
||||||
|
onCheckedChange={toggleEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Badge className={config?.is_enabled ? "bg-primary/20 text-primary border-0" : "bg-muted text-muted-foreground"}>
|
||||||
|
{config?.is_enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-lg border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{config?.is_test_mode ? (
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||||
|
) : (
|
||||||
|
<Shield className="w-5 h-5 text-primary" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{config?.is_test_mode ? "Sandbox Mode" : "Live Mode"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{config?.is_test_mode
|
||||||
|
? "Using Coinbase Commerce sandbox for testing"
|
||||||
|
: "Processing real cryptocurrency payments"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="test-mode" className="text-sm">Test Mode</Label>
|
||||||
|
<Switch
|
||||||
|
id="test-mode"
|
||||||
|
checked={config?.is_test_mode ?? true}
|
||||||
|
onCheckedChange={toggleTestMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Health Status */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{healthStatus.canConnect ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-primary" />
|
||||||
|
) : healthStatus.lastChecked ? (
|
||||||
|
<XCircle className="w-5 h-5 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Connection Status</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{healthStatus.lastChecked
|
||||||
|
? healthStatus.canConnect
|
||||||
|
? "Connected successfully"
|
||||||
|
: healthStatus.error || "Connection failed"
|
||||||
|
: "Not tested yet"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={testConnection} disabled={!isConfigured}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Credentials Tabs */}
|
||||||
|
<Tabs defaultValue="sandbox" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="sandbox">Sandbox Credentials</TabsTrigger>
|
||||||
|
<TabsTrigger value="live">Live Credentials</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="sandbox" className="space-y-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>API Key</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type={showSecrets["sandbox_key"] ? "text" : "password"}
|
||||||
|
value={sandboxConfig.api_key}
|
||||||
|
onChange={(e) => setSandboxConfig((prev) => ({ ...prev, api_key: e.target.value }))}
|
||||||
|
placeholder="Sandbox API Key"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => toggleSecretVisibility("sandbox_key")}
|
||||||
|
>
|
||||||
|
{showSecrets["sandbox_key"] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Webhook Shared Secret (Optional)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type={showSecrets["sandbox_secret"] ? "text" : "password"}
|
||||||
|
value={sandboxConfig.webhook_secret}
|
||||||
|
onChange={(e) => setSandboxConfig((prev) => ({ ...prev, webhook_secret: e.target.value }))}
|
||||||
|
placeholder="Webhook shared secret for signature verification"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => toggleSecretVisibility("sandbox_secret")}
|
||||||
|
>
|
||||||
|
{showSecrets["sandbox_secret"] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="live" className="space-y-4">
|
||||||
|
<Alert className="border-destructive/50 bg-destructive/10">
|
||||||
|
<Shield className="w-4 h-4 text-destructive" />
|
||||||
|
<AlertDescription className="text-destructive">
|
||||||
|
These credentials will process real cryptocurrency payments. Double-check before saving.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>API Key</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type={showSecrets["live_key"] ? "text" : "password"}
|
||||||
|
value={liveConfig.api_key}
|
||||||
|
onChange={(e) => setLiveConfig((prev) => ({ ...prev, api_key: e.target.value }))}
|
||||||
|
placeholder="Live API Key"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => toggleSecretVisibility("live_key")}
|
||||||
|
>
|
||||||
|
{showSecrets["live_key"] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Webhook Shared Secret</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type={showSecrets["live_secret"] ? "text" : "password"}
|
||||||
|
value={liveConfig.webhook_secret}
|
||||||
|
onChange={(e) => setLiveConfig((prev) => ({ ...prev, webhook_secret: e.target.value }))}
|
||||||
|
placeholder="Webhook shared secret for signature verification"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => toggleSecretVisibility("live_secret")}
|
||||||
|
>
|
||||||
|
{showSecrets["live_secret"] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Webhook URL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Webhook URL</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={webhookUrl} readOnly className="font-mono text-sm" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(webhookUrl, "Webhook URL")}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add this URL in Coinbase Commerce Dashboard → Settings → Webhook subscriptions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supported Cryptocurrencies */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Supported Cryptocurrencies</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{["Bitcoin (BTC)", "Ethereum (ETH)", "Litecoin (LTC)", "Dogecoin (DOGE)", "Bitcoin Cash (BCH)", "USDC", "DAI"].map((crypto) => (
|
||||||
|
<Badge key={crypto} variant="secondary" className="text-xs">
|
||||||
|
{crypto}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-4">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a
|
||||||
|
href="https://commerce.coinbase.com/dashboard"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Open Coinbase Dashboard
|
||||||
|
<ExternalLink className="w-4 h-4 ml-2" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveConfig} disabled={saving}>
|
||||||
|
{saving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Save Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Alert className="border-primary/20 bg-primary/5">
|
||||||
|
<Shield className="w-4 h-4 text-primary" />
|
||||||
|
<AlertDescription>
|
||||||
|
Coinbase Commerce credentials are stored securely in the database and only accessible by admins.
|
||||||
|
Cryptocurrency payments are non-reversible once confirmed on the blockchain.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Plus, Edit, RefreshCw, Trash2, Coins, Star, TrendingDown } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface CreditPack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
credits: number;
|
||||||
|
price: number;
|
||||||
|
is_active: boolean;
|
||||||
|
stripe_price_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreditPacksManagerProps {
|
||||||
|
packs: CreditPack[];
|
||||||
|
onUpdate: (id: string, updates: Partial<CreditPack>) => Promise<boolean>;
|
||||||
|
onCreate: (pack: Omit<CreditPack, "id">) => Promise<boolean>;
|
||||||
|
onDelete: (id: string) => Promise<boolean>;
|
||||||
|
onRefresh: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPricePerCredit(price: number, credits: number): number {
|
||||||
|
if (credits <= 0) return 0;
|
||||||
|
return price / credits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreditPacksManager = ({
|
||||||
|
packs,
|
||||||
|
onUpdate,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
onRefresh,
|
||||||
|
loading,
|
||||||
|
}: CreditPacksManagerProps) => {
|
||||||
|
const [editingPack, setEditingPack] = useState<CreditPack | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<Partial<CreditPack>>({});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (editingPack) {
|
||||||
|
const success = await onUpdate(editingPack.id, formData);
|
||||||
|
if (success) {
|
||||||
|
toast.success("Credit pack updated successfully");
|
||||||
|
setEditingPack(null);
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to update credit pack");
|
||||||
|
}
|
||||||
|
} else if (isCreating) {
|
||||||
|
const success = await onCreate({
|
||||||
|
name: formData.name || "",
|
||||||
|
description: formData.description || null,
|
||||||
|
credits: formData.credits || 0,
|
||||||
|
price: formData.price || 0,
|
||||||
|
is_active: formData.is_active ?? true,
|
||||||
|
stripe_price_id: formData.stripe_price_id || null,
|
||||||
|
});
|
||||||
|
if (success) {
|
||||||
|
toast.success("Credit pack created successfully");
|
||||||
|
setIsCreating(false);
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to create credit pack");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFormData({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
const success = await onDelete(id);
|
||||||
|
if (success) {
|
||||||
|
toast.success("Credit pack deleted");
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to delete credit pack");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (pack: CreditPack) => {
|
||||||
|
setEditingPack(pack);
|
||||||
|
setFormData(pack);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setIsCreating(true);
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
credits: 100,
|
||||||
|
price: 10,
|
||||||
|
is_active: true,
|
||||||
|
stripe_price_id: "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine best value pack (lowest price per credit)
|
||||||
|
const activePacks = packs.filter(p => p.is_active && p.credits > 0 && p.price > 0);
|
||||||
|
const bestValueId = activePacks.length > 0
|
||||||
|
? activePacks.sort((a, b) => getPricePerCredit(a.price, a.credits) - getPricePerCredit(b.price, b.credits))[0]?.id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Get the highest price per credit for computing savings
|
||||||
|
const maxPricePerCredit = activePacks.length > 0
|
||||||
|
? Math.max(...activePacks.map(p => getPricePerCredit(p.price, p.credits)))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Credit Packs</h1>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground">Manage one-time credit purchases — users buy credits to build apps</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onRefresh} className="w-full sm:w-auto">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Dialog open={isCreating} onOpenChange={setIsCreating}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" onClick={openCreate} className="w-full sm:w-auto">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Pack
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Credit Pack</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<CreditPackForm formData={formData} setFormData={setFormData} onSave={handleSave} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
packs.map((pack) => {
|
||||||
|
const ppc = getPricePerCredit(pack.price, pack.credits);
|
||||||
|
const isBestValue = pack.id === bestValueId;
|
||||||
|
const savingsPct = maxPricePerCredit > 0 && ppc > 0 && ppc < maxPricePerCredit
|
||||||
|
? Math.round(((maxPricePerCredit - ppc) / maxPricePerCredit) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={pack.id} className={`relative ${!pack.is_active ? "opacity-60" : ""} ${isBestValue ? "ring-2 ring-primary" : ""}`}>
|
||||||
|
{isBestValue && (
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<Badge className="bg-primary text-primary-foreground gap-1">
|
||||||
|
<Star className="w-3 h-3" />
|
||||||
|
Best Value
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-2 rounded-lg bg-accent/10">
|
||||||
|
<Coins className="w-4 h-4 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">{pack.name}</CardTitle>
|
||||||
|
<Badge variant={pack.is_active ? "default" : "secondary"} className="mt-1">
|
||||||
|
{pack.is_active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Dialog
|
||||||
|
open={editingPack?.id === pack.id}
|
||||||
|
onOpenChange={(open) => !open && setEditingPack(null)}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEdit(pack)}>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Credit Pack</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<CreditPackForm formData={formData} setFormData={setFormData} onSave={handleSave} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Credit Pack?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete "{pack.name}". This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => handleDelete(pack.id)}>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-2xl font-bold text-accent">{pack.credits}</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-lg font-medium">${pack.price}</span>
|
||||||
|
{savingsPct > 0 && (
|
||||||
|
<Badge variant="outline" className="ml-2 text-xs gap-1 text-green-600 border-green-300 bg-green-50 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800">
|
||||||
|
<TrendingDown className="w-3 h-3" />
|
||||||
|
Save {savingsPct}%
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pack.credits > 0 && pack.price > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground border-t border-border pt-2">
|
||||||
|
<span className="font-medium text-primary">${ppc.toFixed(2)}</span> per credit
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pack.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{pack.description}</p>
|
||||||
|
)}
|
||||||
|
{pack.stripe_price_id && (
|
||||||
|
<div className="pt-2 border-t">
|
||||||
|
<p className="text-xs text-muted-foreground truncate" title={pack.stripe_price_id}>
|
||||||
|
Stripe: {pack.stripe_price_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CreditPackFormProps {
|
||||||
|
formData: Partial<CreditPack>;
|
||||||
|
setFormData: (data: Partial<CreditPack>) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreditPackForm = ({ formData, setFormData, onSave }: CreditPackFormProps) => (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.name || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="e.g., Starter Pack"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Credits</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.credits || 0}
|
||||||
|
onChange={(e) => setFormData({ ...formData, credits: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Price ($)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.price || 0}
|
||||||
|
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(formData.credits || 0) > 0 && (formData.price || 0) > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
${((formData.price || 0) / (formData.credits || 1)).toFixed(2)} per credit
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="Brief description of the pack"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stripe Configuration */}
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<h4 className="font-medium text-foreground mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-accent" />
|
||||||
|
Stripe Integration
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Stripe Price ID</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="price_xxx..."
|
||||||
|
value={formData.stripe_price_id || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, stripe_price_id: e.target.value })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Optional: Create a one-time price in Stripe and paste the ID here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<Label>Active</Label>
|
||||||
|
<Switch
|
||||||
|
checked={formData.is_active ?? true}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={onSave} className="w-full">
|
||||||
|
Save Credit Pack
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
RefreshCw,
|
||||||
|
Smartphone,
|
||||||
|
Globe,
|
||||||
|
Shield,
|
||||||
|
Terminal,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// Build timestamp - updated at build time via Vite
|
||||||
|
const BUILD_TIMESTAMP = new Date().toISOString();
|
||||||
|
|
||||||
|
// Core dependency versions (from package.json)
|
||||||
|
const dependencies = {
|
||||||
|
core: [
|
||||||
|
{ name: "React", version: "18.3.1", category: "Framework" },
|
||||||
|
{ name: "TypeScript", version: "5.8.3", category: "Language" },
|
||||||
|
{ name: "Vite", version: "7.3.1", category: "Build Tool" },
|
||||||
|
{ name: "Tailwind CSS", version: "3.4.19", category: "Styling" },
|
||||||
|
],
|
||||||
|
capacitor: [
|
||||||
|
{ name: "@capacitor/core", version: "8.0.1", category: "Core" },
|
||||||
|
{ name: "@capacitor/cli", version: "8.0.1", category: "CLI" },
|
||||||
|
{ name: "@capacitor/android", version: "8.0.1", category: "Platform" },
|
||||||
|
{ name: "@capacitor/ios", version: "8.0.1", category: "Platform" },
|
||||||
|
{ name: "@capacitor/camera", version: "8.0.0", category: "Plugin" },
|
||||||
|
{ name: "@capacitor/haptics", version: "8.0.0", category: "Plugin" },
|
||||||
|
{ name: "@capacitor/push-notifications", version: "8.0.0", category: "Plugin" },
|
||||||
|
],
|
||||||
|
ui: [
|
||||||
|
{ name: "shadcn/ui (Radix)", version: "Latest", category: "Components" },
|
||||||
|
{ name: "Lucide React", version: "0.462.0", category: "Icons" },
|
||||||
|
{ name: "Framer Motion", version: "12.24.12", category: "Animation" },
|
||||||
|
{ name: "Recharts", version: "2.15.4", category: "Charts" },
|
||||||
|
],
|
||||||
|
data: [
|
||||||
|
{ name: "Better Auth", version: "1.6.20", category: "Backend" },
|
||||||
|
{ name: "pg (node-postgres)", version: "8.13.1", category: "Backend" },
|
||||||
|
{ name: "Express", version: "4.21.2", category: "Backend" },
|
||||||
|
{ name: "@tanstack/react-query", version: "5.83.0", category: "State" },
|
||||||
|
{ name: "Zustand", version: "5.0.9", category: "State" },
|
||||||
|
{ name: "React Hook Form", version: "7.61.1", category: "Forms" },
|
||||||
|
{ name: "Zod", version: "3.25.76", category: "Validation" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const upgradeChecklist = [
|
||||||
|
{
|
||||||
|
id: "backup",
|
||||||
|
label: "Create a backup or commit current state",
|
||||||
|
description: "Ensure you have a rollback point before making changes",
|
||||||
|
icon: Shield,
|
||||||
|
category: "Preparation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "audit",
|
||||||
|
label: "Run npm audit to check for vulnerabilities",
|
||||||
|
description: "npm audit --omit=dev --audit-level=high",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
category: "Preparation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "update-deps",
|
||||||
|
label: "Update dependencies (npm update or npm install)",
|
||||||
|
description: "Review breaking changes in release notes first",
|
||||||
|
icon: Package,
|
||||||
|
category: "Update",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cap-sync",
|
||||||
|
label: "Run npx cap sync after Capacitor updates",
|
||||||
|
description: "Syncs web assets and plugins to native projects",
|
||||||
|
icon: Smartphone,
|
||||||
|
category: "Native",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cap-update",
|
||||||
|
label: "Run npx cap update ios/android if needed",
|
||||||
|
description: "Updates native platform dependencies",
|
||||||
|
icon: Terminal,
|
||||||
|
category: "Native",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "test-build",
|
||||||
|
label: "Run npm run build to verify production build",
|
||||||
|
description: "Catch TypeScript and bundling errors early",
|
||||||
|
icon: CheckCircle2,
|
||||||
|
category: "Verification",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "test-native",
|
||||||
|
label: "Test on native devices/emulators",
|
||||||
|
description: "Run npx cap run android/ios to verify native builds",
|
||||||
|
icon: Smartphone,
|
||||||
|
category: "Verification",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pwa-cache",
|
||||||
|
label: "Bust PWA cache if service worker updated",
|
||||||
|
description: "Clear browser cache or increment SW version",
|
||||||
|
icon: Globe,
|
||||||
|
category: "PWA",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "deploy",
|
||||||
|
label: "Deploy and verify in production",
|
||||||
|
description: "Test critical user flows after deployment",
|
||||||
|
icon: RefreshCw,
|
||||||
|
category: "Deployment",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DependencyHealth = () => {
|
||||||
|
const [checkedItems, setCheckedItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleCheck = (id: string) => {
|
||||||
|
const newChecked = new Set(checkedItems);
|
||||||
|
if (newChecked.has(id)) {
|
||||||
|
newChecked.delete(id);
|
||||||
|
} else {
|
||||||
|
newChecked.add(id);
|
||||||
|
}
|
||||||
|
setCheckedItems(newChecked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetChecklist = () => {
|
||||||
|
setCheckedItems(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = Math.round((checkedItems.size / upgradeChecklist.length) * 100);
|
||||||
|
|
||||||
|
const groupedChecklist = upgradeChecklist.reduce((acc, item) => {
|
||||||
|
if (!acc[item.category]) {
|
||||||
|
acc[item.category] = [];
|
||||||
|
}
|
||||||
|
acc[item.category].push(item);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, typeof upgradeChecklist>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">Dependency Health</h2>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Monitor frontend versions and follow safe upgrade procedures
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Build Info */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-5 h-5 text-primary" />
|
||||||
|
<CardTitle className="text-lg">Build Information</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="p-3 rounded-lg bg-muted/50">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">Build Timestamp</p>
|
||||||
|
<p className="text-sm font-medium mt-1">
|
||||||
|
{new Date(BUILD_TIMESTAMP).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-muted/50">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">Environment</p>
|
||||||
|
<p className="text-sm font-medium mt-1">
|
||||||
|
{import.meta.env.MODE === "production" ? "Production" : "Development"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg bg-muted/50">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">Node Target</p>
|
||||||
|
<p className="text-sm font-medium mt-1">ES2020+ / Modern Browsers</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dependency Lists */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Core Dependencies */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package className="w-5 h-5 text-primary" />
|
||||||
|
<CardTitle className="text-lg">Core Stack</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Framework and build tooling</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{dependencies.core.map((dep) => (
|
||||||
|
<div key={dep.name} className="flex items-center justify-between py-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{dep.name}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">{dep.category}</Badge>
|
||||||
|
</div>
|
||||||
|
<code className="text-xs bg-muted px-2 py-0.5 rounded">{dep.version}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Capacitor Dependencies */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Smartphone className="w-5 h-5 text-accent-foreground" />
|
||||||
|
<CardTitle className="text-lg">Capacitor / Native</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Mobile app and native plugins</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[180px]">
|
||||||
|
<div className="space-y-2 pr-4">
|
||||||
|
{dependencies.capacitor.map((dep) => (
|
||||||
|
<div key={dep.name} className="flex items-center justify-between py-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium truncate max-w-[160px]">{dep.name}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">{dep.category}</Badge>
|
||||||
|
</div>
|
||||||
|
<code className="text-xs bg-muted px-2 py-0.5 rounded">{dep.version}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* UI Dependencies */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="w-5 h-5 text-secondary-foreground" />
|
||||||
|
<CardTitle className="text-lg">UI Libraries</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Components, icons, and animations</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{dependencies.ui.map((dep) => (
|
||||||
|
<div key={dep.name} className="flex items-center justify-between py-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{dep.name}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">{dep.category}</Badge>
|
||||||
|
</div>
|
||||||
|
<code className="text-xs bg-muted px-2 py-0.5 rounded">{dep.version}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Data Dependencies */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-5 h-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-lg">Data & State</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Backend, state management, and forms</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{dependencies.data.map((dep) => (
|
||||||
|
<div key={dep.name} className="flex items-center justify-between py-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium truncate max-w-[160px]">{dep.name}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">{dep.category}</Badge>
|
||||||
|
</div>
|
||||||
|
<code className="text-xs bg-muted px-2 py-0.5 rounded">{dep.version}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upgrade Checklist */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-primary" />
|
||||||
|
<CardTitle className="text-lg">Safe Upgrade Checklist</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant={progress === 100 ? "default" : "secondary"}>
|
||||||
|
{progress}% Complete
|
||||||
|
</Badge>
|
||||||
|
<Button variant="outline" size="sm" onClick={resetChecklist}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Follow these steps in order when upgrading dependencies
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{Object.entries(groupedChecklist).map(([category, items]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||||
|
{category}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
||||||
|
checkedItems.has(item.id)
|
||||||
|
? "bg-primary/5 border-primary/20"
|
||||||
|
: "bg-card border-border hover:border-muted-foreground/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={item.id}
|
||||||
|
checked={checkedItems.has(item.id)}
|
||||||
|
onCheckedChange={() => toggleCheck(item.id)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<label
|
||||||
|
htmlFor={item.id}
|
||||||
|
className={`text-sm font-medium cursor-pointer ${
|
||||||
|
checkedItems.has(item.id) ? "line-through text-muted-foreground" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 font-mono">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<item.icon className={`w-4 h-4 shrink-0 ${
|
||||||
|
checkedItems.has(item.id) ? "text-primary" : "text-muted-foreground"
|
||||||
|
}`} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Separator className="mt-4" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Edit, RefreshCw, Mail, Eye } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface EmailTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subject: string;
|
||||||
|
html_content: string;
|
||||||
|
text_content: string | null;
|
||||||
|
variables: unknown;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailTemplateEditorProps {
|
||||||
|
templates: EmailTemplate[];
|
||||||
|
onUpdate: (id: string, updates: Partial<EmailTemplate>) => Promise<boolean>;
|
||||||
|
onRefresh: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailTemplateEditor = ({
|
||||||
|
templates,
|
||||||
|
onUpdate,
|
||||||
|
onRefresh,
|
||||||
|
loading,
|
||||||
|
}: EmailTemplateEditorProps) => {
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||||
|
const [previewTemplate, setPreviewTemplate] = useState<EmailTemplate | null>(null);
|
||||||
|
const [formData, setFormData] = useState<Partial<EmailTemplate>>({});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!editingTemplate) return;
|
||||||
|
const success = await onUpdate(editingTemplate.id, formData);
|
||||||
|
if (success) {
|
||||||
|
toast.success("Template updated successfully");
|
||||||
|
setEditingTemplate(null);
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to update template");
|
||||||
|
}
|
||||||
|
setFormData({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (template: EmailTemplate) => {
|
||||||
|
setEditingTemplate(template);
|
||||||
|
setFormData(template);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Email Templates</h1>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground">Customize your email communications</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={onRefresh} className="w-full sm:w-auto">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="w-5 h-5 rounded" />
|
||||||
|
<Skeleton className="h-5 w-28" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="h-5 w-20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-5 w-10 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
templates.map((template) => (
|
||||||
|
<Card key={template.id} className={!template.is_active ? "opacity-60" : ""}>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-lg capitalize">{template.name}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setPreviewTemplate(template)}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEdit(template)}>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">{template.subject}</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Array.isArray(template.variables) && template.variables.map((v: string) => (
|
||||||
|
<Badge key={v} variant="secondary" className="text-xs">
|
||||||
|
{`{{${v}}}`}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{template.is_active ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
<Badge variant={template.is_active ? "default" : "secondary"}>
|
||||||
|
{template.is_active ? "Live" : "Draft"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!editingTemplate}
|
||||||
|
onOpenChange={(open) => !open && setEditingTemplate(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Template: {editingTemplate?.name}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Subject</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.subject || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>HTML Content</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.html_content || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, html_content: e.target.value })}
|
||||||
|
rows={10}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Plain Text Content (Optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.text_content || ""}
|
||||||
|
onChange={(e) => setFormData({ ...formData, text_content: e.target.value })}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Active</Label>
|
||||||
|
<Switch
|
||||||
|
checked={formData.is_active ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFormData({ ...formData, is_active: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSave} className="w-full">
|
||||||
|
Save Template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Preview Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!previewTemplate}
|
||||||
|
onOpenChange={(open) => !open && setPreviewTemplate(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Preview: {previewTemplate?.name}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-muted rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Subject:</p>
|
||||||
|
<p className="font-medium">{previewTemplate?.subject}</p>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg p-4">
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{ __html: previewTemplate?.html_content || "" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
import { useState, useEffect, lazy, Suspense } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { backend } from "@/lib/backend-client";
|
||||||
|
import { useSystemSettings } from "@/hooks/useSystemSettings";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Mail, Smartphone, Brain, Key, Save, Eye, EyeOff, CheckCircle2, XCircle, RefreshCw, Loader2, Server, Send } from "lucide-react";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
interface IntegrationKey {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
api_key_masked: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTEGRATIONS = [
|
||||||
|
{
|
||||||
|
key: "resend",
|
||||||
|
label: "Resend",
|
||||||
|
icon: Mail,
|
||||||
|
description: "Email delivery service for transactional emails",
|
||||||
|
fields: [
|
||||||
|
{ name: "api_key", label: "API Key", placeholder: "re_xxxxxxxxxx" },
|
||||||
|
{ name: "from_email", label: "From Email", placeholder: "noreply@yourdomain.com", type: "email" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "appetize",
|
||||||
|
label: "Appetize.io",
|
||||||
|
icon: Smartphone,
|
||||||
|
description: "Live mobile app previews in the browser",
|
||||||
|
fields: [
|
||||||
|
{ name: "api_key", label: "API Token", placeholder: "tok_xxxxxxxxxx" },
|
||||||
|
{ name: "timeout_seconds", label: "Upload Timeout (seconds)", placeholder: "30", type: "number" },
|
||||||
|
{ name: "max_retries", label: "Max Retries", placeholder: "3", type: "number" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ai",
|
||||||
|
label: "OpenAI & Gemini",
|
||||||
|
icon: Brain,
|
||||||
|
description: "Configure OpenAI and Google Gemini API keys for the AI assistant",
|
||||||
|
fields: [
|
||||||
|
{ name: "openai_api_key", label: "OpenAI API Key", placeholder: "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" },
|
||||||
|
{ name: "gemini_api_key", label: "Gemini API Key", placeholder: "AIzaSyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" },
|
||||||
|
{ name: "model", label: "Default Model", placeholder: "Select a model", options: [
|
||||||
|
{ value: "google/gemini-3-flash-preview", label: "Gemini 3 Flash (Preview)" },
|
||||||
|
{ value: "google/gemini-3-pro-preview", label: "Gemini 3 Pro (Preview)" },
|
||||||
|
{ value: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||||
|
{ value: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||||
|
{ value: "google/gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
|
||||||
|
{ value: "openai/gpt-5", label: "GPT-5" },
|
||||||
|
{ value: "openai/gpt-5-mini", label: "GPT-5 Mini" },
|
||||||
|
{ value: "openai/gpt-5-nano", label: "GPT-5 Nano" },
|
||||||
|
{ value: "openai/gpt-5.2", label: "GPT-5.2" },
|
||||||
|
]},
|
||||||
|
{ name: "provider", label: "Active Provider", placeholder: "Select provider", options: [
|
||||||
|
{ value: "gemini", label: "Google Gemini" },
|
||||||
|
{ value: "openai", label: "OpenAI" },
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "codemagic",
|
||||||
|
label: "Codemagic",
|
||||||
|
icon: Server,
|
||||||
|
description: "Cloud build pipeline for compiling real Android APKs via Codemagic CI/CD",
|
||||||
|
fields: [
|
||||||
|
{ name: "api_token", label: "API Token", placeholder: "Your Codemagic API token" },
|
||||||
|
{ name: "app_id", label: "App ID", placeholder: "Auto-detected or paste from Codemagic dashboard" },
|
||||||
|
{ name: "workflow_id", label: "Workflow ID", placeholder: "android-build" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const IntegrationsManager = ({ loading = false, isDemo = false }: { loading?: boolean; isDemo?: boolean }) => {
|
||||||
|
const { settings } = useSystemSettings();
|
||||||
|
const [configs, setConfigs] = useState<Record<string, IntegrationKey>>({});
|
||||||
|
const [formValues, setFormValues] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
const [testing, setTesting] = useState<string | null>(null);
|
||||||
|
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({});
|
||||||
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
const [sendingTestEmail, setSendingTestEmail] = useState(false);
|
||||||
|
const [testEmailAddress, setTestEmailAddress] = useState("");
|
||||||
|
const [syncingAppId, setSyncingAppId] = useState(false);
|
||||||
|
|
||||||
|
const handleSyncCodemagicAppId = async () => {
|
||||||
|
const token = (formValues["codemagic"] || {})["api_token"];
|
||||||
|
if (!token) {
|
||||||
|
toast.error("Enter your Codemagic API token first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSyncingAppId(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://api.codemagic.io/apps", {
|
||||||
|
headers: { "x-auth-token": token },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Codemagic returned ${res.status} — check your API token`);
|
||||||
|
const data = await res.json();
|
||||||
|
const apps = (data.applications || data || []) as Array<Record<string, unknown>>;
|
||||||
|
if (apps.length === 0) {
|
||||||
|
toast.error("No apps found on this Codemagic account");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstApp = apps[0];
|
||||||
|
const appId = String(firstApp._id || firstApp.id || "");
|
||||||
|
const appName = String(firstApp.appName || firstApp.name || "Unknown");
|
||||||
|
if (!appId) {
|
||||||
|
toast.error("Could not determine App ID from Codemagic response");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleFieldChange("codemagic", "app_id", appId);
|
||||||
|
toast.success(`Synced App ID from "${appName}" — click Save to persist`);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Sync failed";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setSyncingAppId(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfigs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchConfigs = async () => {
|
||||||
|
const { data, error } = await backend
|
||||||
|
.from("api_configurations")
|
||||||
|
.select("*")
|
||||||
|
.in("provider", ["resend", "appetize", "ai", "codemagic"]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error fetching integration configs:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped: Record<string, IntegrationKey> = {};
|
||||||
|
const values: Record<string, Record<string, string>> = {};
|
||||||
|
|
||||||
|
(data || []).forEach((row) => {
|
||||||
|
mapped[row.provider] = {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
provider: row.provider,
|
||||||
|
api_key_masked: row.api_key_masked,
|
||||||
|
is_active: row.is_active,
|
||||||
|
config: (row.config as Record<string, unknown>) || {},
|
||||||
|
};
|
||||||
|
const cfg = (row.config as Record<string, string>) || {};
|
||||||
|
values[row.provider] = {};
|
||||||
|
const integration = INTEGRATIONS.find((i) => i.key === row.provider);
|
||||||
|
integration?.fields.forEach((f) => {
|
||||||
|
values[row.provider][f.name] = cfg[f.name] || "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setConfigs(mapped);
|
||||||
|
setFormValues(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldChange = (provider: string, field: string, value: string) => {
|
||||||
|
setFormValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[provider]: { ...(prev[provider] || {}), [field]: value },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (providerKey: string) => {
|
||||||
|
setSaving(providerKey);
|
||||||
|
const integration = INTEGRATIONS.find((i) => i.key === providerKey)!;
|
||||||
|
const values = formValues[providerKey] || {};
|
||||||
|
|
||||||
|
const configPayload: Record<string, string> = {};
|
||||||
|
integration.fields.forEach((f) => {
|
||||||
|
if (values[f.name]) configPayload[f.name] = values[f.name];
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiKeyValue = values["api_key"] || "";
|
||||||
|
const masked = apiKeyValue
|
||||||
|
? `${"*".repeat(Math.max(0, apiKeyValue.length - 4))}${apiKeyValue.slice(-4)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const existing = configs[providerKey];
|
||||||
|
|
||||||
|
if (existing?.id) {
|
||||||
|
const { error } = await backend
|
||||||
|
.from("api_configurations")
|
||||||
|
.update({
|
||||||
|
config: configPayload,
|
||||||
|
api_key_masked: masked,
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
.eq("id", existing.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error("Failed to update configuration");
|
||||||
|
console.error(error);
|
||||||
|
} else {
|
||||||
|
toast.success(`${integration.label} configuration updated`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { error } = await backend.from("api_configurations").insert({
|
||||||
|
name: integration.label,
|
||||||
|
provider: providerKey,
|
||||||
|
config: configPayload,
|
||||||
|
api_key_masked: masked,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error("Failed to save configuration");
|
||||||
|
console.error(error);
|
||||||
|
} else {
|
||||||
|
toast.success(`${integration.label} configuration saved`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchConfigs();
|
||||||
|
setSaving(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConnection = async (providerKey: string) => {
|
||||||
|
setTesting(providerKey);
|
||||||
|
const values = formValues[providerKey] || {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (providerKey) {
|
||||||
|
case "resend": {
|
||||||
|
const apiKey = values["api_key"];
|
||||||
|
if (!apiKey) throw new Error("API Key is required");
|
||||||
|
if (!apiKey.startsWith("re_")) throw new Error("Invalid API key format — Resend keys start with 're_'");
|
||||||
|
// Resend API blocks browser CORS, so validate format only
|
||||||
|
setTestResults((prev) => ({ ...prev, [providerKey]: { success: true, message: "Resend API key format valid. Save to apply." } }));
|
||||||
|
toast.success("Resend API key format validated");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "appetize": {
|
||||||
|
const apiKey = values["api_key"];
|
||||||
|
if (!apiKey) throw new Error("API Token is required");
|
||||||
|
const res = await fetch("https://api.appetize.io/v2/apps", {
|
||||||
|
headers: { Authorization: `Basic ${btoa(apiKey + ":")}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Invalid API token");
|
||||||
|
setTestResults((prev) => ({ ...prev, [providerKey]: { success: true, message: "Connected to Appetize.io" } }));
|
||||||
|
toast.success("Appetize.io connection successful");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ai": {
|
||||||
|
const activeProvider = (values["provider"] || "gemini").toLowerCase();
|
||||||
|
if (activeProvider === "openai") {
|
||||||
|
const apiKey = values["openai_api_key"];
|
||||||
|
if (!apiKey) throw new Error("OpenAI API Key is required");
|
||||||
|
const res = await fetch("https://api.openai.com/v1/models", {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Invalid OpenAI API key");
|
||||||
|
setTestResults((prev) => ({ ...prev, [providerKey]: { success: true, message: "Connected to OpenAI" } }));
|
||||||
|
toast.success("OpenAI connection successful");
|
||||||
|
} else {
|
||||||
|
const apiKey = values["gemini_api_key"];
|
||||||
|
if (!apiKey) throw new Error("Gemini API Key is required");
|
||||||
|
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);
|
||||||
|
if (!res.ok) throw new Error("Invalid Gemini API key");
|
||||||
|
setTestResults((prev) => ({ ...prev, [providerKey]: { success: true, message: "Connected to Gemini" } }));
|
||||||
|
toast.success("Gemini connection successful");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "codemagic": {
|
||||||
|
const token = values["api_token"];
|
||||||
|
if (!token) throw new Error("API Token is required");
|
||||||
|
const res = await fetch("https://api.codemagic.io/apps", {
|
||||||
|
headers: { "x-auth-token": token },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Invalid API token (${res.status})`);
|
||||||
|
const data = await res.json();
|
||||||
|
const appCount = (data.applications || data || []).length;
|
||||||
|
setTestResults((prev) => ({ ...prev, [providerKey]: { success: true, message: `Connected — ${appCount} app(s) found` } }));
|
||||||
|
toast.success("Codemagic connection successful");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown provider");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Connection failed";
|
||||||
|
setTestResults((prev) => ({ ...prev, [providerKey]: { success: false, message } }));
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setTesting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendTestEmail = async () => {
|
||||||
|
const email = testEmailAddress.trim();
|
||||||
|
if (!email) {
|
||||||
|
toast.error("Please enter a recipient email address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSendingTestEmail(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await backend.functions.invoke("send-email", {
|
||||||
|
body: {
|
||||||
|
to: email,
|
||||||
|
templateName: "test_email",
|
||||||
|
variables: { app_name: settings.app_name },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success(`Test email sent to ${email}`);
|
||||||
|
} else {
|
||||||
|
toast.error(data?.message || data?.error || "Failed to send test email");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to send test email";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setSendingTestEmail(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-10 w-64" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodemagicSetupWizard = lazy(() => import("./CodemagicSetupWizard"));
|
||||||
|
|
||||||
|
// Check if Codemagic is configured
|
||||||
|
const codemagicConfigured = !!configs["codemagic"]?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Integrations</h1>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground">
|
||||||
|
Configure API keys for external services used across the app
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Codemagic Setup Wizard Banner */}
|
||||||
|
{!codemagicConfigured && !showWizard && (
|
||||||
|
<Card className="border-accent/30 bg-accent/5">
|
||||||
|
<CardContent className="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center">
|
||||||
|
<Server className="w-5 h-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-foreground">Build Pipeline Not Configured</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Set up Codemagic to compile real Android APKs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setShowWizard(true)} disabled={isDemo}>
|
||||||
|
Setup Wizard
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showWizard && (
|
||||||
|
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
|
||||||
|
<CodemagicSetupWizard onClose={() => { setShowWizard(false); fetchConfigs(); }} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs defaultValue="resend" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
{INTEGRATIONS.map((int) => (
|
||||||
|
<TabsTrigger key={int.key} value={int.key} className="gap-2">
|
||||||
|
<int.icon className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">{int.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{INTEGRATIONS.map((integration) => {
|
||||||
|
const config = configs[integration.key];
|
||||||
|
const values = formValues[integration.key] || {};
|
||||||
|
const isConfigured = !!config?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent key={integration.key} value={integration.key}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10">
|
||||||
|
<integration.icon className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{integration.label}</CardTitle>
|
||||||
|
<CardDescription>{integration.description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={isConfigured ? "default" : "secondary"} className="gap-1">
|
||||||
|
{isConfigured ? (
|
||||||
|
<><CheckCircle2 className="w-3 h-3" /> Configured</>
|
||||||
|
) : (
|
||||||
|
<><XCircle className="w-3 h-3" /> Not Set</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{integration.fields.map((field) => {
|
||||||
|
const fieldOptions = (field as any).options as { value: string; label: string }[] | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.name} className="space-y-2">
|
||||||
|
<Label htmlFor={`${integration.key}-${field.name}`}>{field.label}</Label>
|
||||||
|
{fieldOptions ? (
|
||||||
|
<Select
|
||||||
|
value={values[field.name] || ""}
|
||||||
|
onValueChange={(val) => handleFieldChange(integration.key, field.name, val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`${integration.key}-${field.name}`} className="bg-background">
|
||||||
|
<SelectValue placeholder={field.placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-popover z-50">
|
||||||
|
{fieldOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id={`${integration.key}-${field.name}`}
|
||||||
|
type={
|
||||||
|
field.name.includes("key") || field.name.includes("secret")
|
||||||
|
? showKeys[`${integration.key}-${field.name}`]
|
||||||
|
? "text"
|
||||||
|
: "password"
|
||||||
|
: (field as any).type || "text"
|
||||||
|
}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
value={values[field.name] || ""}
|
||||||
|
onChange={(e) => handleFieldChange(integration.key, field.name, e.target.value)}
|
||||||
|
disabled={(field as any).disabled}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
{(field.name.includes("key") || field.name.includes("secret")) && !(field as any).disabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||||
|
onClick={() =>
|
||||||
|
setShowKeys((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[`${integration.key}-${field.name}`]: !prev[`${integration.key}-${field.name}`],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showKeys[`${integration.key}-${field.name}`] ? (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isConfigured && field.name.includes("key") && config?.api_key_masked && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Current: <span className="font-mono">{config.api_key_masked}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{testResults[integration.key] && (
|
||||||
|
<div className={`flex items-center gap-2 p-3 rounded-lg text-sm ${
|
||||||
|
testResults[integration.key].success
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "bg-destructive/10 text-destructive"
|
||||||
|
}`}>
|
||||||
|
{testResults[integration.key].success ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-4 h-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
{testResults[integration.key].message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleTestConnection(integration.key)}
|
||||||
|
disabled={testing === integration.key || isDemo}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{testing === integration.key ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{testing === integration.key ? "Testing..." : "Test Connection"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleSave(integration.key)}
|
||||||
|
disabled={saving === integration.key || isDemo}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{saving === integration.key ? "Saving..." : isConfigured ? "Update" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Codemagic Status Panel */}
|
||||||
|
{integration.key === "codemagic" && isConfigured && (
|
||||||
|
<div className="pt-4 border-t border-border space-y-3">
|
||||||
|
<Label className="text-sm font-semibold">Pipeline Status</Label>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30 p-3 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">App ID</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-mono font-medium text-foreground truncate flex-1">
|
||||||
|
{values["app_id"] || <span className="text-muted-foreground italic">Not set</span>}
|
||||||
|
</p>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
onClick={handleSyncCodemagicAppId}
|
||||||
|
disabled={syncingAppId || isDemo}
|
||||||
|
>
|
||||||
|
{syncingAppId ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Sync App ID from Codemagic</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30 p-3 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">Workflow ID</p>
|
||||||
|
<p className="text-sm font-mono font-medium text-foreground truncate">
|
||||||
|
{values["workflow_id"] || <span className="text-muted-foreground italic">Not set</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30 p-3 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">Last Test</p>
|
||||||
|
{testResults["codemagic"] ? (
|
||||||
|
<div className={`flex items-center gap-1.5 text-sm font-medium ${
|
||||||
|
testResults["codemagic"].success ? "text-primary" : "text-destructive"
|
||||||
|
}`}>
|
||||||
|
{testResults["codemagic"].success ? (
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{testResults["codemagic"].message}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No test run yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Send Test Email — Resend only */}
|
||||||
|
{integration.key === "resend" && isConfigured && (
|
||||||
|
<div className="pt-4 border-t border-border space-y-3">
|
||||||
|
<Label>Send Test Email</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Send a real email to verify your Resend API key and email template delivery.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="recipient@example.com"
|
||||||
|
value={testEmailAddress}
|
||||||
|
onChange={(e) => setTestEmailAddress(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSendTestEmail}
|
||||||
|
disabled={sendingTestEmail || isDemo || !testEmailAddress.trim()}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{sendingTestEmail ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{sendingTestEmail ? "Sending..." : "Send Test Email"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { PaginationControls } from "@/components/ui/pagination-controls";
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Plus,
|
||||||
|
CheckCircle,
|
||||||
|
Send,
|
||||||
|
Eye,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface InvoiceItem {
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
invoice_number: string;
|
||||||
|
user_id: string;
|
||||||
|
user_email?: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: "draft" | "sent" | "paid" | "overdue" | "cancelled";
|
||||||
|
due_date: string | null;
|
||||||
|
paid_at: string | null;
|
||||||
|
items: InvoiceItem[];
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceManagementProps {
|
||||||
|
invoices: Invoice[];
|
||||||
|
users: { id: string; email: string }[];
|
||||||
|
onCreate: (invoice: Omit<Invoice, "id" | "created_at" | "paid_at">) => Promise<boolean>;
|
||||||
|
onUpdate: (id: string, updates: Partial<Invoice>) => Promise<boolean>;
|
||||||
|
onRefresh: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InvoiceManagement = ({
|
||||||
|
invoices,
|
||||||
|
users,
|
||||||
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
|
onRefresh,
|
||||||
|
loading,
|
||||||
|
}: InvoiceManagementProps) => {
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [viewInvoice, setViewInvoice] = useState<Invoice | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const handleSearch = (value: string) => { setSearch(value); setPage(1); };
|
||||||
|
const handleStatusFilter = (value: string) => { setStatusFilter(value); setPage(1); };
|
||||||
|
const [newInvoice, setNewInvoice] = useState({
|
||||||
|
user_id: "",
|
||||||
|
due_date: "",
|
||||||
|
notes: "",
|
||||||
|
items: [{ description: "", quantity: 1, unit_price: 0 }] as InvoiceItem[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateInvoiceNumber = () => {
|
||||||
|
const prefix = "INV";
|
||||||
|
const date = format(new Date(), "yyyyMM");
|
||||||
|
const random = Math.floor(Math.random() * 10000).toString().padStart(4, "0");
|
||||||
|
return `${prefix}-${date}-${random}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotal = (items: InvoiceItem[]) => {
|
||||||
|
return items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
setNewInvoice({
|
||||||
|
...newInvoice,
|
||||||
|
items: [...newInvoice.items, { description: "", quantity: 1, unit_price: 0 }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveItem = (index: number) => {
|
||||||
|
setNewInvoice({
|
||||||
|
...newInvoice,
|
||||||
|
items: newInvoice.items.filter((_, i) => i !== index),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemChange = (index: number, field: keyof InvoiceItem, value: string | number) => {
|
||||||
|
const items = [...newInvoice.items];
|
||||||
|
items[index] = { ...items[index], [field]: value };
|
||||||
|
setNewInvoice({ ...newInvoice, items });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newInvoice.user_id || newInvoice.items.length === 0) {
|
||||||
|
toast.error("Please fill in all required fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await onCreate({
|
||||||
|
invoice_number: generateInvoiceNumber(),
|
||||||
|
user_id: newInvoice.user_id,
|
||||||
|
amount: calculateTotal(newInvoice.items),
|
||||||
|
currency: "USD",
|
||||||
|
status: "draft",
|
||||||
|
due_date: newInvoice.due_date || null,
|
||||||
|
items: newInvoice.items,
|
||||||
|
notes: newInvoice.notes || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success("Invoice created successfully");
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
setNewInvoice({
|
||||||
|
user_id: "",
|
||||||
|
due_date: "",
|
||||||
|
notes: "",
|
||||||
|
items: [{ description: "", quantity: 1, unit_price: 0 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (invoice: Invoice, status: Invoice["status"]) => {
|
||||||
|
const updates: Partial<Invoice> = { status };
|
||||||
|
if (status === "paid") {
|
||||||
|
updates.paid_at = new Date().toISOString();
|
||||||
|
}
|
||||||
|
const success = await onUpdate(invoice.id, updates);
|
||||||
|
if (success) {
|
||||||
|
toast.success(`Invoice ${status === "sent" ? "sent" : status}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: Invoice["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "paid":
|
||||||
|
return "bg-primary/10 text-primary";
|
||||||
|
case "sent":
|
||||||
|
return "bg-accent/10 text-accent";
|
||||||
|
case "overdue":
|
||||||
|
return "bg-destructive/10 text-destructive";
|
||||||
|
case "cancelled":
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
default:
|
||||||
|
return "bg-secondary text-secondary-foreground";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredInvoices = invoices.filter((inv) => {
|
||||||
|
const matchesStatus = statusFilter === "all" || inv.status === statusFilter;
|
||||||
|
const matchesSearch =
|
||||||
|
inv.invoice_number.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
inv.user_email?.toLowerCase().includes(search.toLowerCase());
|
||||||
|
return matchesStatus && matchesSearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredInvoices.length / PAGE_SIZE));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const paginatedInvoices = filteredInvoices.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
||||||
|
|
||||||
|
|
||||||
|
const totalPaid = invoices
|
||||||
|
.filter((i) => i.status === "paid")
|
||||||
|
.reduce((sum, i) => sum + Number(i.amount), 0);
|
||||||
|
const totalPending = invoices
|
||||||
|
.filter((i) => i.status === "sent")
|
||||||
|
.reduce((sum, i) => sum + Number(i.amount), 0);
|
||||||
|
const totalOverdue = invoices
|
||||||
|
.filter((i) => i.status === "overdue")
|
||||||
|
.reduce((sum, i) => sum + Number(i.amount), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>Total: <strong className="text-foreground">{invoices.length}</strong></span>
|
||||||
|
<span>Paid: <strong className="text-primary">${totalPaid.toLocaleString()}</strong></span>
|
||||||
|
<span>Pending: <strong className="text-accent">${totalPending.toLocaleString()}</strong></span>
|
||||||
|
{totalOverdue > 0 && <span>Overdue: <strong className="text-destructive">${totalOverdue.toLocaleString()}</strong></span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Invoice
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Invoice</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Customer</Label>
|
||||||
|
<Select
|
||||||
|
value={newInvoice.user_id}
|
||||||
|
onValueChange={(value) => setNewInvoice({ ...newInvoice, user_id: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select customer" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{users.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.email}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Due Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={newInvoice.due_date}
|
||||||
|
onChange={(e) => setNewInvoice({ ...newInvoice, due_date: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Line Items</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleAddItem}>
|
||||||
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{newInvoice.items.map((item, index) => (
|
||||||
|
<div key={index} className="grid grid-cols-12 gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
className="col-span-6"
|
||||||
|
placeholder="Description"
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => handleItemChange(index, "description", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="col-span-2"
|
||||||
|
type="number"
|
||||||
|
placeholder="Qty"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => handleItemChange(index, "quantity", Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="col-span-3"
|
||||||
|
type="number"
|
||||||
|
placeholder="Price"
|
||||||
|
value={item.unit_price}
|
||||||
|
onChange={(e) => handleItemChange(index, "unit_price", Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
{index > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="col-span-1"
|
||||||
|
onClick={() => handleRemoveItem(index)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-lg font-semibold">
|
||||||
|
Total: ${calculateTotal(newInvoice.items).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Additional notes..."
|
||||||
|
value={newInvoice.notes}
|
||||||
|
onChange={(e) => setNewInvoice({ ...newInvoice, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleCreate} className="w-full">
|
||||||
|
Create Invoice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-4">
|
||||||
|
<div className="relative flex-1 sm:max-w-sm">
|
||||||
|
<Input
|
||||||
|
placeholder="Search invoices..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={handleStatusFilter}>
|
||||||
|
<SelectTrigger className="w-full sm:w-40">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
|
<SelectItem value="sent">Sent</SelectItem>
|
||||||
|
<SelectItem value="paid">Paid</SelectItem>
|
||||||
|
<SelectItem value="overdue">Overdue</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Invoice #</TableHead>
|
||||||
|
<TableHead>Customer</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Due Date</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
{Array.from({ length: 7 }).map((_, j) => (
|
||||||
|
<TableCell key={j}>
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : filteredInvoices.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||||
|
No invoices found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
paginatedInvoices.map((invoice) => (
|
||||||
|
<TableRow key={invoice.id}>
|
||||||
|
<TableCell className="font-mono font-medium">
|
||||||
|
{invoice.invoice_number}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{invoice.user_email || invoice.user_id.slice(0, 8)}</TableCell>
|
||||||
|
<TableCell className="font-semibold">
|
||||||
|
${Number(invoice.amount).toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={getStatusColor(invoice.status)}>{invoice.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{invoice.due_date
|
||||||
|
? format(new Date(invoice.due_date), "MMM d, yyyy")
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{format(new Date(invoice.created_at), "MMM d, yyyy")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setViewInvoice(invoice)}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{invoice.status === "draft" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleStatusChange(invoice, "sent")}
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{invoice.status === "sent" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleStatusChange(invoice, "paid")}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 text-primary" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<PaginationControls
|
||||||
|
currentPage={safePage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={filteredInvoices.length}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Invoice Detail Dialog */}
|
||||||
|
<Dialog open={!!viewInvoice} onOpenChange={() => setViewInvoice(null)}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Invoice {viewInvoice?.invoice_number}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{viewInvoice && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Customer</p>
|
||||||
|
<p className="font-medium">{viewInvoice.user_email || viewInvoice.user_id}</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={getStatusColor(viewInvoice.status)}>{viewInvoice.status}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg divide-y">
|
||||||
|
{viewInvoice.items.map((item, idx) => (
|
||||||
|
<div key={idx} className="p-3 flex justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{item.description}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{item.quantity} × ${item.unit_price}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium">${(item.quantity * item.unit_price).toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="p-3 flex justify-between bg-muted/50">
|
||||||
|
<p className="font-semibold">Total</p>
|
||||||
|
<p className="font-bold text-lg">${Number(viewInvoice.amount).toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewInvoice.notes && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Notes</p>
|
||||||
|
<p>{viewInvoice.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{viewInvoice.status === "draft" && (
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
handleStatusChange(viewInvoice, "sent");
|
||||||
|
setViewInvoice(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
Send Invoice
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{viewInvoice.status === "sent" && (
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
handleStatusChange(viewInvoice, "paid");
|
||||||
|
setViewInvoice(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Mark as Paid
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Link2,
|
||||||
|
Unlink,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
CreditCard,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { backend } from "@/lib/backend-client";
|
||||||
|
|
||||||
|
interface SubscriptionPlan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tier: string;
|
||||||
|
price_monthly: number;
|
||||||
|
price_yearly: number;
|
||||||
|
monthly_credits: number;
|
||||||
|
is_active: boolean;
|
||||||
|
paypal_product_id?: string | null;
|
||||||
|
paypal_plan_id?: string | null;
|
||||||
|
paypal_yearly_plan_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PayPalBillingPlans = () => {
|
||||||
|
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [syncing, setSyncing] = useState<string | null>(null);
|
||||||
|
const [unlinking, setUnlinking] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPlans = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await backend
|
||||||
|
.from("subscription_plans")
|
||||||
|
.select("*")
|
||||||
|
.order("price_monthly", { ascending: true });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setPlans((data || []) as unknown as SubscriptionPlan[]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading plans:", error);
|
||||||
|
toast.error("Failed to load subscription plans");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncPlan = async (planId: string) => {
|
||||||
|
setSyncing(planId);
|
||||||
|
try {
|
||||||
|
const { data, error } = await backend.functions.invoke("paypal-billing", {
|
||||||
|
body: { action: "sync_plan", plan_id: planId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success("PayPal billing plan created successfully");
|
||||||
|
await loadPlans();
|
||||||
|
} else {
|
||||||
|
throw new Error(data?.error || "Failed to sync plan");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Sync error:", error);
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to sync plan with PayPal");
|
||||||
|
} finally {
|
||||||
|
setSyncing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlinkPlan = async (planId: string) => {
|
||||||
|
setUnlinking(planId);
|
||||||
|
try {
|
||||||
|
const { data, error } = await backend.functions.invoke("paypal-billing", {
|
||||||
|
body: { action: "deactivate_plan", plan_id: planId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success("PayPal billing plan deactivated");
|
||||||
|
await loadPlans();
|
||||||
|
} else {
|
||||||
|
throw new Error(data?.error || "Failed to unlink plan");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Unlink error:", error);
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to unlink plan");
|
||||||
|
} finally {
|
||||||
|
setUnlinking(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlanStatus = (plan: SubscriptionPlan) => {
|
||||||
|
const hasMonthly = !!plan.paypal_plan_id;
|
||||||
|
const hasYearly = !!plan.paypal_yearly_plan_id;
|
||||||
|
const hasProduct = !!plan.paypal_product_id;
|
||||||
|
|
||||||
|
if (hasProduct && hasMonthly && hasYearly) {
|
||||||
|
return { status: "synced", label: "Fully Synced", color: "bg-primary/20 text-primary" };
|
||||||
|
}
|
||||||
|
if (hasProduct || hasMonthly || hasYearly) {
|
||||||
|
return { status: "partial", label: "Partially Synced", color: "bg-amber-500/20 text-amber-600" };
|
||||||
|
}
|
||||||
|
return { status: "unsynced", label: "Not Synced", color: "bg-muted text-muted-foreground" };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-[#0070BA]/10">
|
||||||
|
<CreditCard className="w-5 h-5 text-[#0070BA]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>PayPal Billing Plans</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Sync your subscription plans with PayPal for recurring billing
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={loadPlans}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Alert className="mb-6 border-primary/20 bg-primary/5">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-primary" />
|
||||||
|
<AlertDescription>
|
||||||
|
Syncing creates PayPal Products and Billing Plans that enable automatic recurring charges.
|
||||||
|
Make sure PayPal is properly configured before syncing.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Plan</TableHead>
|
||||||
|
<TableHead>Monthly</TableHead>
|
||||||
|
<TableHead>Yearly</TableHead>
|
||||||
|
<TableHead>PayPal Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const statusInfo = getPlanStatus(plan);
|
||||||
|
return (
|
||||||
|
<TableRow key={plan.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{plan.name}</span>
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{plan.tier}
|
||||||
|
</Badge>
|
||||||
|
{!plan.is_active && (
|
||||||
|
<Badge variant="secondary">Inactive</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>${plan.price_monthly}/mo</span>
|
||||||
|
{plan.paypal_plan_id ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>${plan.price_yearly}/yr</span>
|
||||||
|
{plan.paypal_yearly_plan_id ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={statusInfo.color}>{statusInfo.label}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{statusInfo.status === "unsynced" || statusInfo.status === "partial" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => syncPlan(plan.id)}
|
||||||
|
disabled={syncing === plan.id || !plan.is_active}
|
||||||
|
>
|
||||||
|
{syncing === plan.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Link2 className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Sync
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => unlinkPlan(plan.id)}
|
||||||
|
disabled={unlinking === plan.id}
|
||||||
|
>
|
||||||
|
{unlinking === plan.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Unlink className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Unlink
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{plans.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No subscription plans found. Create plans first in the Pricing Plans section.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">PayPal Plan IDs Reference</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
These IDs are automatically managed when you sync plans
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{plans.filter(p => p.paypal_product_id || p.paypal_plan_id).map((plan) => (
|
||||||
|
<div key={plan.id} className="p-3 bg-muted/50 rounded-lg space-y-2">
|
||||||
|
<p className="font-medium">{plan.name}</p>
|
||||||
|
<div className="grid gap-1 text-sm text-muted-foreground font-mono">
|
||||||
|
{plan.paypal_product_id && (
|
||||||
|
<p>Product: {plan.paypal_product_id}</p>
|
||||||
|
)}
|
||||||
|
{plan.paypal_plan_id && (
|
||||||
|
<p>Monthly: {plan.paypal_plan_id}</p>
|
||||||
|
)}
|
||||||
|
{plan.paypal_yearly_plan_id && (
|
||||||
|
<p>Yearly: {plan.paypal_yearly_plan_id}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{plans.filter(p => p.paypal_product_id || p.paypal_plan_id).length === 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No plans synced yet. Sync a plan to see its PayPal IDs.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||