diff --git a/.gitignore b/.gitignore index 67ef3de1f5..c45c9896a4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ _worker.bundle package-lock.json test-results/ playwright-report/ +coverage/ +apps/project-sites/sites/ Modelfile modelfiles diff --git a/app/components/chat/ModelSelector.tsx b/app/components/chat/ModelSelector.tsx index 052f58a649..b4a8bac864 100644 --- a/app/components/chat/ModelSelector.tsx +++ b/app/components/chat/ModelSelector.tsx @@ -3,7 +3,6 @@ import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; import type { KeyboardEvent } from 'react'; import type { ModelInfo } from '~/lib/modules/llm/types'; import { classNames } from '~/utils/classNames'; -import { DEFAULT_MODEL } from '~/utils/constants'; import { LOCAL_PROVIDERS } from '~/lib/stores/settings'; // Fuzzy search utilities diff --git a/app/components/deploy/DeployButton.tsx b/app/components/deploy/DeployButton.tsx index ffdeb37e9b..5728d02bac 100644 --- a/app/components/deploy/DeployButton.tsx +++ b/app/components/deploy/DeployButton.tsx @@ -13,14 +13,21 @@ import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client'; import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client'; import { useGitHubDeploy } from '~/components/deploy/GitHubDeploy.client'; import { useGitLabDeploy } from '~/components/deploy/GitLabDeploy.client'; +import { useS3Deploy } from '~/components/deploy/S3Deploy.client'; import { GitHubDeploymentDialog } from '~/components/deploy/GitHubDeploymentDialog'; import { GitLabDeploymentDialog } from '~/components/deploy/GitLabDeploymentDialog'; +import { s3Connection } from '~/lib/stores/s3'; +import { toast } from 'react-toastify'; +import { db, chatId, description as chatDescription } from '~/lib/persistence/useChatHistory'; +import { getMessages } from '~/lib/persistence/db'; interface DeployButtonProps { onVercelDeploy?: () => Promise; onNetlifyDeploy?: () => Promise; onGitHubDeploy?: () => Promise; onGitLabDeploy?: () => Promise; + onS3Deploy?: () => Promise; + onProjectSitesDeploy?: () => Promise; } export const DeployButton = ({ @@ -28,20 +35,24 @@ export const DeployButton = ({ onNetlifyDeploy, onGitHubDeploy, onGitLabDeploy, + onS3Deploy, + onProjectSitesDeploy, }: DeployButtonProps) => { const netlifyConn = useStore(netlifyConnection); const vercelConn = useStore(vercelConnection); const gitlabIsConnected = useStore(isGitLabConnected); + const s3Conn = useStore(s3Connection); const [activePreviewIndex] = useState(0); const previews = useStore(workbenchStore.previews); const activePreview = previews[activePreviewIndex]; const [isDeploying, setIsDeploying] = useState(false); - const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'github' | 'gitlab' | null>(null); + const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'github' | 'gitlab' | 's3' | null>(null); const isStreaming = useStore(streamingState); const { handleVercelDeploy } = useVercelDeploy(); const { handleNetlifyDeploy } = useNetlifyDeploy(); const { handleGitHubDeploy } = useGitHubDeploy(); const { handleGitLabDeploy } = useGitLabDeploy(); + const { handleS3Deploy } = useS3Deploy(); const [showGitHubDeploymentDialog, setShowGitHubDeploymentDialog] = useState(false); const [showGitLabDeploymentDialog, setShowGitLabDeploymentDialog] = useState(false); const [githubDeploymentFiles, setGithubDeploymentFiles] = useState | null>(null); @@ -125,6 +136,134 @@ export const DeployButton = ({ } }; + const handleS3DeployClick = async () => { + setIsDeploying(true); + setDeployingTo('s3'); + + try { + if (onS3Deploy) { + await onS3Deploy(); + } else { + await handleS3Deploy(); + } + } finally { + setIsDeploying(false); + setDeployingTo(null); + } + }; + + const [showProjectSitesDialog, setShowProjectSitesDialog] = useState(false); + const [projectSitesSlug, setProjectSitesSlug] = useState(''); + const [projectSitesBuildFolder, setProjectSitesBuildFolder] = useState('dist/'); + + const handleProjectSitesDeployClick = async () => { + if (onProjectSitesDeploy) { + setIsDeploying(true); + setDeployingTo(null); + + try { + await onProjectSitesDeploy(); + } finally { + setIsDeploying(false); + setDeployingTo(null); + } + } else { + setShowProjectSitesDialog(true); + } + }; + + const handleProjectSitesDeployConfirm = async () => { + if (!projectSitesSlug.trim()) { + toast.error('Please enter a site slug'); + return; + } + + setShowProjectSitesDialog(false); + setIsDeploying(true); + setDeployingTo(null); + + try { + toast.info('Packaging site for Project Sites...'); + + // Get the project files and create a ZIP + const zip = await workbenchStore.getZipBlob(projectSitesBuildFolder || undefined); + + if (!zip) { + toast.error('Failed to package project files'); + return; + } + + // Get actual chat messages from current session + let chatMessages: unknown[] = []; + let chatDesc = 'Deployed from Bolt'; + + try { + const currentChatId = chatId.get(); + + if (db && currentChatId) { + const chat = await getMessages(db, currentChatId); + + if (chat && chat.messages && chat.messages.length > 0) { + chatMessages = chat.messages; + chatDesc = chat.description || chatDescription.get() || 'Deployed from Bolt'; + } + } + } catch { + // Fall back to empty messages if chat retrieval fails + } + + const chatData = { + messages: chatMessages, + description: chatDesc, + exportDate: new Date().toISOString(), + }; + + const formData = new FormData(); + formData.append('zip', zip, 'site.zip'); + formData.append('chat', new Blob([JSON.stringify(chatData)], { type: 'application/json' }), 'chat.json'); + formData.append('dist_path', projectSitesBuildFolder || 'dist/'); + + // Find or create the site, then deploy + const siteBaseUrl = 'https://sites.megabyte.space'; + const lookupRes = await fetch(`${siteBaseUrl}/api/sites/lookup?slug=${encodeURIComponent(projectSitesSlug)}`); + + let siteId: string | null = null; + + if (lookupRes.ok) { + const lookupData = (await lookupRes.json()) as { data?: { id: string } }; + siteId = lookupData.data?.id || null; + } + + if (!siteId) { + toast.info('Site not found. Please create the site at sites.megabyte.space first, then deploy.'); + window.open(`${siteBaseUrl}/?create=${encodeURIComponent(projectSitesSlug)}`, '_blank'); + + return; + } + + toast.info('Deploying to Project Sites...'); + + const deployRes = await fetch(`${siteBaseUrl}/api/sites/${siteId}/deploy`, { + method: 'POST', + body: formData, + }); + + if (!deployRes.ok) { + const errText = await deployRes.text(); + toast.error('Deploy failed: ' + errText); + + return; + } + + toast.success(`Deployed to ${projectSitesSlug}-sites.megabyte.space!`); + } catch (err) { + toast.error('Deploy failed: ' + (err instanceof Error ? err.message : String(err))); + } finally { + setIsDeploying(false); + setDeployingTo(null); + } + }; + return ( <>
@@ -236,18 +375,46 @@ export const DeployButton = ({ cloudflare - Deploy to Cloudflare (Coming Soon) + + {!s3Conn.connected + ? 'No S3/R2 Connection Configured' + : `Deploy to ${s3Conn.provider === 'r2' ? 'Cloudflare R2' : 'AWS S3'}`} + + + + + + +
+
+
+ Deploy to Project Sites @@ -272,6 +439,68 @@ export const DeployButton = ({ files={gitlabDeploymentFiles} /> )} + + {/* Project Sites Deployment Dialog */} + {showProjectSitesDialog && ( +
+
+

+
+ Deploy to Project Sites +

+ +
+
+ + setProjectSitesSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} + placeholder="my-site" + className="w-full bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor rounded-lg px-3 py-2 text-sm text-bolt-elements-textPrimary focus:outline-none focus:border-purple-500" + /> +

+ Your site will be at{' '} + {projectSitesSlug || 'slug'}-sites.megabyte.space +

+
+ +
+ + setProjectSitesBuildFolder(e.target.value)} + placeholder="dist/" + className="w-full bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor rounded-lg px-3 py-2 text-sm text-bolt-elements-textPrimary focus:outline-none focus:border-purple-500" + /> +

+ The folder containing your built site (e.g. dist/, build/, public/) +

+
+
+ +
+ + +
+
+
+ )} ); }; diff --git a/app/components/deploy/S3Deploy.client.tsx b/app/components/deploy/S3Deploy.client.tsx new file mode 100644 index 0000000000..da6737c9fd --- /dev/null +++ b/app/components/deploy/S3Deploy.client.tsx @@ -0,0 +1,182 @@ +import { toast } from 'react-toastify'; +import { useStore } from '@nanostores/react'; +import { s3Connection } from '~/lib/stores/s3'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { webcontainer } from '~/lib/webcontainer'; +import { path } from '~/utils/path'; +import { useState } from 'react'; +import type { ActionCallbackData } from '~/lib/runtime/message-parser'; +import { chatId } from '~/lib/persistence/useChatHistory'; +import { formatBuildFailureOutput } from './deployUtils'; + +export function useS3Deploy() { + const [isDeploying, setIsDeploying] = useState(false); + const s3Conn = useStore(s3Connection); + const currentChatId = useStore(chatId); + + const handleS3Deploy = async () => { + if (!s3Conn.connected || !s3Conn.bucket || !s3Conn.accessKeyId) { + toast.error('Please configure S3/R2 connection in Settings first!'); + return false; + } + + if (!currentChatId) { + toast.error('No active chat found'); + return false; + } + + try { + setIsDeploying(true); + + const artifact = workbenchStore.firstArtifact; + + if (!artifact) { + throw new Error('No active project found'); + } + + // Create a deployment artifact for visual feedback + const deploymentId = 'deploy-artifact'; + workbenchStore.addArtifact({ + id: deploymentId, + messageId: deploymentId, + title: `${s3Conn.provider.toUpperCase()} Deployment`, + type: 'standalone', + }); + + const deployArtifact = workbenchStore.artifacts.get()[deploymentId]; + + // Notify that build is starting + deployArtifact.runner.handleDeployAction('building', 'running', { source: s3Conn.provider }); + + // Set up build action + const actionId = 'build-' + Date.now(); + const actionData: ActionCallbackData = { + messageId: `${s3Conn.provider} build`, + artifactId: artifact.id, + actionId, + action: { + type: 'build' as const, + content: 'npm run build', + }, + }; + + // Add the action first, then run it + artifact.runner.addAction(actionData); + await artifact.runner.runAction(actionData); + + const buildOutput = artifact.runner.buildOutput; + + if (!buildOutput || buildOutput.exitCode !== 0) { + deployArtifact.runner.handleDeployAction('building', 'failed', { + error: formatBuildFailureOutput(buildOutput?.output), + source: s3Conn.provider, + }); + throw new Error('Build failed'); + } + + // Build succeeded, start deployment + deployArtifact.runner.handleDeployAction('deploying', 'running', { source: s3Conn.provider }); + + const container = await webcontainer; + const buildPath = buildOutput.path.replace('/home/project', ''); + + // Find the build output directory + let finalBuildPath = buildPath; + const commonOutputDirs = [buildPath, '/dist', '/build', '/out', '/output', '/.next', '/public']; + let buildPathExists = false; + + for (const dir of commonOutputDirs) { + try { + await container.fs.readdir(dir); + finalBuildPath = dir; + buildPathExists = true; + break; + } catch { + continue; + } + } + + if (!buildPathExists) { + throw new Error('Could not find build output directory.'); + } + + // Recursively read all files from build output + async function getAllFiles(dirPath: string): Promise> { + const files: Record = {}; + const entries = await container.fs.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + if (entry.isFile()) { + const content = await container.fs.readFile(fullPath, 'utf-8'); + const deployPath = fullPath.replace(finalBuildPath, ''); + files[deployPath] = content; + } else if (entry.isDirectory()) { + const subFiles = await getAllFiles(fullPath); + Object.assign(files, subFiles); + } + } + + return files; + } + + const fileContents = await getAllFiles(finalBuildPath); + + // Send files to the S3/R2 deploy API + const response = await fetch('/api/s3-deploy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'deploy', + provider: s3Conn.provider, + endpoint: s3Conn.endpoint, + bucket: s3Conn.bucket, + accessKeyId: s3Conn.accessKeyId, + secretAccessKey: s3Conn.secretAccessKey, + region: s3Conn.region, + pathPrefix: s3Conn.pathPrefix, + files: fileContents, + chatId: currentChatId, + }), + }); + + const data = (await response.json()) as { ok: boolean; url?: string; fileCount?: number; error?: string }; + + if (!response.ok || !data.ok) { + deployArtifact.runner.handleDeployAction('deploying', 'failed', { + error: data.error || 'Deployment failed', + source: s3Conn.provider, + }); + throw new Error(data.error || 'Deployment failed'); + } + + // Deployment succeeded + const siteUrl = s3Conn.customDomain + ? `https://${s3Conn.customDomain}${s3Conn.pathPrefix ? '/' + s3Conn.pathPrefix : ''}` + : data.url || `https://${s3Conn.bucket}.${s3Conn.endpoint}`; + + deployArtifact.runner.handleDeployAction('complete', 'complete', { + url: siteUrl, + source: s3Conn.provider, + }); + + toast.success( + `Deployed ${data.fileCount ?? Object.keys(fileContents).length} files to ${s3Conn.provider.toUpperCase()}!`, + ); + + return true; + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Deployment failed'); + return false; + } finally { + setIsDeploying(false); + } + }; + + return { + isDeploying, + handleS3Deploy, + isConnected: s3Conn.connected, + }; +} diff --git a/app/entry.server.tsx b/app/entry.server.tsx index af399c140b..8483e4f0cd 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -70,8 +70,9 @@ export default async function handleRequest( responseHeaders.set('Content-Type', 'text/html'); - responseHeaders.set('Cross-Origin-Embedder-Policy', 'require-corp'); + responseHeaders.set('Cross-Origin-Embedder-Policy', 'credentialless'); responseHeaders.set('Cross-Origin-Opener-Policy', 'same-origin'); + responseHeaders.set('Origin-Agent-Cluster', '?1'); return new Response(body, { headers: responseHeaders, diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index 64f5ee6d1b..0fe47644a3 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -530,7 +530,7 @@ export class ActionRunner { details?: { url?: string; error?: string; - source?: 'netlify' | 'vercel' | 'github' | 'gitlab'; + source?: 'netlify' | 'vercel' | 'github' | 'gitlab' | 's3' | 'r2'; }, ): void { if (!this.onDeployAlert) { diff --git a/app/lib/stores/s3.spec.ts b/app/lib/stores/s3.spec.ts new file mode 100644 index 0000000000..2e7799eb34 --- /dev/null +++ b/app/lib/stores/s3.spec.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +// Mock localStorage before importing the module +const mockStorage = new Map(); +const localStorageMock = { + getItem: vi.fn((key: string) => mockStorage.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => mockStorage.set(key, value)), + removeItem: vi.fn((key: string) => mockStorage.delete(key)), + clear: vi.fn(() => mockStorage.clear()), + get length() { + return mockStorage.size; + }, + key: vi.fn((_index: number) => null), +}; + +vi.stubGlobal('localStorage', localStorageMock); + +// Mock fetch for testS3Connection +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Now import the module +const { s3Connection, updateS3Connection, disconnectS3, testS3Connection } = await import('./s3'); + +beforeEach(() => { + mockStorage.clear(); + vi.clearAllMocks(); + + // Reset store to default state + s3Connection.set({ + provider: 'r2', + endpoint: '', + bucket: '', + accessKeyId: '', + secretAccessKey: '', + region: 'auto', + pathPrefix: '', + customDomain: '', + connected: false, + }); +}); + +describe('s3Connection store — default state', () => { + it('has correct default values', () => { + const state = s3Connection.get(); + expect(state.provider).toBe('r2'); + expect(state.endpoint).toBe(''); + expect(state.bucket).toBe(''); + expect(state.accessKeyId).toBe(''); + expect(state.secretAccessKey).toBe(''); + expect(state.region).toBe('auto'); + expect(state.pathPrefix).toBe(''); + expect(state.customDomain).toBe(''); + expect(state.connected).toBe(false); + }); +}); + +describe('updateS3Connection', () => { + it('updates partial fields', () => { + updateS3Connection({ endpoint: 'https://s3.amazonaws.com', bucket: 'my-bucket' }); + + const state = s3Connection.get(); + expect(state.endpoint).toBe('https://s3.amazonaws.com'); + expect(state.bucket).toBe('my-bucket'); + + // Other fields remain default + expect(state.provider).toBe('r2'); + expect(state.connected).toBe(false); + }); + + it('persists to localStorage', () => { + updateS3Connection({ provider: 's3', bucket: 'test' }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith('s3_connection', expect.stringContaining('"bucket":"test"')); + }); + + it('merges updates cumulatively', () => { + updateS3Connection({ endpoint: 'https://example.com' }); + updateS3Connection({ bucket: 'my-bucket' }); + updateS3Connection({ connected: true }); + + const state = s3Connection.get(); + expect(state.endpoint).toBe('https://example.com'); + expect(state.bucket).toBe('my-bucket'); + expect(state.connected).toBe(true); + }); + + it('handles localStorage errors gracefully', () => { + localStorageMock.setItem.mockImplementationOnce(() => { + throw new Error('QuotaExceeded'); + }); + + // Should not throw + expect(() => updateS3Connection({ bucket: 'test' })).not.toThrow(); + expect(s3Connection.get().bucket).toBe('test'); // State still updated in memory + }); +}); + +describe('disconnectS3', () => { + it('resets all fields to default', () => { + updateS3Connection({ + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + connected: true, + }); + + disconnectS3(); + + const state = s3Connection.get(); + expect(state.provider).toBe('r2'); + expect(state.endpoint).toBe(''); + expect(state.bucket).toBe(''); + expect(state.accessKeyId).toBe(''); + expect(state.secretAccessKey).toBe(''); + expect(state.connected).toBe(false); + }); + + it('removes from localStorage', () => { + updateS3Connection({ bucket: 'test' }); + disconnectS3(); + + expect(localStorageMock.removeItem).toHaveBeenCalledWith('s3_connection'); + }); + + it('handles localStorage errors gracefully', () => { + localStorageMock.removeItem.mockImplementationOnce(() => { + throw new Error('Not allowed'); + }); + + expect(() => disconnectS3()).not.toThrow(); + }); +}); + +describe('testS3Connection', () => { + it('calls /api/s3-deploy with test action', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const conn = { + ...s3Connection.get(), + provider: 's3' as const, + endpoint: 'https://s3.amazonaws.com', + bucket: 'test-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + }; + + const result = await testS3Connection(conn); + + expect(result.ok).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/s3-deploy'); + expect(opts.method).toBe('POST'); + + const body = JSON.parse(opts.body); + expect(body.action).toBe('test'); + expect(body.provider).toBe('s3'); + expect(body.bucket).toBe('test-bucket'); + }); + + it('returns error from API', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: false, error: 'AccessDenied' }), { + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const conn = { + ...s3Connection.get(), + endpoint: 'https://s3.amazonaws.com', + bucket: 'test', + accessKeyId: 'bad', + secretAccessKey: 'creds', + region: 'us-east-1', + }; + + const result = await testS3Connection(conn); + + expect(result.ok).toBe(false); + expect(result.error).toBe('AccessDenied'); + }); + + it('handles network errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('fetch failed')); + + const conn = { + ...s3Connection.get(), + endpoint: 'https://unreachable.example.com', + bucket: 'test', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + }; + + const result = await testS3Connection(conn); + + expect(result.ok).toBe(false); + expect(result.error).toBe('fetch failed'); + }); + + it('handles non-Error exceptions', async () => { + mockFetch.mockRejectedValueOnce('string error'); + + const conn = { + ...s3Connection.get(), + endpoint: 'https://example.com', + bucket: 'test', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + }; + + const result = await testS3Connection(conn); + + expect(result.ok).toBe(false); + expect(result.error).toBe('Connection failed'); + }); +}); diff --git a/app/lib/stores/s3.ts b/app/lib/stores/s3.ts new file mode 100644 index 0000000000..af9ab50032 --- /dev/null +++ b/app/lib/stores/s3.ts @@ -0,0 +1,88 @@ +import { atom } from 'nanostores'; + +export interface S3Connection { + provider: 's3' | 'r2'; + endpoint: string; + bucket: string; + accessKeyId: string; + secretAccessKey: string; + region: string; + pathPrefix: string; + customDomain: string; + connected: boolean; +} + +const STORAGE_KEY = 's3_connection'; + +const defaultState: S3Connection = { + provider: 'r2', + endpoint: '', + bucket: '', + accessKeyId: '', + secretAccessKey: '', + region: 'auto', + pathPrefix: '', + customDomain: '', + connected: false, +}; + +function loadFromStorage(): S3Connection { + try { + const stored = localStorage.getItem(STORAGE_KEY); + + if (stored) { + return { ...defaultState, ...JSON.parse(stored) }; + } + } catch { + // Ignore parsing errors + } + + return defaultState; +} + +export const s3Connection = atom(loadFromStorage()); + +export function updateS3Connection(update: Partial): void { + const current = s3Connection.get(); + const next = { ...current, ...update }; + s3Connection.set(next); + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // Ignore storage errors + } +} + +export function disconnectS3(): void { + s3Connection.set(defaultState); + + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Ignore storage errors + } +} + +export async function testS3Connection(conn: S3Connection): Promise<{ ok: boolean; error?: string }> { + try { + const res = await fetch('/api/s3-deploy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'test', + provider: conn.provider, + endpoint: conn.endpoint, + bucket: conn.bucket, + accessKeyId: conn.accessKeyId, + secretAccessKey: conn.secretAccessKey, + region: conn.region, + }), + }); + const data = (await res.json()) as { ok: boolean; error?: string }; + + return data; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Connection failed' }; + } +} diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index c0399d2ee5..3f0a57bd2f 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -791,6 +791,43 @@ export class WorkbenchStore { return { slug, url }; } + async getZipBlob(subFolder?: string): Promise { + const zip = new JSZip(); + const files = this.files.get(); + + for (const [filePath, dirent] of Object.entries(files)) { + if (dirent?.type === 'file' && !dirent.isBinary) { + let relativePath = extractRelativePath(filePath); + + if (subFolder) { + const prefix = subFolder.endsWith('/') ? subFolder : `${subFolder}/`; + + if (!relativePath.startsWith(prefix)) { + continue; + } + + relativePath = relativePath.slice(prefix.length); + } + + const pathSegments = relativePath.split('/'); + + if (pathSegments.length > 1) { + let currentFolder = zip; + + for (let i = 0; i < pathSegments.length - 1; i++) { + currentFolder = currentFolder.folder(pathSegments[i])!; + } + + currentFolder.file(pathSegments[pathSegments.length - 1], dirent.content); + } else { + zip.file(relativePath, dirent.content); + } + } + } + + return await zip.generateAsync({ type: 'blob' }); + } + async downloadZip() { const zip = new JSZip(); const files = this.files.get(); diff --git a/app/routes/api.s3-deploy.spec.ts b/app/routes/api.s3-deploy.spec.ts new file mode 100644 index 0000000000..cb78d52a7b --- /dev/null +++ b/app/routes/api.s3-deploy.spec.ts @@ -0,0 +1,413 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +/** + * We test the route's action function by importing the module + * and calling action() with mock Request objects. + * + * We mock global `fetch` to capture outgoing S3/R2 calls. + * We mock `crypto.subtle` for deterministic signature tests. + */ + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Import after mocks are set up +const { action } = await import('./api.s3-deploy'); + +function makeRequest(body: Record): Request { + return new Request('http://localhost/api/s3-deploy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +async function callAction(body: Record) { + const request = makeRequest(body); + const response = await action({ + request, + params: {}, + context: {} as never, + }); + + // Remix json() returns a Response + const data = await response.json(); + + return { status: response.status, data }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('api.s3-deploy — validation', () => { + it('returns 400 when endpoint is missing', async () => { + const { status, data } = await callAction({ + action: 'test', + provider: 's3', + endpoint: '', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + }); + expect(status).toBe(400); + expect(data.ok).toBe(false); + expect(data.error).toMatch(/Missing required/); + }); + + it('returns 400 when bucket is missing', async () => { + const { status, data } = await callAction({ + action: 'test', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: '', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + }); + expect(status).toBe(400); + expect(data.ok).toBe(false); + }); + + it('returns 400 when accessKeyId is missing', async () => { + const { status, data } = await callAction({ + action: 'test', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: '', + secretAccessKey: 'secret', + region: 'us-east-1', + }); + expect(status).toBe(400); + expect(data.ok).toBe(false); + }); + + it('returns 400 when secretAccessKey is missing', async () => { + const { status, data } = await callAction({ + action: 'test', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: '', + region: 'us-east-1', + }); + expect(status).toBe(400); + expect(data.ok).toBe(false); + }); + + it('returns 400 for invalid action', async () => { + const { status, data } = await callAction({ + action: 'invalid', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + }); + expect(status).toBe(400); + expect(data.ok).toBe(false); + expect(data.error).toMatch(/Invalid action/); + }); +}); + +describe('api.s3-deploy — test connection', () => { + it('returns ok:true when S3 responds 200', async () => { + mockFetch.mockResolvedValueOnce(new Response('', { status: 200 })); + + const { data } = await callAction({ + action: 'test', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + }); + + expect(data.ok).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Verify it called the correct URL pattern + const fetchUrl = mockFetch.mock.calls[0][0]; + expect(fetchUrl).toContain('s3.amazonaws.com/my-bucket'); + expect(fetchUrl).toContain('list-type=2'); + expect(fetchUrl).toContain('max-keys=1'); + }); + + it('returns ok:false when S3 responds with error', async () => { + mockFetch.mockResolvedValueOnce(new Response('AccessDenied', { status: 403 })); + + const { data } = await callAction({ + action: 'test', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + }); + + expect(data.ok).toBe(false); + expect(data.error).toContain('403'); + }); + + it('returns ok:false on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network failure')); + + const { data } = await callAction({ + action: 'test', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + }); + + expect(data.ok).toBe(false); + expect(data.error).toContain('Network failure'); + }); + + it('uses region=auto for R2 provider', async () => { + mockFetch.mockResolvedValueOnce(new Response('', { status: 200 })); + + await callAction({ + action: 'test', + provider: 'r2', + endpoint: 'https://abc123.r2.cloudflarestorage.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', // should be overridden to 'auto' + }); + + /* + * R2 uses auto region — we can't directly verify the signing, + * but we verify fetch was called with the R2 endpoint + */ + const fetchUrl = mockFetch.mock.calls[0][0]; + expect(fetchUrl).toContain('r2.cloudflarestorage.com/my-bucket'); + }); + + it('prepends https:// if missing from endpoint', async () => { + mockFetch.mockResolvedValueOnce(new Response('', { status: 200 })); + + await callAction({ + action: 'test', + provider: 's3', + endpoint: 's3.amazonaws.com', + bucket: 'test-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + }); + + const fetchUrl = mockFetch.mock.calls[0][0]; + expect(fetchUrl).toMatch(/^https:\/\//); + }); +}); + +describe('api.s3-deploy — deploy files', () => { + it('returns 400 when no files provided', async () => { + const { status, data } = await callAction({ + action: 'deploy', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + files: {}, + }); + expect(status).toBe(400); + expect(data.ok).toBe(false); + expect(data.error).toMatch(/No files/); + }); + + it('uploads files and returns success', async () => { + // All uploads succeed + mockFetch.mockResolvedValue(new Response('', { status: 200 })); + + const { data } = await callAction({ + action: 'deploy', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + files: { + '/index.html': 'Hello', + '/styles.css': 'body { margin: 0; }', + '/app.js': 'console.log("hello");', + }, + }); + + expect(data.ok).toBe(true); + expect(data.fileCount).toBe(3); + expect(data.totalFiles).toBe(3); + expect(data.url).toContain('index.html'); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('respects pathPrefix when uploading', async () => { + mockFetch.mockResolvedValue(new Response('', { status: 200 })); + + await callAction({ + action: 'deploy', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + pathPrefix: 'my-site/v1', + files: { + '/index.html': '', + }, + }); + + const putUrl = mockFetch.mock.calls[0][0]; + expect(putUrl).toContain('my-bucket/my-site/v1/index.html'); + }); + + it('handles partial upload failures', async () => { + // First file succeeds, second fails + mockFetch + .mockResolvedValueOnce(new Response('', { status: 200 })) + .mockResolvedValueOnce(new Response('Forbidden', { status: 403 })); + + const { data } = await callAction({ + action: 'deploy', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + files: { + '/index.html': '', + '/style.css': 'body{}', + }, + }); + + expect(data.ok).toBe(true); // partial success + expect(data.fileCount).toBe(1); + expect(data.totalFiles).toBe(2); + expect(data.errors).toHaveLength(1); + expect(data.errors[0]).toContain('403'); + }); + + it('returns 500 when all uploads fail', async () => { + mockFetch.mockResolvedValue(new Response('AccessDenied', { status: 403 })); + + const { status, data } = await callAction({ + action: 'deploy', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + files: { + '/index.html': '', + }, + }); + + expect(status).toBe(500); + expect(data.ok).toBe(false); + expect(data.error).toContain('All uploads failed'); + }); + + it('handles fetch exceptions per file gracefully', async () => { + // First file succeeds, second throws network error + mockFetch + .mockResolvedValueOnce(new Response('', { status: 200 })) + .mockRejectedValueOnce(new Error('Connection reset')); + + const { data } = await callAction({ + action: 'deploy', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + files: { + '/index.html': '', + '/other.js': 'x', + }, + }); + + expect(data.ok).toBe(true); + expect(data.fileCount).toBe(1); + expect(data.errors[0]).toContain('Connection reset'); + }); + + it('sets correct MIME types via headers', async () => { + mockFetch.mockResolvedValue(new Response('', { status: 200 })); + + await callAction({ + action: 'deploy', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + files: { + '/index.html': '', + }, + }); + + const fetchOpts = mockFetch.mock.calls[0][1]; + expect(fetchOpts.headers['content-type']).toBe('text/html; charset=utf-8'); + }); + + it('includes AWS Signature V4 Authorization header', async () => { + mockFetch.mockResolvedValue(new Response('', { status: 200 })); + + await callAction({ + action: 'deploy', + provider: 's3', + endpoint: 'https://s3.amazonaws.com', + bucket: 'my-bucket', + accessKeyId: 'AKID', + secretAccessKey: 'secret', + region: 'us-east-1', + files: { + '/test.js': 'var x = 1;', + }, + }); + + const fetchOpts = mockFetch.mock.calls[0][1]; + expect(fetchOpts.headers.Authorization).toMatch(/^AWS4-HMAC-SHA256 Credential=AKID\//); + expect(fetchOpts.headers['x-amz-date']).toBeDefined(); + expect(fetchOpts.headers['x-amz-content-sha256']).toBeDefined(); + }); +}); + +describe('api.s3-deploy — error handling', () => { + it('returns 500 on malformed JSON body', async () => { + const request = new Request('http://localhost/api/s3-deploy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{ invalid json }}}', + }); + + const response = await action({ + request, + params: {}, + context: {} as never, + }); + + const data = await response.json(); + expect(response.status).toBe(500); + expect(data.ok).toBe(false); + }); +}); diff --git a/app/routes/api.s3-deploy.ts b/app/routes/api.s3-deploy.ts new file mode 100644 index 0000000000..ed6c281a75 --- /dev/null +++ b/app/routes/api.s3-deploy.ts @@ -0,0 +1,243 @@ +import { type ActionFunctionArgs, json } from '@remix-run/cloudflare'; + +interface S3DeployRequest { + action: 'deploy' | 'test'; + provider: 's3' | 'r2'; + endpoint: string; + bucket: string; + accessKeyId: string; + secretAccessKey: string; + region: string; + pathPrefix?: string; + files?: Record; + chatId?: string; +} + +/** + * Detect MIME type from file extension. + */ +function getMimeType(filePath: string): string { + const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; + const mimeTypes: Record = { + html: 'text/html; charset=utf-8', + css: 'text/css; charset=utf-8', + js: 'application/javascript; charset=utf-8', + mjs: 'application/javascript; charset=utf-8', + json: 'application/json; charset=utf-8', + svg: 'image/svg+xml', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + ico: 'image/x-icon', + woff: 'font/woff', + woff2: 'font/woff2', + ttf: 'font/ttf', + eot: 'application/vnd.ms-fontobject', + txt: 'text/plain; charset=utf-8', + xml: 'application/xml; charset=utf-8', + map: 'application/json', + webmanifest: 'application/manifest+json', + }; + + return mimeTypes[ext] || 'application/octet-stream'; +} + +/** + * Sign an AWS S3-compatible request using AWS Signature V4. + * Works with both AWS S3 and Cloudflare R2. + */ +async function signS3Request( + method: string, + url: string, + body: string | null, + opts: { + accessKeyId: string; + secretAccessKey: string; + region: string; + service: string; + }, +): Promise> { + const urlObj = new URL(url); + const now = new Date(); + const dateStamp = now + .toISOString() + .replace(/[:-]|\.\d{3}/g, '') + .substring(0, 8); + const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ''); + + const credentialScope = `${dateStamp}/${opts.region}/${opts.service}/aws4_request`; + + // Hash the payload + const payloadHash = body + ? Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body)))) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + : 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; // empty string hash + + const headers: Record = { + host: urlObj.host, + 'x-amz-date': amzDate, + 'x-amz-content-sha256': payloadHash, + }; + + if (body) { + headers['content-type'] = getMimeType(urlObj.pathname); + } + + // Canonical request + const signedHeaderKeys = Object.keys(headers).sort(); + const signedHeaders = signedHeaderKeys.join(';'); + const canonicalHeaders = signedHeaderKeys.map((k) => `${k}:${headers[k]}\n`).join(''); + const canonicalRequest = [ + method, + urlObj.pathname, + urlObj.searchParams.toString(), + canonicalHeaders, + signedHeaders, + payloadHash, + ].join('\n'); + + // String to sign + const canonicalRequestHash = Array.from( + new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalRequest))), + ) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, canonicalRequestHash].join('\n'); + + // Signing key + async function hmac(key: ArrayBuffer | string, message: string): Promise { + const keyData = typeof key === 'string' ? new TextEncoder().encode(key) : key; + const cryptoKey = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + + return crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(message)); + } + + const kDate = await hmac('AWS4' + opts.secretAccessKey, dateStamp); + const kRegion = await hmac(kDate, opts.region); + const kService = await hmac(kRegion, opts.service); + const kSigning = await hmac(kService, 'aws4_request'); + + const signature = Array.from(new Uint8Array(await hmac(kSigning, stringToSign))) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + const authorization = `AWS4-HMAC-SHA256 Credential=${opts.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + + return { + ...headers, + Authorization: authorization, + }; +} + +export async function action({ request }: ActionFunctionArgs) { + try { + const body = (await request.json()) as S3DeployRequest; + const { action: deployAction, provider, endpoint, bucket, accessKeyId, secretAccessKey, region } = body; + + if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) { + return json({ ok: false, error: 'Missing required S3/R2 credentials' }, { status: 400 }); + } + + // Normalize endpoint + const baseUrl = endpoint.startsWith('https://') ? endpoint : `https://${endpoint}`; + const service = provider === 'r2' ? 's3' : 's3'; + const effectiveRegion = provider === 'r2' ? 'auto' : region || 'us-east-1'; + + // Test connection + if (deployAction === 'test') { + try { + const listUrl = `${baseUrl}/${bucket}?list-type=2&max-keys=1`; + const headers = await signS3Request('GET', listUrl, null, { + accessKeyId, + secretAccessKey, + region: effectiveRegion, + service, + }); + + const res = await fetch(listUrl, { method: 'GET', headers }); + + if (res.ok) { + return json({ ok: true }); + } + + const errorText = await res.text(); + + return json({ ok: false, error: `Connection failed (${res.status}): ${errorText.substring(0, 200)}` }); + } catch (err) { + return json({ ok: false, error: `Connection error: ${err instanceof Error ? err.message : 'Unknown'}` }); + } + } + + // Deploy files + if (deployAction === 'deploy') { + const files = body.files; + + if (!files || Object.keys(files).length === 0) { + return json({ ok: false, error: 'No files to deploy' }, { status: 400 }); + } + + const prefix = body.pathPrefix ? body.pathPrefix.replace(/^\/|\/$/g, '') + '/' : ''; + let uploadedCount = 0; + const errors: string[] = []; + + for (const [filePath, content] of Object.entries(files)) { + const key = prefix + filePath.replace(/^\//, ''); + const putUrl = `${baseUrl}/${bucket}/${key}`; + + try { + const headers = await signS3Request('PUT', putUrl, content, { + accessKeyId, + secretAccessKey, + region: effectiveRegion, + service, + }); + + headers['content-type'] = getMimeType(filePath); + + const res = await fetch(putUrl, { + method: 'PUT', + headers, + body: content, + }); + + if (res.ok) { + uploadedCount++; + } else { + const errorText = await res.text(); + errors.push(`${key}: ${res.status} ${errorText.substring(0, 100)}`); + } + } catch (err) { + errors.push(`${key}: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + } + + if (uploadedCount === 0) { + return json( + { ok: false, error: `All uploads failed. Errors: ${errors.slice(0, 3).join('; ')}` }, + { status: 500 }, + ); + } + + const url = body.pathPrefix ? `${baseUrl}/${bucket}/${prefix}index.html` : `${baseUrl}/${bucket}/index.html`; + + return json({ + ok: true, + fileCount: uploadedCount, + totalFiles: Object.keys(files).length, + errors: errors.length > 0 ? errors.slice(0, 5) : undefined, + url, + }); + } + + return json({ ok: false, error: 'Invalid action' }, { status: 400 }); + } catch (error) { + return json( + { ok: false, error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 }, + ); + } +} diff --git a/app/types/actions.ts b/app/types/actions.ts index 32eeaeb360..cacda2f1e8 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -59,7 +59,7 @@ export interface DeployAlert { stage?: 'building' | 'deploying' | 'complete'; buildStatus?: 'pending' | 'running' | 'complete' | 'failed'; deployStatus?: 'pending' | 'running' | 'complete' | 'failed'; - source?: 'vercel' | 'netlify' | 'github' | 'gitlab'; + source?: 'vercel' | 'netlify' | 'github' | 'gitlab' | 's3' | 'r2'; } export interface LlmErrorAlertType { diff --git a/apps/project-sites/e2e/admin-modals.spec.ts b/apps/project-sites/e2e/admin-modals.spec.ts new file mode 100644 index 0000000000..e736e87d1d --- /dev/null +++ b/apps/project-sites/e2e/admin-modals.spec.ts @@ -0,0 +1,248 @@ +/** + * E2E tests for admin dashboard modal functionality, + * ripple animation, search deduplication, trust section, + * and escapeJsString fix. + */ + +import { test, expect } from './fixtures.js'; + +test.describe('Admin Dashboard Modals', () => { + test('Inline editing functions exist (edit-site-modal removed)', async ({ page }) => { + await page.goto('/'); + + const fns = await page.evaluate(() => { + const w = window as unknown as Record; + return { + startInlineEdit: typeof w.startInlineEdit === 'function', + saveInlineEdit: typeof w.saveInlineEdit === 'function', + cancelInlineEdit: typeof w.cancelInlineEdit === 'function', + onSlugInput: typeof w.onSlugInput === 'function', + }; + }); + expect(fns.startInlineEdit).toBe(true); + expect(fns.saveInlineEdit).toBe(true); + expect(fns.cancelInlineEdit).toBe(true); + expect(fns.onSlugInput).toBe(true); + }); + + test('Delete modal exists and is initially hidden', async ({ page }) => { + await page.goto('/'); + + const deleteModal = page.locator('#delete-modal'); + await expect(deleteModal).toBeAttached(); + await expect(deleteModal).not.toHaveClass(/visible/); + }); + + test('Deploy modal exists and is initially hidden', async ({ page }) => { + await page.goto('/'); + + const deployModal = page.locator('#deploy-modal'); + await expect(deployModal).toBeAttached(); + await expect(deployModal).not.toHaveClass(/visible/); + }); + + test('Domain modal exists and is initially hidden', async ({ page }) => { + await page.goto('/'); + + const domainModal = page.locator('#domain-modal'); + await expect(domainModal).toBeAttached(); + await expect(domainModal).not.toHaveClass(/visible/); + }); + + test('Logs modal exists and is initially hidden', async ({ page }) => { + await page.goto('/'); + + const logsModal = page.locator('#site-logs-modal'); + await expect(logsModal).toBeAttached(); + await expect(logsModal).not.toHaveClass(/visible/); + }); + + test('openResetModal function exists', async ({ page }) => { + await page.goto('/'); + + const exists = await page.evaluate(() => { + return typeof (window as unknown as Record).openResetModal === 'function'; + }); + expect(exists).toBe(true); + }); + + test('openNewWebsiteModal function exists', async ({ page }) => { + await page.goto('/'); + + const exists = await page.evaluate(() => { + return ( + typeof (window as unknown as Record).openNewWebsiteModal === 'function' + ); + }); + expect(exists).toBe(true); + }); +}); + +test.describe('escapeJsString function', () => { + test('escapeJsString properly escapes single quotes for JS string literals', async ({ page }) => { + await page.goto('/'); + + const result = await page.evaluate(() => { + const fn = (window as unknown as Record string>).escapeJsString; + return fn("Vito's Men's Salon"); + }); + // Should use JS backslash escaping — no unescaped single quotes remain + expect(result).toBe("Vito\\'s Men\\'s Salon"); + }); + + test('escapeJsString handles backslashes and angle brackets', async ({ page }) => { + await page.goto('/'); + + const result = await page.evaluate(() => { + const fn = (window as unknown as Record string>).escapeJsString; + return fn('test\\path + + + + + + + + {title} + + + + + {noIndex ? : } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + + + + + diff --git a/apps/project-sites/frontend/src/layouts/LegalLayout.astro b/apps/project-sites/frontend/src/layouts/LegalLayout.astro new file mode 100644 index 0000000000..ab015be4a2 --- /dev/null +++ b/apps/project-sites/frontend/src/layouts/LegalLayout.astro @@ -0,0 +1,38 @@ +--- +import BaseLayout from './BaseLayout.astro'; +import Header from '../components/Header.astro'; +import Footer from '../components/Footer.astro'; +import '../styles/global.css'; + +interface Props { + title: string; + description: string; + canonicalPath: string; + pageTitle: string; + lastUpdated: string; +} + +const { title, description, canonicalPath, pageTitle, lastUpdated } = Astro.props; +--- + + +
+ +
+ +
+ +
+ diff --git a/apps/project-sites/frontend/src/pages/content.astro b/apps/project-sites/frontend/src/pages/content.astro new file mode 100644 index 0000000000..c7db33f9e4 --- /dev/null +++ b/apps/project-sites/frontend/src/pages/content.astro @@ -0,0 +1,80 @@ +--- +import LegalLayout from '../layouts/LegalLayout.astro'; +--- + + +

1. Overview

+

This Content Policy outlines the standards for content created, hosted, and displayed through Project Sites at sites.megabyte.space. This applies to both user-provided content and AI-generated content.

+ +

2. AI-Generated Content Standards

+

Our AI generates website content using publicly available business information, including data from Google Places, social media profiles, and public listings. We strive to ensure AI-generated content is:

+
    +
  • Accurate: Based on verified public data sources.
  • +
  • Professional: Written in a clear, business-appropriate tone.
  • +
  • Original: Uniquely composed for each business, not copied from existing sites.
  • +
  • Compliant: Adhering to applicable advertising and consumer protection laws.
  • +
+ +

3. User Responsibilities

+

When you use Project Sites, you are responsible for:

+
    +
  • Reviewing all AI-generated content for accuracy before publishing.
  • +
  • Ensuring uploaded images and custom text comply with this policy.
  • +
  • Maintaining truthful and non-misleading business information.
  • +
  • Updating content when business information changes.
  • +
+ +

4. Prohibited Content

+

The following types of content are prohibited on Project Sites:

+
    +
  • Illegal Content: Content that violates any applicable law or regulation.
  • +
  • Fraudulent Content: Misleading claims, fake reviews, or deceptive business practices.
  • +
  • Harmful Content: Content promoting violence, hate speech, discrimination, or harassment.
  • +
  • Adult Content: Sexually explicit material or content inappropriate for general audiences.
  • +
  • Infringing Content: Content that violates copyright, trademark, or other intellectual property rights.
  • +
  • Malicious Content: Malware, phishing attempts, or other security threats.
  • +
  • Spam: Bulk generated sites, doorway pages, or content created solely for SEO manipulation.
  • +
+ +

5. Content Moderation

+

We employ automated and manual review processes to ensure content compliance. We reserve the right to:

+
    +
  • Remove or modify content that violates this policy.
  • +
  • Suspend or terminate accounts associated with policy violations.
  • +
  • Report illegal content to appropriate authorities.
  • +
+ +

6. Intellectual Property

+

You represent that you have the right to use any content you upload (images, logos, text). AI-generated content is created for your use as part of the Service. You may not claim AI-generated content as solely human-authored where disclosure is legally required.

+ +

7. DMCA & Takedown Requests

+

If you believe content on Project Sites infringes your intellectual property rights, please contact us at hey@megabyte.space with:

+
    +
  • A description of the copyrighted work.
  • +
  • The URL of the infringing content.
  • +
  • Your contact information.
  • +
  • A statement of good faith belief that the use is unauthorized.
  • +
+ +

8. Third-Party Content

+

Websites generated through our Service may include data sourced from third parties (Google Places, public listings). We do not guarantee the accuracy of third-party data and are not responsible for errors in source material.

+ +

9. Content Availability

+

We do not guarantee uninterrupted availability of hosted content. We perform regular backups but recommend users maintain copies of important custom content.

+ +

10. Changes to This Policy

+

We may update this Content Policy at any time. Material changes will be communicated through the Service or via email.

+ +

11. Contact Us

+

For content-related questions or to report a policy violation:

+ +
diff --git a/apps/project-sites/frontend/src/pages/index.astro b/apps/project-sites/frontend/src/pages/index.astro new file mode 100644 index 0000000000..19d937577d --- /dev/null +++ b/apps/project-sites/frontend/src/pages/index.astro @@ -0,0 +1,5240 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import Header from '../components/Header.astro'; +import Footer from '../components/Footer.astro'; +import '../styles/global.css'; +--- + + + + + + + + + + + + + + + + +
+ + +
+
+ +
+
+ + Your Websites + + +
+
+ + + +
+
+ + + + + +
+
Loading your websites...
+
+ + +
+
+ + + + + + + + + + + + + + +
+
+ +

Describe your custom website

+

Include everything important for your website. We'll also search the web for public info about your business.

+ + + + + + + +
+ +
+ +
+
+
Improving with AI...
+
+
+
+ +
+ +
+
+ + + +
+
+
+ + +
+ + + + + + + + +
+ + +
+
+
+
+
+
+
+ + + + + +
+
+ +

We're building your website...

+

Give us a few minutes. We'll notify you when it's ready.

+ + +
+
+
+
+
+ build-progress +
+
+
Initializing build pipeline...
+
+
+
+
+ +
+ + + + + + + + +
+ + + + + + + + + + diff --git a/apps/project-sites/frontend/src/pages/login.astro b/apps/project-sites/frontend/src/pages/login.astro new file mode 100644 index 0000000000..6ada17f7d5 --- /dev/null +++ b/apps/project-sites/frontend/src/pages/login.astro @@ -0,0 +1,167 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import SocialLinks from '../components/SocialLinks.astro'; +import '../styles/global.css'; +--- + + +
+
+ +
+
+ +
+ +
+ + +
diff --git a/apps/project-sites/frontend/src/pages/privacy.astro b/apps/project-sites/frontend/src/pages/privacy.astro new file mode 100644 index 0000000000..de0771175a --- /dev/null +++ b/apps/project-sites/frontend/src/pages/privacy.astro @@ -0,0 +1,71 @@ +--- +import LegalLayout from '../layouts/LegalLayout.astro'; +--- + + +

1. Introduction

+

Welcome to Project Sites, operated by Megabyte LLC ("we," "us," or "our"). This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you visit our website at sites.megabyte.space and use our services.

+ +

2. Information We Collect

+

We collect information you provide directly to us, including:

+
    +
  • Account Information: Email address and authentication data when you sign in via Google OAuth or email magic link.
  • +
  • Business Information: Business name, address, and details from Google Places API when you search for and generate a website.
  • +
  • Payment Information: Billing details processed securely through Stripe. We do not store credit card numbers on our servers.
  • +
  • Usage Data: Pages visited, features used, and interactions with the platform, collected through PostHog analytics.
  • +
+ +

3. How We Use Your Information

+

We use the information we collect to:

+
    +
  • Provide, maintain, and improve our website generation services.
  • +
  • Process transactions and manage your subscription.
  • +
  • Send transactional emails (magic links, build notifications).
  • +
  • Analyze usage patterns to improve the platform.
  • +
  • Detect and prevent fraud or abuse.
  • +
+ +

4. Information Sharing

+

We do not sell your personal information. We may share information with:

+
    +
  • Service Providers: Cloudflare (hosting & CDN), Stripe (payments), Google (OAuth & Places API), PostHog (analytics), SendGrid/Resend (email).
  • +
  • Legal Requirements: When required by law, regulation, or legal process.
  • +
+ +

5. Data Security

+

We implement appropriate technical and organizational measures to protect your information, including encryption in transit (TLS), secure authentication tokens, and access controls. However, no method of electronic storage is 100% secure.

+ +

6. Data Retention

+

We retain your account information for as long as your account is active. Site data is retained while your subscription is active. You may request deletion of your data by contacting us at hey@megabyte.space.

+ +

7. Cookies & Tracking

+

We use essential cookies for authentication and session management. We use PostHog for privacy-friendly analytics (server-side, identified-only). Google Tag Manager is used for marketing analytics. You may disable cookies in your browser settings.

+ +

8. Your Rights

+

Depending on your jurisdiction, you may have the right to:

+
    +
  • Access, correct, or delete your personal information.
  • +
  • Object to or restrict processing of your data.
  • +
  • Data portability.
  • +
  • Withdraw consent at any time.
  • +
+ +

9. Children's Privacy

+

Our services are not directed to children under 13. We do not knowingly collect personal information from children under 13.

+ +

10. Changes to This Policy

+

We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.

+ +

11. Contact Us

+

If you have questions about this Privacy Policy, please contact us at:

+ +
diff --git a/apps/project-sites/frontend/src/pages/terms.astro b/apps/project-sites/frontend/src/pages/terms.astro new file mode 100644 index 0000000000..d26bc48a10 --- /dev/null +++ b/apps/project-sites/frontend/src/pages/terms.astro @@ -0,0 +1,73 @@ +--- +import LegalLayout from '../layouts/LegalLayout.astro'; +--- + + +

1. Acceptance of Terms

+

By accessing or using Project Sites at sites.megabyte.space ("the Service"), operated by Megabyte LLC ("we," "us," or "our"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.

+ +

2. Description of Service

+

Project Sites is an AI-powered website builder that generates professional websites for small businesses. The Service includes:

+
    +
  • Free preview website generation (no account required).
  • +
  • AI-generated content, design, and legal pages.
  • +
  • Custom domain hosting and management (paid plans).
  • +
  • Ongoing AI-powered content updates and improvements.
  • +
+ +

3. Accounts & Authentication

+

You may create an account using Google OAuth or email magic link. You are responsible for maintaining the security of your account credentials. You agree to provide accurate information and to notify us of any unauthorized access.

+ +

4. Free Preview & Paid Plans

+

The free preview provides a fully AI-generated website hosted on a subdomain (e.g., your-business-sites.megabyte.space). Paid plans ($50/month) include custom domain support, unlimited AI edits, SSL certificates, and priority support. You may cancel at any time.

+ +

5. Billing & Payments

+

All payments are processed through Stripe. Subscriptions are billed monthly. You may cancel at any time; access continues through the end of the billing period. Refunds are handled on a case-by-case basis.

+ +

6. User Content

+

You retain ownership of any content you provide (business information, uploaded images, custom text). By using the Service, you grant us a license to host, display, and process your content as necessary to provide the Service.

+ +

7. AI-Generated Content

+

Websites generated by our AI are created using publicly available information and our proprietary algorithms. While we strive for accuracy, AI-generated content may contain errors. You are responsible for reviewing and approving all content before publishing.

+ +

8. Prohibited Uses

+

You may not use the Service to:

+
    +
  • Generate websites for illegal activities or fraudulent businesses.
  • +
  • Create content that is defamatory, obscene, or violates third-party rights.
  • +
  • Attempt to circumvent security measures or access restrictions.
  • +
  • Resell or redistribute the Service without authorization.
  • +
  • Use automated tools to scrape or overload the Service.
  • +
+ +

9. Intellectual Property

+

The Service, including its design, code, AI models, and branding, is owned by Megabyte LLC and protected by intellectual property laws. You may not copy, modify, or distribute any part of the Service without permission.

+ +

10. Limitation of Liability

+

TO THE MAXIMUM EXTENT PERMITTED BY LAW, MEGABYTE LLC SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING OUT OF YOUR USE OF THE SERVICE. Our total liability shall not exceed the amount you paid us in the 12 months preceding the claim.

+ +

11. Disclaimer of Warranties

+

THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND. WE DO NOT GUARANTEE THAT THE SERVICE WILL BE UNINTERRUPTED, ERROR-FREE, OR THAT AI-GENERATED CONTENT WILL BE ACCURATE.

+ +

12. Termination

+

We may suspend or terminate your access to the Service at any time for violation of these Terms. Upon termination, your right to use the Service ceases immediately. Data may be deleted after a reasonable retention period.

+ +

13. Governing Law

+

These Terms are governed by the laws of the State of New Jersey, without regard to conflict of law principles. Any disputes shall be resolved in the courts of Morris County, New Jersey.

+ +

14. Changes to Terms

+

We may update these Terms from time to time. Continued use of the Service after changes constitutes acceptance of the new Terms.

+ +

15. Contact Us

+

If you have questions about these Terms, please contact us at:

+ +
diff --git a/apps/project-sites/frontend/src/styles/global.css b/apps/project-sites/frontend/src/styles/global.css new file mode 100644 index 0000000000..9f1c339693 --- /dev/null +++ b/apps/project-sites/frontend/src/styles/global.css @@ -0,0 +1,3582 @@ + /* ====================================================== + CSS Custom Properties + ====================================================== */ + :root { + --bg-primary: #0a0a1a; + --bg-secondary: #111128; + --bg-card: #161635; + --bg-card-hover: #1c1c45; + --bg-input: #0f0f2a; + --accent: #50a5db; + --accent-dim: rgba(80, 165, 219, 0.12); + --accent-glow: rgba(80, 165, 219, 0.25); + --secondary: #7c3aed; + --secondary-dim: rgba(124, 58, 237, 0.15); + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --border: rgba(80, 165, 219, 0.1); + --border-hover: rgba(80, 165, 219, 0.3); + --error: #ef4444; + --error-dim: rgba(239, 68, 68, 0.12); + --success: #22c55e; + --radius: 12px; + --radius-lg: 16px; + --shadow: 0 4px 24px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.5); + --shadow-accent: 0 0 30px rgba(80, 165, 219, 0.15); + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap'); + + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } + + html { scroll-behavior: smooth; height: 100%; } + + body { + font-family: var(--font); + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + min-height: 100vh; + } + + /* ====================================================== + Scrollbar + ====================================================== */ + ::-webkit-scrollbar { width: 6px; } + ::-webkit-scrollbar-track { background: var(--bg-primary); } + ::-webkit-scrollbar-thumb { background: var(--bg-card); border-radius: 3px; } + ::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); } + + /* ====================================================== + Animations + ====================================================== */ + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes fadeInUp { + from { opacity: 0; transform: translateY(30px); } + to { opacity: 1; transform: translateY(0); } + } + @keyframes fadeInScale { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } + } + @keyframes gradientShift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } + } + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + @keyframes shimmer { + 0% { background-position: -200% center; } + 100% { background-position: 200% center; } + } + @keyframes ripple-expand { + 0% { transform: scale(0); opacity: 0.5; } + 100% { transform: scale(4); opacity: 0; } + } + @keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } + } + @keyframes orbFloat1 { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(60px, -40px) scale(1.1); } + 66% { transform: translate(-30px, 30px) scale(0.95); } + } + @keyframes orbFloat2 { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(-50px, 50px) scale(0.9); } + 66% { transform: translate(40px, -20px) scale(1.05); } + } + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + @keyframes dotPulse { + 0%, 80%, 100% { transform: scale(0); opacity: 0; } + 40% { transform: scale(1); opacity: 1; } + } + + /* Respect user's reduced-motion preference */ + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } + + /* ====================================================== + Header / Nav + ====================================================== */ + .header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + padding: 0 24px; + height: 60px; + display: flex; + align-items: center; + background: rgba(10, 10, 26, 0.85); + backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border); + } + .header-inner { + max-width: 1200px; + width: 100%; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + } + .logo { + display: flex; + align-items: center; + text-decoration: none; + cursor: pointer; + } + .logo img { flex-shrink: 0; } + + .header-auth { + display: flex; + align-items: center; + gap: 12px; + } + .header-auth-user { + color: rgba(255,255,255,0.7); + font-size: 0.85rem; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .header-auth-btn { + padding: 6px 16px; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: var(--accent); + font-size: 0.85rem; + cursor: pointer; + transition: background 0.2s, border-color 0.2s, opacity 0.2s, box-shadow 0.2s; + white-space: nowrap; + } + .header-auth-btn:hover { + background: rgba(100, 255, 218, 0.1); + border-color: var(--accent); + } + .header-auth-btn:focus { + outline: none; + box-shadow: 0 0 0 2px var(--accent-dim); + } + .header-auth-btn:active { + opacity: 0.7; + } + + /* ====================================================== + Admin Dashboard Panel + ====================================================== */ + .admin-panel { + display: none; + background: linear-gradient(135deg, rgba(15, 15, 35, 0.95), rgba(10, 10, 26, 0.98)); + border-bottom: 1px solid var(--border); + margin-top: 60px; + padding: 24px 0; + min-height: 500px; + animation: fadeIn 0.3s ease; + } + .admin-panel.visible { display: block; } + .admin-panel-inner { + max-width: 1100px; + margin: 0 auto; + padding: 0 24px; + } + .admin-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 12px; + } + .admin-panel-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 10px; + } + .admin-panel-title svg { color: var(--accent); } + .admin-panel-actions { + display: flex; + gap: 10px; + align-items: center; + } + .admin-btn { + padding: 7px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-secondary); + font-size: 0.8rem; + cursor: pointer; + transition: border-color 0.2s, color 0.2s, background 0.2s, opacity 0.2s, box-shadow 0.2s; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + } + .admin-btn:hover { + border-color: var(--accent); + color: var(--accent); + background: rgba(100, 255, 218, 0.05); + } + .admin-btn:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim); + } + .admin-btn:active { + opacity: 0.7; + } + .admin-btn-accent { + border-color: var(--accent); + color: var(--accent); + } + + /* Site Cards Grid */ + .site-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } + .site-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; + transition: border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease; + position: relative; + display: flex; + flex-direction: column; + gap: 10px; + } + .site-card:hover { + border-color: var(--border-hover); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), 0 0 0 1px var(--border-hover); + } + .site-card-preview { + height: 100px; + border-radius: 8px; + background: linear-gradient(135deg, rgba(100, 255, 218, 0.05), rgba(124, 58, 237, 0.08)); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; + } + .site-card-preview iframe { + width: 800px; + height: 600px; + transform: scale(0.26); + transform-origin: top left; + pointer-events: none; + border: none; + position: absolute; + top: 0; + left: 0; + } + .site-card-preview-placeholder { + color: var(--text-muted); + font-size: 0.75rem; + text-align: center; + } + .site-card-name { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); + font-family: var(--font); + letter-spacing: normal; + line-height: 1.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + /* Title edit wrap inherits same font as .site-card-name so input matches */ + .site-card-title-row .inline-edit-wrap { + font-size: 0.9rem; + font-weight: 600; + font-family: var(--font); + letter-spacing: normal; + line-height: 1.5; + color: var(--text-primary); + } + .site-card-status { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.65rem; + font-weight: 600; + padding: 3px 8px; + border-radius: 4px; + width: fit-content; + flex-shrink: 0; + text-transform: uppercase; + letter-spacing: 0.03em; + } + .site-card-status.published { background: rgba(34, 197, 94, 0.12); color: #22c55e; } + .site-card-status.building, .site-card-status.queued { background: rgba(251, 191, 36, 0.12); color: #fbbf24; } + .site-card-status.collecting { background: rgba(80, 165, 219, 0.12); color: var(--accent); } + .site-card-status.generating { background: rgba(124, 58, 237, 0.12); color: #a78bfa; } + .site-card-status.uploading { background: rgba(34, 197, 94, 0.12); color: #4ade80; } + .site-card-status.error, .site-card-status.failed { background: rgba(239, 68, 68, 0.12); color: #ef4444; } + .site-card-status.draft { background: rgba(148, 163, 184, 0.12); color: #94a3b8; } + /* Status + title row */ + .site-card-title-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 2px; + } + .site-card-domain { + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .site-card-domain a { + color: var(--accent); + text-decoration: none; + } + .site-card-domain a:hover { text-decoration: underline; } + .site-card-actions { + display: flex; + gap: 6px; + margin-top: auto; + } + .site-card-btn { + flex: 1; + padding: 6px 0; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-secondary); + font-size: 0.7rem; + cursor: pointer; + transition: border-color 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), color 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); + text-align: center; + } + .site-card-btn:hover { + border-color: var(--accent); + color: var(--accent); + background: rgba(80, 165, 219, 0.06); + box-shadow: 0 2px 8px rgba(80, 165, 219, 0.12); + } + .site-card-btn:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim), 0 0 12px rgba(80, 165, 219, 0.1); + } + .site-card-btn:focus:not(:focus-visible) { outline: none; } + .site-card-btn:active { + background: rgba(80, 165, 219, 0.14); + border-color: #4ecdc4; + box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.15); + transition-duration: 0.08s; + } + .site-card-btn.danger:hover { + border-color: var(--error); + color: var(--error); + background: rgba(239, 68, 68, 0.06); + box-shadow: 0 2px 8px rgba(239, 68, 68, 0.12); + } + .site-card-btn.danger:focus-visible { + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.3); + } + .site-card-btn.danger:active { + background: rgba(239, 68, 68, 0.14); + border-color: #dc2626; + } + + /* New Site Card */ + .site-card-new { + background: transparent; + border: 2px dashed var(--border); + border-radius: 12px; + padding: 16px; + cursor: pointer; + transition: border-color 0.25s ease, background 0.25s ease, box-shadow 0.25s ease; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 200px; + } + .site-card-new:hover { + border-color: var(--accent); + background: rgba(100, 255, 218, 0.03); + } + .site-card-new:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim); + } + .site-card-new:active { + background: rgba(100, 255, 218, 0.06); + box-shadow: 0 0 0 2px rgba(100, 255, 218, 0.3); + } + .site-card-new svg { color: var(--text-muted); transition: color 0.2s; } + .site-card-new:hover svg { color: var(--accent); } + .site-card-new span { color: var(--text-muted); font-size: 0.85rem; transition: color 0.2s; } + .site-card-new:hover span { color: var(--accent); } + + /* Domain Modal */ + .modal-overlay { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 1000; + animation: fadeIn 0.15s ease; + align-items: center; + justify-content: center; + } + .modal-overlay.visible { display: flex; } + .modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 28px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + animation: fadeInScale 0.2s ease; + } + .modal-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 20px; + display: flex; + align-items: center; + justify-content: space-between; + } + .modal-close { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + font-size: 1.2rem; + line-height: 1; + } + .modal-close:hover { color: var(--text-primary); } + .modal-close:focus { outline: none; color: var(--accent); } + .modal-close:active { opacity: 0.6; transition: opacity 0.06s; } + + /* Site Logs Modal */ + .logs-modal { max-width: 860px; width: 95%; max-height: 90vh; padding: 24px; } + .logs-container { + background: #0c0c1e; + border: 1px solid rgba(100, 255, 218, 0.1); + border-radius: 8px; + padding: 0; + max-height: 65vh; + overflow-y: auto; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', Consolas, monospace; + font-size: 0.75rem; + line-height: 1.6; + } + .logs-container::-webkit-scrollbar { width: 6px; } + .logs-container::-webkit-scrollbar-track { background: transparent; } + .logs-container::-webkit-scrollbar-thumb { background: rgba(100, 255, 218, 0.2); border-radius: 3px; } + .logs-empty { + padding: 32px; + text-align: center; + color: var(--text-muted); + font-style: italic; + } + .log-entry { + display: flex; + gap: 10px; + padding: 6px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + align-items: flex-start; + transition: background 0.15s ease; + } + .log-entry:hover { background: rgba(100, 255, 218, 0.03); } + .log-entry:last-child { border-bottom: none; } + .log-ts { + color: #6b7280; + flex-shrink: 0; + white-space: nowrap; + min-width: 140px; + font-size: 0.68rem; + letter-spacing: 0.02em; + padding: 2px 8px; + background: rgba(107, 114, 128, 0.08); + border-radius: 4px; + border-left: 2px solid rgba(100, 255, 218, 0.3); + } + .log-action { + font-weight: 600; + flex-shrink: 0; + min-width: 140px; + } + .log-action.site-created { color: #22c55e; } + .log-action.site-deleted { color: #ef4444; } + .log-action.site-updated { color: #3b82f6; } + .log-action.site-reset { color: #f59e0b; } + .log-action.site-deployed { color: #8b5cf6; } + .log-action.hostname-provisioned { color: #06b6d4; } + .log-action.hostname-unsubscribed { color: #fbbf24; } + .log-action.hostname-verified { color: #22c55e; } + .log-action.hostname-deprovisioned { color: #ef4444; } + .log-action.hostname-deleted { color: #ef4444; } + .log-action.hostname-set-primary { color: #a78bfa; } + .log-action.workflow-queued { color: #fbbf24; } + .log-action.workflow-started { color: var(--accent); } + .log-action.workflow-step-profile_research_complete { color: #06b6d4; } + .log-action.workflow-step-parallel_research_complete { color: #06b6d4; } + .log-action.workflow-step-html_generation_complete { color: #8b5cf6; } + .log-action.workflow-step-legal_and_scoring_complete { color: #a78bfa; } + .log-action.workflow-step-upload_to_r2_complete { color: #3b82f6; } + .log-action.workflow-completed { color: #22c55e; } + .log-action.workflow-failed { color: #ef4444; } + .log-action.auth-magic_link_verified { color: #06b6d4; } + .log-action.auth-google_oauth_verified { color: #06b6d4; } + .log-action.default-action { color: var(--text-secondary); } + .log-meta { + color: var(--text-muted); + word-break: break-word; + flex: 1; + } + .log-meta code { + background: rgba(100, 255, 218, 0.08); + padding: 1px 4px; + border-radius: 3px; + font-size: 0.68rem; + } + .logs-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + gap: 8px; + } + .logs-toolbar .logs-count { + font-size: 0.75rem; + color: var(--text-muted); + } + .logs-toolbar .logs-refresh-btn { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + padding: 4px 10px; + font-size: 0.72rem; + transition: border-color 0.2s, color 0.2s; + } + .logs-toolbar .logs-refresh-btn:hover { border-color: var(--accent); color: var(--accent); } + + .hostname-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + } + .hostname-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + } + .hostname-item-name { + font-size: 0.85rem; + color: var(--text-primary); + } + .hostname-item-name a { color: var(--accent); text-decoration: none; } + .hostname-item-name a:hover { text-decoration: underline; } + .hostname-item-type { + font-size: 0.65rem; + color: var(--text-muted); + margin-top: 2px; + } + .hostname-delete-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + transition: color 0.2s; + } + .hostname-delete-btn:hover { color: var(--error); } + .hostname-add-form { + display: flex; + gap: 8px; + margin-top: 12px; + } + .hostname-add-form input { + flex: 1; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 0.85rem; + } + .hostname-add-form input:focus { + outline: none; + border-color: var(--accent); + } + .hostname-add-form button { + padding: 8px 16px; + border-radius: 8px; + border: 1px solid var(--accent); + background: rgba(100, 255, 218, 0.1); + color: var(--accent); + font-size: 0.8rem; + cursor: pointer; + white-space: nowrap; + } + .hostname-add-form button:hover { background: rgba(100, 255, 218, 0.2); } + + /* Billing info row */ + /* Per-site plan badge */ + .site-card-plan-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + .plan-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border-radius: 8px; + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .plan-badge.free { background: rgba(148, 163, 184, 0.15); color: #94a3b8; cursor: pointer; transition: background 0.2s, color 0.2s; } + .plan-badge.free:hover { background: rgba(148, 163, 184, 0.25); color: #cbd5e1; } + .plan-badge.free:focus { outline: none; box-shadow: 0 0 0 2px rgba(148, 163, 184, 0.3); } + .plan-badge.free:active { opacity: 0.7; } + .plan-badge.paid { background: rgba(100, 255, 218, 0.15); color: var(--accent); cursor: default; } + .plan-badge.paid:hover { background: rgba(100, 255, 218, 0.22); } + .plan-badge.paid:focus { outline: none; box-shadow: 0 0 0 2px var(--accent-dim); } + .site-card-upgrade-btn { + padding: 8px 16px; + border-radius: 8px; + border: 2px solid var(--accent); + background: linear-gradient(135deg, rgba(80, 165, 219, 0.15), rgba(124, 58, 237, 0.1)); + color: var(--accent); + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), filter 0.3s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; + text-transform: uppercase; + letter-spacing: 0.04em; + animation: upgradeGlow 2s ease-in-out infinite; + } + .site-card-upgrade-btn:hover { + background: linear-gradient(135deg, rgba(80, 165, 219, 0.25), rgba(124, 58, 237, 0.2)); + box-shadow: 0 0 20px rgba(80, 165, 219, 0.35), 0 0 40px rgba(80, 165, 219, 0.15); + transform: translateY(-1px); + } + .site-card-upgrade-btn:focus { + outline: none; + box-shadow: 0 0 0 3px var(--accent-dim), 0 0 20px rgba(80, 165, 219, 0.25); + } + .site-card-upgrade-btn:active { + filter: brightness(0.9); + box-shadow: 0 0 0 3px rgba(80, 165, 219, 0.4); + } + @keyframes upgradeGlow { + 0%, 100% { box-shadow: 0 0 8px rgba(80, 165, 219, 0.15); } + 50% { box-shadow: 0 0 16px rgba(80, 165, 219, 0.3), 0 0 32px rgba(124, 58, 237, 0.1); } + } + + .admin-site-count { + font-size: 0.7rem; + background: rgba(100, 255, 218, 0.1); + color: var(--accent); + padding: 2px 8px; + border-radius: 10px; + font-weight: 600; + display: none; + } + .admin-site-count.loaded { display: inline-block; } + .credits-pill { + font-size: 0.65rem; + background: rgba(124, 58, 237, 0.15); + color: #a78bfa; + padding: 2px 8px; + border-radius: 10px; + font-weight: 600; + margin-left: 4px; + } + .admin-sites-usage { + font-size: 0.8rem; + color: var(--text-muted); + } + .site-card-url-row { + display: flex; + align-items: center; + gap: 4px; + } + .site-card-copy-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 2px; + transition: color 0.2s; + flex-shrink: 0; + } + .site-card-copy-btn:hover { color: var(--accent); } + .site-card-copy-btn:focus { outline: none; color: var(--accent); } + .site-card-copy-btn:active { color: #4ecdc4; } + .site-card-copy-btn.copied { color: #22c55e; } + .copy-toast { + position: absolute; + bottom: calc(100% + 6px); + right: 0; + background: rgba(34, 197, 94, 0.95); + color: #fff; + font-size: 0.65rem; + font-weight: 600; + padding: 3px 8px; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transform: translateY(4px); + animation: toast-pop 1.4s ease forwards; + z-index: 10; + } + @keyframes toast-pop { + 0% { opacity: 0; transform: translateY(4px); } + 15% { opacity: 1; transform: translateY(0); } + 75% { opacity: 1; transform: translateY(0); } + 100% { opacity: 0; transform: translateY(-4px); } + } + .site-card-date { + display: flex; + justify-content: space-between; + font-size: 0.7rem; + color: var(--text-muted); + opacity: 0.6; + } + .site-card-date-modified { + text-align: right; + } + .admin-empty { + text-align: center; + padding: 32px 16px; + color: var(--text-muted); + font-size: 0.9rem; + } + + /* Domain Summary Bar */ + .domain-summary-bar { + display: flex; + align-items: center; + gap: 16px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 18px; + margin-bottom: 16px; + flex-wrap: wrap; + } + .domain-summary-bar .domain-stat { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + color: var(--text-secondary); + } + .domain-stat-value { + font-weight: 600; + color: var(--text-primary); + font-size: 0.85rem; + } + .domain-stat-dot { + width: 6px; + height: 6px; + border-radius: 50%; + display: inline-block; + } + .domain-stat-dot.active { background: var(--success); } + .domain-stat-dot.pending { background: #fbbf24; } + .domain-stat-dot.failed { background: var(--error); } + + @media (max-width: 600px) { + .site-grid { grid-template-columns: 1fr; gap: 10px; } + .admin-panel-header { flex-direction: column; align-items: flex-start; } + .admin-panel-actions { width: 100%; } + .domain-summary-bar { flex-direction: column; align-items: flex-start; gap: 8px; } + } + + /* ====================================================== + Build Terminal (Waiting Screen) + ====================================================== */ + .build-terminal { + margin-top: 28px; + background: #0d0d1f; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + text-align: left; + min-width: min(500px, 100%); + max-width: 600px; + max-height: 270px; + width: 100%; + margin-left: auto; + margin-right: auto; + } + .build-terminal-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: rgba(255,255,255,0.03); + border-bottom: 1px solid var(--border); + } + .build-terminal-dot { + width: 8px; + height: 8px; + border-radius: 50%; + } + .build-terminal-dot.red { background: #ef4444; } + .build-terminal-dot.yellow { background: #fbbf24; } + .build-terminal-dot.green { background: #22c55e; } + .build-terminal-title { + flex: 1; + text-align: center; + font-size: 0.7rem; + color: var(--text-muted); + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + } + .build-terminal-body { + padding: 12px; + max-height: 400px; + overflow-y: auto; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 0.75rem; + line-height: 1.8; + } + .build-terminal-line { + color: var(--text-muted); + white-space: pre-wrap; + word-break: break-word; + } + .build-terminal-line.active { + color: var(--accent); + } + .build-terminal-line.done { + color: var(--success); + } + .build-terminal-line.error { + color: var(--error); + } + .build-terminal-line.done::before { + content: '\2713 '; + color: var(--success); + margin-right: 8px; + } + .build-terminal-line.active::before { + content: '\25B6 '; + color: var(--accent); + animation: pulse 1.5s ease-in-out infinite; + margin-right: 8px; + } + .build-terminal-line.pending { + color: var(--text-muted); + opacity: 0.5; + } + .build-terminal-line.pending::before { + content: '\25CB '; + margin-right: 8px; + } + .build-terminal-line.error::before { + content: '\2717 '; + color: var(--error); + margin-right: 8px; + } + .build-terminal-line.info { + color: #94a3b8; + font-size: 0.68rem; + padding-left: 22px; + } + + /* ====================================================== + Edit Site Modal + ====================================================== */ + /* Inline editable fields on site cards */ + .inline-edit-wrap { + display: inline-flex; + align-items: center; + gap: 4px; + min-height: 26px; + max-width: 100%; + } + .inline-edit-wrap .inline-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: default; + } + .inline-edit-wrap .inline-edit-btn { + background: none; + border: none; + cursor: pointer; + color: var(--accent); + padding: 2px; + flex-shrink: 0; + opacity: 0.85; + transition: opacity 0.2s, color 0.2s, transform 0.15s; + line-height: 1; + } + .inline-edit-wrap .inline-edit-btn:hover { + opacity: 1; + color: #4ecdc4; + } + .inline-edit-wrap .inline-edit-btn:focus { + outline: none; + opacity: 1; + color: var(--accent); + } + .inline-edit-wrap .inline-edit-btn:active { + opacity: 0.7; + transition-duration: 0.06s; + } + .inline-edit-wrap .inline-input { + flex: 0 1 auto; + min-width: 60px; + max-width: 220px; + padding: 0; + margin: 0; + border: none; + border-radius: 0; + background: transparent; + color: inherit; + font-size: inherit; + font-weight: inherit; + font-family: inherit; + font-style: inherit; + letter-spacing: inherit; + line-height: inherit; + word-spacing: inherit; + text-transform: inherit; + outline: none; + box-shadow: none; + caret-color: var(--accent); + } + .inline-edit-wrap .inline-input.slug-input, + .inline-slug-url .inline-input.slug-input { + /* Inherit everything from parent for perfect style match */ + font-family: inherit; + font-size: inherit; + font-weight: inherit; + font-style: inherit; + letter-spacing: inherit; + line-height: inherit; + word-spacing: inherit; + text-transform: inherit; + text-decoration: underline; + text-decoration-style: solid; + text-decoration-color: currentColor; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + background: transparent; + color: inherit; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + padding: 0; + margin: 0; + caret-color: var(--accent); + } + .inline-edit-wrap .inline-save-btn { + background: none; + border: none; + cursor: pointer; + color: #22c55e; + padding: 2px; + flex-shrink: 0; + transition: color 0.2s; + line-height: 1; + } + .inline-edit-wrap .inline-save-btn:hover { color: #16a34a; } + .inline-edit-wrap .inline-save-btn:disabled { + color: var(--text-muted); + cursor: not-allowed; + opacity: 0.4; + } + .inline-edit-wrap .inline-cancel-btn { + background: none; + border: none; + cursor: pointer; + color: #ef4444; + padding: 2px; + flex-shrink: 0; + transition: color 0.2s; + line-height: 1; + } + .inline-edit-wrap .inline-cancel-btn:hover { color: #dc2626; } + .inline-edit-wrap.inline-edit-inline { + display: inline-flex; + gap: 2px; + min-height: auto; + vertical-align: baseline; + } + .inline-edit-wrap.inline-edit-inline .slug-editable { + font-family: inherit; + font-size: inherit; + font-weight: inherit; + font-style: inherit; + letter-spacing: inherit; + line-height: inherit; + word-spacing: inherit; + text-transform: inherit; + color: inherit; + cursor: text; + text-decoration: underline; + text-decoration-style: solid; + text-decoration-color: currentColor; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + transition: color 0.15s, text-decoration-color 0.15s; + } + .inline-edit-wrap.inline-edit-inline .slug-editable:hover { + color: #4ecdc4; + text-decoration-color: #4ecdc4; + } + .inline-edit-wrap.inline-edit-inline .inline-input { + display: inline; + width: auto; + min-width: 40px; + max-width: 160px; + padding: 0; + margin: 0; + font-size: inherit; + font-family: inherit; + font-weight: inherit; + font-style: inherit; + letter-spacing: inherit; + line-height: inherit; + word-spacing: inherit; + text-transform: inherit; + vertical-align: baseline; + border: none; + border-radius: 0; + background: transparent; + color: inherit; + outline: none; + box-shadow: none; + text-decoration: underline; + text-decoration-style: solid; + text-decoration-color: currentColor; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + caret-color: var(--accent); + } + .inline-edit-wrap.inline-edit-inline .inline-save-btn, + .inline-edit-wrap.inline-edit-inline .inline-cancel-btn { + display: inline-flex; + align-items: center; + vertical-align: middle; + padding: 0 2px; + background: transparent; + border: none; + outline: none; + box-shadow: none; + } + .inline-edit-wrap.inline-edit-inline .inline-save-btn:focus, + .inline-edit-wrap.inline-edit-inline .inline-cancel-btn:focus { + outline: none; + box-shadow: none; + } + .inline-slug-url { + font-size: 0.78rem; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + letter-spacing: normal; + font-weight: normal; + transition: color 0.2s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; + } + /* Save/cancel buttons when inside slug URL (not inside .inline-edit-wrap) */ + .inline-slug-url .inline-save-btn, + .inline-slug-url .inline-cancel-btn { + background: none; + border: none; + cursor: pointer; + padding: 0 2px; + flex-shrink: 0; + transition: color 0.2s, opacity 0.15s; + line-height: 1; + display: inline-flex; + align-items: center; + vertical-align: baseline; + text-decoration: none; + } + .inline-slug-url .inline-save-btn { color: #22c55e; } + .inline-slug-url .inline-save-btn:hover { color: #16a34a; } + .inline-slug-url .inline-cancel-btn { color: #ef4444; } + .inline-slug-url .inline-cancel-btn:hover { color: #dc2626; } + .inline-slug-url .inline-save-btn:focus, + .inline-slug-url .inline-cancel-btn:focus { + outline: none; + box-shadow: none; + } + .inline-slug-url.status-default { color: var(--accent); } + .inline-slug-url.status-available { color: #22c55e; } + .inline-slug-url.status-taken { color: #ef4444; } + .inline-slug-url.status-invalid { color: #ef4444; } + .inline-slug-url.status-checking { color: #fbbf24; animation: slug-pulse 1s ease-in-out infinite; } + @keyframes slug-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + .slug-hint { + display: block; + font-size: 0.6rem; + margin-top: 2px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + opacity: 0.85; + } + .slug-hint.hint-error { color: #ef4444; } + .slug-hint.hint-taken { color: #ef4444; } + .slug-hint.hint-available { color: #22c55e; } + .slug-hint.hint-checking { color: #fbbf24; } + + /* ====================================================== + Deploy Modal + ====================================================== */ + .deploy-upload-zone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 24px; + text-align: center; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; + margin-bottom: 12px; + } + .deploy-upload-zone:hover { + border-color: var(--accent); + background: rgba(80,165,219,0.03); + } + .deploy-upload-zone.has-file { + border-color: var(--success); + background: rgba(34,197,94,0.04); + } + .deploy-file-name { + font-size: 0.85rem; + color: var(--success); + margin-top: 8px; + font-weight: 600; + } + + /* Deploy File Manager */ + .deploy-file-manager { + display: none; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #0d0d1f; + max-height: 220px; + overflow-y: auto; + margin-bottom: 16px; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 0.78rem; + } + .deploy-file-manager.visible { display: block; animation: fadeIn 0.2s ease; } + .deploy-file-manager-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: rgba(255,255,255,0.03); + border-bottom: 1px solid var(--border); + font-size: 0.72rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; + } + .deploy-folder-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + cursor: pointer; + transition: background 0.15s; + color: var(--text-secondary); + border-bottom: 1px solid rgba(255,255,255,0.03); + } + .deploy-folder-item:last-child { border-bottom: none; } + .deploy-folder-item:hover { background: rgba(100, 255, 218, 0.05); } + .deploy-folder-item.selected { + background: rgba(100, 255, 218, 0.1); + color: var(--accent); + font-weight: 600; + } + .deploy-folder-item.selected svg { stroke: var(--accent); } + .deploy-folder-icon { flex-shrink: 0; } + .deploy-folder-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .deploy-folder-count { font-size: 0.68rem; color: var(--text-muted); flex-shrink: 0; } + .deploy-selected-path { + font-size: 0.75rem; + color: var(--accent); + margin-top: 8px; + margin-bottom: 24px; + font-family: 'SF Mono', 'Fira Code', monospace; + } + + /* ====================================================== + Domain Search Results + ====================================================== */ + .domain-search-wrap { + position: relative; + margin-bottom: 12px; + } + .domain-search-input { + width: 100%; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 0.9rem; + font-family: var(--font); + z-index: 99; + position: relative; + } + .domain-search-input:focus { + outline: none; + border-color: white; + } + .domain-search-results { + position: relative; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + max-height: 320px; + overflow-y: auto; + margin-top: 12px; + display: none; + } + .domain-search-results.open { display: block; } + .domain-results-table { padding: 0; } + .domain-result-item { + padding: 10px 0; + display: flex; + align-items: center; + gap: 10px; + border-bottom: 1px solid rgba(255,255,255,0.06); + transition: background 0.15s; + } + .domain-result-item:last-child { border-bottom: none; } + .domain-result-item:hover { background: rgba(100, 255, 218, 0.03); } + .domain-result-item.domain-taken { opacity: 0.6; } + .domain-result-status { flex-shrink: 0; width: 18px; display: flex; align-items: center; } + .domain-result-item .domain-result-name { flex: 1; } + .domain-result-item .domain-result-price { flex-shrink: 0; min-width: 70px; text-align: right; } + .domain-result-name { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); + } + .domain-result-price { + font-size: 0.8rem; + color: var(--accent); + font-weight: 600; + } + .domain-result-unavailable { + font-size: 0.75rem; + color: var(--text-muted); + font-style: italic; + } + .domain-tabs { + display: flex; + gap: 0; + margin-bottom: 16px; + border-bottom: 1px solid var(--border); + } + .domain-tab { + flex: 1; + padding: 10px; + text-align: center; + font-size: 0.82rem; + font-weight: 600; + color: var(--text-muted); + background: none; + border: none; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 0.2s, border-color 0.2s, background 0.2s; + } + .domain-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + } + .domain-tab:hover { color: var(--text-primary); } + .domain-tab:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--accent-dim); } + .domain-tab:active { opacity: 0.7; } + + /* Hostname status squares */ + .hostname-status-square { + width: 20px; + height: 20px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + .hostname-status-square.active { + background: #22c55e; + } + .hostname-status-square.inactive { + background: #ef4444; + } + .hostname-status-square svg { width: 12px; height: 12px; } + + /* Hostname chips below URL */ + .hostname-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; + } + .hostname-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.65rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + letter-spacing: 0.02em; + } + .hostname-chip.default { background: rgba(148,163,184,0.15); color: #94a3b8; } + .hostname-chip.primary { background: rgba(80,165,219,0.15); color: var(--accent); } + .hostname-chip-setprimary { + font-size: 0.62rem; + color: var(--accent); + text-decoration: underline; + cursor: pointer; + margin-left: 4px; + background: none; + border: none; + padding: 0; + font-family: inherit; + } + .hostname-chip-setprimary:hover { color: #4ecdc4; } + + /* AI Validation Tooltip */ + .ai-validation-tooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: rgba(80, 165, 219, 0.95); + color: #fff; + font-size: 0.72rem; + font-weight: 600; + padding: 6px 12px; + border-radius: 6px; + white-space: nowrap; + pointer-events: none; + z-index: 100; + animation: tooltip-fade 0.3s ease; + } + .ai-validation-tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: rgba(80, 165, 219, 0.95); + } + @keyframes tooltip-fade { + from { opacity: 0; transform: translateX(-50%) translateY(4px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } + } + + /* Login page centered layout */ + .screen-signin-centered { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: calc(100vh - 60px); + padding: 24px 16px; + } + .signin-content-centered { + width: 100%; + max-width: 400px; + } + .signin-footer-fixed { + position: fixed; + bottom: 0; + left: 0; + right: 0; + text-align: center; + padding: 12px 16px; + font-size: 0.72rem; + color: var(--text-muted); + background: var(--bg-primary); + border-top: 1px solid var(--border); + z-index: 10; + } + @media (max-height: 600px) { + .signin-footer { + position: static; + margin-top: 24px; + border-top: none; + background: transparent; + } + .screen-signin { + min-height: auto; + padding-bottom: 0; + } + } + + /* Gorgeous button states - global refinements */ + .btn:focus-visible, .admin-btn:focus-visible, .header-auth-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--accent-dim), 0 0 16px rgba(80, 165, 219, 0.15); + } + .btn:focus:not(:focus-visible), .admin-btn:focus:not(:focus-visible) { outline: none; } + .hostname-delete-btn { + transition: color 0.2s, opacity 0.2s, background 0.2s; + border-radius: 4px; + } + .hostname-delete-btn:hover { + background: rgba(255,255,255,0.06); + } + .hostname-delete-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--accent-dim); + } + .hostname-delete-btn:active { + opacity: 0.6; + } + + /* ====================================================== + Details Business Name Search + ====================================================== */ + .details-biz-search-wrap { + position: relative; + z-index: 99999; + } + .details-biz-search-wrap #business-name-input { + position: relative; + z-index: 99999; + } + .details-biz-dropdown { + position: absolute; + top: 60px; + left: 0; + right: 0; + padding-top: 20px; + background: var(--bg-card); + border: 1px solid var(--border-hover); + border-top: none; + border-radius: 0 0 var(--radius) var(--radius); + max-height: 280px; + overflow-y: auto; + z-index: 9999; + display: none; + box-shadow: var(--shadow-lg); + } + .details-biz-dropdown .search-result { + padding: 12px 16px; + } + .details-biz-dropdown.open { display: block; animation: fadeInScale 0.15s ease; } + + /* ====================================================== + Screens Container + ====================================================== */ + .app { + min-height: 100vh; + padding-top: 60px; + position: relative; + } + .screen { + display: none; + min-height: calc(100vh - 60px); + animation: fadeIn 0.3s ease; + } + .screen-search { + min-height: auto; + } + .screen.active { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + .screen-search.active { + justify-content: flex-start; + padding-top: 12vh; + } + + /* ====================================================== + Background Orbs (shared) + ====================================================== */ + .bg-orbs { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + overflow: hidden; + } + .bg-orbs .orb { + position: absolute; + border-radius: 50%; + filter: blur(100px); + opacity: 0.18; + } + .bg-orbs .orb-1 { + width: 500px; height: 500px; + background: radial-gradient(circle, var(--secondary), transparent 70%); + top: -120px; right: -120px; + animation: orbFloat1 20s ease-in-out infinite; + } + .bg-orbs .orb-2 { + width: 400px; height: 400px; + background: radial-gradient(circle, var(--accent), transparent 70%); + bottom: -80px; left: -100px; + animation: orbFloat2 18s ease-in-out infinite; + } + .bg-orbs .orb-3 { + width: 300px; height: 300px; + background: radial-gradient(circle, #f472b6, transparent 70%); + top: 40%; left: 50%; + animation: orbFloat1 22s ease-in-out infinite reverse; + } + + /* ====================================================== + Screen 1: Search / Hero + ====================================================== */ + .screen-search { + padding: 0 24px; + text-align: center; + position: relative; + z-index: 1; + } + .hero-brand { + margin-bottom: 16px; + animation: fadeInUp 0.6s ease; + } + .hero-brand h1 { + font-size: clamp(2.2rem, 5.5vw, 3.8rem); + font-weight: 900; + letter-spacing: -0.03em; + line-height: 1.1; + margin-bottom: 12px; + } + .hero-brand h1 .gradient-text { + background: linear-gradient(135deg, var(--accent), var(--secondary), #f472b6); + background-size: 200% auto; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: shimmer 5s linear infinite; + } + .hero-brand .tagline { + font-size: clamp(1rem, 2vw, 1.25rem); + color: var(--text-secondary); + font-weight: 400; + } + + /* Search box */ + .search-wrapper { + position: relative; + z-index: 10; + width: 100%; + max-width: 640px; + margin: 32px auto 0; + animation: fadeInUp 0.6s ease 0.15s both; + } + .search-input-wrap { + position: relative; + display: flex; + align-items: center; + } + .search-icon { + position: absolute; + left: 18px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + pointer-events: none; + display: flex; + } + .search-input { + width: 100%; + padding: 18px 20px 18px 52px; + font-size: 1.1rem; + font-family: var(--font); + background: var(--bg-input); + border: 2px solid var(--border); + border-radius: var(--radius-lg); + color: var(--text-primary); + outline: none; + transition: border-color var(--transition), box-shadow var(--transition); + position: relative; + z-index: 2; + } + .search-input::placeholder { + color: var(--text-muted); + } + .search-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 4px var(--accent-dim), 0 0 40px var(--accent-glow); + } + .search-spinner { + position: absolute; + right: 18px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + width: 20px; + height: 20px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s; + z-index: 3; + } + .search-spinner.visible { opacity: 1; visibility: visible; } + + /* Dropdown */ + .search-dropdown { + position: absolute; + top: 100%; + margin-top: -14px; + left: 0; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border-hover); + border-top: none; + border-radius: 0 0 var(--radius) var(--radius); + max-height: 400px; + overflow-y: auto; + z-index: 1; + display: none; + box-shadow: var(--shadow-lg); + } + .search-dropdown.open { + display: block; + animation: fadeInScale 0.2s ease; + } + .search-result { + padding: 14px 20px; + cursor: pointer; + transition: background var(--transition); + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + text-align: left; + display: flex; + align-items: flex-start; + gap: 14px; + } + .search-result:first-child { padding-top: 28px; } + .search-result:last-child { border-bottom: none; } + .search-result:hover { + background: var(--bg-card-hover); + } + .search-result-icon { + flex-shrink: 0; + width: 38px; + height: 38px; + border-radius: 10px; + background: var(--accent-dim); + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; + } + .search-result-icon svg { + width: 18px; + height: 18px; + color: var(--accent); + } + .search-result-text { flex: 1; min-width: 0; } + .search-result-name { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .search-result-address { + font-size: 0.82rem; + color: var(--text-muted); + font-style: italic; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* Custom website option */ + .search-result-custom { + background: var(--secondary-dim); + border-top: 1px solid rgba(124, 58, 237, 0.2); + } + .search-result-custom:hover { + background: rgba(124, 58, 237, 0.22); + } + .search-result-custom .search-result-icon { + background: var(--secondary-dim); + } + .search-result-custom .search-result-icon svg { + color: var(--secondary); + } + .search-result-custom .search-result-name { + color: #c4b5fd; + } + .search-result-custom .search-result-address { + color: #a78bfa; + font-style: normal; + } + + .search-hint { + margin-top: 20px; + font-size: 0.85rem; + color: var(--text-muted); + animation: fadeInUp 0.6s ease 0.3s both; + } + + /* ====================================================== + Screen 3: Sign-In Gate + ====================================================== */ + .screen-signin { + padding: 0 24px 60px; + text-align: center; + position: relative; + z-index: 1; + min-height: 100vh; + } + .screen-signin.active { + justify-content: center; + gap: 0; + } + /* Compact signin footer - fixed at bottom */ + .signin-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + width: 100%; + padding: 12px 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + opacity: 0.6; + transition: opacity 0.2s; + background: var(--bg-primary); + border-top: 1px solid var(--border); + z-index: 10; + } + .signin-footer:hover { opacity: 0.85; } + .signin-footer-social { + display: flex; + gap: 12px; + } + .signin-footer-social a { + color: var(--text-muted); + transition: color 0.2s, transform 0.15s; + display: flex; + } + .signin-footer-social a:hover { transform: scale(1.15); } + .signin-footer-social a[aria-label="GitHub"]:hover { color: #f0f6fc; } + .signin-footer-social a[aria-label="X"]:hover { color: #f0f6fc; } + .signin-footer-social a[aria-label="LinkedIn"]:hover { color: #0a66c2; } + .signin-footer-social a[aria-label="YouTube"]:hover { color: #ff0000; } + .signin-footer-social a[aria-label="Instagram"]:hover { color: #e4405f; } + .signin-footer-social a[aria-label="Facebook"]:hover { color: #1877f2; } + .signin-footer-social a svg { width: 14px; height: 14px; } + .signin-footer-legal { + font-size: 0.7rem; + color: var(--text-muted); + } + .signin-footer-legal a { + color: var(--text-muted); + text-decoration: none; + transition: color 0.15s; + } + .signin-footer-legal a:hover { color: var(--accent); } + .signin-card { + width: 100%; + max-width: 440px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 40px 32px; + animation: fadeInScale 0.35s ease; + } + .signin-card h2 { + font-size: 1.5rem; + font-weight: 800; + margin-bottom: 8px; + letter-spacing: -0.02em; + } + .signin-card .signin-subtitle { + font-size: 0.9rem; + color: var(--text-secondary); + margin-bottom: 32px; + } + + .signin-methods { display: flex; flex-direction: column; gap: 12px; } + + .signin-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + width: 100%; + padding: 14px 20px; + border-radius: var(--radius); + font-size: 0.95rem; + font-weight: 600; + font-family: var(--font); + cursor: pointer; + transition: transform var(--transition), box-shadow var(--transition), background var(--transition), border-color var(--transition); + border: 1px solid var(--border); + background: var(--bg-input); + color: var(--text-primary); + } + .signin-btn:hover { + transform: translateY(-1px); + border-color: var(--border-hover); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + } + .signin-btn:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim), 0 0 20px rgba(80, 165, 219, 0.1); + } + .signin-btn:active { + transform: translateY(1px) scale(0.98); + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); + transition-duration: 0.08s; + } + .signin-btn svg { flex-shrink: 0; } + + .signin-btn-google { + background: #fff; + color: #1f1f1f; + border-color: #dadce0; + } + .signin-btn-google:hover { + background: #f8f9fa; + border-color: #c4c7cc; + } + + .signin-divider { + display: flex; + align-items: center; + gap: 16px; + margin: 20px 0; + color: var(--text-muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + } + .signin-divider::before, + .signin-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); + } + + /* Phone / Email form panels */ + .signin-panel { + display: none; + animation: fadeInUp 0.3s ease; + } + .signin-panel.active { display: block; } + + .input-group { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + text-align: left; + } + .input-group label { + font-size: 0.82rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; + } + .input-field { + width: 100%; + padding: 14px 16px; + font-size: 1rem; + font-family: var(--font); + background: var(--bg-input); + border: 1.5px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + outline: none; + transition: border-color var(--transition), box-shadow var(--transition); + } + .input-field::placeholder { color: var(--text-secondary); } + .input-field:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim); + } + + textarea.input-field { + resize: vertical; + min-height: 120px; + line-height: 1.6; + } + #details-textarea { + min-height: 210px; + } + + .back-link { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + color: var(--text-muted); + cursor: pointer; + margin-top: 20px; + transition: color var(--transition); + background: none; + border: none; + font-family: var(--font); + } + .back-link:hover { color: var(--accent); } + .back-link:active { opacity: 0.7; transition-duration: 0.06s; } + + /* ====================================================== + Buttons (shared) + ====================================================== */ + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px 28px; + border-radius: var(--radius); + font-size: 1rem; + font-weight: 600; + font-family: var(--font); + text-decoration: none; + cursor: pointer; + border: none; + transition: box-shadow var(--transition), background var(--transition), opacity var(--transition), filter var(--transition), border-color var(--transition); + } + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; + } + .btn-accent { + background: linear-gradient(135deg, var(--accent) 0%, #4ecdc4 50%, var(--accent) 100%); + background-size: 200% 200%; + color: var(--bg-primary); + transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease, background-position 0.5s ease, filter 0.3s ease; + } + .btn-accent:hover:not(:disabled) { + background-position: 100% 0; + box-shadow: + 0 0 20px rgba(80, 165, 219, 0.4), + 0 0 60px rgba(80, 165, 219, 0.15), + 0 4px 16px rgba(0, 0, 0, 0.3); + filter: brightness(1.1); + } + .btn-accent:focus:not(:disabled) { + outline: none; + box-shadow: + 0 0 0 3px var(--accent-dim), + 0 0 30px rgba(80, 165, 219, 0.25), + inset 0 0 12px rgba(255, 255, 255, 0.08); + } + .btn-accent:active:not(:disabled) { + box-shadow: + 0 0 0 3px rgba(80, 165, 219, 0.5), + inset 0 2px 6px rgba(0, 0, 0, 0.2); + filter: brightness(0.92); + transition-duration: 0.08s; + } + .btn-secondary-outline { + background: transparent; + color: var(--text-primary); + border: 1.5px solid var(--border); + transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease, border-color 0.3s ease, color 0.3s ease, background 0.3s ease; + } + .btn-secondary-outline:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); + background: rgba(80, 165, 219, 0.04); + box-shadow: + 0 0 20px rgba(80, 165, 219, 0.1), + 0 4px 12px rgba(0, 0, 0, 0.2); + } + .btn-secondary-outline:focus:not(:disabled) { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim), 0 0 20px rgba(80, 165, 219, 0.1); + } + .btn-secondary-outline:active:not(:disabled) { + background: rgba(80, 165, 219, 0.08); + border-color: #4ecdc4; + box-shadow: 0 0 0 2px rgba(78, 205, 196, 0.3); + transition-duration: 0.08s; + } + .btn-full { width: 100%; } + .btn-large { + padding: 16px 32px; + font-size: 1.05rem; + border-radius: var(--radius-lg); + } + + /* Material-style ripple effect */ + .btn, .site-card-btn, .admin-btn, .admin-btn-accent, .logs-refresh-btn, + .domain-tab, .hostname-delete-btn, .signin-btn, .back-link, + .modal-close, .details-modal-close, .header-auth-btn, + .site-card-new, .site-card-upgrade-btn, + .inline-edit-btn, .inline-save-btn, .inline-cancel-btn, + .faq-question, .btn-allow, .btn-skip, .improve-ai-link, + .plan-badge { + position: relative; + overflow: hidden; + } + .ripple-circle { + position: absolute; + border-radius: 50%; + background: rgba(255, 255, 255, 0.35); + pointer-events: none; + animation: ripple-expand 0.65s cubic-bezier(0.4, 0, 0.2, 1) forwards; + } + /* Brighter ripple on accent/bright buttons for visibility */ + .btn-accent .ripple-circle, + .btn-allow .ripple-circle, + .site-card-upgrade-btn .ripple-circle { + background: rgba(255, 255, 255, 0.5); + } + /* Tinted ripple on outlined/ghost buttons */ + .btn-secondary-outline .ripple-circle, + .btn-ghost .ripple-circle { + background: rgba(80, 165, 219, 0.2); + } + + /* ====================================================== + Inline Error / Success Messages + ====================================================== */ + .msg { + padding: 10px 14px; + border-radius: 8px; + font-size: 0.85rem; + margin-top: 12px; + text-align: left; + min-height: 40px; + visibility: hidden; + opacity: 0; + max-height: 0; + overflow: hidden; + transition: opacity 0.2s ease, max-height 0.25s ease, padding 0.25s ease, margin 0.25s ease; + padding: 0 14px; + margin-top: 0; + } + .msg.visible { + visibility: visible; + opacity: 1; + max-height: 120px; + padding: 10px 14px; + margin-top: 12px; + } + .msg-error { + background: var(--error-dim); + color: var(--error); + border: 1px solid rgba(239, 68, 68, 0.2); + } + .msg-success { + background: rgba(34, 197, 94, 0.1); + color: var(--success); + border: 1px solid rgba(34, 197, 94, 0.2); + } + .msg-info { + background: var(--accent-dim); + color: var(--accent); + border: 1px solid rgba(80, 165, 219, 0.15); + } + + /* ====================================================== + Location Permission Modal + ====================================================== */ + .location-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease; + } + .location-modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 32px; + max-width: 400px; + width: 90%; + text-align: center; + animation: fadeInScale 0.3s ease; + } + .location-modal-icon { + margin-bottom: 16px; + color: var(--accent); + } + .location-modal h3 { + color: var(--text-primary); + font-size: 1.15rem; + margin-bottom: 8px; + } + .location-modal p { + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.5; + margin-bottom: 24px; + } + .location-modal-actions { + display: flex; + gap: 12px; + } + .location-modal-actions button { + flex: 1; + padding: 10px 16px; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + border: none; + transition: var(--transition); + } + .location-modal-actions .btn-allow { + background: linear-gradient(135deg, var(--accent), var(--secondary)); + color: #fff; + } + .location-modal-actions .btn-allow:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-accent); + } + .location-modal-actions .btn-allow:focus { + outline: none; + box-shadow: 0 0 0 3px var(--accent-dim), var(--shadow-accent); + } + .location-modal-actions .btn-allow:active { + transform: translateY(1px) scale(0.97); + transition-duration: 0.06s; + } + .location-modal-actions .btn-skip { + background: var(--bg-input); + color: var(--text-secondary); + border: 1px solid var(--border); + } + .location-modal-actions .btn-skip:hover { + border-color: var(--border-hover); + color: var(--text-primary); + } + .location-modal-actions .btn-skip:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim); + } + .location-modal-actions .btn-skip:active { + opacity: 0.7; + transition-duration: 0.06s; + } + + /* ====================================================== + Loading dots + ====================================================== */ + .loading-dots { + display: inline-flex; + gap: 4px; + align-items: center; + } + .loading-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + animation: dotPulse 1.4s ease-in-out infinite; + } + .loading-dots span:nth-child(2) { animation-delay: 0.16s; } + .loading-dots span:nth-child(3) { animation-delay: 0.32s; } + + /* ====================================================== + Screen 3b: Details + Upload + ====================================================== */ + .screen-details { + padding: 24px; + position: relative; + z-index: 1; + width: 100%; + } + /* Details Modal Overlay */ + .details-modal-overlay { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.75); + z-index: 2000; + align-items: flex-start; + justify-content: center; + animation: fadeIn 0.15s ease; + overflow-y: auto; + padding: 24px; + } + .details-modal-overlay.visible { display: flex; } + .details-modal-close { + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 6px; + font-size: 1.4rem; + line-height: 1; + z-index: 2; + transition: color 0.2s; + } + .details-modal-close:hover { color: var(--text-primary); } + .details-modal-close:focus { outline: none; color: var(--accent); } + .details-modal-close:active { opacity: 0.6; transition: opacity 0.06s; } + .details-card { + width: 100%; + max-width: 600px; + margin: 20px auto; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 36px 32px; + animation: fadeInScale 0.35s ease; + position: relative; + flex-shrink: 0; + } + .details-card h2 { + font-size: 1.4rem; + font-weight: 800; + margin-bottom: 8px; + letter-spacing: -0.02em; + } + .details-card .details-subtitle { + font-size: 0.88rem; + color: var(--text-secondary); + margin-bottom: 24px; + } + + .selected-business-badge { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--accent-dim); + border: 1px solid rgba(80, 165, 219, 0.15); + border-radius: var(--radius); + margin-bottom: 24px; + } + .selected-business-badge .badge-icon { + width: 36px; + height: 36px; + border-radius: 8px; + background: var(--accent-dim); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + .selected-business-badge .badge-text { text-align: left; } + .selected-business-badge .badge-name { + font-size: 0.9rem; + font-weight: 600; + color: var(--accent); + } + .selected-business-badge .badge-addr { + font-size: 0.78rem; + color: var(--text-muted); + } + .selected-business-badge .badge-dismiss { + margin-left: auto; + flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + border: none; + background: rgba(255,255,255,0.06); + color: var(--text-muted); + cursor: pointer; + transition: background var(--transition), color var(--transition); + padding: 0; + } + .selected-business-badge .badge-dismiss:hover { + background: rgba(239, 68, 68, 0.15); + color: var(--error); + } + + /* ── Address Autocomplete Dropdown ────────── */ + #business-address-input { + position: relative; + z-index: 9999; + } + .address-dropdown { + position: absolute; + padding-top: 20px; + top: 60px; + left: 0; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border-hover); + border-top: none; + border-radius: 0 0 var(--radius) var(--radius); + max-height: 280px; + overflow-y: auto; + z-index: 999; + display: none; + box-shadow: var(--shadow-lg); + } + .address-dropdown.open { display: block; animation: fadeInScale 0.15s ease; } + .address-item { + padding: 12px 16px; + cursor: pointer; + transition: background var(--transition); + border-bottom: 1px solid rgba(255,255,255,0.04); + display: flex; + align-items: center; + gap: 10px; + } + .address-item:last-child { border-bottom: none; } + .address-item:hover { background: var(--bg-card-hover); } + .address-item-icon { + flex-shrink: 0; + color: var(--accent); + opacity: 0.7; + } + .address-item-text { + flex: 1; + min-width: 0; + } + .address-item-main { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .address-item-secondary { + font-size: 0.78rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .uppy-section { + margin: 20px 0; + } + .uppy-section label { + display: block; + font-size: 0.82rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 8px; + text-align: left; + } + #uppy-dashboard-area { + border-radius: var(--radius); + overflow: hidden; + } + + /* ── Improve with AI overlay ────────── */ + .improve-ai-link { + font-size: 0.78rem; + color: var(--accent); + cursor: pointer; + font-weight: 600; + margin-left: 8px; + transition: color 0.2s; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 4px; + } + .improve-ai-link:hover { color: #7c3aed; } + .improve-ai-link:focus { outline: none; text-decoration: underline; } + .improve-ai-link:active { opacity: 0.7; transition: opacity 0.06s; } + .improve-ai-overlay { + display: none; + position: absolute; + inset: 0; + background: rgba(10, 10, 26, 0.85); + border-radius: var(--radius); + z-index: 10; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 12px; + } + .improve-ai-overlay.visible { display: flex; } + .improve-ai-spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + .improve-ai-text { + font-size: 0.85rem; + color: var(--accent); + font-weight: 600; + } + + /* ── Premium domain badge ────────── */ + .premium-domain-badge { + display: inline-flex; + align-items: center; + gap: 4px; + background: linear-gradient(135deg, rgba(124,58,237,0.15), rgba(80,165,219,0.15)); + border: 1px solid rgba(124,58,237,0.25); + color: #c4b5fd; + font-size: 0.65rem; + font-weight: 700; + padding: 2px 8px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.03em; + } + .premium-domain-badge svg { width: 10px; height: 10px; } + + /* ── Unsubscribe domain warning modal ────────── */ + .domain-unsub-warning { + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: 8px; + padding: 12px 14px; + font-size: 0.82rem; + color: var(--error); + margin-top: 8px; + line-height: 1.5; + } + + /* ── Responsive terminal fix ────────── */ + @media (max-width: 620px) { + .build-terminal { min-width: auto; max-width: 100%; } + } + + /* Uppy dark theme overrides */ + .uppy-Dashboard-inner { + background: var(--bg-input) !important; + border-color: var(--border) !important; + } + + /* ====================================================== + Screen 4: Waiting + ====================================================== */ + .screen-waiting { + padding: 24px; + text-align: center; + position: relative; + z-index: 1; + } + .screen-waiting.active { + justify-content: flex-start; + padding-top: 40px; + } + .waiting-content { + max-width: 480px; + animation: fadeInScale 0.4s ease; + } + .waiting-anim { + width: 160px; + height: 160px; + margin: 0 auto 32px; + position: relative; + } + .waiting-anim-ring { + position: absolute; + border-radius: 50%; + border: 3px solid transparent; + animation: waitSpin 2.4s cubic-bezier(0.68, -0.55, 0.27, 1.55) infinite; + } + .waiting-anim-ring:nth-child(1) { + inset: 0; + border-top-color: var(--accent); + border-right-color: var(--accent); + } + .waiting-anim-ring:nth-child(2) { + inset: 18px; + border-bottom-color: #7c3aed; + border-left-color: #7c3aed; + animation-delay: -0.4s; + animation-direction: reverse; + } + .waiting-anim-ring:nth-child(3) { + inset: 36px; + border-top-color: #3b82f6; + border-right-color: #3b82f6; + animation-delay: -0.8s; + } + .waiting-anim-icon { + position: absolute; + inset: 52px; + display: flex; + align-items: center; + justify-content: center; + animation: waitPulse 2s ease-in-out infinite; + } + @keyframes waitSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + @keyframes waitPulse { + 0%, 100% { opacity: 0.7; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.15); } + } + .waiting-title { + font-size: clamp(1.4rem, 3vw, 1.8rem); + font-weight: 800; + margin-bottom: 10px; + letter-spacing: -0.02em; + transition: color 0.3s ease, text-shadow 0.3s ease; + cursor: default; + } + .waiting-title:hover { + color: var(--accent); + text-shadow: 0 0 20px rgba(100, 255, 218, 0.3); + } + .waiting-subtitle { + font-size: 1rem; + color: var(--text-secondary); + margin-bottom: 8px; + } + .waiting-contact { + font-size: 0.85rem; + color: var(--text-muted); + margin-top: 16px; + } + /* waiting-status removed */ + + /* ====================================================== + Responsive + ====================================================== */ + @media (max-width: 640px) { + .signin-card, .details-card { + padding: 28px 20px; + } + .search-input { + font-size: 1rem; + padding: 16px 18px 16px 48px; + } + .hero-brand h1 { + font-size: 2rem; + } + } + @media (max-width: 380px) { + .header { padding: 0 16px; } + .screen-search, .screen-signin, .screen-details, .screen-waiting, .screen-legal { + padding: 16px; + } + } + + /* ====================================================== + Legal Pages (Privacy, Terms, Content Policy) + ====================================================== */ + .screen-legal { + padding: 40px 24px 80px; + min-height: auto; + } + .screen-legal.active { + justify-content: flex-start; + align-items: flex-start; + } + /* When a legal/content page is active, unset .app min-height so + the page height follows the content naturally. */ + .app:has(.screen-legal.active) { + min-height: unset; + } + .legal-container { + max-width: 760px; + width: 100%; + margin: 0 auto; + position: relative; + z-index: 1; + } + .legal-container h2 { + font-size: 2rem; + font-weight: 800; + margin-bottom: 8px; + background: linear-gradient(135deg, var(--accent), var(--secondary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + .legal-container .legal-updated { + color: var(--text-muted); + font-size: 0.85rem; + margin-bottom: 32px; + } + .legal-container h3 { + color: var(--accent); + font-size: 1.15rem; + font-weight: 700; + margin: 28px 0 12px; + } + .legal-container p, + .legal-container li { + color: var(--text-secondary); + font-size: 0.92rem; + line-height: 1.7; + margin-bottom: 12px; + } + .legal-container ul { + padding-left: 20px; + margin-bottom: 16px; + } + .legal-container a { + color: var(--accent); + text-decoration: none; + } + .legal-container a:hover { + text-decoration: underline; + } + .legal-back { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + font-size: 0.85rem; + cursor: pointer; + background: none; + border: none; + padding: 0; + margin-bottom: 24px; + transition: color 0.2s; + } + .legal-back:hover { color: var(--accent); } + + /* ====================================================== + Marketing Sections (visible on search screen) + ====================================================== */ + .marketing-sections { + width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 0 24px; + position: relative; + z-index: 1; + } + + .section-title { + text-align: center; + font-size: clamp(1.6rem, 3.5vw, 2.4rem); + font-weight: 800; + letter-spacing: -0.03em; + margin-bottom: 12px; + } + .section-subtitle { + text-align: center; + font-size: 1rem; + color: var(--text-secondary); + margin-bottom: 48px; + max-width: 600px; + margin-left: auto; + margin-right: auto; + } + + /* ── How It Works ─────────────────────────────── */ + #how-it-works { padding: 80px 0 60px; } + + .steps-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 28px; + } + .step-card { + text-align: center; + padding: 36px 24px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition); + opacity: 0; + transform: translateY(30px); + } + .step-card.animate-in { + animation: fadeInUp 0.6s ease forwards; + } + .step-card:nth-child(2).animate-in { animation-delay: 0.15s; } + .step-card:nth-child(3).animate-in { animation-delay: 0.3s; } + .step-card:hover { + transform: translateY(-4px); + border-color: var(--border-hover); + box-shadow: var(--shadow-accent); + } + .step-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--secondary)); + color: #ffffff; + font-weight: 800; + font-size: 1.2rem; + margin-bottom: 20px; + } + .step-card h3 { + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 10px; + } + .step-card p { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.6; + } + + /* ── Features / Selling Points ────────────────── */ + #features { padding: 60px 0; } + + .features-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + } + .feature-card { + padding: 32px 24px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition); + opacity: 0; + transform: translateY(30px); + } + .feature-card.animate-in { + animation: fadeInUp 0.6s ease forwards; + } + .feature-card:nth-child(2).animate-in { animation-delay: 0.15s; } + .feature-card:nth-child(3).animate-in { animation-delay: 0.3s; } + .feature-card:hover { + transform: translateY(-3px); + border-color: var(--border-hover); + box-shadow: var(--shadow-accent); + } + .feature-card:last-child { + grid-column: 2; + } + .feature-icon { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 16px; + } + .feature-icon svg { + width: 24px; + height: 24px; + color: var(--accent); + } + .feature-card h3 { + font-size: 1.05rem; + font-weight: 700; + margin-bottom: 8px; + } + .feature-card p { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.6; + } + + /* ── Competitor Comparison ─────────────────────── */ + #comparison { padding: 60px 0; } + + .comparison-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + overflow: hidden; + } + .comparison-table th, + .comparison-table td { + padding: 16px 18px; + text-align: center; + font-size: 0.9rem; + border-bottom: 1px solid var(--border); + } + .comparison-table thead th { + background: var(--bg-secondary); + font-weight: 700; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary); + } + .comparison-table thead th:first-child { + text-align: left; + } + .comparison-table thead th.highlight { + color: var(--accent); + background: rgba(80, 165, 219, 0.06); + } + .comparison-table tbody td:first-child { + text-align: left; + font-weight: 600; + color: var(--text-primary); + } + .comparison-table tbody td.highlight { + background: rgba(80, 165, 219, 0.04); + color: var(--accent); + font-weight: 600; + } + .comparison-table tbody tr:last-child td { + border-bottom: none; + } + .check-icon { color: var(--success); font-weight: 700; } + .cross-icon { color: var(--text-muted); } + + /* ── Pricing ──────────────────────────────────── */ + #pricing { padding: 60px 0; } + + .pricing-card { + max-width: 420px; + margin: 0 auto; + text-align: center; + background: var(--bg-card); + border: 2px solid var(--accent); + border-radius: var(--radius-lg); + padding: 48px 36px; + box-shadow: var(--shadow-accent); + opacity: 0; + transform: translateY(30px); + } + .pricing-card.animate-in { + animation: fadeInUp 0.6s ease forwards; + animation-delay: 0.15s; + } + .pricing-card .plan-label { + display: inline-block; + background: var(--accent); + color: var(--bg-primary); + font-size: 0.75rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 4px 14px; + border-radius: 20px; + margin-bottom: 20px; + } + .pricing-card-free .plan-label { + display: inline-block; + padding: 5px 15px; + margin-bottom: 15px; + margin-top: 10px; + font-size: 0.75rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; + border-radius: 20px; + } + .pricing-price { + font-size: 3.5rem; + font-weight: 900; + letter-spacing: -0.04em; + color: var(--text-primary); + line-height: 1; + margin-bottom: 4px; + min-height: 3.5rem; + } + .pricing-price span { + font-size: 1.2rem; + font-weight: 500; + color: var(--text-secondary); + } + .pricing-desc { + font-size: 0.9rem; + color: var(--text-secondary); + margin-bottom: 28px; + } + .pricing-features { + list-style: none; + text-align: left; + margin-bottom: 32px; + } + .pricing-features li { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + font-size: 0.9rem; + color: var(--text-primary); + border-bottom: 1px solid rgba(255,255,255,0.04); + } + .pricing-features li:last-child { border-bottom: none; } + .pricing-features li svg { + flex-shrink: 0; + width: 18px; + height: 18px; + color: var(--accent); + } + + /* ── Footer ───────────────────────────────────── */ + .site-footer { + border-top: 1px solid var(--border); + padding: 48px 24px 32px; + margin-top: 60px; + position: relative; + z-index: 1; + } + .footer-inner { + max-width: 1100px; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + } + .footer-links { + display: flex; + gap: 24px; + flex-wrap: wrap; + justify-content: center; + } + .footer-links a { + color: var(--text-secondary); + text-decoration: none; + font-size: 0.88rem; + transition: color var(--transition); + } + .footer-links a:hover { color: var(--accent); } + + .footer-social { + display: flex; + gap: 16px; + } + .footer-social a { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + background: transparent; + border: none; + color: var(--text-secondary); + transition: color 0.2s ease, transform 0.2s ease; + } + .footer-social a:hover { transform: scale(1.15); } + .footer-social a[aria-label="GitHub"]:hover { color: #f0f6fc; } + .footer-social a[aria-label="X"]:hover { color: #f0f6fc; } + .footer-social a[aria-label="LinkedIn"]:hover { color: #0a66c2; } + .footer-social a[aria-label="YouTube"]:hover { color: #ff0000; } + .footer-social a[aria-label="Instagram"]:hover { color: #e4405f; } + .footer-social a[aria-label="Facebook"]:hover { color: #1877f2; } + .footer-social a svg { + width: 18px; + height: 18px; + } + + .footer-bottom { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + font-size: 0.82rem; + color: var(--text-muted); + } + .footer-bottom a { + color: var(--text-muted); + text-decoration: none; + } + .footer-bottom a:hover { color: var(--accent); } + + /* ── Hero CTAs ────────────────────────────────── */ + .hero-ctas { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin-top: 24px; + animation: fadeInUp 0.6s ease 0.45s both; + } + .btn-ghost { + background: transparent; + color: var(--text-secondary); + border: 1.5px solid var(--border); + padding: 12px 24px; + border-radius: var(--radius); + font-size: 0.95rem; + font-weight: 600; + font-family: var(--font); + cursor: pointer; + text-decoration: none; + transition: color var(--transition), border-color var(--transition); + } + .btn-ghost:hover { + color: var(--accent); + border-color: var(--accent); + } + .hero-clarify { + font-size: 0.82rem; + color: var(--text-muted); + margin-top: 12px; + animation: fadeInUp 0.6s ease 0.5s both; + } + .hero-clarify strong { color: var(--accent); font-weight: 600; } + + /* ── Proof / Gallery ─────────────────────────── */ + #proof { padding: 80px 0 60px; } + .gallery-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-bottom: 48px; + } + .site-thumb { + position: relative; + border-radius: var(--radius-lg); + overflow: hidden; + aspect-ratio: 16/10; + background: var(--bg-card); + border: 1px solid var(--border); + transition: transform var(--transition), box-shadow var(--transition); + cursor: pointer; + } + .site-thumb:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-accent); + } + .site-thumb-preview { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + } + .site-thumb-bar { + height: 28px; + background: var(--bg-secondary); + display: flex; + align-items: center; + padding: 0 10px; + gap: 5px; + } + .site-thumb-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); + opacity: 0.4; + } + .site-thumb-body { + flex: 1; + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; + } + .site-thumb-hero-block { + height: 40%; + border-radius: 6px; + opacity: 0.7; + } + .site-thumb-line { + height: 6px; + border-radius: 3px; + background: var(--border); + } + .site-thumb-line.short { width: 60%; } + .site-thumb-label { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 10px 14px; + background: linear-gradient(transparent, rgba(10,10,26,0.95)); + } + .site-thumb-name { + font-size: 0.85rem; + font-weight: 700; + color: var(--text-primary); + } + .site-thumb-industry { + font-size: 0.72rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + /* Testimonials */ + .testimonials-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + } + .testimonial-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 28px 24px; + } + .testimonial-stars { + color: #fbbf24; + font-size: 0.9rem; + margin-bottom: 12px; + letter-spacing: 2px; + } + .testimonial-quote { + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.7; + margin-bottom: 16px; + font-style: italic; + } + .testimonial-author { + display: flex; + align-items: center; + gap: 10px; + } + .testimonial-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.85rem; + color: var(--bg-primary); + flex-shrink: 0; + } + .testimonial-name { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-primary); + } + .testimonial-role { + font-size: 0.75rem; + color: var(--text-muted); + } + + /* ── What's Handled (value prop) ─────────────── */ + #handled { padding: 60px 0; } + .handled-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; + } + .handled-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 32px 24px; + text-align: center; + transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition); + opacity: 0; + transform: translateY(30px); + } + .handled-card.animate-in { + animation: fadeInUp 0.6s ease forwards; + } + .handled-card:nth-child(2).animate-in { animation-delay: 0.15s; } + .handled-card:nth-child(3).animate-in { animation-delay: 0.3s; } + .handled-card:hover { + transform: translateY(-3px); + border-color: var(--border-hover); + box-shadow: var(--shadow-accent); + } + .handled-card .handled-icon { + width: 56px; + height: 56px; + border-radius: 14px; + background: var(--accent-dim); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 20px; + } + .handled-card .handled-icon svg { + width: 28px; + height: 28px; + color: var(--accent); + } + .handled-card h3 { + font-size: 1.05rem; + font-weight: 700; + margin-bottom: 8px; + } + .handled-card p { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.6; + } + .handled-summary { + text-align: center; + margin-top: 32px; + padding: 24px; + background: var(--accent-dim); + border: 1px solid rgba(80,165,219,0.15); + border-radius: var(--radius-lg); + font-size: 0.95rem; + color: var(--text-primary); + line-height: 1.7; + transition: border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease; + } + .handled-summary:hover { + border-color: rgba(80,165,219,0.3); + box-shadow: 0 4px 20px rgba(80,165,219,0.08); + transform: translateY(-1px); + } + .handled-summary strong { color: var(--accent); } + + /* ── Trust Bar ─────────────────────── */ + .trust-bar { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 24px 40px; + } + .trust-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 500; + } + + /* ── Done-for-you vs DIY ─────────────────────── */ + #dvd { padding: 60px 0; } + .dvd-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + } + .dvd-column { + background: var(--bg-card); + border-radius: var(--radius-lg); + overflow: hidden; + opacity: 0; + transform: translateY(30px); + transition: box-shadow 0.3s ease, border-color 0.3s ease; + } + .dvd-column:hover { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), 0 0 0 1px var(--border-hover); + } + .dvd-column.dvd-highlight:hover { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), 0 0 20px rgba(80, 165, 219, 0.15); + } + .dvd-column.animate-in { + animation: fadeInUp 0.6s ease forwards; + } + .dvd-column:nth-child(2).animate-in { animation-delay: 0.15s; } + .dvd-column.dvd-highlight { + border: 2px solid var(--accent); + box-shadow: var(--shadow-accent); + } + .dvd-column.dvd-other { + border: 1px solid var(--border); + } + .dvd-header { + padding: 20px 24px; + font-weight: 700; + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .dvd-highlight .dvd-header { + background: rgba(80,165,219,0.06); + color: var(--accent); + } + .dvd-other .dvd-header { + background: var(--bg-secondary); + color: var(--text-secondary); + } + .dvd-list { + padding: 16px 24px 24px; + list-style: none; + } + .dvd-list li { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 0; + font-size: 0.9rem; + border-bottom: 1px solid rgba(255,255,255,0.04); + line-height: 1.5; + } + .dvd-list li:last-child { border-bottom: none; } + .dvd-list li svg { + flex-shrink: 0; + width: 18px; + height: 18px; + margin-top: 2px; + } + .dvd-highlight .dvd-list li svg { color: var(--accent); } + .dvd-other .dvd-list li svg { color: var(--text-muted); } + .dvd-highlight .dvd-list li { color: var(--text-primary); } + .dvd-other .dvd-list li { color: var(--text-secondary); } + + /* ── FAQ ──────────────────────────────────────── */ + #faq { padding: 60px 0; } + .faq-list { + max-width: 740px; + margin: 0 auto; + } + .faq-item { + border-bottom: 1px solid var(--border); + opacity: 0; + transform: translateY(20px); + } + .faq-item.animate-in { + animation: fadeInUp 0.4s ease forwards; + } + .faq-question { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 0; + background: none; + border: none; + color: var(--text-primary); + font-size: 1rem; + font-weight: 600; + font-family: var(--font); + cursor: pointer; + text-align: left; + line-height: 1.5; + transition: color var(--transition); + } + .faq-question:hover { color: var(--accent); } + .faq-question:focus { outline: none; color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); border-radius: 4px; } + .faq-question:active { opacity: 0.8; transition: opacity 0.06s; } + .faq-question svg { + flex-shrink: 0; + width: 20px; + height: 20px; + color: var(--text-muted); + transition: transform var(--transition); + } + .faq-item.open .faq-question svg { + transform: rotate(180deg); + } + .faq-answer { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + } + .faq-item.open .faq-answer { + max-height: 400px; + } + .faq-answer-inner { + padding: 0 0 20px; + text-align: left; + font-size: 0.92rem; + color: var(--text-secondary); + line-height: 1.7; + } + + /* ── Pricing (updated) ───────────────────────── */ + .pricing-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-bottom: 32px; + } + .pricing-toggle-label { + font-size: 0.9rem; + color: var(--text-secondary); + font-weight: 500; + cursor: pointer; + } + .pricing-toggle-label.active { color: var(--text-primary); font-weight: 700; } + .pricing-toggle-switch { + width: 48px; + height: 26px; + border-radius: 13px; + background: var(--bg-card); + border: 2px solid var(--border); + position: relative; + cursor: pointer; + transition: background var(--transition), border-color var(--transition); + } + .pricing-toggle-switch.annual { + background: var(--accent-dim); + border-color: var(--accent); + } + .pricing-toggle-switch::after { + content: ''; + position: absolute; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--text-secondary); + top: 2px; + left: 2px; + transition: transform var(--transition), background var(--transition); + } + .pricing-toggle-switch.annual::after { + transform: translateX(22px); + background: var(--accent); + } + .pricing-grid { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: start; + gap: 24px; + max-width: 840px; + margin: 0 auto; + } + .pricing-grid > .pricing-card { + max-width: none; + margin: 0; + } + .pricing-card-free { + background: linear-gradient(135deg, rgba(80,165,219,0.04), rgba(124,58,237,0.04)); + border: 2px dashed var(--accent); + border-radius: var(--radius-lg); + padding: 36px 28px; + text-align: center; + position: relative; + overflow: hidden; + opacity: 0; + transform: translateY(30px); + transition: box-shadow 0.3s ease, border-color 0.3s ease; + } + .pricing-card-free:hover { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), 0 0 20px rgba(80, 165, 219, 0.1); + border-color: #4ecdc4; + } + .pricing-card-free.animate-in { + animation: fadeInUp 0.6s ease forwards; + } + .pricing-card-free::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--accent), var(--secondary)); + } + .pricing-save-badge { + display: inline-block; + background: var(--success); + color: var(--bg-primary); + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 3px 10px; + border-radius: 12px; + margin-left: 8px; + } + .pricing-guarantee { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 24px; + font-size: 0.82rem; + color: var(--text-muted); + } + .pricing-guarantee svg { + width: 16px; + height: 16px; + color: var(--success); + flex-shrink: 0; + } + .pricing-original { + text-decoration: line-through; + color: var(--text-muted); + font-size: 1.2rem; + margin-right: 4px; + } + + /* ── Responsive (marketing) ───────────────────── */ + @media (max-width: 720px) { + .header-auth-user { display: none !important; } + } + @media (max-width: 768px) { + .steps-row { grid-template-columns: 1fr; } + .features-grid { grid-template-columns: 1fr; } + .feature-card:last-child { grid-column: auto; } + .gallery-grid { grid-template-columns: repeat(2, 1fr); } + .testimonials-grid { grid-template-columns: 1fr; } + .handled-grid { grid-template-columns: 1fr; } + .dvd-grid { grid-template-columns: 1fr; } + .pricing-grid { grid-template-columns: 1fr; max-width: 440px; } + .pricing-card { padding: 36px 24px; } + .hero-ctas { flex-direction: column; gap: 10px; } + } + @media (max-width: 480px) { + .gallery-grid { grid-template-columns: 1fr; } + } diff --git a/apps/project-sites/frontend/tsconfig.json b/apps/project-sites/frontend/tsconfig.json new file mode 100644 index 0000000000..bcbf8b5090 --- /dev/null +++ b/apps/project-sites/frontend/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "astro/tsconfigs/strict" +} diff --git a/apps/project-sites/prompts/research_images.prompt.md b/apps/project-sites/prompts/research_images.prompt.md index feda95c59d..a29b6b6268 100644 --- a/apps/project-sites/prompts/research_images.prompt.md +++ b/apps/project-sites/prompts/research_images.prompt.md @@ -23,13 +23,18 @@ notes: You are a visual content strategist. Given business information, determine what images are needed for the website and provide search strategies to find them. -## Rules -- Suggest search queries to find the business's actual storefront/location photos. -- Suggest search queries for the business's team/staff photos if they are a service business. -- For the hero carousel, suggest 3 image concepts that would work well. -- For each image need, provide a Google Images search query AND a fallback Unsplash search query. -- Include confidence scores for finding actual business photos vs needing stock alternatives. -- Describe ideal image dimensions and aspect ratios. +## Rules — Image Integrity Is Critical + +### STRICT: No stock photos, no Getty images, no copyrighted content +- **NEVER suggest Getty, Shutterstock, iStock, or paid stock photo sources.** +- **NEVER suggest generic stock photos as fallbacks.** If no real photo exists, the system will use CSS gradient/pattern placeholders instead. +- Only suggest search queries that would find the ACTUAL business's photos (Google Street View, Google Maps photos, business website, social media). +- For hero images, suggest concepts that can be achieved with CSS gradients/patterns if no real photos are found. +- Confidence scores should reflect the REALISTIC likelihood of finding actual photos of THIS specific business online. +- For small local businesses with no web presence, confidence should be very low (0.1-0.2). +- Only suggest Unsplash/Pexels for GENERIC category images (e.g. "barber tools close-up") that are clearly royalty-free, NOT for business-specific photos. +- Mark ALL image suggestions as either "actual_business" or "generic_category" to distinguish source type. +- Filter out any image concepts that don't match the business type (e.g. no food photos for a barber shop). ## Output Format diff --git a/apps/project-sites/prompts/research_profile.prompt.md b/apps/project-sites/prompts/research_profile.prompt.md index 61fd6e05dc..9752b09e3d 100644 --- a/apps/project-sites/prompts/research_profile.prompt.md +++ b/apps/project-sites/prompts/research_profile.prompt.md @@ -1,35 +1,61 @@ --- id: research_profile version: 1 -description: Deep research on business profile - name, contact, hours, description, services, mission +description: Deep research on business profile - enriched with contact, geo, booking, services menu, team, policies, amenities, SEO models: - "@cf/meta/llama-3.1-70b-instruct" - "@cf/meta/llama-3.1-8b-instruct" params: temperature: 0.3 - max_tokens: 4096 + max_tokens: 8192 inputs: required: [business_name] - optional: [business_address, business_phone, google_place_id, additional_context] + optional: [business_address, business_phone, google_place_id, additional_context, google_places_data] outputs: format: json schema: ResearchProfileOutput notes: pii: "Never fabricate specific customer names or testimonials" quality: "All claims must be plausible for the business type" + confidence: "Include confidence scores (0.0-1.0) for uncertain data" --- # System -You are a business intelligence analyst. Given a business name and optional details, produce a comprehensive JSON profile that could populate a professional portfolio website. +You are a business intelligence analyst specializing in local business research. Given a business name and optional details, produce an extremely comprehensive JSON profile that powers a professional website with booking, SEO, and rich structured data. -## Rules -- Infer the business type from the name and any provided context. -- Generate plausible operating hours for the business type if not known. -- Create a compelling but honest description and mission statement. -- List 4-8 specific services that match the business type. -- Generate 3-5 FAQ entries a potential customer would ask. +## Rules — Data Confidence Is Critical + +### STRICT: Only include data you can verify or strongly infer +- **DO NOT fabricate payment methods** (Apple Pay, Google Pay, etc.) unless explicitly stated in source data. If unsure, use ONLY ["Cash", "Credit Cards"] which are near-universal. +- **DO NOT fabricate amenities** unless clearly implied by the business type. A basic barber shop probably has "Walk-ins welcome" but claiming "Free WiFi" without evidence is wrong. +- **DO NOT invent team members or staff names** unless found in source data. Leave the team array empty rather than guess. +- **DO NOT fabricate reviews or testimonials.** Only include reviews from the Google Places data if provided. +- **DO NOT assume specific booking platforms** unless found in source data. + +### Verified data (high confidence — include these): +- Business name, address, phone, website from Google Places = nearly 100% accurate +- Operating hours from Google Places = nearly 100% accurate +- Rating and review count from Google Places = nearly 100% accurate +- Business type inferred from name + Google categories = high confidence + +### Inferred data (lower confidence — mark clearly): +- Service menu: reasonable for the business type but prices are guesswork +- Payment methods: ONLY include if explicitly in source data. Default to null if unknown. +- Amenities: ONLY include obvious ones (e.g. barber → "Walk-ins welcome") +- Accessibility: ONLY include if Google Places data confirms +- Policies: ONLY include generic ones appropriate for the business type + +### Generated data (creative content — always mark as generated): +- Tagline, description, mission statement: clearly generated content +- FAQ entries: plausible but not from the business +- SEO keywords: generated for optimization + +### General rules: +- If Google Places data is provided, use it as primary truth source. - All text must be professional, concise, and free of jargon. +- Include geo coordinates (lat/lng) if available from Google Places or address. +- Prefer EMPTY/NULL fields over fabricated data. Honesty > completeness. ## Output Format @@ -41,14 +67,27 @@ Return valid JSON with exactly this structure: "description": "string (2-4 sentences about the business)", "mission_statement": "string (1-2 sentences, the WHY behind the business)", "business_type": "string (e.g. salon, restaurant, plumber, dentist)", + "categories": ["Primary Category", "Secondary Category"], "services": [ - { "name": "string", "description": "string (1 sentence)", "price_hint": "string or null" } + { + "name": "string", + "description": "string (1 sentence)", + "price_hint": "string or null (e.g. '$25-$40')", + "price_from": 25, + "duration_minutes": 30, + "variants": ["Classic", "Premium", "Deluxe"], + "add_ons": [{ "name": "Extra Service", "price_from": 10, "duration_minutes": 10 }], + "requirements": "string or null", + "category": "string (e.g. 'Haircuts', 'Shaves', 'Packages')" + } ], "hours": [ { "day": "Monday", "open": "9:00 AM", "close": "6:00 PM", "closed": false } ], - "phone": "string or null", + "phone": "string or null (E.164 format preferred: +1XXXXXXXXXX)", "email": "string or null", + "website_url": "string or null", + "primary_contact_name": "string or null (owner/manager name if known)", "address": { "street": "string or null", "city": "string or null", @@ -56,11 +95,75 @@ Return valid JSON with exactly this structure: "zip": "string or null", "country": "US" }, + "geo": { "lat": 40.88, "lng": -74.38 }, + "google": { + "place_id": "string or null", + "maps_url": "string or null (construct from business name + address)", + "cid": "string or null" + }, + "service_area": { + "zips": ["07034", "07054"], + "towns": ["Lake Hiawatha", "Parsippany"] + }, + "neighborhood": "string or null", + "parking": "string or null (e.g. 'Free lot parking', 'Street parking available')", + "public_transit": "string or null", + "landmarks_nearby": ["string"], + "booking": { + "url": "string or null (Booksy, Fresha, Square, Calendly URL if inferrable)", + "platform": "string or null (platform name)", + "walkins_accepted": true, + "typical_wait_minutes": 15, + "appointment_required": false, + "lead_time_minutes": 0 + }, + "policies": { + "cancellation": "string or null", + "late": "string or null", + "no_show": "string or null", + "age": "string or null (e.g. 'Children under 12 welcome')", + "discount_rules": "string or null (e.g. 'Seniors 65+ get 10% off')" + }, + "payments": null, + "amenities": [], + "accessibility": { + "wheelchair": true, + "hearing_loop": false, + "service_animals": true, + "notes": "string or null" + }, + "languages_spoken": ["English"], + "products_sold": ["string (products the business sells, e.g. 'pomade', 'beard oil')"], + "team": [ + { + "name": "string", + "role": "string (e.g. 'Owner & Master Barber')", + "bio": "string or null (1-2 sentences)", + "specialties": ["string"], + "years_experience": 8, + "instagram": "string or null" + } + ], + "reviews_summary": { + "aggregate_rating": 4.5, + "review_count": 50, + "featured_reviews": [ + { "quote": "string", "name": "string", "source": "Google" } + ] + }, "faq": [ { "question": "string", "answer": "string (2-3 sentences)" } ], - "seo_title": "string (under 60 chars)", - "seo_description": "string (under 160 chars)" + "seo": { + "title": "string (under 60 chars)", + "description": "string (under 160 chars)", + "primary_keywords": ["barber shop lake hiawatha", "haircut lake hiawatha nj"], + "secondary_keywords": ["men's grooming", "fade haircut"], + "service_keywords": ["haircut", "shave", "beard trim"], + "neighborhood_keywords": ["lake hiawatha", "parsippany", "07034"] + }, + "schema_org_type": "BarberShop", + "guarantee_details": "string or null (what 'satisfaction guarantee' means in practice)" } ``` @@ -71,5 +174,6 @@ Address: {{business_address}} Phone: {{business_phone}} Google Place ID: {{google_place_id}} Additional Context: {{additional_context}} +Google Places Data: {{google_places_data}} -Research this business thoroughly and return the JSON profile. +Research this business thoroughly and return the comprehensive enriched JSON profile. Include ALL fields even if you need to make educated inferences — mark uncertain data with conservative estimates. diff --git a/apps/project-sites/public/index.html b/apps/project-sites/public/index.html index 275563bc5a..40937ff2fc 100644 --- a/apps/project-sites/public/index.html +++ b/apps/project-sites/public/index.html @@ -1,6 +1,21 @@ + + + + + + @@ -16,6 +31,10 @@ + + + + @@ -164,7 +183,10 @@ + @@ -2858,8 +4415,13 @@ posthog.init(__phKey.content, { api_host: 'https://us.i.posthog.com', person_profiles: 'identified_only' }); } + + + +
@@ -2876,7 +4438,7 @@
- +
@@ -2890,22 +4452,43 @@ Your Websites +
- - -
+ + +
Loading your websites...
@@ -2916,43 +4499,52 @@
-