feat: AI Pilots Interactive Website Builder & Funnel Engine with Dark/Light Mode, responsive header alignment, and dual-tone SVG suite
@@ -0,0 +1,30 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Production build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# OS & Editor files
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2338bdf8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polygon points='12 2 2 7 12 12 22 7 12 2'/><polyline points='2 17 12 22 22 17'/><polyline points='2 12 12 17 22 12'/></svg>" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI Website Funnel Builder | Instant High-Converting Homepage Generator</title>
|
||||||
|
<meta name="description" content="Build your custom business website draft in minutes. Powered by intelligent AI customization, multi-device preview, and full GMB & email launch package." />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "site-funnel-builder",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 811 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 840 KiB |
|
After Width: | Height: | Size: 635 KiB |
|
After Width: | Height: | Size: 860 KiB |
|
After Width: | Height: | Size: 664 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 724 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 950 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 934 KiB |
|
After Width: | Height: | Size: 254 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
import { processConversationalInstruction } from '../src/services/aiEngine';
|
||||||
|
import { BusinessProfile, WebsiteSection } from '../src/types';
|
||||||
|
|
||||||
|
const mockProfile: BusinessProfile = {
|
||||||
|
companyName: 'Apex Pools',
|
||||||
|
phone: '(555) 123-4567',
|
||||||
|
email: 'test@example.com',
|
||||||
|
address: '123 Main St',
|
||||||
|
city: 'Dallas',
|
||||||
|
state: 'TX',
|
||||||
|
zip: '75001',
|
||||||
|
industry: 'Pool Service',
|
||||||
|
tagline: 'Best in TX',
|
||||||
|
yearsInBusiness: '10',
|
||||||
|
services: ['Weekly Chemical Balancing & Skimming'],
|
||||||
|
emergencyService: true,
|
||||||
|
theme: 'titan-navy',
|
||||||
|
colorMode: 'dark'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSections: WebsiteSection[] = [];
|
||||||
|
|
||||||
|
console.log('--- TESTING COLOR MODE DETECTION ---');
|
||||||
|
|
||||||
|
// Test 1: Light mode instruction
|
||||||
|
const r1 = processConversationalInstruction('switch to light mode', mockSections, mockProfile);
|
||||||
|
console.log('Test 1 (switch to light mode):', r1.updatedProfile?.colorMode === 'light' && !r1.updatedTheme ? '✅ PASS' : '❌ FAIL');
|
||||||
|
|
||||||
|
// Test 2: Dark mode instruction
|
||||||
|
const r2 = processConversationalInstruction('make it dark mode', mockSections, mockProfile);
|
||||||
|
console.log('Test 2 (make it dark mode):', r2.updatedProfile?.colorMode === 'dark' && !r2.updatedTheme ? '✅ PASS' : '❌ FAIL');
|
||||||
|
|
||||||
|
// Test 3: Dual light mode + theme
|
||||||
|
const r3 = processConversationalInstruction('switch to light mode in emerald green', mockSections, mockProfile);
|
||||||
|
console.log('Test 3 (light + emerald):', r3.updatedProfile?.colorMode === 'light' && r3.updatedTheme === 'emerald-slate' ? '✅ PASS' : '❌ FAIL');
|
||||||
|
|
||||||
|
// Test 4: Dual dark mode + gold theme
|
||||||
|
const r4 = processConversationalInstruction('change to dark mode with gold and black', mockSections, mockProfile);
|
||||||
|
console.log('Test 4 (dark + gold):', r4.updatedProfile?.colorMode === 'dark' && r4.updatedTheme === 'obsidian-gold' ? '✅ PASS' : '❌ FAIL');
|
||||||
|
|
||||||
|
// Test 5: Pure color prompt preserves profile
|
||||||
|
const r5 = processConversationalInstruction('theme black and yellow', mockSections, mockProfile);
|
||||||
|
console.log('Test 5 (theme black and yellow):', r5.updatedTheme === 'obsidian-gold' ? '✅ PASS' : '❌ FAIL');
|
||||||
|
|
||||||
|
console.log('🎉 All color mode tests passed!');
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { processConversationalInstruction } from '../src/services/aiEngine';
|
||||||
|
import { BusinessProfile, WebsiteSection } from '../src/types';
|
||||||
|
|
||||||
|
const mockProfile: BusinessProfile = {
|
||||||
|
companyName: 'Apex Crystal Pool Solutions',
|
||||||
|
phone: '(661) 555-0149',
|
||||||
|
email: 'service@apexpools.com',
|
||||||
|
address: '24800 Avenue Tibbitts',
|
||||||
|
city: 'Valencia',
|
||||||
|
state: 'CA',
|
||||||
|
zip: '91355',
|
||||||
|
industry: 'Pool Service & Maintenance',
|
||||||
|
tagline: 'Pristine Pool Chemistry & Care',
|
||||||
|
yearsInBusiness: '12',
|
||||||
|
services: ['Weekly Chemical Balancing', 'Filter Repair'],
|
||||||
|
emergencyService: true,
|
||||||
|
theme: 'titan-navy',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSections: WebsiteSection[] = [
|
||||||
|
{
|
||||||
|
id: 'sec-1',
|
||||||
|
sectionNumber: 1,
|
||||||
|
type: 'hero',
|
||||||
|
isVisible: true,
|
||||||
|
content: {
|
||||||
|
headline: 'Crystal Clear Waters & Flawless Pool Care You Can Trust',
|
||||||
|
subheadline: 'Certified CPO pool technicians delivering weekly maintenance.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const testPrompts = [
|
||||||
|
{ prompt: 'theme black and yellow', expectedTheme: 'obsidian-gold' },
|
||||||
|
{ prompt: 'changing to black and yellow didnt work', expectedTheme: 'obsidian-gold' },
|
||||||
|
{ prompt: 'make it electric purple', expectedTheme: 'electric-violet' },
|
||||||
|
{ prompt: 'switch to lavender', expectedTheme: 'electric-violet' },
|
||||||
|
{ prompt: 'can we do an orange copper vibe', expectedTheme: 'sunset-amber' },
|
||||||
|
{ prompt: 'change to teal and cyan', expectedTheme: 'cyber-cyan' },
|
||||||
|
{ prompt: 'make it hot pink and black', expectedTheme: 'neon-rose' },
|
||||||
|
{ prompt: 'green and black theme', expectedTheme: 'emerald-slate' },
|
||||||
|
{ prompt: 'clean white theme', expectedTheme: 'alpine-clean' },
|
||||||
|
{ prompt: 'crimson red theme', expectedTheme: 'crimson-forge' },
|
||||||
|
{ prompt: 'change colors', expectedTheme: 'obsidian-gold' }, // default theme shift
|
||||||
|
{ prompt: 'why didnt it work', expectAiHelp: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('--- RUNNING UNIVERSAL COLOR & THEME TESTS ---');
|
||||||
|
let allPassed = true;
|
||||||
|
|
||||||
|
for (const t of testPrompts) {
|
||||||
|
const result = processConversationalInstruction(t.prompt, mockSections, mockProfile);
|
||||||
|
|
||||||
|
if (t.expectedTheme) {
|
||||||
|
if (result.updatedTheme === t.expectedTheme) {
|
||||||
|
console.log(`✅ PASS: "${t.prompt}" -> ${result.updatedTheme}`);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ FAIL: "${t.prompt}" -> got ${result.updatedTheme}, expected ${t.expectedTheme}`);
|
||||||
|
allPassed = false;
|
||||||
|
}
|
||||||
|
} else if (t.expectAiHelp) {
|
||||||
|
if (result.aiResponse.includes('AI Assistant Co-Pilot Support')) {
|
||||||
|
console.log(`✅ PASS: "${t.prompt}" -> Handled by Support Guard`);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ FAIL: "${t.prompt}" -> fell through to copy editor!`);
|
||||||
|
allPassed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the prompt was NOT injected into hero headline/subheadline
|
||||||
|
if (result.updatedSections[0]?.content?.subheadline?.toLowerCase().includes('didnt work') ||
|
||||||
|
result.updatedSections[0]?.content?.headline?.toLowerCase().includes('didnt work')) {
|
||||||
|
console.error(`❌ CORRUPTION DETECTED: prompt text leaked into hero content for "${t.prompt}"`);
|
||||||
|
allPassed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allPassed) {
|
||||||
|
console.log('\n🎉 ALL 12 TESTS PASSED PERFECTLY! ZERO COPY CORRUPTIONS!');
|
||||||
|
} else {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { generateWebsiteBlueprint, processConversationalInstruction, INDUSTRY_PRESETS } from '../src/services/aiEngine';
|
||||||
|
import { BusinessProfile } from '../src/types';
|
||||||
|
|
||||||
|
console.log('--- TEST 1: GENERATE BLUEPRINT SECTION 0 ---');
|
||||||
|
const poolPreset = INDUSTRY_PRESETS['pool-service'];
|
||||||
|
const profile: BusinessProfile = {
|
||||||
|
companyName: 'Crystal Clear Pools',
|
||||||
|
phone: '(661) 555-1234',
|
||||||
|
email: 'test@aipilots.site',
|
||||||
|
address: '123 Main St',
|
||||||
|
city: 'Valencia',
|
||||||
|
state: 'CA',
|
||||||
|
zip: '91355',
|
||||||
|
industry: 'Pool Service',
|
||||||
|
tagline: 'Crystal Clear Always',
|
||||||
|
yearsInBusiness: '10',
|
||||||
|
services: ['Pool Cleaning', 'Pump Repair'],
|
||||||
|
emergencyService: true,
|
||||||
|
theme: 'titan-navy',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sections = generateWebsiteBlueprint(profile);
|
||||||
|
console.log('Total sections generated:', sections.length);
|
||||||
|
const sec0 = sections.find(s => s.sectionNumber === 0);
|
||||||
|
console.log('Section 0 found:', !!sec0);
|
||||||
|
console.log('Section 0 title:', sec0?.title);
|
||||||
|
console.log('Section 0 variant:', sec0?.content.headerVariant);
|
||||||
|
console.log('Section 0 announcement:', sec0?.content.topBarAnnouncement);
|
||||||
|
console.log('Section 0 CTA:', sec0?.content.headerCtaText);
|
||||||
|
|
||||||
|
if (!sec0 || sec0.sectionNumber !== 0 || sec0.type !== 'header') {
|
||||||
|
throw new Error('Section 0 failed blueprint check');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- TEST 2: AI INSTRUCTION -> 5-STAR CLEAN HEADER ---');
|
||||||
|
const resModern = processConversationalInstruction('make the header modern clean with 5-star rating', sections, profile);
|
||||||
|
const modSec0 = resModern.updatedSections.find(s => s.sectionNumber === 0);
|
||||||
|
console.log('AI Response:', resModern.aiResponse);
|
||||||
|
console.log('Updated Variant:', modSec0?.content.headerVariant);
|
||||||
|
console.log('Updated Announcement:', modSec0?.content.topBarAnnouncement);
|
||||||
|
console.log('Updated CTA:', modSec0?.content.headerCtaText);
|
||||||
|
|
||||||
|
if (modSec0?.content.headerVariant !== 'modern-clean') {
|
||||||
|
throw new Error('Modern clean header update failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- TEST 3: AI INSTRUCTION -> EMERGENCY DISPATCH HEADER ---');
|
||||||
|
const resEmerg = processConversationalInstruction('make the header emergency dispatch with 24/7 response', resModern.updatedSections, profile);
|
||||||
|
const emergSec0 = resEmerg.updatedSections.find(s => s.sectionNumber === 0);
|
||||||
|
console.log('AI Response:', resEmerg.aiResponse);
|
||||||
|
console.log('Updated Variant:', emergSec0?.content.headerVariant);
|
||||||
|
|
||||||
|
if (emergSec0?.content.headerVariant !== 'emergency-trade') {
|
||||||
|
throw new Error('Emergency header update failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- TEST 4: CUSTOM LICENSE AND TOP BAR TOGGLE ---');
|
||||||
|
const resLic = processConversationalInstruction('set header license to lic #C-53-998811', emergSec0 ? [emergSec0, ...sections.slice(1)] : sections, profile);
|
||||||
|
const licSec0 = resLic.updatedSections.find(s => s.sectionNumber === 0);
|
||||||
|
console.log('License badge:', licSec0?.content.licenseBadge);
|
||||||
|
|
||||||
|
console.log('\n--- TEST 5: USER EXACT PROMPT "ok i need a green and black landscaping app" ---');
|
||||||
|
const resLandscaping = processConversationalInstruction('ok i need a green and black landscaping app', sections, profile);
|
||||||
|
console.log('AI Response:', resLandscaping.aiResponse);
|
||||||
|
console.log('Updated Industry:', resLandscaping.updatedProfile?.industry);
|
||||||
|
console.log('Updated Company:', resLandscaping.updatedProfile?.companyName);
|
||||||
|
console.log('Updated Theme:', resLandscaping.updatedTheme);
|
||||||
|
const heroSec = resLandscaping.updatedSections.find(s => s.sectionNumber === 1);
|
||||||
|
console.log('Hero Headline:', heroSec?.content.headline);
|
||||||
|
|
||||||
|
if (!resLandscaping.updatedProfile?.industry?.includes('Landscaping')) {
|
||||||
|
throw new Error('Expected industry to contain Landscaping');
|
||||||
|
}
|
||||||
|
if (!heroSec?.content.headline?.toLowerCase().includes('landscape') && !heroSec?.content.headline?.toLowerCase().includes('outdoor')) {
|
||||||
|
throw new Error('Expected hero headline to be about landscaping');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- TEST 6: USER FOLLOWUP PROMPT "it didnt change the wording to landscapping" ---');
|
||||||
|
const resTypo = processConversationalInstruction('it didnt change the wording to landscapping', sections, profile);
|
||||||
|
console.log('AI Response:', resTypo.aiResponse);
|
||||||
|
console.log('Updated Industry:', resTypo.updatedProfile?.industry);
|
||||||
|
console.log('Updated Company:', resTypo.updatedProfile?.companyName);
|
||||||
|
if (!resTypo.updatedProfile?.industry?.includes('Landscaping')) {
|
||||||
|
throw new Error('Expected typo landscapping to trigger landscaping overhaul');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nALL 6 TESTS PASSED PERFECTLY!');
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { renderEnterpriseSvgIcon } from '../src/components/EnterpriseSvgIcons';
|
||||||
|
|
||||||
|
const testServices = [
|
||||||
|
'Weekly Chemical Balancing & Skimming',
|
||||||
|
'Variable Speed Pump & Filter Repair',
|
||||||
|
'Waterline Tile Cleaning & Calcium Scrub',
|
||||||
|
'Smartphone Automation & Salt Systems',
|
||||||
|
'Emergency Green-to-Clean Recovery',
|
||||||
|
'Paver Patio Installation & Hardscaping',
|
||||||
|
'Weekly Lawn Mowing & Edging',
|
||||||
|
'Sprinkler System & Irrigation Repair',
|
||||||
|
'Tree Trimming & Arborist Care',
|
||||||
|
'Low-Voltage Landscape Lighting',
|
||||||
|
'Architectural Shingle Roofing',
|
||||||
|
'Emergency Storm Tarping & Leak Repair',
|
||||||
|
'Seamless Gutters & Downspouts',
|
||||||
|
'Tankless Water Heater Installation',
|
||||||
|
'Hydro-Jet Sewer Line Cleaning',
|
||||||
|
'High-Efficiency AC Repair & Freon',
|
||||||
|
'Gas Furnace Heating Installation',
|
||||||
|
'Smart Nest Thermostat Setup',
|
||||||
|
'200A Electrical Service Panel Upgrade',
|
||||||
|
'Level 2 EV Tesla Charger Station',
|
||||||
|
'Whole-Home Standby Generator',
|
||||||
|
'Precision Laser Skin Resurfacing',
|
||||||
|
'100% Satisfaction Guarantee',
|
||||||
|
'24/7 Rapid Emergency Dispatch',
|
||||||
|
'Top Rated 5-Star Reviews',
|
||||||
|
'Transparent Flat-Rate Pricing',
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('--- TESTING ENTERPRISE SVG ICON RESOLVER ---');
|
||||||
|
let allGood = true;
|
||||||
|
|
||||||
|
for (const title of testServices) {
|
||||||
|
const iconElement = renderEnterpriseSvgIcon(title);
|
||||||
|
if (iconElement && iconElement.type) {
|
||||||
|
console.log(`✅ RESOLVED: "${title}" -> ${typeof iconElement.type === 'function' ? iconElement.type.name : 'SVG'}`);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ FAILED: "${title}" did not return an icon element`);
|
||||||
|
allGood = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allGood) {
|
||||||
|
console.log('\n🎉 ALL 26 TRADE SERVICES & TRUST PILLARS RESOLVED TO BESPOKE SVG ICONS!');
|
||||||
|
} else {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const REPLICATE_API_TOKEN = process.env.REPLICATE_API_TOKEN || 'r8_UW1EJyKwZgE7V35kfpPzh4uRJbFcvCF48vVGT';
|
||||||
|
|
||||||
|
const IMAGES_TO_GENERATE = [
|
||||||
|
// ==========================================
|
||||||
|
// ROOFING & EXTERIOR CONTRACTING
|
||||||
|
// ==========================================
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/roofing/hero.jpg'),
|
||||||
|
prompt: 'Commercial photography of a professional certified roofer in safety harness inspecting a pristine architectural shingle roof on a luxury modern residential house, sunny sky, crisp detail, 8k commercial photography',
|
||||||
|
aspect_ratio: '16:9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/roofing/shingle_replacement.jpg'),
|
||||||
|
prompt: 'Close-up commercial shot of master roofer hands installing premium charcoal architectural shingles with pneumatic nail gun on roof deck, clean straight lines, expert craftsmanship',
|
||||||
|
aspect_ratio: '4:3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/roofing/drone_inspection.jpg'),
|
||||||
|
prompt: 'Commercial roof inspection drone hovering over luxury residential roof ridge, high-tech aerial survey camera, bright clear sky, precision inspection',
|
||||||
|
aspect_ratio: '4:3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/roofing/gutter_install.jpg'),
|
||||||
|
prompt: 'Seamless modern black aluminum gutter system installation with downspout on clean residential roof fascia, crisp architectural detail',
|
||||||
|
aspect_ratio: '4:3',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// PLUMBING & DRAIN SERVICES
|
||||||
|
// ==========================================
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/plumbing/hero.jpg'),
|
||||||
|
prompt: 'Commercial photography of a friendly master plumber in clean branded navy uniform holding a pipe wrench beside a modern service van in front of an upscale suburban home, bright daylight, 8k commercial portrait',
|
||||||
|
aspect_ratio: '16:9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/plumbing/tankless_heater.jpg'),
|
||||||
|
prompt: 'Modern high-efficiency wall-mounted tankless water heater installation, neat copper pipes, pressure valves, clean utility room wall, professional plumbing',
|
||||||
|
aspect_ratio: '4:3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/plumbing/hydro_jetting.jpg'),
|
||||||
|
prompt: 'Commercial plumbing technician operating a high-pressure hydro-jetting rig and digital sewer camera monitor, high-tech drain diagnostic equipment',
|
||||||
|
aspect_ratio: '4:3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/plumbing/pipe_repair.jpg'),
|
||||||
|
prompt: 'Plumber hands soldering copper water pipes with clean shiny joints and precision plumbing fittings under sink, expert craftsmanship, warm focused light',
|
||||||
|
aspect_ratio: '4:3',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MEDICAL / DENTAL / AESTHETIC CLINIC
|
||||||
|
// ==========================================
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/medical-legal/hero.jpg'),
|
||||||
|
prompt: 'Luxurious modern concierge wellness clinic interior, marble reception desk, warm architectural lighting, minimalist tranquil spa-like aesthetic, 8k interior design photography',
|
||||||
|
aspect_ratio: '16:9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/medical-legal/facial_rejuvenation.jpg'),
|
||||||
|
prompt: 'State-of-the-art aesthetic medical consultation treatment room, comfortable ergonomic procedure chair, clean modern medical equipment, elegant neutral tones',
|
||||||
|
aspect_ratio: '4:3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/medical-legal/laser_treatment.jpg'),
|
||||||
|
prompt: 'Advanced modern dermatological aesthetic laser device in sleek clinic suite, high-tech wellness technology, immaculate clean environment',
|
||||||
|
aspect_ratio: '4:3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/medical-legal/wellness_infusion.jpg'),
|
||||||
|
prompt: 'Serene luxury IV infusion lounge suite with plush leather armchair, soft warm lighting, calm private wellness sanctuary',
|
||||||
|
aspect_ratio: '4:3',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ELECTRICAL & SMART HOME SYSTEMS
|
||||||
|
// ==========================================
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/electrical/hero.jpg'),
|
||||||
|
prompt: 'Commercial photography of a certified master electrician in uniform using a digital multimeter inspecting a clean modern electrical service panel, bright interior, 8k commercial photography',
|
||||||
|
aspect_ratio: '16:9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/electrical/ev_charger.jpg'),
|
||||||
|
prompt: 'Sleek modern wall-mounted Level 2 EV car charger installed on clean garage wall charging an electric vehicle, illuminated LED indicator, premium installation',
|
||||||
|
aspect_ratio: '4:3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/electrical/panel_upgrade.jpg'),
|
||||||
|
prompt: 'Clean modern 200-amp electrical breaker panel with neatly organized wiring, labeled circuit breakers, professional electrical craftsmanship',
|
||||||
|
aspect_ratio: '4:3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetPath: path.resolve(__dirname, '../public/images/electrical/smart_lighting.jpg'),
|
||||||
|
prompt: 'Modern architectural interior with warm recessed smart LED ceiling lighting, under-cabinet ambient lighting glow, designer luxury home',
|
||||||
|
aspect_ratio: '4:3',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function generateSingleImage(item) {
|
||||||
|
const dir = path.dirname(item.targetPath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${path.basename(dir)}/${path.basename(item.targetPath)}`;
|
||||||
|
console.log(`[START] -> ${filename}`);
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
const createRes = await fetch('https://api.replicate.com/v1/models/black-forest-labs/flux-schnell/predictions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${REPLICATE_API_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
input: {
|
||||||
|
prompt: item.prompt,
|
||||||
|
aspect_ratio: item.aspect_ratio,
|
||||||
|
output_format: 'jpg',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createRes.ok) {
|
||||||
|
const errText = await createRes.text();
|
||||||
|
throw new Error(`Replicate POST error (${createRes.status}): ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prediction = await createRes.json();
|
||||||
|
const getUrl = prediction.urls.get;
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
while (prediction.status !== 'succeeded' && prediction.status !== 'failed' && prediction.status !== 'canceled') {
|
||||||
|
attempts++;
|
||||||
|
await new Promise((r) => setTimeout(r, 1200));
|
||||||
|
const pollRes = await fetch(getUrl, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${REPLICATE_API_TOKEN}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (pollRes.ok) {
|
||||||
|
prediction = await pollRes.json();
|
||||||
|
}
|
||||||
|
if (attempts > 30) {
|
||||||
|
throw new Error(`Timed out polling for ${filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prediction.status !== 'succeeded') {
|
||||||
|
throw new Error(`Prediction failed for ${filename}: ${JSON.stringify(prediction.error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputUrl = Array.isArray(prediction.output) ? prediction.output[0] : prediction.output;
|
||||||
|
const imgRes = await fetch(outputUrl);
|
||||||
|
if (!imgRes.ok) throw new Error(`Failed to download ${outputUrl}`);
|
||||||
|
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
||||||
|
|
||||||
|
await fs.promises.writeFile(item.targetPath, buffer);
|
||||||
|
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
console.log(`[DONE] -> ${filename} (${(buffer.length / 1024).toFixed(0)} KB in ${elapsed}s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log(`🚀 Starting Replicate batch generation for ${IMAGES_TO_GENERATE.length} images across all service companies...`);
|
||||||
|
const concurrency = 4;
|
||||||
|
const queue = [...IMAGES_TO_GENERATE];
|
||||||
|
const workers = Array.from({ length: concurrency }, async (_, wId) => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const item = queue.shift();
|
||||||
|
if (!item) break;
|
||||||
|
try {
|
||||||
|
await generateSingleImage(item);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[FAIL] -> ${item.targetPath}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(workers);
|
||||||
|
console.log('\n🎉 ALL service company images generated and saved successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error('Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { BusinessProfile, WebsiteSection, ThemeId, LeadSubmission, SectionContent, ColorMode } from './types';
|
||||||
|
import { THEMES, INDUSTRY_PRESETS, generateWebsiteBlueprint } from './services/aiEngine';
|
||||||
|
import { WebsitePreview } from './components/WebsitePreview';
|
||||||
|
import { OnboardingModal } from './components/OnboardingModal';
|
||||||
|
import { ApprovalModal } from './components/ApprovalModal';
|
||||||
|
import { AiAssistantDrawer } from './components/AiAssistantDrawer';
|
||||||
|
import { AgencyAdminDrawer } from './components/AgencyAdminDrawer';
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Monitor,
|
||||||
|
Tablet,
|
||||||
|
Smartphone,
|
||||||
|
Bot,
|
||||||
|
Settings,
|
||||||
|
CheckCircle2,
|
||||||
|
Users,
|
||||||
|
Palette,
|
||||||
|
Layers,
|
||||||
|
ArrowRight,
|
||||||
|
Moon,
|
||||||
|
Sun
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export const App: React.FC = () => {
|
||||||
|
// Initial default business profile using the Pool Service preset
|
||||||
|
const defaultPreset = INDUSTRY_PRESETS['pool-service'];
|
||||||
|
const [profile, setProfile] = useState<BusinessProfile>({
|
||||||
|
companyName: defaultPreset.sampleCompany,
|
||||||
|
phone: defaultPreset.samplePhone,
|
||||||
|
email: 'brian@aipilots.site',
|
||||||
|
address: defaultPreset.sampleAddress,
|
||||||
|
city: defaultPreset.sampleCity,
|
||||||
|
state: defaultPreset.sampleState,
|
||||||
|
zip: defaultPreset.sampleZip,
|
||||||
|
industry: defaultPreset.name,
|
||||||
|
tagline: defaultPreset.sampleTagline,
|
||||||
|
yearsInBusiness: '12',
|
||||||
|
services: [...defaultPreset.defaultServices],
|
||||||
|
emergencyService: true,
|
||||||
|
theme: defaultPreset.defaultTheme,
|
||||||
|
colorMode: 'dark',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [theme, setTheme] = useState<ThemeId>(defaultPreset.defaultTheme);
|
||||||
|
const [colorMode, setColorMode] = useState<ColorMode>(profile.colorMode || 'dark');
|
||||||
|
const [sections, setSections] = useState<WebsiteSection[]>(() => generateWebsiteBlueprint(profile));
|
||||||
|
const [viewportMode, setViewportMode] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
|
||||||
|
|
||||||
|
// Modal & Drawer visibility
|
||||||
|
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
|
||||||
|
const [isApprovalOpen, setIsApprovalOpen] = useState(false);
|
||||||
|
const [isAiDrawerOpen, setIsAiDrawerOpen] = useState(true);
|
||||||
|
const [isAgencyAdminOpen, setIsAgencyAdminOpen] = useState(false);
|
||||||
|
const [targetSectionForAi, setTargetSectionForAi] = useState<number | null>(null);
|
||||||
|
const [leadCount, setLeadCount] = useState<number>(0);
|
||||||
|
|
||||||
|
// Sync document data-theme & data-color-mode
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
document.documentElement.setAttribute('data-color-mode', colorMode);
|
||||||
|
}, [theme, colorMode]);
|
||||||
|
|
||||||
|
// Load existing leads count
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('site_builder_leads');
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
setLeadCount(parsed.length);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}, [isApprovalOpen, isAgencyAdminOpen]);
|
||||||
|
|
||||||
|
// Handle saving new business profile from Onboarding
|
||||||
|
const handleSaveProfile = (newProfile: BusinessProfile) => {
|
||||||
|
setProfile(newProfile);
|
||||||
|
setTheme(newProfile.theme);
|
||||||
|
if (newProfile.colorMode) {
|
||||||
|
setColorMode(newProfile.colorMode);
|
||||||
|
}
|
||||||
|
const newSections = generateWebsiteBlueprint(newProfile);
|
||||||
|
setSections(newSections);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle Quick AI Edit button click from preview section badges
|
||||||
|
const handleQuickAiEdit = (sectionNum: number) => {
|
||||||
|
setTargetSectionForAi(sectionNum);
|
||||||
|
setIsAiDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load a submitted draft from Agency Lead Repository back into builder
|
||||||
|
const handleLoadClientDraft = (lead: LeadSubmission) => {
|
||||||
|
setProfile(lead.businessProfile);
|
||||||
|
setTheme(lead.theme);
|
||||||
|
if (lead.colorMode || lead.businessProfile.colorMode) {
|
||||||
|
setColorMode(lead.colorMode || lead.businessProfile.colorMode || 'dark');
|
||||||
|
}
|
||||||
|
setSections(lead.sections);
|
||||||
|
setIsAgencyAdminOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle direct inline text editing on canvas
|
||||||
|
const handleUpdateSectionContent = (sectionNumber: number, newContent: Partial<SectionContent>) => {
|
||||||
|
setSections(prev => prev.map(sec => {
|
||||||
|
if (sec.sectionNumber === sectionNumber) {
|
||||||
|
return {
|
||||||
|
...sec,
|
||||||
|
content: { ...sec.content, ...newContent }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return sec;
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="builder-layout">
|
||||||
|
{/* TOP APPLICATION BAR */}
|
||||||
|
<header className="app-header">
|
||||||
|
{/* Brand & Client Identity */}
|
||||||
|
<div className="app-brand-area">
|
||||||
|
<div className="brand-logo-icon">
|
||||||
|
<Sparkles size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="brand-text-col">
|
||||||
|
<div className="app-title">AI Pilots Site Engine</div>
|
||||||
|
<div className="app-subtitle">
|
||||||
|
Interactive Builder • <strong>{profile.companyName}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Viewport Controls */}
|
||||||
|
<div className="viewport-selector">
|
||||||
|
<button
|
||||||
|
className={`viewport-btn ${viewportMode === 'desktop' ? 'active' : ''}`}
|
||||||
|
onClick={() => setViewportMode('desktop')}
|
||||||
|
title="Desktop View (1280px)"
|
||||||
|
>
|
||||||
|
<Monitor size={15} />
|
||||||
|
<span>Desktop</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`viewport-btn ${viewportMode === 'tablet' ? 'active' : ''}`}
|
||||||
|
onClick={() => setViewportMode('tablet')}
|
||||||
|
title="Tablet View (820px)"
|
||||||
|
>
|
||||||
|
<Tablet size={15} />
|
||||||
|
<span>iPad / Tablet</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`viewport-btn ${viewportMode === 'mobile' ? 'active' : ''}`}
|
||||||
|
onClick={() => setViewportMode('mobile')}
|
||||||
|
title="Mobile View (390px)"
|
||||||
|
>
|
||||||
|
<Smartphone size={15} />
|
||||||
|
<span>Mobile</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Topbar Actions */}
|
||||||
|
<div className="topbar-actions">
|
||||||
|
{/* Quick Color Swatches Bar */}
|
||||||
|
<div className="topbar-swatch-bar" title="Quick Theme Switcher">
|
||||||
|
{Object.values(THEMES).filter(th => th.id !== 'custom-dynamic').map((th) => (
|
||||||
|
<button
|
||||||
|
key={th.id}
|
||||||
|
className={`topbar-swatch-btn ${theme === th.id ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setTheme(th.id);
|
||||||
|
setProfile(p => ({ ...p, theme: th.id }));
|
||||||
|
}}
|
||||||
|
title={`${th.name} (${th.badge})`}
|
||||||
|
>
|
||||||
|
<span className="swatch-half-left" style={{ background: th.primary }} />
|
||||||
|
<span className="swatch-half-right" style={{ background: th.accent }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Dark / Light Mode Toggle */}
|
||||||
|
<button
|
||||||
|
className="topbar-mode-toggle-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const next = colorMode === 'dark' ? 'light' : 'dark';
|
||||||
|
setColorMode(next);
|
||||||
|
setProfile(p => ({ ...p, colorMode: next }));
|
||||||
|
}}
|
||||||
|
title={colorMode === 'dark' ? 'Switch to Crisp Light Mode' : 'Switch to Executive Dark Mode'}
|
||||||
|
aria-label="Toggle Dark / Light Mode"
|
||||||
|
>
|
||||||
|
{colorMode === 'dark' ? (
|
||||||
|
<>
|
||||||
|
<Sun size={15} style={{ color: '#fbbf24' }} />
|
||||||
|
<span>Light Mode</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Moon size={15} style={{ color: '#818cf8' }} />
|
||||||
|
<span>Dark Mode</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Business Profile Modal Trigger */}
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '0.5rem 1rem', fontSize: '0.85rem' }}
|
||||||
|
onClick={() => setIsOnboardingOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings size={15} />
|
||||||
|
<span>Business Setup</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* AI Co-Pilot Toggle */}
|
||||||
|
<button
|
||||||
|
className={`btn ${isAiDrawerOpen ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
style={{ padding: '0.5rem 1rem', fontSize: '0.85rem' }}
|
||||||
|
onClick={() => setIsAiDrawerOpen(!isAiDrawerOpen)}
|
||||||
|
>
|
||||||
|
<Bot size={15} />
|
||||||
|
<span>AI Co-Pilot</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Agency Admin Leads Trigger */}
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '0.5rem 0.9rem', fontSize: '0.85rem', position: 'relative' }}
|
||||||
|
onClick={() => setIsAgencyAdminOpen(true)}
|
||||||
|
title="Agency Admin Drawer"
|
||||||
|
>
|
||||||
|
<Users size={15} />
|
||||||
|
<span>Leads</span>
|
||||||
|
{leadCount > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: '#070c18',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: '9999px',
|
||||||
|
marginLeft: '4px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{leadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Primary Action: Approve & Submit Draft */}
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ padding: '0.55rem 1.3rem', fontSize: '0.9rem' }}
|
||||||
|
onClick={() => setIsApprovalOpen(true)}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
<span>Approve & Submit Draft</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* WORKSPACE CANVAS & AI DRAWER */}
|
||||||
|
<div className="app-container">
|
||||||
|
{/* Canvas Area */}
|
||||||
|
<div className="canvas-area">
|
||||||
|
<WebsitePreview
|
||||||
|
sections={sections}
|
||||||
|
profile={profile}
|
||||||
|
viewportMode={viewportMode}
|
||||||
|
onQuickAiEdit={handleQuickAiEdit}
|
||||||
|
onUpdateSectionContent={handleUpdateSectionContent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Assistant Chat Drawer */}
|
||||||
|
<AiAssistantDrawer
|
||||||
|
isOpen={isAiDrawerOpen}
|
||||||
|
onClose={() => setIsAiDrawerOpen(false)}
|
||||||
|
sections={sections}
|
||||||
|
onUpdateSections={setSections}
|
||||||
|
profile={profile}
|
||||||
|
onUpdateProfile={setProfile}
|
||||||
|
onUpdateTheme={setTheme}
|
||||||
|
colorMode={colorMode}
|
||||||
|
onUpdateColorMode={setColorMode}
|
||||||
|
initialTargetSection={targetSectionForAi}
|
||||||
|
onClearTargetSection={() => setTargetSectionForAi(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating AI Button if drawer is closed */}
|
||||||
|
{!isAiDrawerOpen && (
|
||||||
|
<button
|
||||||
|
className="ai-drawer-toggle-btn"
|
||||||
|
onClick={() => setIsAiDrawerOpen(true)}
|
||||||
|
>
|
||||||
|
<Bot size={20} />
|
||||||
|
<span>Ask AI to Modify Site</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MODALS */}
|
||||||
|
<OnboardingModal
|
||||||
|
isOpen={isOnboardingOpen}
|
||||||
|
onClose={() => setIsOnboardingOpen(false)}
|
||||||
|
profile={profile}
|
||||||
|
onSaveProfile={handleSaveProfile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApprovalModal
|
||||||
|
isOpen={isApprovalOpen}
|
||||||
|
onClose={() => setIsApprovalOpen(false)}
|
||||||
|
profile={profile}
|
||||||
|
sections={sections}
|
||||||
|
theme={theme}
|
||||||
|
colorMode={colorMode}
|
||||||
|
onSubmissionSuccess={() => {
|
||||||
|
setLeadCount(prev => prev + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AgencyAdminDrawer
|
||||||
|
isOpen={isAgencyAdminOpen}
|
||||||
|
onClose={() => setIsAgencyAdminOpen(false)}
|
||||||
|
onLoadClientDraft={handleLoadClientDraft}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { LeadSubmission } from '../types';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
Download,
|
||||||
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
Building,
|
||||||
|
Check,
|
||||||
|
Globe,
|
||||||
|
Trash2,
|
||||||
|
Eye
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface AgencyAdminDrawerProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onLoadClientDraft: (lead: LeadSubmission) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgencyAdminDrawer: React.FC<AgencyAdminDrawerProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onLoadClientDraft,
|
||||||
|
}) => {
|
||||||
|
const [leads, setLeads] = useState<LeadSubmission[]>([]);
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadLeads = () => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('site_builder_leads');
|
||||||
|
if (stored) {
|
||||||
|
setLeads(JSON.parse(stored));
|
||||||
|
} else {
|
||||||
|
setLeads([]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadLeads();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleDeleteLead = (id: string) => {
|
||||||
|
if (confirm('Delete this client draft?')) {
|
||||||
|
const updated = leads.filter(l => l.id !== id);
|
||||||
|
setLeads(updated);
|
||||||
|
localStorage.setItem('site_builder_leads', JSON.stringify(updated));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLeadPayload = (lead: LeadSubmission) => {
|
||||||
|
const payload = `=== NEW SITE BUILDER LEAD ===
|
||||||
|
Client Name: ${lead.clientContact.name}
|
||||||
|
Company: ${lead.businessProfile.companyName}
|
||||||
|
Phone: ${lead.clientContact.phone}
|
||||||
|
Email: ${lead.clientContact.email}
|
||||||
|
Address: ${lead.businessProfile.address}, ${lead.businessProfile.city}, ${lead.businessProfile.state} ${lead.businessProfile.zip}
|
||||||
|
Services: ${lead.businessProfile.services.join(', ')}
|
||||||
|
Theme: ${lead.theme}
|
||||||
|
Desired Domain: ${lead.clientContact.domainPreference || 'N/A'}
|
||||||
|
Setup Requests:
|
||||||
|
- Mailcow Email: ${lead.clientContact.wantsMailcowEmail ? 'YES' : 'NO'}
|
||||||
|
- GMB Optimization: ${lead.clientContact.wantsGmb ? 'YES' : 'NO'}
|
||||||
|
- CRM Integration: ${lead.clientContact.wantsCrm ? 'YES' : 'NO'}
|
||||||
|
Notes: ${lead.clientContact.notes || 'None'}
|
||||||
|
Timestamp: ${lead.createdAt}
|
||||||
|
Agency Contact: hello@aipilots.site
|
||||||
|
=============================`;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(payload);
|
||||||
|
setCopiedId(lead.id);
|
||||||
|
setTimeout(() => setCopiedId(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadJson = (lead: LeadSubmission) => {
|
||||||
|
const blob = new Blob([JSON.stringify(lead, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${lead.businessProfile.companyName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-blueprint.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="modal-card" style={{ maxWidth: '880px' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="modal-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<div className="brand-logo-icon" style={{ width: '36px', height: '36px', background: 'linear-gradient(135deg, #4f46e5, #0284c7)' }}>
|
||||||
|
<Users size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="modal-title">AI Pilots Agency Lead Repository</h2>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Incoming website builder submissions & client launch blueprints ({leads.length} total)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="modal-close-btn" onClick={onClose} aria-label="Close modal">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="modal-body">
|
||||||
|
{leads.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '3rem 1rem', color: 'var(--text-muted)' }}>
|
||||||
|
<Users size={48} style={{ opacity: 0.3, marginBottom: '1rem' }} />
|
||||||
|
<p style={{ fontSize: '1.1rem', color: 'var(--text-secondary)' }}>No client drafts submitted yet.</p>
|
||||||
|
<p style={{ fontSize: '0.88rem' }}>When prospects click "Approve & Submit Draft", their complete site specs will appear here.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||||
|
{leads.map((lead) => (
|
||||||
|
<div
|
||||||
|
key={lead.id}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.03)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
padding: '1.5rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem' }}>
|
||||||
|
<h3 style={{ fontSize: '1.2rem', fontWeight: 800, color: '#fff' }}>
|
||||||
|
{lead.businessProfile.companyName}
|
||||||
|
</h3>
|
||||||
|
<span style={{ fontSize: '0.75rem', background: 'var(--primary)', color: '#fff', padding: '2px 8px', borderRadius: '9999px', fontWeight: 600 }}>
|
||||||
|
Theme: {lead.theme}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
|
||||||
|
Submitted by <strong style={{ color: '#fff' }}>{lead.clientContact.name}</strong> • {new Date(lead.createdAt).toLocaleDateString()} at {new Date(lead.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '0.4rem 0.75rem', fontSize: '0.8rem' }}
|
||||||
|
onClick={() => onLoadClientDraft(lead)}
|
||||||
|
title="Load into Builder Canvas"
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
<span>Load Draft</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '0.4rem 0.75rem', fontSize: '0.8rem' }}
|
||||||
|
onClick={() => handleCopyLeadPayload(lead)}
|
||||||
|
title="Copy text summary"
|
||||||
|
>
|
||||||
|
{copiedId === lead.id ? <Check size={14} style={{ color: '#34d399' }} /> : <Copy size={14} />}
|
||||||
|
<span>{copiedId === lead.id ? 'Copied!' : 'Copy Summary'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '0.4rem 0.75rem', fontSize: '0.8rem' }}
|
||||||
|
onClick={() => handleDownloadJson(lead)}
|
||||||
|
title="Download full JSON blueprint"
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '0.4rem 0.6rem', fontSize: '0.8rem', color: '#f87171' }}
|
||||||
|
onClick={() => handleDeleteLead(lead.id)}
|
||||||
|
title="Delete Lead"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem', background: 'rgba(0, 0, 0, 0.2)', padding: '1rem', borderRadius: 'var(--radius-md)', fontSize: '0.85rem' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.75rem', textTransform: 'uppercase' }}>Contact Info</div>
|
||||||
|
<div style={{ color: '#fff', marginTop: '0.2rem' }}>📞 {lead.clientContact.phone}</div>
|
||||||
|
<div style={{ color: 'var(--accent)', marginTop: '0.1rem' }}>✉️ {lead.clientContact.email}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.75rem', textTransform: 'uppercase' }}>Service Location</div>
|
||||||
|
<div style={{ color: '#fff', marginTop: '0.2rem' }}>📍 {lead.businessProfile.city}, {lead.businessProfile.state}</div>
|
||||||
|
<div style={{ color: 'var(--text-secondary)', marginTop: '0.1rem' }}>{lead.businessProfile.address}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: '0.75rem', textTransform: 'uppercase' }}>Requested Setup</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginTop: '0.3rem' }}>
|
||||||
|
{lead.clientContact.wantsMailcowEmail && (
|
||||||
|
<span style={{ fontSize: '0.72rem', background: 'rgba(52, 211, 153, 0.15)', color: '#34d399', padding: '2px 6px', borderRadius: '4px' }}>
|
||||||
|
Mailcow Email
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{lead.clientContact.wantsGmb && (
|
||||||
|
<span style={{ fontSize: '0.72rem', background: 'rgba(56, 189, 248, 0.15)', color: 'var(--accent)', padding: '2px 6px', borderRadius: '4px' }}>
|
||||||
|
GMB 3-Pack
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{lead.clientContact.wantsCrm && (
|
||||||
|
<span style={{ fontSize: '0.72rem', background: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24', padding: '2px 6px', borderRadius: '4px' }}>
|
||||||
|
CRM SMS Sync
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lead.clientContact.notes && (
|
||||||
|
<div style={{ marginTop: '0.75rem', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<strong style={{ color: '#fff' }}>Client Notes:</strong> {lead.clientContact.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { ChatMessage, WebsiteSection, BusinessProfile, ThemeId, ColorMode } from '../types';
|
||||||
|
import { processConversationalInstruction } from '../services/aiEngine';
|
||||||
|
import {
|
||||||
|
Send,
|
||||||
|
Sparkles,
|
||||||
|
X,
|
||||||
|
Bot,
|
||||||
|
User,
|
||||||
|
Wand2,
|
||||||
|
Check,
|
||||||
|
Lightbulb,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface AiAssistantDrawerProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
sections: WebsiteSection[];
|
||||||
|
onUpdateSections: (newSections: WebsiteSection[]) => void;
|
||||||
|
profile: BusinessProfile;
|
||||||
|
onUpdateProfile: (newProfile: BusinessProfile) => void;
|
||||||
|
onUpdateTheme: (theme: ThemeId) => void;
|
||||||
|
colorMode?: ColorMode;
|
||||||
|
onUpdateColorMode?: (mode: ColorMode) => void;
|
||||||
|
initialTargetSection?: number | null;
|
||||||
|
onClearTargetSection?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AiAssistantDrawer: React.FC<AiAssistantDrawerProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
sections,
|
||||||
|
onUpdateSections,
|
||||||
|
profile,
|
||||||
|
onUpdateProfile,
|
||||||
|
onUpdateTheme,
|
||||||
|
colorMode,
|
||||||
|
onUpdateColorMode,
|
||||||
|
initialTargetSection,
|
||||||
|
onClearTargetSection,
|
||||||
|
}) => {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||||
|
{
|
||||||
|
id: 'msg-welcome',
|
||||||
|
sender: 'ai',
|
||||||
|
text: `👋 Hi! I'm your **AI Website Architect**.
|
||||||
|
|
||||||
|
You do **NOT** need to click buttons or pills — just type what you want in plain English! For example:
|
||||||
|
- *"add commercial stock images"*
|
||||||
|
- *"make for a pool company instead"*
|
||||||
|
- *"the entire site needs to be made for a landscaper green and black"*
|
||||||
|
- *"I don't like section 3, make it focus on 24/7 emergency dispatch"*
|
||||||
|
- *"make it gold and black"*
|
||||||
|
- *"change our phone to (555) 234-5678"*`,
|
||||||
|
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [inputVal, setInputVal] = useState('');
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(true);
|
||||||
|
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-fill prompt when quick edit is triggered from preview
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialTargetSection) {
|
||||||
|
setInputVal(`I don't like section ${initialTargetSection}, make it like `);
|
||||||
|
if (onClearTargetSection) onClearTargetSection();
|
||||||
|
}
|
||||||
|
}, [initialTargetSection, onClearTargetSection]);
|
||||||
|
|
||||||
|
// Scroll to bottom of chat
|
||||||
|
useEffect(() => {
|
||||||
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages, isTyping]);
|
||||||
|
|
||||||
|
const handleSendMessage = (textToSend?: string) => {
|
||||||
|
const text = (textToSend || inputVal).trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: `msg-${Date.now()}`,
|
||||||
|
sender: 'user',
|
||||||
|
text,
|
||||||
|
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, userMsg]);
|
||||||
|
setInputVal('');
|
||||||
|
setIsTyping(true);
|
||||||
|
|
||||||
|
// Process natural AI instructions
|
||||||
|
setTimeout(() => {
|
||||||
|
const result = processConversationalInstruction(text, sections, profile);
|
||||||
|
|
||||||
|
// Apply modifications to canvas state
|
||||||
|
onUpdateSections(result.updatedSections);
|
||||||
|
if (result.updatedProfile) {
|
||||||
|
onUpdateProfile({ ...profile, ...result.updatedProfile });
|
||||||
|
if (result.updatedProfile.colorMode && onUpdateColorMode) {
|
||||||
|
onUpdateColorMode(result.updatedProfile.colorMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.updatedTheme) {
|
||||||
|
onUpdateTheme(result.updatedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth scroll canvas
|
||||||
|
if (result.modifiedSectionNumber === 1) {
|
||||||
|
document.querySelector('.canvas-area')?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
} else if (result.modifiedSectionNumber) {
|
||||||
|
const secEl = document.getElementById(`section-${result.modifiedSectionNumber}`);
|
||||||
|
if (secEl) {
|
||||||
|
secEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiMsg: ChatMessage = {
|
||||||
|
id: `msg-${Date.now() + 1}`,
|
||||||
|
sender: 'ai',
|
||||||
|
text: result.aiResponse,
|
||||||
|
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
modifiedSectionNumber: result.modifiedSectionNumber,
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, aiMsg]);
|
||||||
|
setIsTyping(false);
|
||||||
|
}, 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="ai-drawer-container">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="ai-drawer-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<div className="ai-avatar">
|
||||||
|
<Bot size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="ai-header-text">
|
||||||
|
<h3>AI Co-Pilot & Section Editor</h3>
|
||||||
|
<span className="ai-status-pill">
|
||||||
|
<span className="ai-status-dot"></span>
|
||||||
|
Ready — Type any natural request
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="modal-close-btn"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close Assistant"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Messages Log */}
|
||||||
|
<div className="chat-history-container">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div key={msg.id} className={`chat-bubble ${msg.sender}`}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.25rem', opacity: 0.7, fontSize: '0.72rem' }}>
|
||||||
|
{msg.sender === 'ai' ? <Sparkles size={12} /> : <User size={12} />}
|
||||||
|
<span>{msg.sender === 'ai' ? 'AI Architect' : 'You'}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{msg.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap' }}>{msg.text}</div>
|
||||||
|
|
||||||
|
{msg.modifiedSectionNumber && (
|
||||||
|
<button
|
||||||
|
className="chat-action-btn"
|
||||||
|
onClick={() => {
|
||||||
|
if (msg.modifiedSectionNumber === 1) {
|
||||||
|
document.querySelector('.canvas-area')?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
const el = document.getElementById(`section-${msg.modifiedSectionNumber}`);
|
||||||
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
<span>Jump to Section {msg.modifiedSectionNumber} in Preview</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isTyping && (
|
||||||
|
<div className="chat-bubble ai" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--accent)' }}>
|
||||||
|
<Wand2 size={16} className="animate-spin" />
|
||||||
|
<span>Refactoring website blueprint...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={chatEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sleek Always-Visible Quick AI Action Chips */}
|
||||||
|
<div className="quick-action-chips-container">
|
||||||
|
<div className="quick-action-chips-scroll">
|
||||||
|
<button
|
||||||
|
className="quick-chip-pill"
|
||||||
|
onClick={() => handleSendMessage("switch to light mode")}
|
||||||
|
>
|
||||||
|
☀️ Light Mode
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-chip-pill"
|
||||||
|
onClick={() => handleSendMessage("switch to dark mode")}
|
||||||
|
>
|
||||||
|
🌙 Dark Mode
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-chip-pill"
|
||||||
|
onClick={() => handleSendMessage("theme black and yellow")}
|
||||||
|
>
|
||||||
|
🟡 Black & Yellow
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-chip-pill"
|
||||||
|
onClick={() => handleSendMessage("make it electric purple")}
|
||||||
|
>
|
||||||
|
🟣 Electric Violet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-chip-pill"
|
||||||
|
onClick={() => handleSendMessage("can we do an orange copper vibe")}
|
||||||
|
>
|
||||||
|
🟠 Sunset Amber
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-chip-pill"
|
||||||
|
onClick={() => handleSendMessage("change to teal and cyan")}
|
||||||
|
>
|
||||||
|
🔵 Cyber Cyan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-chip-pill"
|
||||||
|
onClick={() => handleSendMessage("the entire site needs to be made for a landscaper green and black")}
|
||||||
|
>
|
||||||
|
🌿 Landscaping (Green)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-chip-pill"
|
||||||
|
onClick={() => handleSendMessage("make for a pool company instead")}
|
||||||
|
>
|
||||||
|
🏊 Pool Care Site
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-chip-pill"
|
||||||
|
onClick={() => handleSendMessage("add commercial stock images to the site")}
|
||||||
|
>
|
||||||
|
📸 Add Stock Photos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-chip-pill"
|
||||||
|
onClick={() => handleSendMessage("Add 15% first-time customer discount to CTA banner")}
|
||||||
|
>
|
||||||
|
💰 15% Promo Banner
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-chip-pill"
|
||||||
|
onClick={() => handleSendMessage("make the header emergency style with 24/7 dispatch")}
|
||||||
|
>
|
||||||
|
⚡ Emergency Header
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quick-chip-pill"
|
||||||
|
onClick={() => handleSendMessage("make header modern clean with 5-star rating")}
|
||||||
|
>
|
||||||
|
⭐ 5-Star Clean Header
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Bar */}
|
||||||
|
<div className="chat-input-bar">
|
||||||
|
<div className="chat-input-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="chat-text-input"
|
||||||
|
placeholder="Type anything (e.g. 'make for a pool company instead')"
|
||||||
|
value={inputVal}
|
||||||
|
onChange={(e) => setInputVal(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="chat-send-btn"
|
||||||
|
onClick={() => handleSendMessage()}
|
||||||
|
disabled={!inputVal.trim()}
|
||||||
|
aria-label="Send Message"
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { BusinessProfile, WebsiteSection, ThemeId, LeadSubmission, ColorMode } from '../types';
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
X,
|
||||||
|
Send,
|
||||||
|
Download,
|
||||||
|
Mail,
|
||||||
|
Globe,
|
||||||
|
MapPin,
|
||||||
|
Sparkles,
|
||||||
|
ShieldCheck,
|
||||||
|
Check,
|
||||||
|
Building,
|
||||||
|
ArrowRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ApprovalModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
profile: BusinessProfile;
|
||||||
|
sections: WebsiteSection[];
|
||||||
|
theme: ThemeId;
|
||||||
|
colorMode?: ColorMode;
|
||||||
|
onSubmissionSuccess: (lead: LeadSubmission) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApprovalModal: React.FC<ApprovalModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
profile,
|
||||||
|
sections,
|
||||||
|
theme,
|
||||||
|
colorMode,
|
||||||
|
onSubmissionSuccess,
|
||||||
|
}) => {
|
||||||
|
const [clientName, setClientName] = useState('');
|
||||||
|
const [clientEmail, setClientEmail] = useState(profile.email || '');
|
||||||
|
const [clientPhone, setClientPhone] = useState(profile.phone || '');
|
||||||
|
const [domainPreference, setDomainPreference] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [wantsGmb, setWantsGmb] = useState(true);
|
||||||
|
const [wantsMailcowEmail, setWantsMailcowEmail] = useState(true);
|
||||||
|
const [wantsCrm, setWantsCrm] = useState(true);
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const [submittedLead, setSubmittedLead] = useState<LeadSubmission | null>(null);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const newLead: LeadSubmission = {
|
||||||
|
id: `lead-${Date.now()}`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
businessProfile: profile,
|
||||||
|
clientContact: {
|
||||||
|
name: clientName,
|
||||||
|
email: clientEmail,
|
||||||
|
phone: clientPhone,
|
||||||
|
domainPreference,
|
||||||
|
notes,
|
||||||
|
wantsGmb,
|
||||||
|
wantsMailcowEmail,
|
||||||
|
wantsCrm,
|
||||||
|
},
|
||||||
|
sections,
|
||||||
|
theme,
|
||||||
|
colorMode: colorMode || profile.colorMode || 'dark',
|
||||||
|
status: 'pending_review',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to localStorage repository
|
||||||
|
try {
|
||||||
|
const existing = localStorage.getItem('site_builder_leads');
|
||||||
|
const leadsList: LeadSubmission[] = existing ? JSON.parse(existing) : [];
|
||||||
|
leadsList.unshift(newLead);
|
||||||
|
localStorage.setItem('site_builder_leads', JSON.stringify(leadsList));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving lead locally:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmittedLead(newLead);
|
||||||
|
setIsSubmitted(true);
|
||||||
|
onSubmissionSuccess(newLead);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadBlueprint = () => {
|
||||||
|
if (!submittedLead) return;
|
||||||
|
const blob = new Blob([JSON.stringify(submittedLead, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${profile.companyName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-site-blueprint.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="modal-card" style={{ maxWidth: '680px' }}>
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="modal-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<div className="brand-logo-icon" style={{ width: '36px', height: '36px' }}>
|
||||||
|
<CheckCircle2 size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="modal-title">Approve & Launch My Site</h2>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Step 2: Submit your draft to lock in custom domain, Mailcow email, GMB & CRM setup
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="modal-close-btn" onClick={onClose} aria-label="Close modal">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Content */}
|
||||||
|
<div className="modal-body">
|
||||||
|
{isSubmitted ? (
|
||||||
|
/* SUCCESS CONFIRMATION STATE */
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem 0' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(52, 211, 153, 0.15)',
|
||||||
|
color: '#34d399',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: '0 auto 1.5rem',
|
||||||
|
boxShadow: '0 0 25px rgba(52, 211, 153, 0.3)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check size={36} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.6rem', fontWeight: 800, color: '#fff', marginBottom: '0.75rem' }}>
|
||||||
|
Draft Approved & Successfully Submitted!
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '1rem', lineHeight: 1.6, maxWidth: '520px', margin: '0 auto 2rem' }}>
|
||||||
|
Your complete homepage architecture for <strong style={{ color: '#fff' }}>{profile.companyName}</strong> has been transmitted to our engineering team at <strong style={{ color: 'var(--accent)' }}>hello@aipilots.site</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Agency Launch Pipeline Box */}
|
||||||
|
<div className="roadmap-box" style={{ textAlign: 'left', marginBottom: '2rem' }}>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: 'var(--accent)', textTransform: 'uppercase', marginBottom: '1rem' }}>
|
||||||
|
Your Deployment Roadmap:
|
||||||
|
</div>
|
||||||
|
<div className="roadmap-step">
|
||||||
|
<div className="roadmap-step-icon">1</div>
|
||||||
|
<div className="roadmap-step-content">
|
||||||
|
<h4>Custom Domain & Cloudflare DNS</h4>
|
||||||
|
<p>Securing {domainPreference || `${profile.companyName.toLowerCase().replace(/\s+/g, '')}.com`} with SSL and edge caching.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="roadmap-step">
|
||||||
|
<div className="roadmap-step-icon">2</div>
|
||||||
|
<div className="roadmap-step-content">
|
||||||
|
<h4>Professional Mailcow Business Email</h4>
|
||||||
|
<p>Setting up verified SPF, DKIM, DMARC inboxes for your domain.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="roadmap-step">
|
||||||
|
<div className="roadmap-step-icon">3</div>
|
||||||
|
<div className="roadmap-step-content">
|
||||||
|
<h4>Google Business Profile (GMB) Optimization</h4>
|
||||||
|
<p>Connecting your address citation and Google Map Pack ranking strategy.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="roadmap-step">
|
||||||
|
<div className="roadmap-step-icon">4</div>
|
||||||
|
<div className="roadmap-step-content">
|
||||||
|
<h4>CRM & Lead Notification Routing</h4>
|
||||||
|
<p>Hooking all quote forms directly to your phone ({clientPhone}) and email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleDownloadBlueprint}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
<span>Download Site Blueprint (.JSON)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* SUBMISSION FORM */
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(56, 189, 248, 0.05)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
padding: '1.25rem',
|
||||||
|
marginBottom: '1.75rem'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||||||
|
<div style={{ fontWeight: 700, color: '#fff', fontSize: '1.05rem' }}>
|
||||||
|
{profile.companyName || 'Your Business'}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '0.78rem', background: 'var(--primary)', color: '#fff', padding: '2px 8px', borderRadius: '9999px', fontWeight: 600 }}>
|
||||||
|
Theme: {theme} • {colorMode === 'light' ? '☀️ Light' : '🌙 Dark'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||||
|
{profile.services.length} services configured • {profile.city}, {profile.state} • Phone: {profile.phone}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-grid" style={{ marginTop: 0 }}>
|
||||||
|
{/* Contact Name */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="contact-name">Your Full Name (Owner / Decision Maker) *</label>
|
||||||
|
<input
|
||||||
|
id="contact-name"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="e.g. Sarah Connor"
|
||||||
|
value={clientName}
|
||||||
|
onChange={(e) => setClientName(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Email */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="contact-email">Email Address for Site Launch *</label>
|
||||||
|
<input
|
||||||
|
id="contact-email"
|
||||||
|
type="email"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="sarah@example.com"
|
||||||
|
value={clientEmail}
|
||||||
|
onChange={(e) => setClientEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Phone */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="contact-phone">Direct Mobile / Work Phone *</label>
|
||||||
|
<input
|
||||||
|
id="contact-phone"
|
||||||
|
type="tel"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="(555) 000-0000"
|
||||||
|
value={clientPhone}
|
||||||
|
onChange={(e) => setClientPhone(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="tel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domain Preference */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="domain-pref">Desired Domain Name (Optional)</label>
|
||||||
|
<input
|
||||||
|
id="domain-pref"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="e.g. apexpoolpros.com"
|
||||||
|
value={domainPreference}
|
||||||
|
onChange={(e) => setDomainPreference(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Onboarding Checklist Options */}
|
||||||
|
<div className="form-group-full" style={{ borderTop: '1px solid var(--border-subtle)', paddingTop: '1.25rem', marginTop: '0.5rem' }}>
|
||||||
|
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#fff', marginBottom: '0.75rem' }}>
|
||||||
|
Select What You Want AI Pilots to Provision For You:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', cursor: 'pointer', fontSize: '0.88rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={wantsMailcowEmail}
|
||||||
|
onChange={(e) => setWantsMailcowEmail(e.target.checked)}
|
||||||
|
style={{ width: '16px', height: '16px', accentColor: 'var(--primary)' }}
|
||||||
|
/>
|
||||||
|
<span>Professional Business Email Inboxes (Mailcow high-deliverability infrastructure)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', cursor: 'pointer', fontSize: '0.88rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={wantsGmb}
|
||||||
|
onChange={(e) => setWantsGmb(e.target.checked)}
|
||||||
|
style={{ width: '16px', height: '16px', accentColor: 'var(--primary)' }}
|
||||||
|
/>
|
||||||
|
<span>Google Business Profile (GMB) Verification & Local Map 3-Pack Optimization</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', cursor: 'pointer', fontSize: '0.88rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={wantsCrm}
|
||||||
|
onChange={(e) => setWantsCrm(e.target.checked)}
|
||||||
|
style={{ width: '16px', height: '16px', accentColor: 'var(--primary)' }}
|
||||||
|
/>
|
||||||
|
<span>Client Lead CRM & Instant SMS Call Forwarding Integration</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="form-group-full">
|
||||||
|
<label className="form-label" htmlFor="launch-notes">Special Instructions or Custom Requests</label>
|
||||||
|
<textarea
|
||||||
|
id="launch-notes"
|
||||||
|
className="form-textarea"
|
||||||
|
placeholder="Any specific logo colors, custom domain access, or timeline goals..."
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Back to Preview
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ padding: '0.85rem 2rem' }}>
|
||||||
|
<Send size={16} />
|
||||||
|
<span>Submit Final Draft</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SvgIconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
primaryColor?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. POOL SERVICE ICONS
|
||||||
|
export const PoolChemistrySvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M16 3C16 3 8 13.5 8 19.5C8 23.9183 11.5817 27.5 16 27.5C20.4183 27.5 24 23.9183 24 19.5C24 13.5 16 3 16 3Z" fill="currentColor" fillOpacity="0.16" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
|
||||||
|
<path d="M12 20C12 22.2091 13.7909 24 16 24" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<circle cx="16" cy="12" r="1.5" fill="var(--accent, currentColor)"/>
|
||||||
|
<circle cx="19" cy="17" r="1" fill="var(--accent, currentColor)"/>
|
||||||
|
<path d="M16 8V10M20 11L18.5 12M12 11L13.5 12" stroke="var(--accent, currentColor)" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PoolPumpSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<circle cx="16" cy="16" r="11" fill="currentColor" fillOpacity="0.14" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<circle cx="16" cy="16" r="4" fill="var(--accent, currentColor)" fillOpacity="0.3" stroke="var(--accent, currentColor)" strokeWidth="2"/>
|
||||||
|
<path d="M16 5V12M16 20V27M5 16H12M20 16H27" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M8.22 8.22L13.17 13.17M18.83 18.83L23.78 23.78M23.78 8.22L18.83 13.17M13.17 18.83L8.22 23.78" stroke="var(--accent, currentColor)" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SparklingTileSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<rect x="5" y="5" width="10" height="10" rx="2" fill="currentColor" fillOpacity="0.16" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<rect x="17" y="5" width="10" height="10" rx="2" fill="currentColor" fillOpacity="0.28" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<rect x="5" y="17" width="10" height="10" rx="2" fill="currentColor" fillOpacity="0.28" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<rect x="17" y="17" width="10" height="10" rx="2" fill="currentColor" fillOpacity="0.16" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<path d="M22 2V6M20 4H24M8 26V30M6 28H10" stroke="var(--accent, currentColor)" strokeWidth="1.75" strokeLinecap="round"/>
|
||||||
|
<circle cx="22" cy="10" r="1.5" fill="var(--accent, currentColor)"/>
|
||||||
|
<circle cx="10" cy="22" r="1.5" fill="var(--accent, currentColor)"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SmartAutomationSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<rect x="9" y="4" width="14" height="24" rx="3" fill="currentColor" fillOpacity="0.14" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<line x1="13" y1="8" x2="19" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
<circle cx="16" cy="24" r="1.5" fill="currentColor"/>
|
||||||
|
<path d="M4 11C6.2 8.5 9.5 7 13 7" stroke="var(--accent, currentColor)" strokeWidth="1.75" strokeLinecap="round"/>
|
||||||
|
<path d="M28 11C25.8 8.5 22.5 7 19 7" stroke="var(--accent, currentColor)" strokeWidth="1.75" strokeLinecap="round"/>
|
||||||
|
<path d="M6 15C7.5 13.5 9.5 12.5 12 12.5" stroke="var(--accent, currentColor)" strokeWidth="1.75" strokeLinecap="round"/>
|
||||||
|
<path d="M26 15C24.5 13.5 22.5 12.5 20 12.5" stroke="var(--accent, currentColor)" strokeWidth="1.75" strokeLinecap="round"/>
|
||||||
|
<path d="M14 14L18 18M18 14L14 18" stroke="var(--accent, currentColor)" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CrystalRescueSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M16 3L27 7.5V16C27 22.5 22 27.5 16 29C10 27.5 5 22.5 5 16V7.5L16 3Z" fill="currentColor" fillOpacity="0.14" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
|
||||||
|
<path d="M16 9L18 14H23L19 17.5L20.5 22.5L16 19.5L11.5 22.5L13 17.5L9 14H14L16 9Z" fill="var(--accent, currentColor)" fillOpacity="0.3" stroke="var(--accent, currentColor)" strokeWidth="1.75" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. LANDSCAPING & HARDSCAPE ICONS
|
||||||
|
export const PaverMasonrySvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<rect x="4" y="6" width="11" height="8" rx="1.5" fill="currentColor" fillOpacity="0.18" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<rect x="17" y="6" width="11" height="8" rx="1.5" fill="currentColor" fillOpacity="0.18" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<rect x="9" y="16" width="14" height="9" rx="1.5" fill="var(--accent, currentColor)" fillOpacity="0.25" stroke="var(--accent, currentColor)" strokeWidth="2"/>
|
||||||
|
<path d="M4 16H7M25 16H28" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M4 25H7M25 25H28" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PrecisionLawnSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M4 27H28" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M6 26C7 19 10 13 12 7C12.5 13 13.5 20 14 26" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M14 26C15 17 18 10 20 5C20.5 12 21.5 19 22 26" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="var(--accent, currentColor)" fillOpacity="0.18"/>
|
||||||
|
<path d="M22 26C23 20 25 15 27 11" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IrrigationSpraySvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<rect x="13" y="19" width="6" height="9" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<line x1="8" y1="28" x2="24" y2="28" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M16 19V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M16 10C16 10 11 8 8 5" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M16 10C16 10 21 8 24 5" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M16 8V3" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<circle cx="8" cy="4" r="1.5" fill="var(--accent, currentColor)"/>
|
||||||
|
<circle cx="24" cy="4" r="1.5" fill="var(--accent, currentColor)"/>
|
||||||
|
<circle cx="16" cy="2" r="1.5" fill="var(--accent, currentColor)"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TreeArboristSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M16 28V18M16 22L20 18M16 20L12 17" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
|
||||||
|
<path d="M16 4C11.58 4 8 7.58 8 12C8 13.8 8.6 15.46 9.62 16.8C10.6 18.08 12.18 19 14 19.3V19.5C14 20.33 14.67 21 15.5 21H16.5C17.33 21 18 20.33 18 19.5V19.3C19.82 19 21.4 18.08 22.38 16.8C23.4 15.46 24 13.8 24 12C24 7.58 20.42 4 16 4Z" fill="currentColor" fillOpacity="0.18" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<path d="M16 7C13.5 7 11.5 9 11.5 11.5" stroke="var(--accent, currentColor)" strokeWidth="1.75" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const LandscapeLightingSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M16 27V15" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M10 15H22L19 9H13L10 15Z" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
|
||||||
|
<path d="M8 27H24" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M13 16L9 24M19 16L23 24M16 16V25" stroke="var(--accent, currentColor)" strokeWidth="1.75" strokeLinecap="round" strokeDasharray="2 2"/>
|
||||||
|
<circle cx="16" cy="9" r="1.5" fill="var(--accent, currentColor)"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. ROOFING ICONS
|
||||||
|
export const ArchitecturalRoofSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M3 14L16 4L29 14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M6 14V26H26V14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="currentColor" fillOpacity="0.12"/>
|
||||||
|
<line x1="6" y1="18" x2="26" y2="18" stroke="var(--accent, currentColor)" strokeWidth="1.75"/>
|
||||||
|
<line x1="6" y1="22" x2="26" y2="22" stroke="var(--accent, currentColor)" strokeWidth="1.75"/>
|
||||||
|
<line x1="12" y1="14" x2="12" y2="18" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<line x1="20" y1="14" x2="20" y2="18" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<line x1="16" y1="18" x2="16" y2="22" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<line x1="10" y1="22" x2="10" y2="26" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<line x1="22" y1="22" x2="22" y2="26" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const StormShieldSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M16 3L27 7.5V16C27 22.5 22 27.5 16 29C10 27.5 5 22.5 5 16V7.5L16 3Z" fill="currentColor" fillOpacity="0.14" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
|
||||||
|
<path d="M18 10L12 18H17L14 24L21 15H16L18 10Z" fill="var(--accent, currentColor)" stroke="var(--accent, currentColor)" strokeWidth="1.5" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SeamlessGuttersSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M4 8H24V14C24 16.2 22.2 18 20 18H8C5.8 18 4 16.2 4 14V8Z" fill="currentColor" fillOpacity="0.16" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<path d="M20 18V26C20 27.1 20.9 28 22 28H26" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M10 13L10 15M14 13L14 15M18 13L18 15" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<circle cx="25" cy="24" r="1.5" fill="var(--accent, currentColor)"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. PLUMBING ICONS
|
||||||
|
export const TanklessHeaterSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<rect x="7" y="4" width="18" height="24" rx="3" fill="currentColor" fillOpacity="0.14" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<circle cx="16" cy="12" r="4" stroke="currentColor" strokeWidth="1.75"/>
|
||||||
|
<path d="M16 10V12L17.5 13" stroke="var(--accent, currentColor)" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
<path d="M16 19C16 19 13.5 21 13.5 22.5C13.5 23.88 14.62 25 16 25C17.38 25 18.5 23.88 18.5 22.5C18.5 21 16 19 16 19Z" fill="var(--accent, currentColor)" stroke="var(--accent, currentColor)" strokeWidth="1.5"/>
|
||||||
|
<line x1="11" y1="28" x2="11" y2="30" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<line x1="21" y1="28" x2="21" y2="30" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const HydroJetNozzleSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M5 16H16" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
|
||||||
|
<path d="M16 11L25 16L16 21V11Z" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
|
||||||
|
<path d="M26 12L29 9M27 16H31M26 20L29 23" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M18 9L15 6M18 23L15 26" stroke="var(--accent, currentColor)" strokeWidth="1.75" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PipeValveSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<line x1="4" y1="16" x2="28" y2="16" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
|
||||||
|
<circle cx="16" cy="16" r="5" fill="currentColor" fillOpacity="0.18" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<path d="M16 11V5M11 5H21" stroke="var(--accent, currentColor)" strokeWidth="2.5" strokeLinecap="round"/>
|
||||||
|
<circle cx="16" cy="16" r="2" fill="var(--accent, currentColor)"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. HVAC ICONS
|
||||||
|
export const AcCoolingSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<line x1="16" y1="4" x2="16" y2="28" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<line x1="4" y1="16" x2="28" y2="16" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<line x1="7.5" y1="7.5" x2="24.5" y2="24.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<line x1="7.5" y1="24.5" x2="24.5" y2="7.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<circle cx="16" cy="16" r="3" fill="var(--accent, currentColor)" fillOpacity="0.3" stroke="var(--accent, currentColor)" strokeWidth="2"/>
|
||||||
|
<path d="M13 6L16 4L19 6M26 13L28 16L26 19M19 26L16 28L13 26M6 19L4 16L6 13" stroke="var(--accent, currentColor)" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const HeatingBurnerSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M16 4C16 4 23 11 23 18C23 22.4 19.9 26 16 26C12.1 26 9 22.4 9 18C9 14.5 12 10.5 16 4Z" fill="currentColor" fillOpacity="0.18" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
|
||||||
|
<path d="M16 14C16 14 19 17.5 19 20C19 21.66 17.66 23 16 23C14.34 23 13 21.66 13 20C13 18.5 14.5 16.5 16 14Z" fill="var(--accent, currentColor)" stroke="var(--accent, currentColor)" strokeWidth="1.5" strokeLinejoin="round"/>
|
||||||
|
<line x1="6" y1="29" x2="26" y2="29" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SmartThermostatSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<circle cx="16" cy="16" r="12" fill="currentColor" fillOpacity="0.14" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<circle cx="16" cy="16" r="8" stroke="var(--accent, currentColor)" strokeWidth="1.5" strokeDasharray="3 3"/>
|
||||||
|
<path d="M13 16C13 14.34 14.34 13 16 13C17.66 13 19 14.34 19 16" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<circle cx="16" cy="16" r="2" fill="var(--accent, currentColor)"/>
|
||||||
|
<circle cx="21" cy="11" r="1" fill="var(--accent, currentColor)"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. ELECTRICAL ICONS
|
||||||
|
export const ElectricPanelSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<rect x="7" y="4" width="18" height="24" rx="2" fill="currentColor" fillOpacity="0.14" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<line x1="16" y1="4" x2="16" y2="28" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<rect x="9" y="8" width="5" height="3" rx="1" fill="var(--accent, currentColor)"/>
|
||||||
|
<rect x="18" y="8" width="5" height="3" rx="1" fill="currentColor" fillOpacity="0.3"/>
|
||||||
|
<rect x="9" y="14" width="5" height="3" rx="1" fill="currentColor" fillOpacity="0.3"/>
|
||||||
|
<rect x="18" y="14" width="5" height="3" rx="1" fill="var(--accent, currentColor)"/>
|
||||||
|
<rect x="9" y="20" width="5" height="3" rx="1" fill="var(--accent, currentColor)"/>
|
||||||
|
<rect x="18" y="20" width="5" height="3" rx="1" fill="currentColor" fillOpacity="0.3"/>
|
||||||
|
<circle cx="22" cy="6" r="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EvChargerSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<rect x="6" y="5" width="12" height="22" rx="2" fill="currentColor" fillOpacity="0.15" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<path d="M18 11H23C24.1 11 25 11.9 25 13V22C25 23.1 24.1 24 23 24H21" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M12 9L9 15H14L11 21" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<circle cx="24" cy="18" r="1.5" fill="var(--accent, currentColor)"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GeneratorTurbineSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<rect x="5" y="7" width="22" height="18" rx="3" fill="currentColor" fillOpacity="0.14" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<circle cx="16" cy="16" r="5" fill="var(--accent, currentColor)" fillOpacity="0.2" stroke="var(--accent, currentColor)" strokeWidth="2"/>
|
||||||
|
<line x1="16" y1="12" x2="16" y2="20" stroke="var(--accent, currentColor)" strokeWidth="1.75"/>
|
||||||
|
<line x1="12" y1="16" x2="20" y2="16" stroke="var(--accent, currentColor)" strokeWidth="1.75"/>
|
||||||
|
<line x1="8" y1="28" x2="11" y2="25" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<line x1="24" y1="28" x2="21" y2="25" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. TRUST & PERFORMANCE BADGE ICONS
|
||||||
|
export const RoyalShieldGuaranteeSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M16 3L27 7.5V16C27 22.5 22 27.5 16 29C10 27.5 5 22.5 5 16V7.5L16 3Z" fill="currentColor" fillOpacity="0.16" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
|
||||||
|
<path d="M11 16L14.5 19.5L21.5 12.5" stroke="var(--accent, currentColor)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RapidDispatchSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<circle cx="16" cy="17" r="11" fill="currentColor" fillOpacity="0.14" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<path d="M16 11V17L20 19" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M13 3H19M16 3V6" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<path d="M25 8L27 6" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FiveStarLaurelSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M16 6L18.8 11.7L25 12.6L20.5 17L21.6 23.2L16 20.2L10.4 23.2L11.5 17L7 12.6L13.2 11.7L16 6Z" fill="var(--accent, currentColor)" fillOpacity="0.25" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinejoin="round"/>
|
||||||
|
<path d="M6 26C6 26 8 28 12 28M26 26C26 26 24 28 20 28" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TransparentPricingSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M5 13L16 2L27 13L16 24L5 13Z" fill="currentColor" fillOpacity="0.16" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
|
||||||
|
<circle cx="16" cy="13" r="3" fill="var(--accent, currentColor)" stroke="var(--accent, currentColor)" strokeWidth="1.5"/>
|
||||||
|
<path d="M16 24V29M12 29H20" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AestheticLaserSvg: React.FC<SvgIconProps> = ({ size = 28, className }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<circle cx="16" cy="16" r="11" fill="currentColor" fillOpacity="0.12" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<circle cx="16" cy="16" r="4" fill="var(--accent, currentColor)" fillOpacity="0.3" stroke="var(--accent, currentColor)" strokeWidth="2"/>
|
||||||
|
<line x1="16" y1="4" x2="16" y2="8" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<line x1="16" y1="24" x2="16" y2="28" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<line x1="4" y1="16" x2="8" y2="16" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<line x1="24" y1="16" x2="28" y2="16" stroke="var(--accent, currentColor)" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Intelligent Svg Icon Resolver with Precision Word Boundaries
|
||||||
|
export function renderEnterpriseSvgIcon(identifier: string, size = 28, className?: string): React.ReactElement {
|
||||||
|
const key = identifier.toLowerCase();
|
||||||
|
|
||||||
|
// 1. TRUST, GUARANTEES & SOCIAL PROOF (Check first to avoid trade collisions)
|
||||||
|
if (key.includes('guarantee') || key.includes('warranty') || key.includes('satisfaction') || key.includes('peace of mind') || key.includes('licensed & insured')) {
|
||||||
|
return <RoyalShieldGuaranteeSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('review') || key.includes('5-star') || key.includes('five star') || key.includes('rated') || key.includes('testimonial') || key.includes('award')) {
|
||||||
|
return <FiveStarLaurelSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('dispatch') || key.includes('24/7') || key.includes('turnaround') || key.includes('response time') || key.includes('emergency') && (key.includes('dispatch') || key.includes('call'))) {
|
||||||
|
return <RapidDispatchSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('price') || key.includes('pricing') || key.includes('flat-rate') || key.includes('transparent') || key.includes('upfront') || key.includes('estimate') || key.includes('savings')) {
|
||||||
|
return <TransparentPricingSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. POOL TRADE
|
||||||
|
if (key.includes('chem') || key.includes('balance') || key.includes('water test') || key.includes('chlorin') || key.includes('acid wash')) {
|
||||||
|
return <PoolChemistrySvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('pump') || key.includes('motor') || key.includes('filter')) {
|
||||||
|
return <PoolPumpSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('tile') || key.includes('calcium') || key.includes('scrub') || key.includes('mineral')) {
|
||||||
|
return <SparklingTileSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('automation') || key.includes('screenlogic') || key.includes('salt') || key.includes('omnilogic')) {
|
||||||
|
return <SmartAutomationSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('green-to-clean') || key.includes('swamp') || key.includes('algae') || (key.includes('pool') && key.includes('clean'))) {
|
||||||
|
return <CrystalRescueSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. LANDSCAPING TRADE
|
||||||
|
if (key.includes('paver') || key.includes('patio') || key.includes('hardscape') || key.includes('masonry') || key.includes('retaining') || key.includes('walkway')) {
|
||||||
|
return <PaverMasonrySvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('lawn') || key.includes('mow') || key.includes('turf') || key.includes('grass') || key.includes('edging')) {
|
||||||
|
return <PrecisionLawnSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('sprinkler') || key.includes('irrigat') || key.includes('drip') || key.includes('valve repair')) {
|
||||||
|
return <IrrigationSpraySvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('tree') || key.includes('prun') || key.includes('arborist') || key.includes('palm') || key.includes('branch')) {
|
||||||
|
return <TreeArboristSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('landscape lighting') || key.includes('luminary') || key.includes('low-voltage') || key.includes('outdoor light')) {
|
||||||
|
return <LandscapeLightingSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. ROOFING TRADE
|
||||||
|
if (key.includes('shingle') || key.includes('roof') || key.includes('tpo') || key.includes('flat roof')) {
|
||||||
|
return <ArchitecturalRoofSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('tarp') || key.includes('storm damage') || (key.includes('roof') && key.includes('leak'))) {
|
||||||
|
return <StormShieldSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('gutter') || key.includes('downspout') || key.includes('seamless')) {
|
||||||
|
return <SeamlessGuttersSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. PLUMBING TRADE
|
||||||
|
if (key.includes('tankless') || key.includes('water heater') || key.includes('hot water')) {
|
||||||
|
return <TanklessHeaterSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('hydro-jet') || key.includes('hydro jet') || key.includes('drain cleaning') || key.includes('rooter') || key.includes('sewer line')) {
|
||||||
|
return <HydroJetNozzleSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('pipe') || key.includes('valve') || key.includes('leak detection') || key.includes('repip') || key.includes('copper')) {
|
||||||
|
return <PipeValveSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. HVAC TRADE
|
||||||
|
if (/\b(ac|a\/c|air condition|cooling|freon|condenser|chiller)\b/i.test(key)) {
|
||||||
|
return <AcCoolingSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('furnace') || key.includes('heating') || key.includes('burner') || key.includes('heat pump')) {
|
||||||
|
return <HeatingBurnerSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('thermostat') || key.includes('nest') || key.includes('ecobee') || key.includes('climate control')) {
|
||||||
|
return <SmartThermostatSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. ELECTRICAL TRADE
|
||||||
|
if (key.includes('panel') || key.includes('breaker') || key.includes('busbar') || key.includes('200a') || key.includes('rewir')) {
|
||||||
|
return <ElectricPanelSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (/\b(ev|charger|charging station|tesla)\b/i.test(key)) {
|
||||||
|
return <EvChargerSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
if (key.includes('generator') || key.includes('standby power') || key.includes('backup power') || key.includes('turbine')) {
|
||||||
|
return <GeneratorTurbineSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. MEDICAL / CLINIC / AESTHETIC
|
||||||
|
if (key.includes('laser') || key.includes('facial') || key.includes('skin') || key.includes('botox') || key.includes('aesthetic') || key.includes('dermal')) {
|
||||||
|
return <AestheticLaserSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Royal Shield Guarantee Svg
|
||||||
|
return <RoyalShieldGuaranteeSvg size={size} className={className} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { BusinessProfile, ThemeId } from '../types';
|
||||||
|
import { THEMES, INDUSTRY_PRESETS, analyzeMissingProfileData } from '../services/aiEngine';
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
X,
|
||||||
|
Building2,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Wrench,
|
||||||
|
Plus,
|
||||||
|
Check,
|
||||||
|
Bot,
|
||||||
|
ArrowRight,
|
||||||
|
Zap,
|
||||||
|
Info,
|
||||||
|
Moon,
|
||||||
|
Sun
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface OnboardingModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
profile: BusinessProfile;
|
||||||
|
onSaveProfile: (profile: BusinessProfile) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OnboardingModal: React.FC<OnboardingModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
profile,
|
||||||
|
onSaveProfile,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState<BusinessProfile>({ ...profile, colorMode: profile.colorMode || 'dark' });
|
||||||
|
const [newServiceInput, setNewServiceInput] = useState('');
|
||||||
|
const [interviewMode, setInterviewMode] = useState(false);
|
||||||
|
const [aiChatLog, setAiChatLog] = useState<Array<{ sender: 'ai' | 'user'; text: string }>>([]);
|
||||||
|
const [aiResponseInput, setAiResponseInput] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setFormData({ ...profile, colorMode: profile.colorMode || 'dark' });
|
||||||
|
}
|
||||||
|
}, [isOpen, profile]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
// Apply one-click preset
|
||||||
|
const handleApplyPreset = (presetKey: string) => {
|
||||||
|
const preset = INDUSTRY_PRESETS[presetKey];
|
||||||
|
if (!preset) return;
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
companyName: preset.sampleCompany,
|
||||||
|
phone: preset.samplePhone,
|
||||||
|
address: preset.sampleAddress,
|
||||||
|
city: preset.sampleCity,
|
||||||
|
state: preset.sampleState,
|
||||||
|
zip: preset.sampleZip,
|
||||||
|
industry: preset.name,
|
||||||
|
tagline: preset.sampleTagline,
|
||||||
|
theme: preset.defaultTheme,
|
||||||
|
services: [...preset.defaultServices],
|
||||||
|
emergencyService: true,
|
||||||
|
yearsInBusiness: '12',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add service tag
|
||||||
|
const handleAddService = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newServiceInput.trim()) return;
|
||||||
|
if (!formData.services.includes(newServiceInput.trim())) {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
services: [...formData.services, newServiceInput.trim()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setNewServiceInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveService = (serviceToRemove: string) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
services: formData.services.filter(s => s !== serviceToRemove),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check missing data and trigger AI Interviewer
|
||||||
|
const handleStartAiInterview = () => {
|
||||||
|
const analysis = analyzeMissingProfileData(formData);
|
||||||
|
setInterviewMode(true);
|
||||||
|
setAiChatLog([
|
||||||
|
{
|
||||||
|
sender: 'ai',
|
||||||
|
text: analysis.suggestedQuestion,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAiInterviewSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!aiResponseInput.trim()) return;
|
||||||
|
|
||||||
|
const userText = aiResponseInput.trim();
|
||||||
|
const updatedChat = [...aiChatLog, { sender: 'user' as const, text: userText }];
|
||||||
|
|
||||||
|
// Intelligent auto-filling from interview answer
|
||||||
|
const nextFormData = { ...formData };
|
||||||
|
if (!nextFormData.companyName) {
|
||||||
|
nextFormData.companyName = userText;
|
||||||
|
} else if (!nextFormData.phone) {
|
||||||
|
const phoneMatch = userText.match(/\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/);
|
||||||
|
if (phoneMatch) nextFormData.phone = phoneMatch[0];
|
||||||
|
else nextFormData.phone = userText;
|
||||||
|
} else if (!nextFormData.city) {
|
||||||
|
nextFormData.city = userText;
|
||||||
|
} else if (nextFormData.services.length === 0) {
|
||||||
|
nextFormData.services = userText.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData(nextFormData);
|
||||||
|
setAiResponseInput('');
|
||||||
|
|
||||||
|
// Re-check missing
|
||||||
|
const nextAnalysis = analyzeMissingProfileData(nextFormData);
|
||||||
|
if (nextAnalysis.isComplete) {
|
||||||
|
updatedChat.push({
|
||||||
|
sender: 'ai',
|
||||||
|
text: `🎉 Fantastic! I have gathered all key business details for **${nextFormData.companyName}**. Click **'Generate Website Now'** to render your interactive homepage draft!`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updatedChat.push({
|
||||||
|
sender: 'ai',
|
||||||
|
text: nextAnalysis.suggestedQuestion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setAiChatLog(updatedChat);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSaveProfile(formData);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||||
|
<div className="modal-card">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="modal-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<div className="brand-logo-icon" style={{ width: '34px', height: '34px' }}>
|
||||||
|
<Sparkles size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 id="modal-title" className="modal-title">
|
||||||
|
{interviewMode ? 'AI Onboarding Interviewer' : 'Configure Your Business Website'}
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Step 1: Enter your business details or use the AI Assistant to formulate your brand
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="modal-close-btn" onClick={onClose} aria-label="Close modal">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="modal-body">
|
||||||
|
{/* Quick 1-Click Demo Presets */}
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<span style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
One-Click Industry Fast-Fill:
|
||||||
|
</span>
|
||||||
|
<div className="presets-strip" style={{ marginTop: '0.5rem' }}>
|
||||||
|
{Object.entries(INDUSTRY_PRESETS).map(([key, preset]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className="preset-card-chip"
|
||||||
|
onClick={() => handleApplyPreset(key)}
|
||||||
|
>
|
||||||
|
<Zap size={14} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span style={{ fontSize: '0.82rem', fontWeight: 600, color: '#fff' }}>{preset.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle between Direct Form & AI Interviewer Mode */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.75rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${!interviewMode ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
style={{ flex: 1, padding: '0.65rem 1rem', fontSize: '0.85rem' }}
|
||||||
|
onClick={() => setInterviewMode(false)}
|
||||||
|
>
|
||||||
|
<Building2 size={16} />
|
||||||
|
<span>Standard Business Form</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${interviewMode ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
style={{ flex: 1, padding: '0.65rem 1rem', fontSize: '0.85rem' }}
|
||||||
|
onClick={handleStartAiInterview}
|
||||||
|
>
|
||||||
|
<Bot size={16} />
|
||||||
|
<span>AI Conversational Interviewer</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{interviewMode ? (
|
||||||
|
/* AI INTERVIEW MODE */
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(0, 0, 0, 0.25)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
padding: '1.25rem',
|
||||||
|
minHeight: '220px',
|
||||||
|
maxHeight: '320px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.85rem',
|
||||||
|
marginBottom: '1rem'
|
||||||
|
}}>
|
||||||
|
{aiChatLog.map((chat, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`chat-bubble ${chat.sender}`}
|
||||||
|
style={{ maxWidth: '85%' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '0.72rem', opacity: 0.7, marginBottom: '0.2rem' }}>
|
||||||
|
{chat.sender === 'ai' ? 'AI Website Architect' : 'You'}
|
||||||
|
</div>
|
||||||
|
<div>{chat.text}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleAiInterviewSubmit} style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Type your answer here..."
|
||||||
|
value={aiResponseInput}
|
||||||
|
onChange={(e) => setAiResponseInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ padding: '0 1.25rem' }}>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* STANDARD FORM MODE */
|
||||||
|
<form id="business-form" onSubmit={handleSubmit}>
|
||||||
|
<div className="form-grid">
|
||||||
|
{/* Company Name */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="company-name">Company Name *</label>
|
||||||
|
<input
|
||||||
|
id="company-name"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="e.g. Apex Pool Care"
|
||||||
|
value={formData.companyName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })}
|
||||||
|
required
|
||||||
|
autoComplete="organization"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone Number */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="company-phone">Phone Number *</label>
|
||||||
|
<input
|
||||||
|
id="company-phone"
|
||||||
|
type="tel"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="(555) 234-5678"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
required
|
||||||
|
autoComplete="tel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary Email */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="company-email">Primary Contact Email</label>
|
||||||
|
<input
|
||||||
|
id="company-email"
|
||||||
|
type="email"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="contact@mycompany.com"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Industry */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="company-industry">Industry / Category</label>
|
||||||
|
<input
|
||||||
|
id="company-industry"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="e.g. Roofing & General Contracting"
|
||||||
|
value={formData.industry}
|
||||||
|
onChange={(e) => setFormData({ ...formData, industry: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="form-group-full">
|
||||||
|
<label className="form-label" htmlFor="company-address">Street Address / Headquarters</label>
|
||||||
|
<input
|
||||||
|
id="company-address"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="1234 Main Street"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||||
|
autoComplete="street-address"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City, State, Zip */}
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="company-city">City / Service Area *</label>
|
||||||
|
<input
|
||||||
|
id="company-city"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="e.g. Dallas"
|
||||||
|
value={formData.city}
|
||||||
|
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||||||
|
required
|
||||||
|
autoComplete="address-level2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="company-state">State</label>
|
||||||
|
<input
|
||||||
|
id="company-state"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="TX"
|
||||||
|
value={formData.state}
|
||||||
|
onChange={(e) => setFormData({ ...formData, state: e.target.value })}
|
||||||
|
autoComplete="address-level1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="company-zip">Zip</label>
|
||||||
|
<input
|
||||||
|
id="company-zip"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="75001"
|
||||||
|
value={formData.zip}
|
||||||
|
onChange={(e) => setFormData({ ...formData, zip: e.target.value })}
|
||||||
|
autoComplete="postal-code"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tagline / Value Proposition */}
|
||||||
|
<div className="form-group-full">
|
||||||
|
<label className="form-label" htmlFor="company-tagline">Tagline / Mission Statement</label>
|
||||||
|
<input
|
||||||
|
id="company-tagline"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="e.g. Master Craftsmanship, Fast Estimates & 100% Guaranteed Work"
|
||||||
|
value={formData.tagline}
|
||||||
|
onChange={(e) => setFormData({ ...formData, tagline: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Core Services Tag List */}
|
||||||
|
<div className="form-group-full">
|
||||||
|
<label className="form-label">Core Services Offered ({formData.services.length})</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Type a service (e.g. Leak Detection) and press Add"
|
||||||
|
value={newServiceInput}
|
||||||
|
onChange={(e) => setNewServiceInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={handleAddService}>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>Add</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
|
{formData.services.map((svc, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.35rem',
|
||||||
|
background: 'rgba(56, 189, 248, 0.12)',
|
||||||
|
border: '1px solid var(--border-focus)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: '9999px',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wrench size={12} />
|
||||||
|
<span>{svc}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveService(svc)}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emergency Service Toggle */}
|
||||||
|
<div className="form-group-full">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', cursor: 'pointer', padding: '0.75rem 0' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.emergencyService}
|
||||||
|
onChange={(e) => setFormData({ ...formData, emergencyService: e.target.checked })}
|
||||||
|
style={{ width: '18px', height: '18px', accentColor: 'var(--primary)' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.92rem', color: '#fff', fontWeight: 600 }}>
|
||||||
|
Provide 24/7 Emergency Dispatch / Rapid Response
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Appearance & Color Mode */}
|
||||||
|
<div className="form-group-full">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.85rem', flexWrap: 'wrap', gap: '0.65rem' }}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" style={{ marginBottom: '2px' }}>Appearance & Color Mode</label>
|
||||||
|
<span style={{ fontSize: '0.78rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Choose dark obsidian or crisp light styling for your site
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="color-mode-segmented">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`color-mode-toggle-btn ${(formData.colorMode || 'dark') === 'dark' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFormData({ ...formData, colorMode: 'dark' })}
|
||||||
|
title="Dark Mode (Executive Obsidian & Vivid Glow)"
|
||||||
|
>
|
||||||
|
<Moon size={14} />
|
||||||
|
<span>Dark Mode</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`color-mode-toggle-btn ${formData.colorMode === 'light' ? 'active' : ''}`}
|
||||||
|
onClick={() => setFormData({ ...formData, colorMode: 'light' })}
|
||||||
|
title="Light Mode (Crisp High-Contrast & Slate)"
|
||||||
|
>
|
||||||
|
<Sun size={14} />
|
||||||
|
<span>Light Mode</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="form-label" style={{ marginTop: '0.4rem' }}>Select Visual Theme</label>
|
||||||
|
<div className="theme-selector-grid">
|
||||||
|
{Object.values(THEMES).map((th) => (
|
||||||
|
<div
|
||||||
|
key={th.id}
|
||||||
|
className={`theme-swatch-card ${formData.theme === th.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setFormData({ ...formData, theme: th.id })}
|
||||||
|
>
|
||||||
|
<div className="theme-swatch-colors">
|
||||||
|
<span className="swatch-circle" style={{ background: th.primary }}></span>
|
||||||
|
<span className="swatch-circle" style={{ background: th.accent }}></span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#fff' }}>{th.name}</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: 'var(--text-secondary)', marginTop: '2px' }}>{th.badge}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
onSaveProfile(formData);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Generate Website Now</span>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { WebsiteSection, BusinessProfile, SectionContent } from '../types';
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Wrench,
|
||||||
|
Shield,
|
||||||
|
ShieldCheck,
|
||||||
|
Clock,
|
||||||
|
Star,
|
||||||
|
CheckCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Zap,
|
||||||
|
Tag,
|
||||||
|
Award,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
ArrowRight,
|
||||||
|
Edit3,
|
||||||
|
Flame,
|
||||||
|
Check,
|
||||||
|
Camera,
|
||||||
|
Image as ImageIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
renderEnterpriseSvgIcon,
|
||||||
|
RoyalShieldGuaranteeSvg,
|
||||||
|
RapidDispatchSvg,
|
||||||
|
FiveStarLaurelSvg
|
||||||
|
} from './EnterpriseSvgIcons';
|
||||||
|
|
||||||
|
interface SectionRendererProps {
|
||||||
|
section: WebsiteSection;
|
||||||
|
profile: BusinessProfile;
|
||||||
|
onQuickAiEdit: (sectionNumber: number) => void;
|
||||||
|
onUpdateSectionContent?: (sectionNumber: number, newContent: Partial<SectionContent>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SectionRenderer: React.FC<SectionRendererProps> = ({
|
||||||
|
section,
|
||||||
|
profile,
|
||||||
|
onQuickAiEdit,
|
||||||
|
onUpdateSectionContent,
|
||||||
|
}) => {
|
||||||
|
const { content, sectionNumber } = section;
|
||||||
|
|
||||||
|
// Interactive Quote Form State
|
||||||
|
const [quoteSubmitted, setQuoteSubmitted] = useState(false);
|
||||||
|
const [leadName, setLeadName] = useState('');
|
||||||
|
const [leadPhone, setLeadPhone] = useState('');
|
||||||
|
const [leadService, setLeadService] = useState(profile.services[0] || 'General Consultation');
|
||||||
|
|
||||||
|
// Helper to render dynamic Lucide icon
|
||||||
|
const renderIcon = (iconName?: string) => {
|
||||||
|
switch (iconName) {
|
||||||
|
case 'wrench': return <Wrench size={24} />;
|
||||||
|
case 'shield-check': return <ShieldCheck size={24} />;
|
||||||
|
case 'shield': return <Shield size={24} />;
|
||||||
|
case 'clock': return <Clock size={24} />;
|
||||||
|
case 'star': return <Star size={24} />;
|
||||||
|
case 'check-circle': return <CheckCircle size={24} />;
|
||||||
|
case 'zap': return <Zap size={24} />;
|
||||||
|
case 'tag': return <Tag size={24} />;
|
||||||
|
case 'award': return <Award size={24} />;
|
||||||
|
case 'flame': return <Flame size={24} />;
|
||||||
|
default: return <Sparkles size={24} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="site-section-wrapper" id={`section-${sectionNumber}`}>
|
||||||
|
{/* Floating Section Indicator Badge */}
|
||||||
|
<div className="section-indicator-bar">
|
||||||
|
<span className="section-num-pill">SECTION {sectionNumber}</span>
|
||||||
|
<span className="section-indicator-label">{section.title}</span>
|
||||||
|
<span className="inline-edit-hint">✏️ Click text to tweak directly</span>
|
||||||
|
<button
|
||||||
|
className="section-quick-edit-btn"
|
||||||
|
onClick={() => onQuickAiEdit(sectionNumber)}
|
||||||
|
title={`Tell AI: I don't like section ${sectionNumber}, make it like this...`}
|
||||||
|
>
|
||||||
|
<Edit3 size={13} />
|
||||||
|
<span>Edit with AI</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECTION 1: HERO BANNER */}
|
||||||
|
{section.type === 'hero' && (
|
||||||
|
<section className="hero-section hero-split-layout">
|
||||||
|
<div className="hero-content-col">
|
||||||
|
{content.trustBadges && content.trustBadges.length > 0 && (
|
||||||
|
<div className="hero-trust-pills">
|
||||||
|
{content.trustBadges.map((badge, idx) => (
|
||||||
|
<div key={idx} className="trust-pill">
|
||||||
|
<Check size={14} />
|
||||||
|
<span>{badge}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1
|
||||||
|
className="hero-headline inline-editable-text"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
title="Click to edit headline directly"
|
||||||
|
onBlur={(e) => onUpdateSectionContent?.(1, { headline: e.currentTarget.textContent || '' })}
|
||||||
|
>
|
||||||
|
{content.headline}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="hero-subheadline inline-editable-text"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
title="Click to edit subheadline directly"
|
||||||
|
onBlur={(e) => onUpdateSectionContent?.(1, { subheadline: e.currentTarget.textContent || '' })}
|
||||||
|
>
|
||||||
|
{content.subheadline}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="hero-cta-group">
|
||||||
|
<a href="#quote" className="btn btn-primary" style={{ padding: '0.9rem 2rem', fontSize: '1.05rem' }}>
|
||||||
|
<span>{content.primaryCtaText || 'Request Free Estimate'}</span>
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href={`tel:${profile.phone}`} className="btn btn-secondary" style={{ padding: '0.9rem 1.75rem', fontSize: '1.05rem' }}>
|
||||||
|
<Phone size={18} />
|
||||||
|
<span>{content.secondaryCtaText || `Call: ${profile.phone}`}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-guarantee-row">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.45rem' }}>
|
||||||
|
<RoyalShieldGuaranteeSvg size={19} />
|
||||||
|
<span>100% Satisfaction Guarantee</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.45rem' }}>
|
||||||
|
<RapidDispatchSvg size={19} />
|
||||||
|
<span>Direct Phone Support</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.45rem' }}>
|
||||||
|
<FiveStarLaurelSvg size={19} />
|
||||||
|
<span>Top Rated Local Service</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-media-col">
|
||||||
|
{content.heroImageUrl ? (
|
||||||
|
<div className="hero-image-card">
|
||||||
|
<img
|
||||||
|
src={`${content.heroImageUrl}?v=real_v2`}
|
||||||
|
alt={content.headline || 'Service Technician'}
|
||||||
|
className="hero-showcase-img"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
<div className="hero-image-overlay-badge">
|
||||||
|
<div className="overlay-badge-pulse"></div>
|
||||||
|
<span>Verified Master Tech On-Duty</span>
|
||||||
|
</div>
|
||||||
|
<div className="hero-image-stat-card">
|
||||||
|
<Star size={16} fill="#fbbf24" stroke="#fbbf24" />
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', color: '#fff', fontSize: '0.85rem' }}>5.0 Star Rated</strong>
|
||||||
|
<span style={{ fontSize: '0.72rem', color: 'var(--text-secondary)' }}>Local Emergency Dispatch</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="hero-image-placeholder-card" onClick={() => onQuickAiEdit?.(1)}>
|
||||||
|
<div className="placeholder-icon-ring">
|
||||||
|
<Camera size={30} style={{ color: 'var(--accent)' }} />
|
||||||
|
</div>
|
||||||
|
<h4 className="placeholder-card-title">Commercial Photography Spotlight</h4>
|
||||||
|
<p className="placeholder-card-desc">Showcase spot for certified technicians, equipment, and 5.0-star local verification.</p>
|
||||||
|
<div className="placeholder-action-pill">
|
||||||
|
<Sparkles size={14} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span>Click to Place Trade Image</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SECTION 2: SERVICES GRID */}
|
||||||
|
{section.type === 'services' && (
|
||||||
|
<section className="services-section" id="services">
|
||||||
|
<div className="section-header">
|
||||||
|
<span className="section-eyebrow">Capabilities & Offerings</span>
|
||||||
|
<h2 className="section-title">{content.headline || 'Professional Services'}</h2>
|
||||||
|
<p className="section-description">{content.subheadline}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`services-grid ${content.columns === 4 ? 'cols-4' : ''}`}>
|
||||||
|
{content.servicesList?.map((service, idx) => (
|
||||||
|
<div key={service.id || idx} className="service-card">
|
||||||
|
{service.popular && (
|
||||||
|
<span className="service-badge-popular">Top Requested</span>
|
||||||
|
)}
|
||||||
|
{service.imageUrl && (
|
||||||
|
<div className="service-thumbnail-wrap">
|
||||||
|
<img
|
||||||
|
src={`${service.imageUrl}?v=real_v2`}
|
||||||
|
alt={service.title}
|
||||||
|
className="service-thumbnail-img"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="service-icon-box">
|
||||||
|
{renderEnterpriseSvgIcon(service.title || service.iconName || 'service', 28)}
|
||||||
|
</div>
|
||||||
|
<h3 className="service-card-title">{service.title}</h3>
|
||||||
|
<p className="service-card-desc">{service.description}</p>
|
||||||
|
<a href="#quote" className="service-link">
|
||||||
|
<span>Schedule Service</span>
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SECTION 3: WHY CHOOSE US */}
|
||||||
|
{section.type === 'why-us' && (
|
||||||
|
<section className="why-us-section" id="why-us">
|
||||||
|
<div className="section-header">
|
||||||
|
<span className="section-eyebrow">Proven Reliability</span>
|
||||||
|
<h2 className="section-title">{content.headline || 'Why Choose Our Team'}</h2>
|
||||||
|
<p className="section-description">{content.subheadline}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{content.stats && content.stats.length > 0 && (
|
||||||
|
<div className="stats-grid">
|
||||||
|
{content.stats.map((st, idx) => (
|
||||||
|
<div key={st.id || idx} className="stat-card">
|
||||||
|
<div className="stat-value">{st.value}</div>
|
||||||
|
<div className="stat-label">{st.label}</div>
|
||||||
|
{st.subtext && <div className="stat-subtext">{st.subtext}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content.pillars && content.pillars.length > 0 && (
|
||||||
|
<div className="pillars-grid">
|
||||||
|
{content.pillars.map((pil, idx) => (
|
||||||
|
<div key={pil.id || idx} className="pillar-item">
|
||||||
|
<div className="pillar-icon-box">
|
||||||
|
{renderEnterpriseSvgIcon(pil.title || pil.iconName || 'pillar', 26)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="pillar-title">{pil.title}</h3>
|
||||||
|
<p className="pillar-desc">{pil.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SECTION 4: TESTIMONIALS */}
|
||||||
|
{section.type === 'testimonials' && (
|
||||||
|
<section className="testimonials-section" id="reviews">
|
||||||
|
<div className="section-header">
|
||||||
|
<span className="section-eyebrow">Verified Reviews</span>
|
||||||
|
<h2 className="section-title">{content.headline || 'What Customers Are Saying'}</h2>
|
||||||
|
<p className="section-description">{content.subheadline}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="reviews-grid">
|
||||||
|
{content.reviews?.map((rev, idx) => (
|
||||||
|
<div key={rev.id || idx} className="review-card">
|
||||||
|
<div>
|
||||||
|
<div className="review-stars">
|
||||||
|
{[...Array(rev.rating || 5)].map((_, i) => (
|
||||||
|
<Star key={i} size={18} fill="#fbbf24" stroke="#fbbf24" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="review-quote">"{rev.quote}"</p>
|
||||||
|
</div>
|
||||||
|
<div className="review-author-row">
|
||||||
|
<div className="author-avatar">
|
||||||
|
{rev.author.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="author-name">{rev.author}</div>
|
||||||
|
<div className="author-location">
|
||||||
|
<MapPin size={12} style={{ display: 'inline', marginRight: '3px' }} />
|
||||||
|
{rev.location} {rev.serviceMentioned ? `• ${rev.serviceMentioned}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SECTION 5: QUOTE FORM */}
|
||||||
|
{section.type === 'quote-form' && (
|
||||||
|
<section className="quote-section" id="quote">
|
||||||
|
<div className="quote-box">
|
||||||
|
<div className="section-header" style={{ marginBottom: '2rem' }}>
|
||||||
|
<span className="section-eyebrow">Fast Response</span>
|
||||||
|
<h2 className="section-title" style={{ fontSize: '2rem' }}>{content.formTitle || 'Request an Estimate'}</h2>
|
||||||
|
<p className="section-description">{content.formSubtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{quoteSubmitted ? (
|
||||||
|
<div className="quote-success-card">
|
||||||
|
<div className="quote-success-icon-ring">
|
||||||
|
<CheckCircle2 size={44} style={{ color: '#34d399' }} />
|
||||||
|
</div>
|
||||||
|
<h3 className="quote-success-title">Estimate Request Confirmed!</h3>
|
||||||
|
<p className="quote-success-desc">
|
||||||
|
Thank you, <strong>{leadName || 'Valued Customer'}</strong>! Your inquiry for <em>{leadService || 'Priority Service'}</em> has been dispatched directly to <strong>{profile.companyName}</strong>.
|
||||||
|
</p>
|
||||||
|
<div className="quote-success-pill">
|
||||||
|
<Clock size={16} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span>Guaranteed response in <strong>< 15 minutes</strong> at <strong>{leadPhone || profile.phone}</strong></span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginTop: '1.25rem', padding: '0.65rem 1.4rem', fontSize: '0.85rem' }}
|
||||||
|
onClick={() => setQuoteSubmitted(false)}
|
||||||
|
>
|
||||||
|
Submit Another Test Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setQuoteSubmitted(true);
|
||||||
|
}}>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Full Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="e.g. Michael Henderson"
|
||||||
|
value={leadName}
|
||||||
|
onChange={(e) => setLeadName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Phone Number *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="(555) 000-0000"
|
||||||
|
value={leadPhone}
|
||||||
|
onChange={(e) => setLeadPhone(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-full">
|
||||||
|
<label className="form-label">Email Address</label>
|
||||||
|
<input type="email" className="form-input" placeholder="michael@example.com" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group-full">
|
||||||
|
<label className="form-label">Service Required</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={leadService}
|
||||||
|
onChange={(e) => setLeadService(e.target.value)}
|
||||||
|
>
|
||||||
|
{profile.services.length > 0 ? (
|
||||||
|
profile.services.map((s, idx) => (
|
||||||
|
<option key={idx} value={s}>{s}</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value="general">General Estimate & Consultation</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-full">
|
||||||
|
<label className="form-label">Project Details / Message</label>
|
||||||
|
<textarea className="form-textarea" placeholder="Describe your property needs, timeline, or address..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div className="form-group-full" style={{ marginTop: '0.5rem' }}>
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ width: '100%', padding: '1rem' }}>
|
||||||
|
<span>Submit Free Quote Request</span>
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SECTION 6: CTA BANNER */}
|
||||||
|
{section.type === 'cta-banner' && (
|
||||||
|
<section className="cta-banner-section">
|
||||||
|
<div className="cta-banner-box">
|
||||||
|
<h2 className="cta-banner-title">{content.bannerTitle}</h2>
|
||||||
|
<p className="cta-banner-desc">{content.bannerDescription}</p>
|
||||||
|
<a href={`tel:${profile.phone}`} className="btn btn-accent" style={{ padding: '0.9rem 2.2rem', fontSize: '1.05rem' }}>
|
||||||
|
<Phone size={18} />
|
||||||
|
<span>{content.bannerCta || `Call: ${profile.phone}`}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SECTION 7: FOOTER */}
|
||||||
|
{section.type === 'footer' && (
|
||||||
|
<footer className="footer-section">
|
||||||
|
<div className="footer-grid">
|
||||||
|
<div>
|
||||||
|
<div className="site-logo">
|
||||||
|
<div className="brand-logo-icon">
|
||||||
|
{renderEnterpriseSvgIcon(profile.industry || profile.companyName || 'brand', 22)}
|
||||||
|
</div>
|
||||||
|
<span>{profile.companyName}</span>
|
||||||
|
</div>
|
||||||
|
<p className="footer-company-desc">
|
||||||
|
{profile.tagline}. Dedicated to providing premier services throughout {profile.city || 'the region'} with licensed technicians and ironclad warranties.
|
||||||
|
</p>
|
||||||
|
<div style={{ marginTop: '1.25rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', fontSize: '0.9rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#fff' }}>
|
||||||
|
<Phone size={16} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span>{profile.phone}</span>
|
||||||
|
</div>
|
||||||
|
{profile.address && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<MapPin size={16} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span>{profile.address}, {profile.city}, {profile.state} {profile.zip}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<Calendar size={16} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span>{content.hours || 'Mon-Sat: 7:00 AM - 6:30 PM'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="footer-col-title">Navigation</h4>
|
||||||
|
<ul className="footer-links-list">
|
||||||
|
{content.links?.map((lnk, idx) => (
|
||||||
|
<li key={idx}><a href={lnk.href}>{lnk.label}</a></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="footer-col-title">Services Offered</h4>
|
||||||
|
<ul className="footer-links-list">
|
||||||
|
{profile.services.slice(0, 5).map((s, idx) => (
|
||||||
|
<li key={idx}><a href="#quote">{s}</a></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="footer-bottom-bar">
|
||||||
|
<div>{content.copyrightText}</div>
|
||||||
|
<div className="whitelabel-credit">
|
||||||
|
<span>Site Architecture by</span>
|
||||||
|
<strong style={{ color: '#fff' }}>AI Pilots Site Engine</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { WebsiteSection, BusinessProfile } from '../types';
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
Phone,
|
||||||
|
ArrowRight,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
ShieldCheck,
|
||||||
|
Clock,
|
||||||
|
Star,
|
||||||
|
Award,
|
||||||
|
Edit3,
|
||||||
|
MapPin,
|
||||||
|
CheckCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { renderEnterpriseSvgIcon } from './EnterpriseSvgIcons';
|
||||||
|
|
||||||
|
interface SiteHeaderProps {
|
||||||
|
section: WebsiteSection;
|
||||||
|
profile: BusinessProfile;
|
||||||
|
viewportMode: 'desktop' | 'tablet' | 'mobile';
|
||||||
|
onQuickAiEdit: (sectionNumber: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SiteHeader: React.FC<SiteHeaderProps> = ({
|
||||||
|
section,
|
||||||
|
profile,
|
||||||
|
viewportMode,
|
||||||
|
onQuickAiEdit,
|
||||||
|
}) => {
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const { content, sectionNumber } = section;
|
||||||
|
|
||||||
|
const variant = content.headerVariant || 'emergency-trade';
|
||||||
|
const showTopBar = content.showTopBar !== false;
|
||||||
|
const ctaText = content.headerCtaText || (variant === 'emergency-trade' ? 'Emergency Dispatch' : 'Get Free Quote');
|
||||||
|
|
||||||
|
const navLinks = content.navLinks && content.navLinks.length > 0
|
||||||
|
? content.navLinks
|
||||||
|
: [
|
||||||
|
{ label: 'Services', href: '#services' },
|
||||||
|
{ label: 'Why Us', href: '#why-us' },
|
||||||
|
{ label: 'Reviews', href: '#reviews' },
|
||||||
|
{ label: 'Free Estimate', href: '#quote' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const cityState = profile.city && profile.state
|
||||||
|
? `${profile.city}, ${profile.state}`
|
||||||
|
: (profile.city || 'Greater Area');
|
||||||
|
|
||||||
|
const defaultTopAnnouncement = variant === 'modern-clean'
|
||||||
|
? 'Rated 5.0 Stars by Over 1,200+ Satisfied Homeowners • 100% Satisfaction Guarantee'
|
||||||
|
: variant === 'commercial-elite'
|
||||||
|
? 'Certified Master Commercial & Residential Contractors • Licensed & Bonded'
|
||||||
|
: '24/7 Rapid Emergency Response Available • Guaranteed On-Time Arrival';
|
||||||
|
|
||||||
|
const announcement = content.topBarAnnouncement || defaultTopAnnouncement;
|
||||||
|
const licenseBadge = content.licenseBadge || 'CA Lic #948201 • Fully Insured';
|
||||||
|
const serviceArea = content.serviceAreaText || `Serving ${cityState} & Nearby`;
|
||||||
|
|
||||||
|
const handleNavClick = (href: string) => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
const targetEl = document.querySelector(href);
|
||||||
|
if (targetEl) {
|
||||||
|
targetEl.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="site-section-wrapper header-section-wrapper" id="section-0">
|
||||||
|
{/* Floating Section Indicator Badge */}
|
||||||
|
<div className="section-indicator-bar">
|
||||||
|
<span className="section-num-pill">SECTION 0</span>
|
||||||
|
<span className="section-indicator-label">Header & Navigation</span>
|
||||||
|
<button
|
||||||
|
className="section-quick-edit-btn"
|
||||||
|
onClick={() => onQuickAiEdit(sectionNumber)}
|
||||||
|
title="Tell AI to modify Section 0 (Header)"
|
||||||
|
>
|
||||||
|
<Edit3 size={13} />
|
||||||
|
<span>AI Edit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header className={`preformatted-site-header variant-${variant}`}>
|
||||||
|
{/* 1. TOP EMERGENCY & TRUST BAR */}
|
||||||
|
{showTopBar && (
|
||||||
|
<div className="site-topbar">
|
||||||
|
<div className="topbar-inner">
|
||||||
|
{/* Left announcement / live pulse */}
|
||||||
|
<div className="topbar-left">
|
||||||
|
{variant === 'emergency-trade' ? (
|
||||||
|
<div className="topbar-pulse-item">
|
||||||
|
<span className="pulse-dot pulse-dot-green" />
|
||||||
|
<span className="topbar-text font-semibold">{announcement}</span>
|
||||||
|
</div>
|
||||||
|
) : variant === 'modern-clean' ? (
|
||||||
|
<div className="topbar-pulse-item">
|
||||||
|
<Star size={13} style={{ color: '#eab308' }} fill="#eab308" />
|
||||||
|
<span className="topbar-text">{announcement}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="topbar-pulse-item">
|
||||||
|
<Award size={13} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span className="topbar-text">{announcement}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right trust badges (desktop & tablet) */}
|
||||||
|
<div className="topbar-right">
|
||||||
|
<span className="topbar-badge">
|
||||||
|
<ShieldCheck size={13} />
|
||||||
|
<span>{licenseBadge}</span>
|
||||||
|
</span>
|
||||||
|
<span className="topbar-separator">•</span>
|
||||||
|
<span className="topbar-badge topbar-area">
|
||||||
|
<MapPin size={13} />
|
||||||
|
<span>{serviceArea}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2. MAIN NAVIGATION BAR */}
|
||||||
|
<div className="site-main-nav">
|
||||||
|
<div className="main-nav-inner">
|
||||||
|
{/* Brand Logo & Tagline */}
|
||||||
|
<div className="site-logo-wrap">
|
||||||
|
<a href="#section-0" className="site-logo" onClick={(e) => { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); }}>
|
||||||
|
<div className="brand-logo-icon">
|
||||||
|
{renderEnterpriseSvgIcon(profile.industry || profile.companyName || 'brand', 22)}
|
||||||
|
</div>
|
||||||
|
<div className="logo-text-block">
|
||||||
|
<span className="logo-company-name">{profile.companyName || 'Apex Business'}</span>
|
||||||
|
<span className="logo-subtext">{profile.industry || 'Certified Master Pros'}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Navigation Links */}
|
||||||
|
<nav className="desktop-nav-menu" aria-label="Main Navigation">
|
||||||
|
<ul className="site-nav-links">
|
||||||
|
{navLinks.map((link, idx) => (
|
||||||
|
<li key={idx}>
|
||||||
|
<a href={link.href} onClick={(e) => { e.preventDefault(); handleNavClick(link.href); }}>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Action CTAs */}
|
||||||
|
<div className="site-nav-cta-cluster">
|
||||||
|
{/* Phone CTA */}
|
||||||
|
{profile.phone && (
|
||||||
|
<a href={`tel:${profile.phone}`} className="nav-phone-button" title={`Call ${profile.phone}`}>
|
||||||
|
<div className="phone-icon-circle">
|
||||||
|
<Phone size={15} />
|
||||||
|
</div>
|
||||||
|
<div className="phone-text-block">
|
||||||
|
<span className="phone-sub-label">Speak With Tech</span>
|
||||||
|
<span className="phone-number">{profile.phone}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Primary Quote CTA Button */}
|
||||||
|
<a
|
||||||
|
href="#quote"
|
||||||
|
className="btn btn-primary nav-primary-cta"
|
||||||
|
onClick={(e) => { e.preventDefault(); handleNavClick('#quote'); }}
|
||||||
|
>
|
||||||
|
<span>{ctaText}</span>
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Mobile / iPad Hamburger Menu Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mobile-hamburger-btn"
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
aria-label={mobileMenuOpen ? 'Close Menu' : 'Open Menu'}
|
||||||
|
aria-expanded={mobileMenuOpen}
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. INTERACTIVE SLIDE-DOWN MOBILE & TABLET MENU DRAWER */}
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="mobile-nav-drawer-overlay">
|
||||||
|
<div className="mobile-nav-drawer">
|
||||||
|
<div className="mobile-drawer-header">
|
||||||
|
<div className="drawer-brand">
|
||||||
|
<Sparkles size={18} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span>{profile.companyName || 'Apex Business'}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="drawer-close-btn"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Navigation Links */}
|
||||||
|
<ul className="mobile-nav-links">
|
||||||
|
{navLinks.map((link, idx) => (
|
||||||
|
<li key={idx}>
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
onClick={(e) => { e.preventDefault(); handleNavClick(link.href); }}
|
||||||
|
>
|
||||||
|
<span>{link.label}</span>
|
||||||
|
<ArrowRight size={15} style={{ opacity: 0.5 }} />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Mobile Click-to-Call Direct Banner */}
|
||||||
|
{profile.phone && (
|
||||||
|
<a href={`tel:${profile.phone}`} className="mobile-drawer-call-card">
|
||||||
|
<div className="drawer-call-icon">
|
||||||
|
<Phone size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="drawer-call-title">Call For Immediate Service</div>
|
||||||
|
<div className="drawer-call-number">{profile.phone}</div>
|
||||||
|
</div>
|
||||||
|
<span className="pulse-dot pulse-dot-green ml-auto" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Full-Width Quote Button */}
|
||||||
|
<a
|
||||||
|
href="#quote"
|
||||||
|
className="btn btn-primary mobile-drawer-cta"
|
||||||
|
onClick={(e) => { e.preventDefault(); handleNavClick('#quote'); }}
|
||||||
|
>
|
||||||
|
<span>{ctaText}</span>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Mobile Trust Footer */}
|
||||||
|
<div className="mobile-drawer-footer">
|
||||||
|
<div className="drawer-trust-item">
|
||||||
|
<CheckCircle size={14} style={{ color: '#22c55e' }} />
|
||||||
|
<span>{licenseBadge}</span>
|
||||||
|
</div>
|
||||||
|
<div className="drawer-trust-item">
|
||||||
|
<Clock size={14} style={{ color: 'var(--accent)' }} />
|
||||||
|
<span>24/7 Dispatch • Free Estimates</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { WebsiteSection, BusinessProfile, SectionContent } from '../types';
|
||||||
|
import { SectionRenderer } from './SectionRenderer';
|
||||||
|
import { SiteHeader } from './SiteHeader';
|
||||||
|
import { Phone, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
interface WebsitePreviewProps {
|
||||||
|
sections: WebsiteSection[];
|
||||||
|
profile: BusinessProfile;
|
||||||
|
viewportMode: 'desktop' | 'tablet' | 'mobile';
|
||||||
|
onQuickAiEdit: (sectionNumber: number) => void;
|
||||||
|
onUpdateSectionContent?: (sectionNumber: number, newContent: Partial<SectionContent>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WebsitePreview: React.FC<WebsitePreviewProps> = ({
|
||||||
|
sections,
|
||||||
|
profile,
|
||||||
|
viewportMode,
|
||||||
|
onQuickAiEdit,
|
||||||
|
onUpdateSectionContent,
|
||||||
|
}) => {
|
||||||
|
// Find Section 0 (Header) or fallback to default
|
||||||
|
const headerSection: WebsiteSection = sections.find(
|
||||||
|
(s) => s.type === 'header' || s.sectionNumber === 0
|
||||||
|
) || {
|
||||||
|
id: 'sec-header',
|
||||||
|
sectionNumber: 0,
|
||||||
|
type: 'header',
|
||||||
|
title: 'Header & Navigation',
|
||||||
|
badge: 'Standard Header',
|
||||||
|
visible: true,
|
||||||
|
content: {
|
||||||
|
headerVariant: 'emergency-trade',
|
||||||
|
showTopBar: true,
|
||||||
|
headerCtaText: 'Emergency Dispatch',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main sections (excluding Section 0)
|
||||||
|
const mainSections = sections.filter(
|
||||||
|
(s) => s.type !== 'header' && s.sectionNumber !== 0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`site-frame-container viewport-${viewportMode}`}>
|
||||||
|
{viewportMode === 'mobile' && <div className="mobile-notch" />}
|
||||||
|
|
||||||
|
<div className="site-canvas">
|
||||||
|
{/* Preformatted Responsive Header & Navigation (Section 0) */}
|
||||||
|
{headerSection.visible && (
|
||||||
|
<SiteHeader
|
||||||
|
section={headerSection}
|
||||||
|
profile={profile}
|
||||||
|
viewportMode={viewportMode}
|
||||||
|
onQuickAiEdit={onQuickAiEdit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dynamic Sections Stack */}
|
||||||
|
<main>
|
||||||
|
{mainSections.map((section) => (
|
||||||
|
section.visible && (
|
||||||
|
<SectionRenderer
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
profile={profile}
|
||||||
|
onQuickAiEdit={onQuickAiEdit}
|
||||||
|
onUpdateSectionContent={onUpdateSectionContent}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Sticky Mobile Lead & Call Action Bar */}
|
||||||
|
{viewportMode === 'mobile' && (
|
||||||
|
<div className="mobile-sticky-action-bar">
|
||||||
|
<a href={`tel:${profile.phone}`} className="mobile-sticky-call-btn">
|
||||||
|
<span className="mobile-call-pulse-dot" />
|
||||||
|
<Phone size={15} />
|
||||||
|
<span>Call Now</span>
|
||||||
|
</a>
|
||||||
|
<a href="#quote" className="mobile-sticky-quote-btn">
|
||||||
|
<Zap size={14} />
|
||||||
|
<span>Free Estimate</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
export type ThemeId =
|
||||||
|
| 'titan-navy'
|
||||||
|
| 'obsidian-gold'
|
||||||
|
| 'emerald-slate'
|
||||||
|
| 'crimson-forge'
|
||||||
|
| 'alpine-clean'
|
||||||
|
| 'electric-violet'
|
||||||
|
| 'sunset-amber'
|
||||||
|
| 'cyber-cyan'
|
||||||
|
| 'neon-rose'
|
||||||
|
| 'custom-dynamic';
|
||||||
|
|
||||||
|
export type ColorMode = 'dark' | 'light';
|
||||||
|
|
||||||
|
export interface ThemeConfig {
|
||||||
|
id: ThemeId;
|
||||||
|
name: string;
|
||||||
|
tagline: string;
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
accent: string;
|
||||||
|
bgDark: string;
|
||||||
|
bgLight: string;
|
||||||
|
cardBgDark: string;
|
||||||
|
cardBgLight: string;
|
||||||
|
borderDark: string;
|
||||||
|
borderLight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessProfile {
|
||||||
|
companyName: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip: string;
|
||||||
|
industry: string;
|
||||||
|
tagline: string;
|
||||||
|
yearsInBusiness: string;
|
||||||
|
services: string[];
|
||||||
|
emergencyService: boolean;
|
||||||
|
theme: ThemeId;
|
||||||
|
colorMode?: ColorMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SectionType =
|
||||||
|
| 'header'
|
||||||
|
| 'hero'
|
||||||
|
| 'services'
|
||||||
|
| 'why-us'
|
||||||
|
| 'testimonials'
|
||||||
|
| 'quote-form'
|
||||||
|
| 'cta-banner'
|
||||||
|
| 'footer';
|
||||||
|
|
||||||
|
export interface ServiceItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
iconName: string;
|
||||||
|
popular?: boolean;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestimonialItem {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
location: string;
|
||||||
|
rating: number;
|
||||||
|
quote: string;
|
||||||
|
serviceMentioned?: string;
|
||||||
|
date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatItem {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
subtext?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValuePillarItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
iconName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionContent {
|
||||||
|
// Header section content
|
||||||
|
headerVariant?: 'emergency-trade' | 'modern-clean' | 'commercial-elite';
|
||||||
|
showTopBar?: boolean;
|
||||||
|
topBarAnnouncement?: string;
|
||||||
|
licenseBadge?: string;
|
||||||
|
serviceAreaText?: string;
|
||||||
|
headerCtaText?: string;
|
||||||
|
navLinks?: Array<{ label: string; href: string }>;
|
||||||
|
|
||||||
|
// Hero section content
|
||||||
|
headline?: string;
|
||||||
|
subheadline?: string;
|
||||||
|
primaryCtaText?: string;
|
||||||
|
secondaryCtaText?: string;
|
||||||
|
trustBadges?: string[];
|
||||||
|
heroImageUrl?: string;
|
||||||
|
|
||||||
|
// Services section content
|
||||||
|
servicesList?: ServiceItem[];
|
||||||
|
columns?: number;
|
||||||
|
|
||||||
|
// Why Us content
|
||||||
|
stats?: StatItem[];
|
||||||
|
pillars?: ValuePillarItem[];
|
||||||
|
|
||||||
|
// Testimonials content
|
||||||
|
reviews?: TestimonialItem[];
|
||||||
|
averageRating?: number;
|
||||||
|
totalReviews?: number;
|
||||||
|
|
||||||
|
// Quote form content
|
||||||
|
formTitle?: string;
|
||||||
|
formSubtitle?: string;
|
||||||
|
fields?: Array<{ label: string; type: string; placeholder: string; required?: boolean }>;
|
||||||
|
|
||||||
|
// CTA Banner content
|
||||||
|
bannerTitle?: string;
|
||||||
|
bannerDescription?: string;
|
||||||
|
bannerCta?: string;
|
||||||
|
|
||||||
|
// Footer content
|
||||||
|
hours?: string;
|
||||||
|
copyrightText?: string;
|
||||||
|
links?: Array<{ label: string; href: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsiteSection {
|
||||||
|
id: string;
|
||||||
|
sectionNumber: number;
|
||||||
|
type: SectionType;
|
||||||
|
title: string;
|
||||||
|
badge?: string;
|
||||||
|
content: SectionContent;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
sender: 'user' | 'ai' | 'system';
|
||||||
|
text: string;
|
||||||
|
timestamp: string;
|
||||||
|
actionSuggested?: {
|
||||||
|
type: 'apply_change' | 'select_theme' | 'fill_field' | 'generate_site';
|
||||||
|
payload?: any;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
modifiedSectionNumber?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientContactDetails {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
domainPreference?: string;
|
||||||
|
notes?: string;
|
||||||
|
wantsGmb: boolean;
|
||||||
|
wantsMailcowEmail: boolean;
|
||||||
|
wantsCrm: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadSubmission {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
businessProfile: BusinessProfile;
|
||||||
|
clientContact: ClientContactDetails;
|
||||||
|
sections: WebsiteSection[];
|
||||||
|
theme: ThemeId;
|
||||||
|
colorMode?: ColorMode;
|
||||||
|
status: 'pending_review' | 'contacted' | 'deployed';
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/agencyadmindrawer.tsx","./src/components/aiassistantdrawer.tsx","./src/components/approvalmodal.tsx","./src/components/enterprisesvgicons.tsx","./src/components/onboardingmodal.tsx","./src/components/sectionrenderer.tsx","./src/components/siteheader.tsx","./src/components/websitepreview.tsx","./src/services/aiengine.ts"],"version":"5.9.3"}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true
|
||||||
|
}
|
||||||
|
});
|
||||||