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>
This commit is contained in:
Leon Serfaty
2026-06-28 04:50:29 -04:00
co-authored by Claude Opus 4.8
commit 1a8b8ee5a0
271 changed files with 63734 additions and 0 deletions
+68
View File
@@ -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=
+32
View File
@@ -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?
+2
View File
@@ -0,0 +1,2 @@
legacy-peer-deps=true
npm audit --omit=dev
+13
View File
@@ -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;
+333
View File
@@ -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
+20
View File
@@ -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"
}
}
+2937
View File
File diff suppressed because it is too large Load Diff
+372
View File
@@ -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();
+1198
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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",
},
},
);
+45
View File
@@ -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>
+12744
View File
File diff suppressed because it is too large Load Diff
+117
View File
@@ -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"
}
}
+98
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+13
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+12
View File
@@ -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

+18
View File
@@ -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

+18
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+14
View File
@@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /
+1
View File
@@ -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"}
+15
View File
@@ -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>
+52
View File
@@ -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");
+413
View File
@@ -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;
+68
View File
@@ -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;
}
+110
View File
@@ -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]
);
}
}
+32
View File
@@ -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;
}
+45
View File
@@ -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.');
}
+80
View File
@@ -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) };
}
+74
View File
@@ -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]);
}
+136
View File
@@ -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;
+22
View File
@@ -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();
+96
View File
@@ -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();
}
}
+33
View File
@@ -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;
}
}
+29
View File
@@ -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;
}
+49
View File
@@ -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
);
}
+52
View File
@@ -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, '/');
}
+39
View File
@@ -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();
}
+173
View File
@@ -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;
+103
View File
@@ -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;
+74
View File
@@ -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;
+166
View File
@@ -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;
+97
View File
@@ -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() })));
+53
View File
@@ -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;
+38
View File
@@ -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;
+96
View File
@@ -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;
+14
View File
@@ -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"]
}
+228
View File
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+18
View File
@@ -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

+18
View File
@@ -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

+106
View File
@@ -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;
+194
View File
@@ -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;
+49
View File
@@ -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;
+31
View File
@@ -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;
+54
View File
@@ -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;
+97
View File
@@ -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;
+215
View File
@@ -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;
+150
View File
@@ -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;
+82
View File
@@ -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>
);
}
+341
View File
@@ -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>
);
}
+111
View File
@@ -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;
+83
View File
@@ -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;
+250
View File
@@ -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;
+90
View File
@@ -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;
+387
View File
@@ -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>
);
}
+106
View File
@@ -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;
+503
View File
@@ -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;
+61
View File
@@ -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;
+98
View File
@@ -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;
+256
View File
@@ -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;
+112
View File
@@ -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;
+114
View File
@@ -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;
+48
View File
@@ -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;
+34
View File
@@ -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 };
};
+36
View File
@@ -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>
);
}
+421
View File
@@ -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>
);
};
+210
View File
@@ -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>
);
};
+139
View File
@@ -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>
</>
);
};
+332
View File
@@ -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>
);
};
+180
View File
@@ -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>
);
};
+365
View File
@@ -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>
);
+354
View File
@@ -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>
);
};
+518
View File
@@ -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>
);
};
+304
View File
@@ -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>
);
};

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