Manage Your AI Workers
Sign in with the email you used when you bought. We'll email you a 6-digit code. No password needed.
// Impulsor AI · Manage Workers (route /manage-workers for now).
// Flow: email -> 6-digit code (emailed by the API) -> toggle cards (current workers ON) -> summary -> confirm.
// Additions go to the add-on checkout (signed-in session attached); removals are requests that billing confirms by email.
import { useEffect, useState, type CSSProperties, type FormEvent } from "react"
import { addPropertyControls, ControlType } from "framer"
type Lang = "en" | "es"
type Localized = Record<Lang, string>
type Worker = { id: string; key?: string; title: Localized; tagline: Localized; description: Localized; monthly: number; soon?: boolean }
// key = short checkout key (must match the billing allowlist). Copy taken from ImpulsorCatalog.
const WORKERS: Worker[] = [
{ id: "scheduling", key: "scheduling", monthly: 300,
title: { en: "Smart Scheduling Assistant", es: "Asistente de Agenda Inteligente" },
tagline: { en: "Books appointments for you", es: "Agenda citas por ti" },
description: { en: "Adds booking to your website chat. It checks your calendar in real time, offers open times and books the job on the spot, even while you're on the job.", es: "Suma reservaciones al chat de tu sitio. Revisa tu calendario en tiempo real, ofrece horarios libres y agenda al momento, aunque estés trabajando." } },
{ id: "missed-call", key: "textback", monthly: 500,
title: { en: "60-Second Missed Call Text-Back", es: "Respuesta por SMS a Llamadas Perdidas en 60 Segundos" },
tagline: { en: "Never lose a missed call", es: "Nunca pierdas una llamada" },
description: { en: "Couldn't pick up on the job? Within 60 seconds, the caller gets a text with a link to book, before they call your competitor.", es: "¿No contestaste por estar trabajando? En menos de 60 segundos, quien llamó recibe un SMS con un enlace para agendar, antes de que llame a tu competencia." } },
{ id: "lead-engine", key: "followup", monthly: 600,
title: { en: "Multi-Channel Lead Follow-Up Engine", es: "Motor de Seguimiento de Leads Multicanal" },
tagline: { en: "Follows up every new lead fast", es: "Da seguimiento rápido a cada prospecto" },
description: { en: "Contacts every new lead from your ads or website forms within 60 seconds, while they're still hot. Then 3 texts and emails over 48 hours turn them into booked jobs.", es: "Contacta a cada nuevo prospecto de tus anuncios o formularios en menos de 60 segundos, mientras sigue interesado. Luego 3 mensajes por SMS y correo en 48 horas lo vuelven un trabajo agendado." } },
{ id: "quote-closer", key: "quotes", monthly: 700,
title: { en: "Estimate & Quote Closer", es: "Cerrador de Cotizaciones y Presupuestos" },
tagline: { en: "Turns sent quotes into signed jobs", es: "Convierte cotizaciones en trabajos cerrados" },
description: { en: "Sent a quote and heard nothing back? It follows up by text and email on days 2, 5 and 10, easing common concerns so more quotes become signed jobs.", es: "¿Enviaste una cotización y no te respondieron? Da seguimiento por SMS y correo los días 2, 5 y 10, resolviendo dudas comunes para que más cotizaciones se conviertan en trabajos firmados." } },
{ id: "review-booster", key: "reviews", monthly: 750,
title: { en: "Google Review Booster", es: "Impulsor de Reseñas en Google" },
tagline: { en: "Gets you more 5-star Google reviews", es: "Más reseñas de 5 estrellas en Google" },
description: { en: "When a job is marked complete, your happy customer gets a text at the right moment with a one-tap link to review you on Google.", es: "Cuando un trabajo se marca como completado, tu cliente satisfecho recibe un SMS en el momento ideal con un enlace directo para dejarte una reseña en Google." } },
{ id: "outbound", monthly: 799, soon: true,
title: { en: "Outbound Growth Engine", es: "Motor de Prospección" },
tagline: { en: "New customers, found and contacted for you.", es: "Nuevos clientes, encontrados y contactados por ti." },
description: { en: "Finds the local buyers most likely to need you and sends personal emails that sound like you. Interested replies land on your calendar as booked calls. Optional AI voice calling, off until you switch it on.", es: "Encuentra a los clientes locales con más probabilidad de necesitarte y les envía correos personales que suenan como tú. Los interesados quedan agendados en tu calendario. Llamadas de voz con IA opcionales, apagadas hasta que tú las actives." } },
{ id: "voice", monthly: 2500, soon: true,
title: { en: "AI Voice Receptionist", es: "Recepcionista de Voz con IA" },
tagline: { en: "An AI that answers your phone", es: "Una IA que contesta tu teléfono" },
description: { en: "A local phone line answered by AI, day or night. It talks with callers in real time, answers questions and books them in.", es: "Una línea local atendida por IA, de día o de noche. Conversa con quien llama en tiempo real, responde preguntas detalladas y agenda sus citas." } },
{ id: "backend", monthly: 3000, soon: true,
title: { en: "Full Central Backend System", es: "Sistema Backend Central Completo" },
tagline: { en: "All your business systems in one place", es: "Todos tus sistemas en un solo lugar" },
description: { en: "For larger operations. Brings your customer records, project tracking, automatic invoicing and calendar together in one dashboard.", es: "Para operaciones más grandes. Reúne tus registros de clientes, seguimiento de proyectos, facturación automática y calendario en un solo panel." } },
]
const T = {
en: {
eyebrow: "Already a client",
title: "Manage Your AI Workers",
body: "Sign in with the email you used when you bought. We'll email you a 6-digit code. No password needed.",
email: "Email you used when you bought",
emailPh: "you@yourbusiness.com",
emailErr: "Enter a valid email address.",
sendCode: "Email me a code",
sending: "Sending…",
codeTitle: "Check your email",
codeBody: (e: string) => `If ${e} belongs to a client, we just sent a 6-digit code to it. It expires in 10 minutes.`,
code: "6-digit code",
codeErr: "That code didn't work. Check it and try again.",
codeTries: (n: number) => `That code didn't work. ${n} ${n === 1 ? "try" : "tries"} left.`,
codeLocked: "Too many tries. Please ask for a new code.",
verify: "Sign in",
verifying: "Checking…",
resend: "Send a new code",
changeEmail: "Use a different email",
rate: "Too many requests in a short time. Please wait a few minutes and try again.",
netErr: "Something went wrong. Please try again, or call or email us.",
notReady: "Signing in online is coming soon. Call or email us and we'll make changes for you.",
signedInAs: "Signed in as",
logout: "Sign out",
expired: "Your session ended. Please sign in again.",
loading: "Loading your workers…",
gridTitle: "Your AI workers",
gridBody: "Workers you have now are on. Turn one on to add it, or off to remove it.",
perMonth: "/mo",
comingSoon: "Coming Soon",
ends: (d: string) => `Ends ${d}`,
pendingBadge: "Removal requested",
noWorkers: "We couldn't find active workers for this email. You can still add workers below, or call us if something looks wrong.",
summary: "Summary",
adding: "Adding",
removing: "Removing",
nothing: "No changes yet. Turn workers on or off above.",
addCost: (v: string) => `+${v}/mo`,
removeCost: (v: string) => `−${v}/mo`,
newTotal: "New monthly total",
noSetup: "No setup fee.",
removeNote: "Removed workers stay active until the end of your paid month. We'll confirm by email before removing anything.",
domain: "Your website",
domainPh: "burkeroofing.com",
domainErr: "Enter your website address, like burkeroofing.com.",
confirm: "Confirm changes",
confirmAdd: "Confirm and go to checkout",
working: "Working…",
checkoutSoon: "Online checkout for new workers is coming soon. Book a call and we'll add them for you.",
bookCall: "Book a Call",
removed: "Removal requested; stays active until the end of your paid month. We'll confirm by email.",
billingNote: "Your first month of AI workers is charged at checkout. Setup starts once we receive all the information we need from you. Days from then until your AI workers go live are credited on your second monthly bill, up to 7 days.",
billingLink: "Update your card or download invoices",
},
es: {
eyebrow: "Ya eres cliente",
title: "Administra tus trabajadores IA",
body: "Entra con el correo que usaste al comprar. Te enviaremos un código de 6 dígitos. No necesitas contraseña.",
email: "Correo que usaste al comprar",
emailPh: "tu@tunegocio.com",
emailErr: "Escribe un correo válido.",
sendCode: "Enviarme un código",
sending: "Enviando…",
codeTitle: "Revisa tu correo",
codeBody: (e: string) => `Si ${e} es de un cliente, acabamos de enviarle un código de 6 dígitos. Vence en 10 minutos.`,
code: "Código de 6 dígitos",
codeErr: "Ese código no funcionó. Revísalo e inténtalo de nuevo.",
codeTries: (n: number) => `Ese código no funcionó. Te ${n === 1 ? "queda 1 intento" : `quedan ${n} intentos`}.`,
codeLocked: "Demasiados intentos. Pide un código nuevo.",
verify: "Entrar",
verifying: "Revisando…",
resend: "Enviar un código nuevo",
changeEmail: "Usar otro correo",
rate: "Demasiadas solicitudes en poco tiempo. Espera unos minutos e inténtalo de nuevo.",
netErr: "Algo salió mal. Inténtalo de nuevo, o llámanos o escríbenos.",
notReady: "El acceso en línea estará disponible pronto. Llámanos o escríbenos y hacemos los cambios por ti.",
signedInAs: "Sesión iniciada como",
logout: "Cerrar sesión",
expired: "Tu sesión terminó. Vuelve a entrar.",
loading: "Cargando tus trabajadores…",
gridTitle: "Tus trabajadores IA",
gridBody: "Los trabajadores que ya tienes aparecen encendidos. Enciende uno para agregarlo o apágalo para darlo de baja.",
perMonth: "/mes",
comingSoon: "Próximamente",
ends: (d: string) => `Termina el ${d}`,
pendingBadge: "Baja solicitada",
noWorkers: "No encontramos trabajadores activos con este correo. Aun así puedes agregar trabajadores abajo, o llámanos si algo no se ve bien.",
summary: "Resumen",
adding: "Agregas",
removing: "Das de baja",
nothing: "Todavía no hay cambios. Enciende o apaga trabajadores arriba.",
addCost: (v: string) => `+${v}/mes`,
removeCost: (v: string) => `−${v}/mes`,
newTotal: "Nuevo total mensual",
noSetup: "Sin cuota de instalación.",
removeNote: "Los trabajadores que des de baja siguen activos hasta el final del mes que ya pagaste. Te confirmaremos por correo antes de quitar cualquier cosa.",
domain: "Tu sitio web",
domainPh: "burkeroofing.com",
domainErr: "Escribe la dirección de tu sitio, por ejemplo burkeroofing.com.",
confirm: "Confirmar cambios",
confirmAdd: "Confirmar e ir al pago",
working: "Procesando…",
checkoutSoon: "El pago en línea de nuevos trabajadores estará disponible pronto. Agenda una llamada y los agregamos por ti.",
bookCall: "Agendar llamada",
removed: "Baja solicitada; sigue activo hasta el final del mes que ya pagaste. Te confirmaremos por correo.",
billingNote: "Tu primer mes de trabajadores IA se cobra al momento de pagar. La configuración empieza cuando recibimos toda la información que necesitamos de ti. Los días desde entonces hasta que tus trabajadores IA estén activos se descontarán de tu segunda factura mensual, hasta 7 días.",
billingLink: "Actualiza tu tarjeta o descarga tus facturas",
},
}
const FOOTER_TEXT: Record<Lang, { line: string; privacy: string; terms: string; ai: string; rights: string; nav: string }> = {
en: { line: "Websites and AI workers for local service businesses.", privacy: "Privacy Policy", terms: "Terms of Service", ai: "AI Disclosure", rights: "All rights reserved.", nav: "Legal" },
es: { line: "Sitios web y trabajadores de IA para negocios de servicios locales.", privacy: "Aviso de Privacidad", terms: "Términos del Servicio", ai: "Aviso sobre IA", rights: "Todos los derechos reservados.", nav: "Legal" },
}
// Same detection as ImpulsorCatalog: saved choice > browser language > Spanish-speaking time zone.
const STORAGE_KEY = "impulsorai-lang"
const ES_TZ = /^(America\/(Mexico_City|Monterrey|Merida|Cancun|Chihuahua|Ciudad_Juarez|Hermosillo|Mazatlan|Matamoros|Ojinaga|Bahia_Banderas|Tijuana|Guatemala|El_Salvador|Tegucigalpa|Managua|Costa_Rica|Panama|Bogota|Caracas|Lima|Guayaquil|La_Paz|Santiago|Asuncion|Montevideo|Argentina\/.+|Buenos_Aires|Havana|Santo_Domingo|Puerto_Rico)|Europe\/Madrid|Atlantic\/Canary|Africa\/Ceuta)$/
function detectLanguage(): Lang {
if (typeof window === "undefined") return "en"
try {
const saved = window.localStorage.getItem(STORAGE_KEY)
if (saved === "en" || saved === "es") return saved
} catch {}
const langs = (navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language]) || []
const first = (langs[0] || "").toLowerCase()
if (first.startsWith("es")) return "es"
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ""
if (first.startsWith("en")) return ES_TZ.test(tz) && langs.some((l) => l.toLowerCase().startsWith("es")) ? "es" : "en"
return ES_TZ.test(tz) ? "es" : "en"
}
/** Strips http(s)://, www., path/query/port; lowercases. Returns "" if it doesn't look like a domain. */
export function normalizeDomain(raw: string): string {
let d = (raw || "").trim().toLowerCase().replace(/^[a-z]+:\/\//, "").replace(/^www\./, "")
d = d.split(/[/?#]/)[0].replace(/:\d+$/, "").replace(/\.$/, "")
return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(d) ? d : ""
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
export const normalizeEmail = (raw: string) => { const e = (raw || "").trim().toLowerCase(); return EMAIL_RE.test(e) ? e : "" }
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
const BG = "#0A0A0B"
const INK = "#0A0A0B"
const TEXT = "#F2F4F8"
const MUTED = "#A3A8B4"
const HAIRLINE = "rgba(255,255,255,0.08)"
const GLASS = "rgba(22,24,32,0.7)"
const FONT = "Inter, system-ui, -apple-system, 'Segoe UI', sans-serif"
const ERR = "#FF7A7C"
const SESSION_KEY = "impulsorai-manage-session"
type Step = "email" | "code" | "manage"
type Status = "active" | "canceling" | "pending_removal"
type Mine = { key: string; status: Status; period_end: string | null }
type Session = { token: string; email: string; exp: number }
interface Props {
apiBaseUrl: string
checkoutUrl: string
billingLinkUrl: string
bookingUrl: string
defaultLanguage: "auto" | Lang
accent: string
style?: CSSProperties
}
function loadSession(): Session | null {
try {
const s = JSON.parse(window.sessionStorage.getItem(SESSION_KEY) || "null") as Session | null
return s && s.token && s.exp > Date.now() ? s : null
} catch { return null }
}
/**
* @framerSupportedLayoutWidth any
* @framerSupportedLayoutHeight auto
*/
export default function ManageWorkers(props: Props) {
const { apiBaseUrl = "", checkoutUrl = "", billingLinkUrl = "", bookingUrl = "https://cal.com/impulsorai/discovery", defaultLanguage = "auto", accent = "#00F2FE", style } = props
const api = apiBaseUrl.trim().replace(/\/+$/, "")
const apiReady = api.length > 0
const [lang, setLang] = useState<Lang>(defaultLanguage === "es" ? "es" : "en")
const L = T[lang]
const F = FOOTER_TEXT[lang]
const [step, setStep] = useState<Step>("email")
const [email, setEmail] = useState("")
const [emailTouched, setEmailTouched] = useState(false)
const [code, setCode] = useState("")
const [session, setSession] = useState<Session | null>(null)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState<{ kind: "err" | "ok"; text: string } | null>(null)
const [mine, setMine] = useState<Mine[] | null>(null)
const [knownDomain, setKnownDomain] = useState<string | null>(null)
const [want, setWant] = useState<Set<string>>(() => new Set())
const [domain, setDomain] = useState("")
const [domainTouched, setDomainTouched] = useState(false)
useEffect(() => { setLang(defaultLanguage === "auto" ? detectLanguage() : defaultLanguage) }, [defaultLanguage])
useEffect(() => {
const s = loadSession()
if (s) { setSession(s); setEmail(s.email); setStep("manage") }
const onShow = () => setBusy(false)
window.addEventListener("pageshow", onShow)
return () => window.removeEventListener("pageshow", onShow)
}, [])
const chooseLang = (l: Lang) => { setLang(l); try { window.localStorage.setItem(STORAGE_KEY, l) } catch {} }
const cleanEmail = normalizeEmail(email)
const post = async (path: string, body: unknown, token?: string) => {
const r = await fetch(api + path, { method: "POST", headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) })
let j: Record<string, unknown> = {}
try { j = await r.json() } catch {}
return { status: r.status, j }
}
const logout = (expired = false) => {
try { window.sessionStorage.removeItem(SESSION_KEY) } catch {}
setSession(null); setMine(null); setWant(new Set()); setCode(""); setStep("email"); setBusy(false)
setMsg(expired ? { kind: "err", text: L.expired } : null)
}
const startLogin = async (e?: FormEvent) => {
e?.preventDefault()
setEmailTouched(true)
if (!cleanEmail || !apiReady || busy) return
setBusy(true); setMsg(null)
try {
const { status } = await post("/auth/start", { email: cleanEmail, lang })
if (status === 200) { setStep("code"); setCode("") }
else setMsg({ kind: "err", text: status === 429 ? L.rate : status === 400 ? L.emailErr : L.netErr })
} catch { setMsg({ kind: "err", text: L.netErr }) }
setBusy(false)
}
const verify = async (e: FormEvent) => {
e.preventDefault()
const c = code.replace(/\D/g, "")
if (c.length !== 6 || busy) { setMsg({ kind: "err", text: L.codeErr }); return }
setBusy(true); setMsg(null)
try {
const { status, j } = await post("/auth/verify", { email: cleanEmail, code: c })
if (status === 200 && typeof j.token === "string") {
const s: Session = { token: j.token, email: cleanEmail, exp: Date.parse(String(j.expires_at)) || Date.now() + 29 * 60 * 1000 }
try { window.sessionStorage.setItem(SESSION_KEY, JSON.stringify(s)) } catch {}
setSession(s); setStep("manage")
} else if (status === 429) setMsg({ kind: "err", text: j.error === "too_many_tries" ? L.codeLocked : L.rate })
else setMsg({ kind: "err", text: typeof j.tries_left === "number" ? L.codeTries(j.tries_left) : L.codeErr })
} catch { setMsg({ kind: "err", text: L.netErr }) }
setBusy(false)
}
const loadMine = async (s: Session) => {
try {
const r = await fetch(api + "/me/workers", { headers: { authorization: `Bearer ${s.token}` } })
if (r.status === 401) return logout(true)
const j = (await r.json()) as { workers?: Mine[]; domain?: string | null }
const list = (j.workers || []).filter((w) => WORKERS.some((x) => x.key === w.key))
setMine(list); setKnownDomain(j.domain || null)
setWant(new Set(list.map((w) => w.key)))
} catch { setMsg({ kind: "err", text: L.netErr }); setMine([]) }
}
useEffect(() => { if (step === "manage" && session && apiReady) loadMine(session) }, [step, session]) // eslint-disable-line react-hooks/exhaustive-deps
const status = (key: string) => mine?.find((w) => w.key === key)
const toggle = (key: string) => {
const m = status(key)
if (m && m.status !== "active") return // already ending or pending: locked
setWant((s) => { const n = new Set(s); n.has(key) ? n.delete(key) : n.add(key); return n })
setMsg(null)
}
const live = WORKERS.filter((w) => !w.soon)
const adds = live.filter((w) => want.has(w.key!) && !status(w.key!))
const removes = live.filter((w) => !want.has(w.key!) && status(w.key!)?.status === "active")
const keeps = live.filter((w) => status(w.key!)?.status === "active" && want.has(w.key!))
const sum = (ws: Worker[]) => ws.reduce((t, w) => t + w.monthly, 0)
const newTotal = sum(keeps) + sum(adds)
const needDomain = adds.length > 0 && !knownDomain
const cleanDomain = normalizeDomain(domain)
const checkoutReady = checkoutUrl.trim().length > 0
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(lang === "es" ? "es-MX" : "en-US", { month: "short", day: "numeric", year: "numeric" }) : "")
const confirm = async () => {
if (!session || busy || (!adds.length && !removes.length)) return
if (needDomain && !cleanDomain) { setDomainTouched(true); return }
if (adds.length && !checkoutReady) return
setBusy(true); setMsg(null)
try {
if (removes.length) {
const { status: st } = await post("/me/remove", { workers: removes.map((w) => w.key) }, session.token)
if (st === 401) return logout(true)
if (st !== 202) { setMsg({ kind: "err", text: L.netErr }); setBusy(false); return }
setMine((m) => (m || []).map((w) => (removes.some((r) => r.key === w.key) ? { ...w, status: "pending_removal" } : w)))
setMsg({ kind: "ok", text: L.removed })
}
if (adds.length) {
// Native form POST so the browser follows the redirect to the hosted checkout page.
const f = document.createElement("form")
f.method = "POST"; f.action = checkoutUrl.trim()
const fields: Record<string, string> = { mode: "addon", workers: adds.map((w) => w.key!).join(","), domain: knownDomain || cleanDomain, email: session.email, session: session.token, lang }
for (const [k, v] of Object.entries(fields)) { const i = document.createElement("input"); i.type = "hidden"; i.name = k; i.value = v; f.appendChild(i) }
document.body.appendChild(f); f.submit()
return
}
} catch { setMsg({ kind: "err", text: L.netErr }) }
setBusy(false)
}
const panel: CSSProperties = { background: GLASS, border: `1px solid ${HAIRLINE}`, borderRadius: 20, backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", boxShadow: "0 10px 30px rgba(0,0,0,0.35)" }
const input = (bad: boolean): CSSProperties => ({ width: "100%", padding: "14px 16px", borderRadius: 12, fontSize: 16, fontFamily: "inherit", color: TEXT, background: "rgba(255,255,255,0.04)", border: `1px solid ${bad ? ERR : "rgba(255,255,255,0.14)"}`, outline: "none" })
const label: CSSProperties = { display: "flex", flexDirection: "column", gap: 8, fontSize: 14, fontWeight: 600, color: TEXT, minWidth: 0 }
const primary = (enabled: boolean): CSSProperties => ({ border: "none", cursor: enabled ? "pointer" : "not-allowed", fontFamily: "inherit", fontSize: 16, fontWeight: 800, padding: "15px 28px", borderRadius: 999, background: accent, color: INK, opacity: enabled ? 1 : 0.5 })
const ghost: CSSProperties = { border: "none", background: "none", padding: 0, cursor: "pointer", color: accent, fontFamily: "inherit", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3 }
const contact = (
<span>
{" "}<a href="tel:+19723145599" style={{ color: accent }}>(972) 314-5599</a> · <a href="mailto:contact@impulsorai.com" style={{ color: accent }}>contact@impulsorai.com</a>
</span>
)
const note = msg && (
<p role={msg.kind === "err" ? "alert" : "status"} className="aw-msg" style={{ margin: 0, padding: "12px 14px", borderRadius: 12, fontSize: 14, lineHeight: 1.55, color: msg.kind === "err" ? ERR : TEXT, background: msg.kind === "err" ? "rgba(255,122,124,0.08)" : `${accent}14`, border: `1px solid ${msg.kind === "err" ? "rgba(255,122,124,0.35)" : `${accent}55`}` }}>
{msg.kind === "ok" && <span aria-hidden style={{ color: accent, fontWeight: 900, marginRight: 8 }}>✓</span>}
{msg.text}
</p>
)
return (
<div className="aw-root" lang={lang} style={{ ...style, width: "100%", minHeight: "100%", background: BG, color: TEXT, fontFamily: FONT, ["--aw-accent" as string]: accent } as CSSProperties}>
<style>{`
.aw-root * { box-sizing: border-box; }
.aw-root input:focus { border-color: var(--aw-accent) !important; box-shadow: 0 0 0 3px ${accent}33; }
.aw-root button:focus-visible, .aw-root a:focus-visible, .aw-sw:focus-visible { outline: 2px solid var(--aw-accent); outline-offset: 3px; }
.aw-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.aw-sw { transition: border-color .2s ease, transform .15s ease, box-shadow .2s ease; }
.aw-sw[aria-disabled="false"]:hover { transform: translateY(-2px); }
.aw-track { transition: background .2s ease; }
.aw-code { letter-spacing: 10px; font-size: 26px !important; font-weight: 800; text-align: center; font-variant-numeric: tabular-nums; }
.aw-footer { margin-top: 56px; padding: 36px 20px 44px; border-top: 1px solid ${HAIRLINE}; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 10px; }
.aw-footer a { color: ${TEXT}; text-decoration: none; } .aw-footer a:hover { color: var(--aw-accent); }
.aw-row { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; line-height: 1.5; }
@media (max-width: 560px) {
.aw-wide-btn { width: 100%; text-align: center; }
.aw-actions { flex-direction: column; align-items: stretch !important; }
.aw-who { flex-direction: column; align-items: flex-start !important; }
.aw-code { letter-spacing: 6px; }
}
@media (prefers-reduced-motion: reduce) { .aw-sw, .aw-track { transition: none; } .aw-sw:hover { transform: none !important; } }
`}</style>
<div style={{ maxWidth: 1080, margin: "0 auto", padding: "24px 20px 0", display: "flex", flexDirection: "column", gap: 28 }}>
<nav style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<a href="/" aria-label="Impulsor AI" style={{ fontWeight: 800, letterSpacing: 1.5, fontSize: 16, color: TEXT, textDecoration: "none" }}>
IMPULSOR<span style={{ color: accent }}>//AI</span>
</a>
<div role="tablist" aria-label="Language / Idioma" style={{ display: "inline-flex", gap: 2, padding: 3, borderRadius: 999, border: "1px solid rgba(255,255,255,0.12)", background: "rgba(255,255,255,0.04)" }}>
{(["en", "es"] as Lang[]).map((c) => (
<button key={c} type="button" role="tab" aria-selected={lang === c} onClick={() => chooseLang(c)}
style={{ border: "none", cursor: "pointer", padding: "6px 12px", borderRadius: 999, fontSize: 12, fontWeight: 800, letterSpacing: 1, fontFamily: "inherit", background: lang === c ? accent : "transparent", color: lang === c ? INK : MUTED }}>
{c.toUpperCase()}
</button>
))}
</div>
</nav>
<header style={{ textAlign: "center", display: "flex", flexDirection: "column", gap: 12, alignItems: "center" }}>
<span style={{ color: accent, fontSize: 13, fontWeight: 800, letterSpacing: 2, textTransform: "uppercase" }}>{L.eyebrow}</span>
<h1 style={{ margin: 0, fontSize: "clamp(32px, 6vw, 52px)", fontWeight: 800, letterSpacing: -1.2, lineHeight: 1.08 }}>{L.title}</h1>
{step !== "manage" && <p style={{ margin: 0, maxWidth: 600, color: MUTED, fontSize: 17, lineHeight: 1.6 }}>{L.body}</p>}
</header>
{step === "email" && (
<form onSubmit={startLogin} noValidate style={{ ...panel, padding: 28, width: "100%", maxWidth: 520, alignSelf: "center", display: "flex", flexDirection: "column", gap: 18 }}>
<label style={label}>
{L.email}
<input type="email" autoComplete="email" autoCapitalize="none" spellCheck={false} placeholder={L.emailPh} value={email}
onChange={(e) => setEmail(e.target.value)} onBlur={() => setEmailTouched(true)}
aria-invalid={emailTouched && !cleanEmail} style={input(emailTouched && !cleanEmail)} />
{emailTouched && !cleanEmail && <span role="alert" style={{ color: ERR, fontSize: 13, fontWeight: 500 }}>{L.emailErr}</span>}
</label>
{note}
<button type="submit" className="aw-wide-btn" disabled={!apiReady || busy} style={primary(apiReady && !busy)}>{busy ? L.sending : `${L.sendCode} →`}</button>
{!apiReady && <p style={{ margin: 0, color: MUTED, fontSize: 13, lineHeight: 1.55 }}>{L.notReady}{contact}</p>}
</form>
)}
{step === "code" && (
<form onSubmit={verify} noValidate style={{ ...panel, padding: 28, width: "100%", maxWidth: 520, alignSelf: "center", display: "flex", flexDirection: "column", gap: 18 }}>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 800 }}>{L.codeTitle}</h2>
<p style={{ margin: 0, color: MUTED, fontSize: 15, lineHeight: 1.6 }}>{L.codeBody(cleanEmail)}</p>
<label style={label}>
{L.code}
<input className="aw-code" type="text" inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]*" maxLength={7} placeholder="000000" value={code}
onChange={(e) => setCode(e.target.value.replace(/[^\d]/g, "").slice(0, 6))} style={input(false)} autoFocus />
</label>
{note}
<button type="submit" className="aw-wide-btn" disabled={busy} style={primary(code.length === 6 && !busy)}>{busy ? L.verifying : `${L.verify} →`}</button>
<div style={{ display: "flex", flexWrap: "wrap", justifyContent: "space-between", gap: 12 }}>
<button type="button" style={ghost} onClick={() => startLogin()}>{L.resend}</button>
<button type="button" style={ghost} onClick={() => { setStep("email"); setMsg(null) }}>{L.changeEmail}</button>
</div>
</form>
)}
{step === "manage" && session && (
<>
<div className="aw-who" style={{ ...panel, borderRadius: 14, padding: "12px 16px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10 }}>
<span style={{ color: MUTED, fontSize: 14, minWidth: 0, overflowWrap: "anywhere" }}>{L.signedInAs} <strong style={{ color: TEXT }}>{session.email}</strong>{knownDomain && <> · {knownDomain}</>}</span>
<button type="button" onClick={() => logout()} style={{ border: "1px solid rgba(255,255,255,0.18)", background: "transparent", color: TEXT, fontFamily: "inherit", fontSize: 13, fontWeight: 700, padding: "8px 14px", borderRadius: 999, cursor: "pointer", flex: "0 0 auto" }}>{L.logout}</button>
</div>
<section aria-labelledby="aw-grid-h" style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<h2 id="aw-grid-h" style={{ margin: 0, fontSize: 22, fontWeight: 800 }}>{L.gridTitle}</h2>
<p style={{ margin: 0, color: MUTED, fontSize: 15, lineHeight: 1.6 }}>{mine === null ? L.loading : mine.length === 0 ? L.noWorkers : L.gridBody}</p>
<div className="aw-grid" role="group" aria-label={L.gridTitle} aria-busy={mine === null}>
{WORKERS.map((w) => {
const m = w.key ? status(w.key) : undefined
const locked = !!w.soon || mine === null || (!!m && m.status !== "active")
const on = !w.soon && (m ? m.status !== "active" || want.has(w.key!) : want.has(w.key!))
const changed = !w.soon && !!w.key && (adds.some((a) => a.key === w.key) || removes.some((r) => r.key === w.key))
const badge = w.soon ? L.comingSoon : m?.status === "canceling" ? L.ends(fmtDate(m.period_end)) : m?.status === "pending_removal" ? L.pendingBadge : null
const ring = changed ? (on ? accent : ERR) : on ? accent : HAIRLINE
return (
<div key={w.id} className="aw-sw" role="switch" aria-checked={on} aria-disabled={locked} tabIndex={locked ? -1 : 0}
onClick={locked ? undefined : () => toggle(w.key!)}
onKeyDown={(e) => { if (!locked && (e.key === " " || e.key === "Enter")) { e.preventDefault(); toggle(w.key!) } }}
style={{ ...panel, borderRadius: 16, padding: 22, display: "flex", flexDirection: "column", gap: 10, userSelect: "none", cursor: locked ? "default" : "pointer", opacity: w.soon ? 0.55 : 1,
borderColor: ring, boxShadow: on || changed ? `0 0 0 1px ${ring}, 0 0 28px ${ring}33` : panel.boxShadow,
background: on ? `linear-gradient(180deg, ${accent}14, transparent 60%), ${GLASS}` : GLASS }}>
{badge && (
<span style={{ alignSelf: "flex-start", fontSize: 11, fontWeight: 800, letterSpacing: 1, textTransform: "uppercase", padding: "4px 10px", borderRadius: 999, color: m?.status === "pending_removal" || m?.status === "canceling" ? "#FFD3A3" : MUTED, background: m?.status ? "rgba(255,170,90,0.12)" : "rgba(255,255,255,0.08)" }}>{badge}</span>
)}
<span style={{ fontSize: 17, fontWeight: 700, lineHeight: 1.3 }}>{w.title[lang]}</span>
<span style={{ fontSize: 14, fontWeight: 600, color: accent }}>{w.tagline[lang]}</span>
<span style={{ fontSize: 14, color: MUTED, lineHeight: 1.55, flex: 1 }}>{w.description[lang]}</span>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, marginTop: 4 }}>
<span style={{ fontSize: 14, fontWeight: 700, padding: "4px 10px", borderRadius: 999, background: "rgba(255,255,255,0.06)" }}>{usd.format(w.monthly)}{L.perMonth}</span>
{!w.soon && (
<span aria-hidden className="aw-track" style={{ width: 44, height: 24, borderRadius: 999, padding: 3, display: "flex", justifyContent: on ? "flex-end" : "flex-start", background: on ? (locked ? "rgba(255,255,255,0.28)" : accent) : "rgba(255,255,255,0.14)" }}>
<span style={{ width: 18, height: 18, borderRadius: 999, background: on ? INK : "#FFFFFF" }} />
</span>
)}
</div>
</div>
)
})}
</div>
</section>
<section aria-labelledby="aw-sum-h" style={{ ...panel, padding: 24, display: "flex", flexDirection: "column", gap: 14 }}>
<h2 id="aw-sum-h" style={{ margin: 0, fontSize: 20, fontWeight: 800 }}>{L.summary}</h2>
{!adds.length && !removes.length && <p style={{ margin: 0, color: MUTED, fontSize: 15 }}>{L.nothing}</p>}
{adds.length > 0 && (
<div>
<div style={{ color: accent, fontSize: 12, fontWeight: 800, letterSpacing: 1.5, textTransform: "uppercase", marginBottom: 6 }}>{L.adding}</div>
{adds.map((w) => <div key={w.id} className="aw-row"><span>{w.title[lang]}</span><span style={{ fontWeight: 700, whiteSpace: "nowrap" }}>{L.addCost(usd.format(w.monthly))}</span></div>)}
<div style={{ color: MUTED, fontSize: 13, marginTop: 4 }}>{L.noSetup}</div>
</div>
)}
{removes.length > 0 && (
<div>
<div style={{ color: ERR, fontSize: 12, fontWeight: 800, letterSpacing: 1.5, textTransform: "uppercase", marginBottom: 6 }}>{L.removing}</div>
{removes.map((w) => <div key={w.id} className="aw-row"><span>{w.title[lang]}</span><span style={{ fontWeight: 700, whiteSpace: "nowrap" }}>{L.removeCost(usd.format(w.monthly))}</span></div>)}
<div style={{ color: MUTED, fontSize: 13, marginTop: 4, lineHeight: 1.55 }}>{L.removeNote}</div>
</div>
)}
{(adds.length > 0 || removes.length > 0) && (
<div className="aw-row" style={{ borderTop: `1px solid ${HAIRLINE}`, paddingTop: 12, fontSize: 17 }}>
<span style={{ fontWeight: 700 }}>{L.newTotal}</span>
<span style={{ fontWeight: 800 }}>{usd.format(newTotal)}<span style={{ color: MUTED, fontWeight: 600, fontSize: 14 }}>{L.perMonth}</span></span>
</div>
)}
{needDomain && (
<label style={label}>
{L.domain}
<input type="text" inputMode="url" autoComplete="url" autoCapitalize="none" spellCheck={false} placeholder={L.domainPh} value={domain}
onChange={(e) => setDomain(e.target.value)} onBlur={() => { setDomainTouched(true); if (cleanDomain) setDomain(cleanDomain) }}
aria-invalid={domainTouched && !cleanDomain} style={input(domainTouched && !cleanDomain)} />
{domainTouched && !cleanDomain && <span role="alert" style={{ color: ERR, fontSize: 13 }}>{L.domainErr}</span>}
</label>
)}
{note}
<div className="aw-actions" style={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: 14 }}>
<button type="button" className="aw-wide-btn" onClick={confirm} disabled={busy || (!adds.length && !removes.length) || (adds.length > 0 && !checkoutReady)}
style={primary(!busy && (adds.length > 0 || removes.length > 0) && (!adds.length || checkoutReady))}>
{busy ? L.working : `${adds.length ? L.confirmAdd : L.confirm} →`}
</button>
{billingLinkUrl.trim() && <a href={billingLinkUrl.trim()} target="_blank" rel="noopener noreferrer" style={{ color: accent, fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, textAlign: "center" }}>{L.billingLink} ↗</a>}
</div>
{adds.length > 0 && !checkoutReady && (
<p style={{ margin: 0, color: MUTED, fontSize: 13, lineHeight: 1.55 }}>{L.checkoutSoon} <a href={bookingUrl} target="_blank" rel="noopener noreferrer" style={{ color: accent, fontWeight: 700 }}>{L.bookCall} →</a></p>
)}
{adds.length > 0 && <p style={{ margin: 0, color: MUTED, fontSize: 13, lineHeight: 1.6, borderTop: `1px solid ${HAIRLINE}`, paddingTop: 12 }}>{L.billingNote}</p>}
</section>
</>
)}
</div>
<footer className="aw-footer" aria-label={F.nav}>
<a href="/" aria-label="Impulsor AI" style={{ fontWeight: 800, letterSpacing: 1.5, fontSize: 16 }}>IMPULSOR<span style={{ color: accent }}>//AI</span></a>
<p style={{ margin: 0, color: MUTED, fontSize: 14 }}>{F.line}</p>
<nav aria-label={F.nav} style={{ display: "flex", flexWrap: "wrap", justifyContent: "center", gap: "6px 12px", fontSize: 14, fontWeight: 600, marginTop: 6 }}>
<a href="/privacy">{F.privacy}</a><span aria-hidden style={{ color: MUTED }}>·</span>
<a href="/terms">{F.terms}</a><span aria-hidden style={{ color: MUTED }}>·</span>
<a href="/ai-disclosure">{F.ai}</a>
</nav>
<p style={{ margin: 0, color: MUTED, fontSize: 13, lineHeight: 1.6 }}>
<a href="mailto:contact@impulsorai.com">contact@impulsorai.com</a><span aria-hidden> · </span><a href="tel:+19723145599">(972) 314-5599</a>
</p>
<p style={{ margin: 0, color: MUTED, fontSize: 13 }}>© 2026 Impulsor AI. {F.rights}</p>
</footer>
</div>
)
}
addPropertyControls(ManageWorkers, {
apiBaseUrl: { type: ControlType.String, title: "API Base URL", defaultValue: "", placeholder: "https://…workers.dev (empty = coming soon)" },
checkoutUrl: { type: ControlType.String, title: "Checkout URL", defaultValue: "", placeholder: "https://…/checkout (empty = book a call)" },
billingLinkUrl: { type: ControlType.String, title: "Card & Invoices URL", defaultValue: "", placeholder: "Optional; hidden if empty" },
bookingUrl: { type: ControlType.Link, title: "Booking URL", defaultValue: "https://cal.com/impulsorai/discovery" },
defaultLanguage: { type: ControlType.Enum, title: "Language", options: ["auto", "en", "es"], optionTitles: ["Auto-detect", "English", "Español"], defaultValue: "auto" },
accent: { type: ControlType.Color, title: "Accent", defaultValue: "#00F2FE" },
})