Add LICENSE, README, and Docs tab to Mission Control
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
const commandFile = path.join(process.cwd(), 'task-history.json')
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
let history = []
|
||||
|
||||
if (fs.existsSync(commandFile)) {
|
||||
history = JSON.parse(fs.readFileSync(commandFile, 'utf-8'))
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const project = searchParams.get('project')
|
||||
|
||||
if (project && project !== 'all') {
|
||||
history = history.filter((h: any) => h.project === project)
|
||||
}
|
||||
|
||||
return NextResponse.json({ history })
|
||||
} catch (error) {
|
||||
console.error('Error reading history:', error)
|
||||
return NextResponse.json({ history: [] })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { task, command, project, action } = body
|
||||
|
||||
let history = []
|
||||
if (fs.existsSync(commandFile)) {
|
||||
history = JSON.parse(fs.readFileSync(commandFile, 'utf-8'))
|
||||
}
|
||||
|
||||
const entry = {
|
||||
id: Date.now().toString(),
|
||||
task,
|
||||
command,
|
||||
project: project || 'sitemente',
|
||||
reply: '',
|
||||
action: action || 'task',
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
notified: false
|
||||
}
|
||||
|
||||
history.push(entry)
|
||||
|
||||
// Keep only last 50 entries
|
||||
if (history.length > 50) {
|
||||
history = history.slice(-50)
|
||||
}
|
||||
|
||||
fs.writeFileSync(commandFile, JSON.stringify(history, null, 2))
|
||||
|
||||
// Send Telegram notification to Horus
|
||||
try {
|
||||
const tgMessage = `📬 *New Task from MC*\n\n*Project:* ${project || 'sitemente'}\n*Task:* ${task}\n*Command:* ${command}\n\n_Reply via /api/command-reply/{id}_`
|
||||
|
||||
// Use message tool to notify (will work if Telegram is configured)
|
||||
const { spawn } = require('child_process')
|
||||
spawn('curl', ['-s', '-X', 'POST',
|
||||
'http://localhost:3000/api/messages/send',
|
||||
'-H', 'Content-Type: application/json',
|
||||
'-d', JSON.stringify({
|
||||
channel: 'telegram',
|
||||
target: '382315644',
|
||||
message: tgMessage
|
||||
})
|
||||
], { detached: true, stdio: 'ignore' })
|
||||
} catch (e) {
|
||||
console.log('Telegram notification skipped')
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, entry })
|
||||
} catch (error) {
|
||||
console.error('Error saving history:', error)
|
||||
return NextResponse.json({ error: 'Failed to save' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
const commandFile = path.join(process.cwd(), 'task-history.json')
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
let history = []
|
||||
if (fs.existsSync(commandFile)) {
|
||||
history = JSON.parse(fs.readFileSync(commandFile, 'utf-8'))
|
||||
}
|
||||
|
||||
const entry = history.find((h: any) => h.id === id)
|
||||
if (!entry) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ entry })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to get' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { id, reply } = body
|
||||
|
||||
if (!id || !reply) {
|
||||
return NextResponse.json({ error: 'Missing id or reply' }, { status: 400 })
|
||||
}
|
||||
|
||||
let history = []
|
||||
if (fs.existsSync(commandFile)) {
|
||||
history = JSON.parse(fs.readFileSync(commandFile, 'utf-8'))
|
||||
}
|
||||
|
||||
const index = history.findIndex((h: any) => h.id === id)
|
||||
if (index === -1) {
|
||||
return NextResponse.json({ error: 'Entry not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
history[index].reply = reply
|
||||
history[index].status = 'replied'
|
||||
history[index].repliedAt = new Date().toISOString()
|
||||
|
||||
fs.writeFileSync(commandFile, JSON.stringify(history, null, 2))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to save reply' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { command, task, action } = body
|
||||
|
||||
if (!command || !task) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing command or task' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Log the command - in production this would trigger Horus
|
||||
console.log(`[Task Command] Task: ${task}, Command: ${command}, Action: ${action}`)
|
||||
|
||||
// Store command in a file for Horus to pick up
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const commandFile = path.join(process.cwd(), 'pending-commands.json')
|
||||
|
||||
let commands = []
|
||||
if (fs.existsSync(commandFile)) {
|
||||
commands = JSON.parse(fs.readFileSync(commandFile, 'utf-8'))
|
||||
}
|
||||
|
||||
commands.push({
|
||||
id: Date.now().toString(),
|
||||
task,
|
||||
command,
|
||||
action,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending'
|
||||
})
|
||||
|
||||
fs.writeFileSync(commandFile, JSON.stringify(commands, null, 2))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Command sent for task: ${task}`,
|
||||
commandId: Date.now().toString()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error processing command:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process command' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
message: 'Command API ready'
|
||||
})
|
||||
}
|
||||
@@ -3,24 +3,53 @@ import { runSiteMenteVoiceTurn } from "../../../../lib/ai/siteMenteAgent";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// Vapi webhook payload types
|
||||
interface VapiMessage {
|
||||
type: string;
|
||||
role?: string;
|
||||
transcript?: string;
|
||||
}
|
||||
|
||||
interface VapiCall {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface VapiWebhookPayload {
|
||||
message: VapiMessage;
|
||||
call: VapiCall;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as { transcript?: string };
|
||||
|
||||
if (!body.transcript || typeof body.transcript !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "transcript is required." },
|
||||
{ status: 400 }
|
||||
);
|
||||
const body = (await request.json()) as VapiWebhookPayload;
|
||||
|
||||
// Extract transcript from Vapi's format
|
||||
const message = body.message;
|
||||
|
||||
// Only process final transcripts
|
||||
if (!message || message.type !== "transcript" || !message.transcript) {
|
||||
// Return empty response for non-transcript messages
|
||||
return NextResponse.json({ results: [] });
|
||||
}
|
||||
|
||||
const transcript = message.transcript;
|
||||
|
||||
// Call MiniMax brain
|
||||
const response = await runSiteMenteVoiceTurn({
|
||||
transcript: body.transcript,
|
||||
transcript: transcript,
|
||||
});
|
||||
|
||||
// Return in Vapi's expected format
|
||||
return NextResponse.json({
|
||||
results: [
|
||||
{
|
||||
result: response.reply,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error("[SiteMente][API] Voice route failed", error);
|
||||
console.error("[SiteMente][Vapi] Voice route failed", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate voice response." },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const STRIPE_KEY = process.env.STRIPE_SECRET_KEY || "";
|
||||
|
||||
const PLANS: Record<string, { name: string; price: number; currency: string }> = {
|
||||
"starter-setup": { name: "AI Chat Setup", price: 90000, currency: "eur" },
|
||||
"starter-monthly": { name: "AI Chat Monthly", price: 29900, currency: "eur" },
|
||||
"site-setup": { name: "Smart Site Setup", price: 350000, currency: "eur" },
|
||||
"site-monthly": { name: "Smart Site Monthly", price: 74900, currency: "eur" },
|
||||
"growth-setup": { name: "AI Growth Setup", price: 500000, currency: "eur" },
|
||||
"growth-monthly": { name: "AI Growth Monthly", price: 195000, currency: "eur" },
|
||||
"demo-real-estate-essential": { name: "Real Estate Essential", price: 39000, currency: "eur" },
|
||||
"demo-real-estate-profesional": { name: "Real Estate Professional", price: 79000, currency: "eur" },
|
||||
"demo-real-estate-premium": { name: "Real Estate Premium", price: 139000, currency: "eur" },
|
||||
"demo-restaurant-essential": { name: "Restaurant Essential", price: 39000, currency: "eur" },
|
||||
"demo-restaurant-profesional": { name: "Restaurant Professional", price: 79000, currency: "eur" },
|
||||
"demo-restaurant-premium": { name: "Restaurant Premium", price: 139000, currency: "eur" },
|
||||
"demo-clinic-essential": { name: "Clinic Essential", price: 39000, currency: "eur" },
|
||||
"demo-clinic-profesional": { name: "Clinic Professional", price: 79000, currency: "eur" },
|
||||
"demo-clinic-premium": { name: "Clinic Premium", price: 139000, currency: "eur" },
|
||||
"demo-home-services-essential": { name: "Home Services Essential", price: 39000, currency: "eur" },
|
||||
"demo-home-services-profesional": { name: "Home Services Professional", price: 79000, currency: "eur" },
|
||||
"demo-home-services-premium": { name: "Home Services Premium", price: 139000, currency: "eur" },
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { planId, email, name, planType = "monthly" } = body;
|
||||
|
||||
const origin = request.headers.get("origin") || "http://45.95.42.114:1284";
|
||||
|
||||
let planKey = planId;
|
||||
if (!planId.startsWith("demo-") && !PLANS[planId]) {
|
||||
planKey = `${planId}-${planType}`;
|
||||
}
|
||||
|
||||
const plan = PLANS[planKey];
|
||||
|
||||
if (!plan) {
|
||||
return NextResponse.json({ error: "Invalid plan selected" }, { status: 400 });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("payment_method_types[]", "card");
|
||||
params.append("line_items[0][price_data][currency]", plan.currency);
|
||||
params.append("line_items[0][price_data][product_data][name]", plan.name);
|
||||
params.append("line_items[0][price_data][product_data][description]", `SiteMente - ${plan.name}`);
|
||||
params.append("line_items[0][price_data][unit_amount]", String(plan.price));
|
||||
params.append("line_items[0][quantity]", "1");
|
||||
params.append("mode", planType === "monthly" ? "subscription" : "payment");
|
||||
params.append("customer_email", email || "");
|
||||
params.append("metadata[customerName]", name || "");
|
||||
params.append("metadata[planId]", planId);
|
||||
params.append("metadata[planType]", planType);
|
||||
params.append("success_url", `${origin}/success?session_id={CHECKOUT_SESSION_ID}`);
|
||||
params.append("cancel_url", `${origin}/?cancelled=true`);
|
||||
|
||||
if (planType === "monthly") {
|
||||
params.append("line_items[0][price_data][recurring][interval]", "month");
|
||||
}
|
||||
|
||||
const response = await fetch("https://api.stripe.com/v1/checkout/sessions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${STRIPE_KEY}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const session = await response.json();
|
||||
|
||||
if (session.error) {
|
||||
return NextResponse.json({ error: session.error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ sessionId: session.id, url: session.url });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create checkout session";
|
||||
console.error("Stripe error:", message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
const dataFile = path.join(process.cwd(), 'trading-traders.json')
|
||||
|
||||
const defaultTraders = [
|
||||
{
|
||||
id: 'dopetrades',
|
||||
name: 'DopeTrades',
|
||||
status: 'learning',
|
||||
framesAnalyzed: 382,
|
||||
patterns: ['Double Top/Bottom', 'Head & Shoulders', 'Triangles', 'Flags', 'Wedges'],
|
||||
entryRules: [
|
||||
'Identify clear structure (swing highs/lows)',
|
||||
'Wait for retest of level',
|
||||
'Confirm momentum in desired direction',
|
||||
'Higher timeframe alignment',
|
||||
'Entry on break of structure or retest',
|
||||
'Confirmation candle required'
|
||||
],
|
||||
exitRules: [
|
||||
'Stop below recent swing low (long)',
|
||||
'Take profit minimum 2:1',
|
||||
'Scale 50% at 1:1',
|
||||
'Trailing stop after 1:1 achieved',
|
||||
'Never move stop loss further'
|
||||
],
|
||||
indicators: [
|
||||
'9 EMA (short term)',
|
||||
'20 EMA (medium term)',
|
||||
'50 SMA (trend filter)',
|
||||
'RSI 14 (momentum)',
|
||||
'Volume profile'
|
||||
],
|
||||
riskParams: [
|
||||
'Max 2% risk per trade',
|
||||
'Max 3 concurrent trades',
|
||||
'6% daily max loss',
|
||||
'10% weekly max loss',
|
||||
'Stop after 3 losses'
|
||||
],
|
||||
timeframe: 'Multi: 4H/Daily trend, 1H structure, 15min entries',
|
||||
notes: 'Frame analysis: 3% bullish, 7% bearish, 90% neutral. Dark charts confirmed.'
|
||||
}
|
||||
]
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
let traders = defaultTraders
|
||||
if (fs.existsSync(dataFile)) {
|
||||
const saved = JSON.parse(fs.readFileSync(dataFile, 'utf-8'))
|
||||
if (saved.length > 0) {
|
||||
traders = saved
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(dataFile, JSON.stringify(defaultTraders, null, 2))
|
||||
}
|
||||
return NextResponse.json({ traders })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ traders: defaultTraders })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
let traders = defaultTraders
|
||||
|
||||
if (fs.existsSync(dataFile)) {
|
||||
traders = JSON.parse(fs.readFileSync(dataFile, 'utf-8'))
|
||||
}
|
||||
|
||||
traders.push({
|
||||
...body,
|
||||
id: body.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
createdAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
fs.writeFileSync(dataFile, JSON.stringify(traders, null, 2))
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to save' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
const dataFile = path.join(process.cwd(), 'trading-trades.json')
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
let trades = []
|
||||
if (fs.existsSync(dataFile)) {
|
||||
trades = JSON.parse(fs.readFileSync(dataFile, 'utf-8'))
|
||||
}
|
||||
return NextResponse.json({ trades })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ trades: [] })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
let trades = []
|
||||
|
||||
if (fs.existsSync(dataFile)) {
|
||||
trades = JSON.parse(fs.readFileSync(dataFile, 'utf-8'))
|
||||
}
|
||||
|
||||
trades.push({
|
||||
...body,
|
||||
id: Date.now().toString(),
|
||||
openedAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
fs.writeFileSync(dataFile, JSON.stringify(trades, null, 2))
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to save' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
type GameState = "upload" | "ready" | "playing" | "gameover";
|
||||
type BossState = "idle" | "attacking" | "stunned" | "dizzy";
|
||||
|
||||
const powerUps: Record<string, { name: string; icon: string; duration: number }> = {
|
||||
double: { name: "2x Damage", icon: "⚔️", duration: 5000 },
|
||||
freeze: { name: "Stun Boss", icon: "❄️", duration: 3000 },
|
||||
bomb: { name: "Bomb", icon: "💣", duration: 0 },
|
||||
none: { name: "", icon: "", duration: 0 },
|
||||
};
|
||||
|
||||
const playSound = (type: "punch" | "combo" | "powerup" | "ko" | "gameover" | "bossAttack" | "hit") => {
|
||||
try {
|
||||
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
const oscillator = audioCtx.createOscillator();
|
||||
const gainNode = audioCtx.createGain();
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioCtx.destination);
|
||||
|
||||
switch (type) {
|
||||
case "punch":
|
||||
oscillator.frequency.setValueAtTime(150, audioCtx.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(50, audioCtx.currentTime + 0.1);
|
||||
gainNode.gain.setValueAtTime(0.5, audioCtx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1);
|
||||
oscillator.start(audioCtx.currentTime);
|
||||
oscillator.stop(audioCtx.currentTime + 0.1);
|
||||
break;
|
||||
case "hit":
|
||||
oscillator.frequency.setValueAtTime(200, audioCtx.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(80, audioCtx.currentTime + 0.15);
|
||||
gainNode.gain.setValueAtTime(0.4, audioCtx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.15);
|
||||
oscillator.start(audioCtx.currentTime);
|
||||
oscillator.stop(audioCtx.currentTime + 0.15);
|
||||
break;
|
||||
case "bossAttack":
|
||||
oscillator.frequency.setValueAtTime(80, audioCtx.currentTime);
|
||||
oscillator.frequency.setValueAtTime(120, audioCtx.currentTime + 0.1);
|
||||
oscillator.frequency.setValueAtTime(80, audioCtx.currentTime + 0.2);
|
||||
gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
|
||||
oscillator.start(audioCtx.currentTime);
|
||||
oscillator.stop(audioCtx.currentTime + 0.3);
|
||||
break;
|
||||
case "combo":
|
||||
oscillator.frequency.setValueAtTime(300, audioCtx.currentTime);
|
||||
oscillator.frequency.setValueAtTime(400, audioCtx.currentTime + 0.1);
|
||||
oscillator.frequency.setValueAtTime(500, audioCtx.currentTime + 0.2);
|
||||
gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
|
||||
oscillator.start(audioCtx.currentTime);
|
||||
oscillator.stop(audioCtx.currentTime + 0.3);
|
||||
break;
|
||||
case "powerup":
|
||||
oscillator.frequency.setValueAtTime(200, audioCtx.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(800, audioCtx.currentTime + 0.3);
|
||||
gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
|
||||
oscillator.start(audioCtx.currentTime);
|
||||
oscillator.stop(audioCtx.currentTime + 0.3);
|
||||
break;
|
||||
case "ko":
|
||||
oscillator.frequency.setValueAtTime(100, audioCtx.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(30, audioCtx.currentTime + 0.5);
|
||||
gainNode.gain.setValueAtTime(0.8, audioCtx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.5);
|
||||
oscillator.start(audioCtx.currentTime);
|
||||
oscillator.stop(audioCtx.currentTime + 0.5);
|
||||
break;
|
||||
case "gameover":
|
||||
oscillator.frequency.setValueAtTime(400, audioCtx.currentTime);
|
||||
oscillator.frequency.setValueAtTime(300, audioCtx.currentTime + 0.2);
|
||||
oscillator.frequency.setValueAtTime(200, audioCtx.currentTime + 0.4);
|
||||
gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.6);
|
||||
oscillator.start(audioCtx.currentTime);
|
||||
oscillator.stop(audioCtx.currentTime + 0.6);
|
||||
break;
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
export default function BossPunch() {
|
||||
const [bossImage, setBossImage] = useState<string | null>(null);
|
||||
const [gameState, setGameState] = useState<GameState>("upload");
|
||||
const [score, setScore] = useState(0);
|
||||
const [bossHealth, setBossHealth] = useState(100);
|
||||
const [playerHealth, setPlayerHealth] = useState(100);
|
||||
const [combo, setCombo] = useState(0);
|
||||
const [maxCombo, setMaxCombo] = useState(0);
|
||||
const [lastHit, setLastHit] = useState<"left" | "right" | null>(null);
|
||||
const [playerX, setPlayerX] = useState(0);
|
||||
const [bossX, setBossX] = useState(0);
|
||||
const [bossRotation, setBossRotation] = useState(0);
|
||||
const [bossState, setBossState] = useState<BossState>("idle");
|
||||
const [isPaid, setIsPaid] = useState(false);
|
||||
const [showPayModal, setShowPayModal] = useState(false);
|
||||
const [activePowerUp, setActivePowerUp] = useState<string>("none");
|
||||
const [powerUpCooldown, setPowerUpCooldown] = useState(0);
|
||||
const [floatingTexts, setFloatingTexts] = useState<{ id: number; text: string; x: number; y: number; type: string }[]>([]);
|
||||
const [screenShake, setScreenShake] = useState(false);
|
||||
const [particles, setParticles] = useState<{ id: number; x: number; y: number; vx: number; vy: number; color: string; life: number }[]>([]);
|
||||
const [winQuote, setWinQuote] = useState("I am sorry Haitham!");
|
||||
const [showWinQuoteInput, setShowWinQuoteInput] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const floatingIdRef = useRef(0);
|
||||
const particleIdRef = useRef(0);
|
||||
const attackIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const addFloatingText = (text: string, x: number, y: number, type: string = "damage") => {
|
||||
const id = floatingIdRef.current++;
|
||||
setFloatingTexts(prev => [...prev, { id, text, x, y, type }]);
|
||||
setTimeout(() => {
|
||||
setFloatingTexts(prev => prev.filter(t => t.id !== id));
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const addParticle = (x: number, y: number, color: string) => {
|
||||
const id = particleIdRef.current++;
|
||||
setParticles(prev => [...prev, {
|
||||
id, x, y,
|
||||
vx: (Math.random() - 0.5) * 10,
|
||||
vy: (Math.random() - 0.5) * 10 - 5,
|
||||
color, life: 1
|
||||
}]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (particles.length > 0) {
|
||||
const timer = setInterval(() => {
|
||||
setParticles(prev => prev.map(p => ({
|
||||
...p,
|
||||
x: p.x + p.vx,
|
||||
y: p.y + p.vy,
|
||||
vy: p.vy + 0.5,
|
||||
life: p.life - 0.05
|
||||
})).filter(p => p.life > 0));
|
||||
}, 16);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [particles.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameState === "playing" && bossState !== "stunned" && bossState !== "dizzy") {
|
||||
attackIntervalRef.current = setInterval(() => {
|
||||
if (bossState === "idle" && Math.random() < 0.3) {
|
||||
bossAttack();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
return () => {
|
||||
if (attackIntervalRef.current) clearInterval(attackIntervalRef.current);
|
||||
};
|
||||
}, [gameState, bossState]);
|
||||
|
||||
const bossAttack = () => {
|
||||
if (bossState !== "idle") return;
|
||||
setBossState("attacking");
|
||||
playSound("bossAttack");
|
||||
setBossX(-30);
|
||||
setTimeout(() => {
|
||||
setBossX(30);
|
||||
setTimeout(() => {
|
||||
const damage = 5 + Math.floor(Math.random() * 10);
|
||||
setPlayerHealth(prev => Math.max(0, prev - damage));
|
||||
playSound("hit");
|
||||
setScreenShake(true);
|
||||
setTimeout(() => setScreenShake(false), 200);
|
||||
addFloatingText(`-${damage}`, 30, 60, "boss");
|
||||
setBossX(0);
|
||||
setBossState("idle");
|
||||
if (playerHealth - damage <= 0) {
|
||||
setGameState("gameover");
|
||||
playSound("gameover");
|
||||
}
|
||||
}, 150);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setBossImage(event.target?.result as string);
|
||||
setGameState("ready");
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const startGame = () => {
|
||||
setGameState("playing");
|
||||
setScore(0);
|
||||
setBossHealth(100);
|
||||
setPlayerHealth(100);
|
||||
setCombo(0);
|
||||
setMaxCombo(0);
|
||||
setActivePowerUp("none");
|
||||
setBossState("idle");
|
||||
};
|
||||
|
||||
const activatePowerUp = (type: string) => {
|
||||
if (powerUpCooldown > 0 || gameState !== "playing") return;
|
||||
playSound("powerup");
|
||||
setActivePowerUp(type);
|
||||
if (type === "bomb") {
|
||||
setBossHealth(prev => Math.max(0, prev - 30));
|
||||
addFloatingText("-30! 💣", 70, 30, "powerup");
|
||||
setScreenShake(true);
|
||||
setTimeout(() => setScreenShake(false), 300);
|
||||
} else if (type === "freeze") {
|
||||
setBossState("stunned");
|
||||
setTimeout(() => setBossState("idle"), 3000);
|
||||
} else {
|
||||
setTimeout(() => setActivePowerUp("none"), powerUps[type]?.duration || 5000);
|
||||
}
|
||||
setPowerUpCooldown(10);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (powerUpCooldown > 0) {
|
||||
const timer = setInterval(() => {
|
||||
setPowerUpCooldown(prev => Math.max(0, prev - 1));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [powerUpCooldown]);
|
||||
|
||||
const punch = useCallback((hand: "left" | "right") => {
|
||||
if (gameState !== "playing" || bossState === "stunned") return;
|
||||
if (Math.random() < 0.15 && bossState === "idle") {
|
||||
setBossState("dizzy");
|
||||
addFloatingText("Miss!", 70, 40, "miss");
|
||||
setTimeout(() => setBossState("idle"), 500);
|
||||
return;
|
||||
}
|
||||
setLastHit(hand);
|
||||
setCombo(c => {
|
||||
const newCombo = c + 1;
|
||||
if (newCombo > maxCombo) setMaxCombo(newCombo);
|
||||
if (newCombo % 10 === 0) {
|
||||
playSound("combo");
|
||||
addFloatingText(`${newCombo} COMBO! 🔥`, 50, 50, "combo");
|
||||
}
|
||||
return newCombo;
|
||||
});
|
||||
let damage = 5 + Math.floor(combo / 5);
|
||||
damage = Math.min(damage, 20);
|
||||
if (activePowerUp === "double") damage *= 2;
|
||||
playSound("punch");
|
||||
const newHealth = Math.max(0, bossHealth - damage);
|
||||
setBossHealth(newHealth);
|
||||
setScore(s => s + damage * 10);
|
||||
setBossRotation(hand === "left" ? -20 - (damage * 2) : 20 + (damage * 2));
|
||||
setBossX(hand === "left" ? -20 : 20);
|
||||
setTimeout(() => { setBossRotation(0); setBossX(0); }, 150);
|
||||
setPlayerX(hand === "left" ? 40 : -40);
|
||||
setTimeout(() => setPlayerX(0), 100);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
addParticle(70, 40, damage > 15 ? "#ff0000" : "#ffffff");
|
||||
}
|
||||
addFloatingText(`-${damage}`, 60 + (Math.random() * 20 - 10), 30 + (Math.random() * 20 - 10), "damage");
|
||||
if (damage > 10) {
|
||||
setScreenShake(true);
|
||||
setTimeout(() => setScreenShake(false), 100);
|
||||
}
|
||||
setTimeout(() => setLastHit(null), 150);
|
||||
if (newHealth <= 0) {
|
||||
playSound("ko");
|
||||
setGameState("gameover");
|
||||
}
|
||||
}, [gameState, combo, bossHealth, activePowerUp, maxCombo, bossState]);
|
||||
|
||||
const resetGame = () => {
|
||||
setGameState("ready");
|
||||
setScore(0);
|
||||
setBossHealth(100);
|
||||
setPlayerHealth(100);
|
||||
setCombo(0);
|
||||
setMaxCombo(0);
|
||||
setActivePowerUp("none");
|
||||
setBossState("idle");
|
||||
};
|
||||
|
||||
const shakeClass = screenShake ? "animate-pulse" : "";
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-gradient-to-b from-red-900 via-red-800 to-orange-900 flex flex-col items-center justify-center p-2 overflow-hidden ${shakeClass}`}>
|
||||
{particles.map(p => (
|
||||
<motion.div
|
||||
key={p.id}
|
||||
initial={{ x: `${p.x}%`, y: `${p.y}%`, opacity: 1 }}
|
||||
animate={{ x: `${p.x + p.vx}%`, y: `${p.y + p.vy}%`, opacity: p.life }}
|
||||
className="absolute w-3 h-3 rounded-full pointer-events-none"
|
||||
style={{ backgroundColor: p.color }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{floatingTexts.map(ft => (
|
||||
<motion.div
|
||||
key={ft.id}
|
||||
initial={{ opacity: 1, y: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 0, y: -50, scale: ft.type === "boss" ? 1.5 : 1.2 }}
|
||||
transition={{ duration: 1 }}
|
||||
className={`absolute pointer-events-none text-2xl font-black ${ft.type === "boss" ? "text-red-400" : ft.type === "miss" ? "text-gray-400" : ft.type === "combo" ? "text-yellow-400" : "text-white"}`}
|
||||
style={{ left: `${ft.x}%`, top: `${ft.y}%` }}
|
||||
>
|
||||
{ft.text}
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<div className="absolute top-0 left-0 right-0 p-2 flex justify-between items-center z-10">
|
||||
<h1 className="text-xl font-black text-white tracking-wider">
|
||||
BOSS<span className="text-red-500">PUNCH</span>
|
||||
</h1>
|
||||
{gameState === "playing" && (
|
||||
<div className="bg-black/50 rounded-full px-3 py-1 flex gap-3 text-xs">
|
||||
<span className="text-white font-bold">Score: {score}</span>
|
||||
<span className="text-yellow-400 font-bold">Combo: {combo}x</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{gameState === "playing" && (
|
||||
<div className="absolute top-12 left-2 right-2 flex flex-col gap-1 z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-white w-12">BOSS</span>
|
||||
<div className="flex-1 h-3 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-red-500 to-green-500 transition-all duration-200" style={{ width: `${bossHealth}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-white w-12">YOU</span>
|
||||
<div className="flex-1 h-3 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-green-500 to-blue-500 transition-all duration-200" style={{ width: `${playerHealth}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameState === "playing" && (
|
||||
<div className="absolute top-24 left-0 right-0 flex justify-center gap-2 z-10">
|
||||
{(["double", "freeze", "bomb"] as string[]).map(pu => (
|
||||
<button
|
||||
key={pu}
|
||||
onClick={() => activatePowerUp(pu)}
|
||||
disabled={powerUpCooldown > 0 || bossState === "stunned"}
|
||||
className={`bg-white/20 backdrop-blur rounded-full p-2 text-xl transition-all hover:scale-110 disabled:opacity-50 disabled:cursor-not-allowed ${activePowerUp === pu ? "ring-2 ring-yellow-400" : ""}`}
|
||||
title={powerUps[pu]?.name}
|
||||
>
|
||||
{powerUps[pu]?.icon}
|
||||
</button>
|
||||
))}
|
||||
{powerUpCooldown > 0 && <span className="absolute -bottom-2 text-xs text-white/70">{powerUpCooldown}s</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-full max-w-lg aspect-square max-h-[45vh] mt-8">
|
||||
<AnimatePresence>
|
||||
{bossImage && (
|
||||
<motion.div
|
||||
className="absolute right-0 top-1/3"
|
||||
animate={{
|
||||
x: bossX,
|
||||
rotate: bossState === "dizzy" ? [0, -10, 10, -10, 10, 0] : bossRotation,
|
||||
scale: bossState === "stunned" ? 0.9 : bossHealth > 0 ? 1 : 0.8,
|
||||
opacity: bossState === "stunned" ? 0.7 : 1
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
>
|
||||
<div className="relative w-32 h-32 md:w-48 md:h-48">
|
||||
<div className={`absolute -left-6 top-1/2 w-10 h-10 ${activePowerUp === "freeze" ? "bg-cyan-300" : bossState === "dizzy" ? "bg-gray-400" : "bg-red-600"} rounded-full border-4 border-red-400 flex items-center justify-center`}>
|
||||
<span className="text-white font-bold text-xs">L</span>
|
||||
</div>
|
||||
<div className={`absolute -right-6 top-1/3 w-10 h-10 ${activePowerUp === "freeze" ? "bg-cyan-300" : bossState === "dizzy" ? "bg-gray-400" : "bg-red-600"} rounded-full border-4 border-red-400 flex items-center justify-center`}>
|
||||
<span className="text-white font-bold text-xs">R</span>
|
||||
</div>
|
||||
<div className={`w-full h-full rounded-2xl overflow-hidden border-4 ${activePowerUp === "freeze" ? "border-cyan-400" : bossState === "stunned" ? "border-blue-400" : "border-white"} shadow-2xl`}>
|
||||
<img src={bossImage} alt="Boss" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
{bossState === "stunned" && <div className="absolute -top-4 left-1/2 -translate-x-1/2 text-2xl">💫</div>}
|
||||
{bossState === "dizzy" && <div className="absolute top-0 right-0 text-xl">😵</div>}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{gameState === "playing" && (
|
||||
<motion.div className="absolute left-0 bottom-0" animate={{ x: playerX }} transition={{ duration: 0.1 }}>
|
||||
<div className="w-20 h-28 md:w-28 md:h-36 relative">
|
||||
<motion.div className="absolute top-1/3 -right-3 w-12 h-12 bg-blue-600 border-4 border-blue-400 rounded-full cursor-pointer flex items-center justify-center text-2xl" whileTap={{ scale: 0.8 }} onClick={() => punch("right")}>👊</motion.div>
|
||||
<motion.div className="absolute top-1/3 -left-3 w-12 h-12 bg-blue-600 border-4 border-blue-400 rounded-full cursor-pointer flex items-center justify-center text-2xl" whileTap={{ scale: 0.8 }} onClick={() => punch("left")}>👊</motion.div>
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-20 bg-blue-700 rounded-t-xl" />
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-10 h-10 bg-amber-200 rounded-full" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{lastHit && (
|
||||
<motion.div initial={{ opacity: 1, scale: 0.5 }} animate={{ opacity: 0, scale: 1.5 }} className="absolute right-1/4 top-1/3 text-5xl font-black text-yellow-400">
|
||||
Pow!
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 w-full max-w-md">
|
||||
{gameState === "upload" && (
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="text-center">
|
||||
<div className="bg-white/10 backdrop-blur rounded-2xl p-6 border-2 border-dashed border-white/30">
|
||||
<div className="text-5xl mb-3">👔</div>
|
||||
<h2 className="text-xl font-bold text-white mb-2">Upload Your Boss!</h2>
|
||||
<p className="text-white/70 mb-4">Take out your frustrations!</p>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleImageUpload} className="hidden" />
|
||||
<button onClick={() => fileInputRef.current?.click()} className="bg-red-500 hover:bg-red-600 text-white font-bold py-3 px-8 rounded-full text-lg transition-all hover:scale-105 shadow-lg">
|
||||
📸 Upload Photo
|
||||
</button>
|
||||
<p className="text-xs text-white/50 mt-4">Free with ads • $9.99 for ad-free</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{gameState === "ready" && bossImage && (
|
||||
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="text-center">
|
||||
<div className="bg-white/10 backdrop-blur rounded-xl p-3 mb-3 border border-white/20">
|
||||
<button onClick={() => setShowWinQuoteInput(!showWinQuoteInput)} className="text-white/70 text-sm hover:text-white flex items-center justify-center gap-2">
|
||||
🏆 {showWinQuoteInput ? "Hide" : "If u win he says..."}
|
||||
</button>
|
||||
{showWinQuoteInput && (
|
||||
<input
|
||||
type="text"
|
||||
value={winQuote}
|
||||
onChange={(e) => setWinQuote(e.target.value)}
|
||||
placeholder="What does boss say when u win?"
|
||||
className="w-full mt-2 bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/50 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white mb-2">Ready to Fight!</h2>
|
||||
<p className="text-white/70 mb-4">Boss fights back! Don't lose!</p>
|
||||
<button onClick={startGame} className="bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white font-bold py-3 px-10 rounded-full text-lg transition-all hover:scale-105 shadow-xl">
|
||||
🥊 START FIGHT
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{gameState === "playing" && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||
<p className="text-white/70 text-sm mb-2">Tap to punch!</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button onClick={() => punch("left")} className="bg-blue-600 hover:scale-110 w-20 h-20 rounded-full border-4 border-blue-400 flex items-center justify-center text-3xl active:scale-90 transition-transform">👊</button>
|
||||
<button onClick={() => punch("right")} className="bg-blue-600 hover:scale-110 w-20 h-20 rounded-full border-4 border-blue-400 flex items-center justify-center text-3xl active:scale-90 transition-transform">👊</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{gameState === "gameover" && (
|
||||
<motion.div initial={{ opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1 }} className="text-center">
|
||||
<div className={`rounded-2xl p-6 border-4 ${bossHealth <= 0 ? "bg-gradient-to-b from-yellow-500 to-orange-500 border-yellow-300" : "bg-gradient-to-b from-red-600 to-red-800 border-red-400"}`}>
|
||||
<div className="text-6xl mb-2">{bossHealth <= 0 ? "🏆" : "💀"}</div>
|
||||
<h2 className="text-3xl font-black text-white mb-2">{bossHealth <= 0 ? "KNOCKOUT!" : "YOU LOSE!"}</h2>
|
||||
{bossHealth <= 0 && (
|
||||
<div className="bg-white/20 rounded-lg p-3 mb-3">
|
||||
<p className="text-lg font-bold text-white">Boss says:</p>
|
||||
<p className="text-white text-xl">"{winQuote}"</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xl font-bold text-white mb-2">Score: {score}</p>
|
||||
<p className="text-white/80 mb-4">Max Combo: {maxCombo}x</p>
|
||||
<div className="space-y-2">
|
||||
<button onClick={resetGame} className="w-full bg-white text-red-600 font-bold py-2 px-6 rounded-full text-base transition-all hover:scale-105">
|
||||
🔄 Fight Again
|
||||
</button>
|
||||
{!isPaid && (
|
||||
<button onClick={() => setShowPayModal(true)} className="w-full bg-yellow-400 text-yellow-900 font-bold py-2 px-6 rounded-full text-base transition-all hover:scale-105 flex items-center justify-center gap-2">
|
||||
⭐ $9.99 - No Ads
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stripe Payment Modal */}
|
||||
{showPayModal && (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-md w-full text-center">
|
||||
<h3 className="text-2xl font-bold text-gray-800 mb-2">Remove Ads</h3>
|
||||
<p className="text-gray-600 mb-4">Get BossPunch ad-free forever!</p>
|
||||
<p className="text-4xl font-black text-purple-600 mb-4">$9.99</p>
|
||||
<button className="w-full bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 rounded-lg mb-2 transition">
|
||||
Pay with Card
|
||||
</button>
|
||||
<button onClick={() => setShowPayModal(false)} className="text-gray-500 text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPaid && gameState !== "upload" && (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-black/80 py-2 text-center">
|
||||
<p className="text-white/60 text-xs">Advertisement</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type Booking = {
|
||||
id: string;
|
||||
customer: string;
|
||||
service: string;
|
||||
date: string;
|
||||
time: string;
|
||||
status: "confirmed" | "pending" | "cancelled";
|
||||
};
|
||||
|
||||
type Lead = {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
message: string;
|
||||
date: string;
|
||||
status: "new" | "contacted" | "qualified";
|
||||
};
|
||||
|
||||
// Mock data for demo
|
||||
const mockBookings: Booking[] = [
|
||||
{ id: "1", customer: "María García", service: "Cita consulta", date: "2026-02-17", time: "10:00", status: "confirmed" },
|
||||
{ id: "2", customer: "John Smith", service: "Treatment", date: "2026-02-17", time: "14:30", status: "pending" },
|
||||
{ id: "3", customer: "Carlos Ruiz", service: "Reserva mesa", date: "2026-02-18", time: "20:00", status: "confirmed" },
|
||||
{ id: "4", customer: "Ana López", service: "Cita estética", date: "2026-02-19", time: "11:00", status: "confirmed" },
|
||||
{ id: "5", customer: "Pierre Dubois", service: "Booking", date: "2026-02-19", time: "16:00", status: "confirmed" },
|
||||
];
|
||||
|
||||
const mockLeads: Lead[] = [
|
||||
{ id: "1", name: "María García", phone: "+34 612 345 678", message: "Hola, me gustaría información sobre...", date: "2026-02-16", status: "new" },
|
||||
{ id: "2", name: "John Smith", phone: "+44 7700 900123", message: "Do you have availability for next week?", date: "2026-02-16", status: "qualified" },
|
||||
{ id: "3", name: "Carlos Ruiz", phone: "+34 654 987 321", message: "Quiero reservar para 4 personas", date: "2026-02-15", status: "contacted" },
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loginError, setLoginError] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"bookings" | "leads" | "analytics">("bookings");
|
||||
|
||||
const handleLogin = () => {
|
||||
if (email === "User" && password === "#SiteMenteUserpass2026") {
|
||||
setIsLoggedIn(true);
|
||||
setLoginError("");
|
||||
} else {
|
||||
setLoginError("Invalid credentials");
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#1a1625] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-5xl mb-4">🏢</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">SiteMente</h1>
|
||||
<p className="text-white/60">Client Dashboard</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-white/60 mb-1 block">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="User"
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder:text-white/30"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-white/60 mb-1 block">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder:text-white/30"
|
||||
/>
|
||||
</div>
|
||||
{loginError && (
|
||||
<p className="text-red-400 text-sm">{loginError}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
className="w-full py-3 bg-brand-pink rounded-lg font-semibold text-white hover:bg-[#ff7bc0] transition"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<p className="text-center text-white/40 text-sm">
|
||||
Ask SiteMente to create your dashboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = {
|
||||
totalBookings: mockBookings.length,
|
||||
confirmed: mockBookings.filter(b => b.status === "confirmed").length,
|
||||
pending: mockBookings.filter(b => b.status === "pending").length,
|
||||
totalLeads: mockLeads.length,
|
||||
newLeads: mockLeads.filter(l => l.status === "new").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#1a1625] text-white">
|
||||
{/* Header */}
|
||||
<header className="border-b border-white/10 bg-[#1a1625]/90 backdrop-blur sticky top-0 z-50">
|
||||
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-2xl">🏢</span>
|
||||
<span className="font-bold text-xl">SiteMente Dashboard</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-white/60">SiteMente Restaurant Demo</span>
|
||||
<button
|
||||
onClick={() => setIsLoggedIn(false)}
|
||||
className="text-sm text-white/60 hover:text-white"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-6xl px-6 py-8">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<p className="text-2xl font-bold text-brand-pink">{stats.totalBookings}</p>
|
||||
<p className="text-sm text-white/60">Total Bookings</p>
|
||||
</div>
|
||||
<div className="bg-green-500/10 rounded-xl p-4 border border-green-500/30">
|
||||
<p className="text-2xl font-bold text-green-400">{stats.confirmed}</p>
|
||||
<p className="text-sm text-white/60">Confirmed</p>
|
||||
</div>
|
||||
<div className="bg-yellow-500/10 rounded-xl p-4 border border-yellow-500/30">
|
||||
<p className="text-2xl font-bold text-yellow-400">{stats.pending}</p>
|
||||
<p className="text-sm text-white/60">Pending</p>
|
||||
</div>
|
||||
<div className="bg-blue-500/10 rounded-xl p-4 border border-blue-500/30">
|
||||
<p className="text-2xl font-bold text-blue-400">{stats.totalLeads}</p>
|
||||
<p className="text-sm text-white/60">Total Leads</p>
|
||||
</div>
|
||||
<div className="bg-purple-500/10 rounded-xl p-4 border border-purple-500/30">
|
||||
<p className="text-2xl font-bold text-purple-400">{stats.newLeads}</p>
|
||||
<p className="text-sm text-white/60">New Leads</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab("bookings")}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition ${activeTab === "bookings" ? "bg-brand-pink" : "bg-white/10 hover:bg-white/20"}`}
|
||||
>
|
||||
📅 Bookings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("leads")}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition ${activeTab === "leads" ? "bg-brand-pink" : "bg-white/10 hover:bg-white/20"}`}
|
||||
>
|
||||
📩 Leads
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("analytics")}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition ${activeTab === "analytics" ? "bg-brand-pink" : "bg-white/10 hover:bg-white/20"}`}
|
||||
>
|
||||
📈 Analytics
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bookings Tab */}
|
||||
{activeTab === "bookings" && (
|
||||
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/5 border-b border-white/10">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60">Customer</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60">Service</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60">Date</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60">Time</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockBookings.map((booking) => (
|
||||
<tr key={booking.id} className="border-b border-white/5">
|
||||
<td className="px-4 py-3">{booking.customer}</td>
|
||||
<td className="px-4 py-3">{booking.service}</td>
|
||||
<td className="px-4 py-3">{booking.date}</td>
|
||||
<td className="px-4 py-3">{booking.time}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
booking.status === "confirmed" ? "bg-green-500/20 text-green-400" :
|
||||
booking.status === "pending" ? "bg-yellow-500/20 text-yellow-400" :
|
||||
"bg-red-500/20 text-red-400"
|
||||
}`}>
|
||||
{booking.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leads Tab */}
|
||||
{activeTab === "leads" && (
|
||||
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/5 border-b border-white/10">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60">Name</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60">Phone</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60">Message</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60">Date</th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockLeads.map((lead) => (
|
||||
<tr key={lead.id} className="border-b border-white/5">
|
||||
<td className="px-4 py-3">{lead.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<a href={`tel:${lead.phone}`} className="text-brand-pink hover:underline">{lead.phone}</a>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white/70 text-sm">{lead.message}</td>
|
||||
<td className="px-4 py-3">{lead.date}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
lead.status === "new" ? "bg-blue-500/20 text-blue-400" :
|
||||
lead.status === "qualified" ? "bg-green-500/20 text-green-400" :
|
||||
"bg-yellow-500/20 text-yellow-400"
|
||||
}`}>
|
||||
{lead.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analytics Tab */}
|
||||
{activeTab === "analytics" && (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Bookings This Week</h3>
|
||||
<div className="flex items-end gap-2 h-40">
|
||||
{[3, 5, 2, 4, 6, 3, 5].map((h, i) => (
|
||||
<div key={i} className="flex-1 bg-brand-pink/50 rounded-t" style={{ height: `${h * 14}%` }}></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-white/40">
|
||||
<span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span><span>Sun</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Lead Sources</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>🌐 Website</span>
|
||||
<span className="text-brand-pink">65%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-brand-pink w-[65%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>💬 WhatsApp</span>
|
||||
<span className="text-green-400">25%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-green-500 w-[25%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>📞 Phone</span>
|
||||
<span className="text-blue-400">10%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 w-[10%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-center text-white/40 text-sm mt-8">
|
||||
Powered by SiteMente AI • Your AI Employee working 24/7
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Demos | SiteMente - Webs IA para cada industria",
|
||||
description:
|
||||
"Ver demos de webs con IA para inmobiliarias, restaurantes, clínicas y servicios del hogar. Precios claros desde €390/mes.",
|
||||
keywords: [
|
||||
"demo web IA España",
|
||||
"inteligencia artificial inmobiliarias",
|
||||
"AI restaurantesdemo",
|
||||
"chatbot clínica",
|
||||
"IA servicios hogar",
|
||||
"web con AI Costa del Sol",
|
||||
],
|
||||
openGraph: {
|
||||
title: "Demos SiteMente | Webs IA por industria",
|
||||
description:
|
||||
"Ver demos de webs con IA para cada tipo de negocio. Desde €390/mes.",
|
||||
url: "https://sitemente.com/demos",
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function DemosLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
+2
-1054
File diff suppressed because it is too large
Load Diff
@@ -100,3 +100,11 @@ body {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% { background-position: 0% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
.animate-gradient {
|
||||
animation: gradient 3s linear infinite;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "HolaCompi - AI Voice Agents for Spanish Businesses",
|
||||
description: "Building the future of automated customer calls",
|
||||
};
|
||||
|
||||
export default function HolaCompiPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#1a1625] to-[#2d1f3d] text-white flex items-center justify-center overflow-hidden">
|
||||
{/* Background effects */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-20 left-10 text-6xl animate-pulse">✨</div>
|
||||
<div className="absolute top-40 right-20 text-5xl animate-bounce" style={{ animationDuration: '2s' }}>🚀</div>
|
||||
<div className="absolute bottom-40 left-20 text-5xl animate-pulse" style={{ animationDelay: '0.5s' }}>💫</div>
|
||||
<div className="absolute bottom-20 right-10 text-6xl animate-bounce" style={{ animationDuration: '3s' }}>🤖</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center px-6 relative z-10">
|
||||
<div className="text-7xl mb-8 animate-bounce" style={{ animationDuration: '2s' }}>
|
||||
🤖📞
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl font-bold mb-6 bg-gradient-to-r from-pink-500 via-purple-500 to-pink-500 bg-[length:200%_auto] animate-gradient bg-clip-text text-transparent">
|
||||
HolaCompi
|
||||
</h1>
|
||||
|
||||
<h2 className="text-3xl text-white/90 mb-6 font-light">
|
||||
AI Voice Agents for Spanish Businesses
|
||||
</h2>
|
||||
|
||||
<div className="inline-block bg-gradient-to-r from-pink-500/30 to-purple-500/30 border border-pink-500/40 rounded-full px-8 py-3 mb-10 backdrop-blur">
|
||||
<span className="text-2xl font-medium">🚀 Launching March 2026</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xl text-white/70 mb-10 max-w-md mx-auto">
|
||||
Building the future of automated customer calls
|
||||
</p>
|
||||
|
||||
<div className="text-white/50 text-lg">
|
||||
<p>For inquiries: <a href="mailto:Holac@HolaCompi.com" className="text-pink-400 hover:text-pink-300 transition">Holac@HolaCompi.com</a></p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 pt-8 border-t border-white/10">
|
||||
<Link href="/" className="text-pink-400 hover:text-pink-300 transition text-lg">
|
||||
← Back to SiteMente
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type LeadStatus = "new" | "contacted" | "qualified" | "proposal" | "won" | "lost";
|
||||
|
||||
interface Lead {
|
||||
id: string;
|
||||
name: string;
|
||||
category: "restaurant" | "real-estate" | "clinic" | "car-rental" | "hp-client" | "other";
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
address: string;
|
||||
rating: number;
|
||||
score: number;
|
||||
notes: string;
|
||||
status: LeadStatus;
|
||||
lastContact: string;
|
||||
nextAction: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const allLeads: Lead[] = [
|
||||
// New leads from research Feb 17
|
||||
{ id: "r11", name: "Restaurante Milan", category: "restaurant", phone: "+34 952 44 58 55", email: "", website: "", address: "Av. Federico Garcia Lorca 7, 29630 Benalmádena", rating: 0, score: 8, notes: "Italian restaurant - call for AI demo", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "r12", name: "Tex Mex Gringos", category: "restaurant", phone: "", email: "reservas@restaurantespuertomarina.com", website: "restaurantespuertomarina.com", address: "Puerto Marina, Benalmádena", rating: 0, score: 8, notes: "Email found - send AI proposal", status: "new", lastContact: "", nextAction: "Email intro", createdAt: "2026-02-17" },
|
||||
{ id: "r13", name: "Lime & Lemon Tapas", category: "restaurant", phone: "", email: "info@limeandlemonbenalmadena.com", website: "limeandlemonbenalmadena.com", address: "Av. Las Palmeras 1, 29630 Benalmádena", rating: 0, score: 8, notes: "Email found - send AI proposal", status: "new", lastContact: "", nextAction: "Email intro", createdAt: "2026-02-17" },
|
||||
{ id: "r14", name: "El Parador", category: "restaurant", phone: "+34 952 44 92 93", email: "", website: "", address: "Av. Juan Luis Peralta 47, 29639 Benalmádena", rating: 4.0, score: 8, notes: "Call for AI demo", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "r15", name: "Escorpio Restaurante", category: "restaurant", phone: "+34 952 569 047", email: "", website: "", address: "Santo Domingo de Guzmán 7, Benalmádena", rating: 0, score: 8, notes: "Call for AI demo", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "r16", name: "The Bull Bar", category: "restaurant", phone: "+34 646 569 374", email: "", website: "", address: "Av del Chorrillo 15, Benalmádena", rating: 0, score: 8, notes: "Call for AI demo", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "r17", name: "La Plaza Restaurant", category: "restaurant", phone: "+34 952 44 84 83", email: "", website: "", address: "Plaza de Espana 2, 29639 Benalmádena", rating: 4.3, score: 8, notes: "Call for AI demo", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "r18", name: "Caliu Restaurant", category: "restaurant", phone: "", email: "caliu.torremolinos@gmail.com", website: "", address: "Torremolinos", rating: 0, score: 7, notes: "Email found - send proposal", status: "new", lastContact: "", nextAction: "Email intro", createdAt: "2026-02-17" },
|
||||
{ id: "r19", name: "The Carvery", category: "restaurant", phone: "", email: "info@thecarverycompany.com", website: "", address: "Benalmádena", rating: 0, score: 7, notes: "Email found - send proposal", status: "new", lastContact: "", nextAction: "Email intro", createdAt: "2026-02-17" },
|
||||
{ id: "re6", name: "Engel & Völkers Costa del Sol", category: "real-estate", phone: "+34 952 650 234", email: "", website: "", address: "CC Diana Local 23, 29688 Estepona", rating: 0, score: 8, notes: "Premium agency - offer AI employee", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "re7", name: "Your Viva Marbella", category: "real-estate", phone: "+34 951 27 27 43", email: "", website: "", address: "CC El Rosario, 29604 Marbella", rating: 0, score: 8, notes: "Premium agency - offer AI employee", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "re8", name: "Panorama Properties", category: "real-estate", phone: "+34 952 774 266", email: "", website: "", address: "Hotel Local 23, 29602 Marbella", rating: 0, score: 8, notes: "Premium agency - offer AI employee", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "re9", name: "Marbella For Sale", category: "real-estate", phone: "+34 952 907 386", email: "", website: "", address: "Edif. Marina Banús, 29660 Puerto Banús", rating: 0, score: 8, notes: "Premium agency - offer AI employee", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "re10", name: "Domus Venari", category: "real-estate", phone: "+34 952 444 295", email: "", website: "", address: "Ctra. N340 KM189, 29604 Marbella", rating: 0, score: 8, notes: "Premium agency - offer AI employee", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "re11", name: "Diana Morales Properties", category: "real-estate", phone: "+34 952 765 138", email: "", website: "", address: "Av. Cánovas del Castillo 4, 29601 Marbella", rating: 0, score: 8, notes: "Premium agency - offer AI employee", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "re12", name: "Hacienda Estates", category: "real-estate", phone: "+34 952 850 154", email: "", website: "", address: "CC Pinogolf Local 2, 29604 Elviria", rating: 0, score: 7, notes: "Premium agency - offer AI employee", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "re13", name: "Sun Med Estates", category: "real-estate", phone: "+34 952 493 372", email: "", website: "", address: "c/ Sedella 3, La Cala de Mijas", rating: 0, score: 7, notes: "Premium agency - offer AI employee", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "c4", name: "Smart Dental (new)", category: "clinic", phone: "+34 911 98 04 65", email: "", website: "", address: "Av. Blas Infante 17, 29631 Benalmádena", rating: 0, score: 9, notes: "Call for AI demo", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
{ id: "c5", name: "Grupo Dental Clinics", category: "clinic", phone: "", email: "", website: "", address: "Av. Cdad. de Melilla 26, 29639 Benalmádena", rating: 0, score: 7, notes: "Find contact info", status: "new", lastContact: "", nextAction: "Find phone", createdAt: "2026-02-17" },
|
||||
{ id: "cr3", name: "Marbesol Car Rental", category: "car-rental", phone: "+34 952 93 44 12", email: "", website: "", address: "Málaga Airport", rating: 0, score: 7, notes: "Offer AI employee", status: "new", lastContact: "", nextAction: "Call demo", createdAt: "2026-02-17" },
|
||||
// Original leads
|
||||
{ id: "r1", name: "Tex Mex Gringos", category: "restaurant", phone: "+34 951 777 848", email: "info@restaurantespuertomarina.com", website: "restaurantespuertomarina.com", address: "Calle La Fragata, s/n, 29630 Benalmádena", rating: 4.5, score: 7, notes: "Has website - offer AI employee 24/7", status: "new", lastContact: "", nextAction: "Call - offer AI employee", createdAt: "2026-02-16" },
|
||||
{ id: "r2", name: "La Mar Chica", category: "restaurant", phone: "+34 951 634 708", email: "info.lamarchica@gmail.com", website: "mar-chica.com", address: "Calle Marbella, 1, 29639 Benalmádena Pueblo", rating: 4.3, score: 7, notes: "Has website - offer AI employee 24/7", status: "new", lastContact: "", nextAction: "Call - offer AI employee", createdAt: "2026-02-16" },
|
||||
{ id: "r3", name: "SALU Grill & Wine", category: "restaurant", phone: "+34 951 715 736", email: "salu.spain@gmail.com", website: "salu-restaurant.com", address: "Calle San José, 6, Benalmádena Pueblo", rating: 4.6, score: 8, notes: "Good site - offer AI employee upgrade", status: "new", lastContact: "", nextAction: "Schedule demo", createdAt: "2026-02-16" },
|
||||
{ id: "r4", name: "Basil", category: "restaurant", phone: "631 971 592", email: "", website: "basilbenalmadena.com", address: "Plaza Nueva Bonanza, 29630 Benalmádena", rating: 4.4, score: 6, notes: "Has WhatsApp - offer AI employee", status: "new", lastContact: "", nextAction: "Call - offer AI employee", createdAt: "2026-02-16" },
|
||||
{ id: "r5", name: "Lime & Lemon Tapas", category: "restaurant", phone: "", email: "", website: "limeandlemonbenalmadena.com", address: "Benalmádena", rating: 0, score: 5, notes: "Has website - offer AI employee 24/7", status: "new", lastContact: "", nextAction: "Find phone first", createdAt: "2026-02-16" },
|
||||
{ id: "r6", name: "Restaurant No7", category: "restaurant", phone: "+34 655 036 827", email: "", website: "", address: "Benalmádena", rating: 0, score: 10, notes: "NO WEBSITE - BIG OPPORTUNITY", status: "new", lastContact: "", nextAction: "CALL NOW", createdAt: "2026-02-16" },
|
||||
{ id: "r7", name: "Capitan Bar & Restaurant", category: "restaurant", phone: "+34 674 591 584", email: "", website: "", address: "Benalmádena", rating: 0, score: 10, notes: "NO WEBSITE - BIG OPPORTUNITY", status: "new", lastContact: "", nextAction: "CALL NOW", createdAt: "2026-02-16" },
|
||||
{ id: "r8", name: "TORO Puerto Marina", category: "restaurant", phone: "+34 952 913 177", email: "", website: "restaurantespuertomarina.com/toro-puerto-marina", address: "Puerto Marina, Benalmádena", rating: 4.5, score: 7, notes: "Has website - offer AI employee", status: "new", lastContact: "", nextAction: "Email introduction", createdAt: "2026-02-16" },
|
||||
{ id: "r9", name: "Trocadero Benalmádena", category: "restaurant", phone: "+34 681 142 944", email: "", website: "", address: "Avenida del Sol 121, Benalmádena", rating: 4.2, score: 10, notes: "NO WEBSITE - BIG OPPORTUNITY", status: "new", lastContact: "", nextAction: "CALL NOW", createdAt: "2026-02-16" },
|
||||
{ id: "r10", name: "Restaurante La Nina", category: "restaurant", phone: "+34 952 449 193", email: "", website: "", address: "Plaza de Espana, Benalmádena Pueblo", rating: 4.4, score: 10, notes: "NO WEBSITE - BIG OPPORTUNITY", status: "new", lastContact: "", nextAction: "CALL NOW", createdAt: "2026-02-16" },
|
||||
{ id: "re1", name: "Hernán Bustos Real Estate", category: "real-estate", phone: "", email: "", website: "hernanbustos.com", address: "Benalmádena, Torremolinos", rating: 0, score: 9, notes: "Needs AI employee 24/7", status: "new", lastContact: "", nextAction: "Find contact info", createdAt: "2026-02-16" },
|
||||
{ id: "re2", name: "ViVi Real Estate", category: "real-estate", phone: "", email: "", website: "vivi-realestate.com", address: "Costa del Sol", rating: 0, score: 9, notes: "Needs AI employee 24/7", status: "new", lastContact: "", nextAction: "Find contact info", createdAt: "2026-02-16" },
|
||||
{ id: "re3", name: "Marbella Mundo", category: "real-estate", phone: "", email: "", website: "marbellamundo.es", address: "Fuengirola, Costa del Sol", rating: 0, score: 9, notes: "Needs AI employee 24/7", status: "new", lastContact: "", nextAction: "Email introduction", createdAt: "2026-02-16" },
|
||||
{ id: "re4", name: "Homenetspain", category: "real-estate", phone: "+34 633 300 956", email: "", website: "homenetspain.com", address: "Fuengirola", rating: 0, score: 8, notes: "Needs AI employee 24/7", status: "new", lastContact: "", nextAction: "Call and demo", createdAt: "2026-02-16" },
|
||||
{ id: "re5", name: "Costa Listings", category: "real-estate", phone: "", email: "", website: "costalistings.es", address: "Benalmádena", rating: 0, score: 8, notes: "Needs AI employee 24/7", status: "new", lastContact: "", nextAction: "Find contact info", createdAt: "2026-02-16" },
|
||||
{ id: "c1", name: "Vithas Xanit Hospital", category: "clinic", phone: "+34 952 367 190", email: "info.xanit@vithas.es", website: "vithas.es", address: "Avenida de los Argonautas, s/n, 29631 Benalmádena", rating: 4.5, score: 8, notes: "BIG opportunity - AI employee", status: "new", lastContact: "", nextAction: "Email introduction", createdAt: "2026-02-16" },
|
||||
{ id: "c2", name: "Smart Dental", category: "clinic", phone: "", email: "", website: "", address: "Benalmádena", rating: 4.5, score: 10, notes: "NO WEBSITE - BIG opportunity", status: "new", lastContact: "", nextAction: "Find phone", createdAt: "2026-02-16" },
|
||||
{ id: "c3", name: "Rosasco Dental", category: "clinic", phone: "", email: "", website: "", address: "Benalmádena", rating: 4.2, score: 10, notes: "NO WEBSITE - BIG opportunity", status: "new", lastContact: "", nextAction: "Find phone", createdAt: "2026-02-16" },
|
||||
{ id: "cr1", name: "Malaga U Drive", category: "car-rental", phone: "", email: "", website: "malagaudrive.com", address: "Malaga/Benalmádena", rating: 0, score: 8, notes: "Needs AI employee 24/7", status: "new", lastContact: "", nextAction: "Email introduction", createdAt: "2026-02-16" },
|
||||
{ id: "cr2", name: "ALL IN Car Hire", category: "car-rental", phone: "", email: "", website: "allincarhire.com", address: "Malaga/Benalmádena", rating: 0, score: 8, notes: "Needs AI employee 24/7", status: "new", lastContact: "", nextAction: "Email introduction", createdAt: "2026-02-16" },
|
||||
];
|
||||
|
||||
const categoryIcons: Record<string, string> = {
|
||||
restaurant: "🍽️",
|
||||
"real-estate": "🏠",
|
||||
clinic: "🏥",
|
||||
"car-rental": "🚗",
|
||||
other: "📌",
|
||||
};
|
||||
|
||||
const statusColors: Record<LeadStatus, string> = {
|
||||
new: "bg-blue-500/20 text-blue-400 border-blue-500/30",
|
||||
contacted: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
|
||||
qualified: "bg-purple-500/20 text-purple-400 border-purple-500/30",
|
||||
proposal: "bg-orange-500/20 text-orange-400 border-orange-500/30",
|
||||
won: "bg-green-500/20 text-green-400 border-green-500/30",
|
||||
lost: "bg-red-500/20 text-red-400 border-red-500/30",
|
||||
};
|
||||
|
||||
type SortField = "name" | "category" | "score" | "status" | "phone";
|
||||
|
||||
export default function LeadsPage() {
|
||||
const [leads, setLeads] = useState<Lead[]>(allLeads);
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [tab, setTab] = useState<"leads" | "email">("leads");
|
||||
const [sortField, setSortField] = useState<SortField>("score");
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const leadsWithEmail = useMemo(() => leads.filter(l => l.email), [leads]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("sitemente:leads");
|
||||
if (saved) {
|
||||
setLeads(JSON.parse(saved));
|
||||
} else {
|
||||
localStorage.setItem("sitemente:leads", JSON.stringify(allLeads));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveLeads = (newLeads: Lead[]) => {
|
||||
setLeads(newLeads);
|
||||
localStorage.setItem("sitemente:leads", JSON.stringify(newLeads));
|
||||
};
|
||||
|
||||
const updateStatus = (id: string, status: LeadStatus) => {
|
||||
const updated = leads.map((l) =>
|
||||
l.id === id ? { ...l, status, lastContact: new Date().toISOString().split("T")[0] } : l
|
||||
);
|
||||
saveLeads(updated);
|
||||
};
|
||||
|
||||
const filteredLeads = useMemo(() => {
|
||||
return leads
|
||||
.filter((l) => {
|
||||
if (filter !== "all" && l.category !== filter && l.status !== filter) return false;
|
||||
if (search && !l.name.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortField === "name") cmp = a.name.localeCompare(b.name);
|
||||
else if (sortField === "category") cmp = a.category.localeCompare(b.category);
|
||||
else if (sortField === "score") cmp = b.score - a.score;
|
||||
else if (sortField === "status") cmp = a.status.localeCompare(b.status);
|
||||
else if (sortField === "phone") cmp = (a.phone ? 1 : 0) - (b.phone ? 1 : 0);
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
}, [leads, filter, sortField, sortAsc, search]);
|
||||
|
||||
const stats = {
|
||||
total: leads.length,
|
||||
withPhone: leads.filter(l => l.phone).length,
|
||||
noWebsite: leads.filter(l => !l.website).length,
|
||||
hot: leads.filter(l => l.score >= 9).length,
|
||||
new: leads.filter((l) => l.status === "new").length,
|
||||
won: leads.filter((l) => l.status === "won").length,
|
||||
};
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortAsc(!sortAsc);
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortAsc(false);
|
||||
}
|
||||
};
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => (
|
||||
<span className="ml-1 opacity-50">{sortField === field ? (sortAsc ? "↑" : "↓") : "↕"}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#1a1625] text-white" suppressHydrationWarning>
|
||||
<header className="border-b border-white/10 bg-[#1a1625]/90 backdrop-blur sticky top-0 z-50">
|
||||
<div className="mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<img src="/sitemente-logo-light.png" alt="SiteMente" width={40} height={40} className="h-10 w-auto" />
|
||||
<span className="font-bold text-xl">Leads CRM</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a href="/mission-control" className="px-4 py-2 bg-white/10 rounded-lg text-sm hover:bg-white/20 transition">← Mission Control</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-7xl px-6 py-8" suppressHydrationWarning>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<p className="text-2xl font-bold text-brand-pink">{stats.total}</p>
|
||||
<p className="text-sm text-white/60">Total Leads</p>
|
||||
</div>
|
||||
<div className="bg-green-500/10 rounded-xl p-4 border border-green-500/30">
|
||||
<p className="text-2xl font-bold text-green-400">{stats.withPhone}</p>
|
||||
<p className="text-sm text-white/60">With Phone</p>
|
||||
</div>
|
||||
<div className="bg-red-500/10 rounded-xl p-4 border border-red-500/30">
|
||||
<p className="text-2xl font-bold text-red-400">{stats.noWebsite}</p>
|
||||
<p className="text-sm text-white/60">No Website</p>
|
||||
</div>
|
||||
<div className="bg-orange-500/10 rounded-xl p-4 border border-orange-500/30">
|
||||
<p className="text-2xl font-bold text-orange-400">{stats.hot}</p>
|
||||
<p className="text-sm text-white/60">Hot (9-10)</p>
|
||||
</div>
|
||||
<div className="bg-blue-500/10 rounded-xl p-4 border border-blue-500/30">
|
||||
<p className="text-2xl font-bold text-blue-400">{stats.won}</p>
|
||||
<p className="text-sm text-white/60">Won</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mb-6">
|
||||
<div className="flex gap-2 bg-white/10 rounded-lg p-1">
|
||||
<button onClick={() => setTab("leads")} className={`px-4 py-2 rounded-lg transition ${tab === "leads" ? "bg-brand-pink text-white" : "hover:bg-white/10"}`}>📋 Leads ({leads.length})</button>
|
||||
<button onClick={() => setTab("email")} className={`px-4 py-2 rounded-lg transition ${tab === "email" ? "bg-brand-pink text-white" : "hover:bg-white/10"}`}>✉️ Email Outreach ({leadsWithEmail.length})</button>
|
||||
</div>
|
||||
{tab === "leads" && (
|
||||
<>
|
||||
<input type="text" placeholder="Search leads..." value={search} onChange={(e) => setSearch(e.target.value)} className="bg-white/10 border border-white/20 rounded-lg px-4 py-2 min-w-[200px]" />
|
||||
<select value={filter} onChange={(e) => setFilter(e.target.value)} className="bg-white/10 border border-white/20 rounded-lg px-4 py-2">
|
||||
<option value="all">All ({leads.length})</option>
|
||||
<option value="restaurant">Restaurants ({leads.filter(l => l.category === 'restaurant').length})</option>
|
||||
<option value="real-estate">Real Estate ({leads.filter(l => l.category === 'real-estate').length})</option>
|
||||
<option value="clinic">Clinics ({leads.filter(l => l.category === 'clinic').length})</option>
|
||||
<option value="car-rental">Car Rental ({leads.filter(l => l.category === 'car-rental').length})</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-white/5 border-b border-white/10">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60 cursor-pointer hover:text-white" onClick={() => handleSort("name")}>Lead <SortIcon field="name" /></th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60 cursor-pointer hover:text-white" onClick={() => handleSort("category")}>Category <SortIcon field="category" /></th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60 cursor-pointer hover:text-white" onClick={() => handleSort("phone")}>Phone <SortIcon field="phone" /></th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60 cursor-pointer hover:text-white" onClick={() => handleSort("score")}>Score <SortIcon field="score" /></th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60 cursor-pointer hover:text-white" onClick={() => handleSort("status")}>Status <SortIcon field="status" /></th>
|
||||
<th className="text-left px-4 py-3 text-sm font-medium text-white/60">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredLeads.map((lead) => (
|
||||
<motion.tr key={lead.id} initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="border-b border-white/5 hover:bg-white/5">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{lead.name}</p>
|
||||
<p className="text-xs text-white/50 truncate max-w-[200px]">{lead.address}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3"><span className="text-lg">{categoryIcons[lead.category]}</span></td>
|
||||
<td className="px-4 py-3">
|
||||
{lead.phone ? <a href={"tel:" + lead.phone} className="text-green-400 hover:underline">{lead.phone}</a> : <span className="text-red-400 text-xs">No phone</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={"inline-flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold " + (lead.score >= 9 ? "bg-green-500/20 text-green-400" : lead.score >= 7 ? "bg-yellow-500/20 text-yellow-400" : "bg-red-500/20 text-red-400")}>{lead.score}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={lead.status} onChange={(e) => updateStatus(lead.id, e.target.value as LeadStatus)} className={"text-xs px-2 py-1 rounded-full border " + statusColors[lead.status as LeadStatus]}>
|
||||
<option value="new">New</option>
|
||||
<option value="contacted">Contacted</option>
|
||||
<option value="qualified">Qualified</option>
|
||||
<option value="proposal">Proposal</option>
|
||||
<option value="won">Won</option>
|
||||
<option value="lost">Lost</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
{lead.phone && <a href={"tel:" + lead.phone} className="p-2 bg-green-500/20 rounded-lg hover:bg-green-500/30 transition" title="Call">📞</a>}
|
||||
{lead.email && <a href={"mailto:" + lead.email} className="p-2 bg-blue-500/20 rounded-lg hover:bg-blue-500/30 transition" title="Email">✉️</a>}
|
||||
{lead.website && <a href={lead.website.startsWith("http") ? lead.website : "https://" + lead.website} target="_blank" rel="noopener noreferrer" className="p-2 bg-purple-500/20 rounded-lg hover:bg-purple-500/30 transition" title="Website">🌐</a>}
|
||||
<button onClick={() => setSelectedLead(lead)} className="p-2 bg-white/10 rounded-lg hover:bg-white/20 transition" title="Details">👁️</button>
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Outreach Tab */}
|
||||
{tab === "email" && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-6">
|
||||
<h3 className="text-lg font-bold mb-2">📧 Email Outreach</h3>
|
||||
<p className="text-white/60 mb-4">Leads with email addresses. Click "Send" to open your email client with pre-filled template.</p>
|
||||
<div className="grid gap-4">
|
||||
{leads.filter(l => l.email).map(lead => (
|
||||
<div key={lead.id} className="bg-white/5 rounded-lg p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-bold">{lead.name}</p>
|
||||
<p className="text-sm text-white/60">{lead.email}</p>
|
||||
<p className="text-xs text-white/40">{categoryIcons[lead.category]} {lead.category}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => updateStatus(lead.id, "contacted")} className="px-3 py-1 bg-yellow-500/20 text-yellow-400 rounded text-sm">Mark Emailed</button>
|
||||
<a href={`mailto:${lead.email}?subject=Tu empleado IA 24/7 - SiteMente&body=Hola,%0D%0A%0D%0AVi tu negocio y me gustaría ofrecerte una solución que puede revolucionar tu atención al cliente.%0D%0A%0D%0ACon SiteMente tienes un empleado IA disponible 24/7 que:%0D%0A- Responde preguntas de clientes%0D%0A- Gestiona reservas automáticamente%0D%0A- Habla en español, inglés, francés, alemán...%0D%0A%0D%0A¿Te interesa ver una demo? Es gratis y sin compromiso.%0D%0A%0D%0ASaludos`} className="px-4 py-2 bg-blue-500 hover:bg-blue-600 rounded-lg font-medium">✉️ Send Email</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{leads.filter(l => l.email).length === 0 && (
|
||||
<p className="text-white/40 text-center py-8">No leads with email found. Research more leads!</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-500/10 border border-green-500/30 rounded-xl p-6">
|
||||
<h3 className="text-lg font-bold mb-2">🤖 AI Calling (Coming Soon)</h3>
|
||||
<p className="text-white/60">Automated voice calls to leads - requires your confirmation before each call.</p>
|
||||
<p className="text-sm text-white/40 mt-2">Integrating with Vapi - stay tuned!</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{selectedLead && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-[#1a1625] border border-white/20 rounded-2xl p-6 max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">{selectedLead.name}</h3>
|
||||
<p className="text-white/60">{categoryIcons[selectedLead.category]} {selectedLead.category}</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedLead(null)} className="text-white/40 hover:text-white">✕</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div><label className="text-sm text-white/60">Address</label><p>{selectedLead.address}</p></div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1"><label className="text-sm text-white/60">Phone</label><p className={selectedLead.phone ? "text-green-400" : "text-red-400"}>{selectedLead.phone || "—"}</p></div>
|
||||
<div className="flex-1"><label className="text-sm text-white/60">Website</label><p className={selectedLead.website ? "" : "text-red-400"}>{selectedLead.website || "—"}</p></div>
|
||||
</div>
|
||||
<div><label className="text-sm text-white/60">Score</label><p className="text-2xl font-bold text-brand-pink">{selectedLead.score}/10</p></div>
|
||||
<div><label className="text-sm text-white/60">Notes</label><p>{selectedLead.notes}</p></div>
|
||||
<div><label className="text-sm text-white/60">Next Action</label><p className="text-green-400">{selectedLead.nextAction}</p></div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-6">
|
||||
{selectedLead.phone && <a href={"tel:" + selectedLead.phone} className="flex-1 py-2 bg-green-500 rounded-lg text-center font-medium">📞 Call</a>}
|
||||
{selectedLead.email && <a href={"mailto:" + selectedLead.email} className="flex-1 py-2 bg-blue-500 rounded-lg text-center font-medium">✉️ Email</a>}
|
||||
<button onClick={() => { updateStatus(selectedLead.id, "won"); setSelectedLead(null); }} className="flex-1 py-2 bg-green-600 rounded-lg font-medium">✅ Won</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# 📚 SiteMente Docs
|
||||
|
||||
Long-term documentation for SiteMente operations.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
- [Quick Start Guide](/docs/quick-start)
|
||||
- [Architecture Overview](/docs/architecture)
|
||||
- [Environment Variables](/docs/env)
|
||||
|
||||
## 🛠 Products
|
||||
|
||||
- [Smart Starter](/docs/products/starter)
|
||||
- [Smart Site](/docs/products/site)
|
||||
- [AI Growth Partner](/docs/products/growth)
|
||||
|
||||
## 🔌 Integrations
|
||||
|
||||
- [Vapi Voice AI](/docs/integrations/vapi)
|
||||
- [MiniMax AI](/docs/integrations/minimax)
|
||||
- [Stripe Payments](/docs/integrations/stripe)
|
||||
|
||||
## 📋 Operational
|
||||
|
||||
- [Onboarding Checklist](/docs/ops/onboarding)
|
||||
- [Troubleshooting Guide](/docs/ops/troubleshooting)
|
||||
- [API Endpoints](/docs/ops/api)
|
||||
|
||||
---
|
||||
|
||||
*Auto-generated by Horus* 👁️
|
||||
@@ -1,10 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import MissionControlDashboard from "@/components/mission-control/MissionControlDashboard";
|
||||
import { MissionControlProvider } from "@/lib/mission-control/store";
|
||||
|
||||
const CORRECT_USER = "Marshall";
|
||||
const CORRECT_PASS = "#1284YallaHorus";
|
||||
|
||||
export default function MissionControlPage() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("sitemente:mc-auth");
|
||||
if (saved === "true") {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (username === CORRECT_USER && password === CORRECT_PASS) {
|
||||
localStorage.setItem("sitemente:mc-auth", "true");
|
||||
setIsAuthenticated(true);
|
||||
setError("");
|
||||
} else {
|
||||
setError("Invalid credentials");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("sitemente:mc-auth");
|
||||
setIsAuthenticated(false);
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
};
|
||||
|
||||
// Show loading until we've checked localStorage on mount
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#1a1625] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-5xl mb-4">👁️</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Mission Control</h1>
|
||||
<p className="text-white/60">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#1a1625] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-5xl mb-4">👁️</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Mission Control</h1>
|
||||
<p className="text-white/60">SiteMente Operations</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-lg bg-white/10 border border-white/20 text-white placeholder:text-white/40 focus:outline-none focus:border-brand-pink"
|
||||
placeholder="Enter username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-lg bg-white/10 border border-white/20 text-white placeholder:text-white/40 focus:outline-none focus:border-brand-pink"
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 bg-brand-pink rounded-lg font-semibold text-white hover:bg-[#ff7bc0] transition"
|
||||
>
|
||||
Access Control
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-white/30 text-xs mt-6">
|
||||
Restricted access. Authorized personnel only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MissionControlProvider>
|
||||
<MissionControlDashboard />
|
||||
<MissionControlDashboard onLogout={handleLogout} />
|
||||
</MissionControlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ const fadeUp = {
|
||||
|
||||
type Language = "es" | "en";
|
||||
|
||||
type LeadFormData = {
|
||||
name: string;
|
||||
phone: string;
|
||||
business: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
const contentByLang = {
|
||||
es: {
|
||||
nav: {
|
||||
@@ -718,6 +725,8 @@ const servicesByLang = {
|
||||
export default function HomePage() {
|
||||
const [lang, setLang] = useState<Language>("es");
|
||||
const [contactOpen, setContactOpen] = useState(false);
|
||||
const [leadForm, setLeadForm] = useState<LeadFormData>({ name: "", phone: "", business: "", type: "restaurant" });
|
||||
const [leadSubmitted, setLeadSubmitted] = useState(false);
|
||||
const content = useMemo(() => contentByLang[lang], [lang]);
|
||||
const heroSlides = useMemo<HeroSlide[]>(
|
||||
() => [
|
||||
@@ -891,6 +900,91 @@ export default function HomePage() {
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Lead Capture Form */}
|
||||
{!leadSubmitted && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-12 max-w-md mx-auto"
|
||||
>
|
||||
<div className="bg-white/10 backdrop-blur rounded-xl p-6 border border-white/20">
|
||||
<h3 className="text-lg font-bold mb-4 text-center">🚀 Pruébalo Ahora - Es Gratis</h3>
|
||||
<p className="text-sm text-white/70 mb-4 text-center">Completa y te mostraremos tu AI en acción</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setLeadSubmitted(true);
|
||||
// Here we'll connect to GHL later
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tu nombre"
|
||||
required
|
||||
value={leadForm.name}
|
||||
onChange={(e) => setLeadForm({ ...leadForm, name: e.target.value })}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-lg px-4 py-2 text-white placeholder-white/50"
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="Tu teléfono"
|
||||
required
|
||||
value={leadForm.phone}
|
||||
onChange={(e) => setLeadForm({ ...leadForm, phone: e.target.value })}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-lg px-4 py-2 text-white placeholder-white/50"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nombre de tu negocio"
|
||||
required
|
||||
value={leadForm.business}
|
||||
onChange={(e) => setLeadForm({ ...leadForm, business: e.target.value })}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-lg px-4 py-2 text-white placeholder-white/50"
|
||||
/>
|
||||
<select
|
||||
value={leadForm.type}
|
||||
onChange={(e) => setLeadForm({ ...leadForm, type: e.target.value })}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-lg px-4 py-2 text-white"
|
||||
>
|
||||
<option value="restaurant" className="text-black">🍽️ Restaurante</option>
|
||||
<option value="real-estate" className="text-black">🏠 Inmobiliaria</option>
|
||||
<option value="clinic" className="text-black">🏥 Clínica</option>
|
||||
<option value="car-rental" className="text-black">🚗 Alquiler de coches</option>
|
||||
<option value="other" className="text-black">📌 Otro</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-brand-pink hover:bg-[#ff7bc0] text-white font-bold py-3 rounded-lg transition"
|
||||
>
|
||||
Ver Demo AI Ahora →
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{leadSubmitted && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="mt-12 max-w-md mx-auto text-center"
|
||||
>
|
||||
<div className="bg-green-500/20 backdrop-blur rounded-xl p-8 border border-green-500/30">
|
||||
<div className="text-5xl mb-4">🎉</div>
|
||||
<h3 className="text-2xl font-bold mb-2">Perfecto, {leadForm.name}!</h3>
|
||||
<p className="text-white/70 mb-4">Preparando tu demo personalizada...</p>
|
||||
<a
|
||||
href={`/demos?type=${leadForm.type}&name=${encodeURIComponent(leadForm.business)}`}
|
||||
className="inline-block bg-brand-pink hover:bg-[#ff7bc0] text-white font-bold px-8 py-3 rounded-lg transition"
|
||||
>
|
||||
🚀 Entrar a Demo AI
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</section>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SuccessPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#5e4a8a] flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="text-6xl mb-6">✅</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-4">¡Pago Exitoso!</h1>
|
||||
<p className="text-white/80 mb-8">
|
||||
Thank you for your payment. We've received your order and will contact you shortly to start setting up your AI solution.
|
||||
</p>
|
||||
<div className="bg-white/10 rounded-xl p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-2">What happens next?</h2>
|
||||
<ul className="text-left text-white/70 space-y-2 text-sm">
|
||||
<li>✓ You'll receive a confirmation email</li>
|
||||
<li>✓ Our team will contact you within 24 hours</li>
|
||||
<li>✓ We'll schedule your onboarding call</li>
|
||||
<li>✓ Your AI solution will be live within 48 hours</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block px-6 py-3 bg-brand-pink rounded-lg font-semibold text-white hover:bg-[#ff7bc0] transition"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user