/* early-access.jsx — Luppy pre-launch / early-access landing page.
Uses Icon + HeroIllustration from hero.jsx (loaded first).
Every "Join early access" trigger (nav, hero, CTA section) dispatches
window 'ea-open-join' — a single modal (mounted once in ea-app.jsx)
owns the full qualifying form and the success state. */
const COMPANY_SIZES = ['Just me (solo)', '2\u201310 employees', '11\u201350 employees', '51+ employees'];
const FORM_ENDPOINT = 'submit-contact.php';
function openJoinModal(prefillEmail) {
window.dispatchEvent(new CustomEvent('ea-open-join', { detail: prefillEmail || '' }));
}
/* POSTs a form to the PHP mailer; resolves with the endpoint's JSON payload. */
async function submitForm(payload) {
const res = await fetch(FORM_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
let data = null;
try { data = await res.json(); } catch (err) {}
if (!res.ok || !data || !data.ok) {
throw new Error((data && data.error) || 'Something went wrong. Please try again.');
}
return data;
}
/* ── the one modal: qualifying form, then success — triggered by any CTA ── */
function EAJoinModal() {
const [open, setOpen] = React.useState(false);
const [step, setStep] = React.useState('form'); // 'form' | 'success'
const [fields, setFields] = React.useState({ name: '', company: '', email: '', size: '', challenge: '', company_website: '' });
const [errors, setErrors] = React.useState({});
const [sending, setSending] = React.useState(false);
const [sendError, setSendError] = React.useState('');
const [result, setResult] = React.useState(null); // { name, email, code }
React.useEffect(() => {
const onOpen = (e) => {
setFields((f) => ({ ...f, email: e.detail || f.email }));
setStep('form');
setErrors({});
setSendError('');
setOpen(true);
};
window.addEventListener('ea-open-join', onOpen);
return () => window.removeEventListener('ea-open-join', onOpen);
}, []);
React.useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = prevOverflow; };
}, [open]);
if (!open) return null;
const set = (key) => (e) => {
const v = e.target.value;
setFields((f) => ({ ...f, [key]: v }));
if (errors[key]) setErrors((er) => ({ ...er, [key]: false }));
};
const submit = async (e) => {
e.preventDefault();
if (sending) return;
const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(fields.email.trim());
const nextErrors = { name: !fields.name.trim(), company: !fields.company.trim(), email: !emailOk, size: !fields.size };
setErrors(nextErrors);
if (Object.values(nextErrors).some(Boolean)) return;
setSendError('');
setSending(true);
try {
const data = await submitForm({
form: 'early-access',
name: fields.name.trim(),
company: fields.company.trim(),
email: fields.email.trim(),
size: fields.size,
challenge: fields.challenge.trim(),
company_website: fields.company_website,
});
setResult({ name: fields.name.trim(), email: fields.email.trim(), code: data.code || '' });
setStep('success');
setFields({ name: '', company: '', email: '', size: '', challenge: '', company_website: '' });
} catch (err) {
setSendError(err.message);
} finally {
setSending(false);
}
};
const firstName = result ? (result.name || '').split(/\s+/)[0] : '';
return ReactDOM.createPortal(
{ if (e.target === e.currentTarget) setOpen(false); }}>
{step === 'form' ? (
Join early access
Takes about a minute. The more we know, the better we build for you — nothing here is shared.
) : (
Welcome, {firstName || 'there'}! 👋
Thanks for joining LUPPY Early Access. We saved your spot. Watch your inbox for your Early Access code, product updates, and launch invitation.
{result.code && (
Your unique early access code
{result.code}
)}
{result.code ? 'A copy is on its way to' : 'We’ll be in touch at'}
{result.email}
Save your Early Access code—it unlocks your launch benefits when LUPPY goes live.
You'll be invited before LUPPY opens to the public.
We'll occasionally ask for your feedback to help shape what's next.
)}
,
document.body);
}
/* ── hero quick-capture — email only, opens the join modal pre-filled ── */
function EarlyAccessForm({ variant }) {
const [email, setEmail] = React.useState('');
const [status, setStatus] = React.useState('idle'); // idle | error
const submit = (e) => {
e.preventDefault();
const val = email.trim();
const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val);
if (!ok) { setStatus('error'); return; }
openJoinModal(val);
};
return (
);
}
/* ── Privacy / Terms — shared modal, opened via footer links ── */
const LEGAL_CONTENT = {
privacy: {
title: 'Privacy Policy',
updated: 'Last updated: July 2026',
body: [
['What we collect', 'When you join Early Access, we collect the information you provide, such as your name, company name, work email, company size, and anything you choose to share about your biggest challenge. That\u2019s it. We don\u2019t track you across the web, and we never sell your data.'],
['Why we collect it', 'To send Early Access updates, understand the needs of cleaning businesses, and let you know when LUPPY is ready to try.'],
['Who sees it', 'We only share your information with trusted service providers that help us operate LUPPY (such as email or hosting providers), and only when necessary. We never sell or rent your information.'],
['Your control', 'Unsubscribe from any email with one click, or ask us to delete your information at hello@luppy.app — we\u2019ll take care of it right away.'],
['Changes', 'If this policy changes in a meaningful way before launch, we\u2019ll let early-access members know by email.']]
},
terms: {
title: 'Terms of Service',
updated: 'Last updated: July 2026',
body: [
['Early access, not a final product', LUPPY is currently in Early Access. Features, pricing, and availability may change before public launch.],
['No commitment either way', 'Joining the Early Access list is free and doesn\u2019t obligate you to purchase anything. You can unsubscribe at any time.'],
['Availability', 'Joining the Early Access list does not guarantee access to the beta or public launch at a specific date. We\u2019ll invite people as the product becomes available.'],
['Acceptable use', 'Please don\u2019t use this website or sign-up form to submit false information, impersonate others, or send spam.'],
['Questions', 'Reach us anytime at hello@luppy.app.']]
}
};
function openLegalModal(kind) {
window.dispatchEvent(new CustomEvent('ea-open-legal', { detail: kind }));
}
function EALegalModal() {
const [kind, setKind] = React.useState(null); // null | 'privacy' | 'terms'
const [canScroll, setCanScroll] = React.useState(false);
const bodyRef = React.useRef(null);
React.useEffect(() => {
const onOpen = (e) => setKind(e.detail);
window.addEventListener('ea-open-legal', onOpen);
return () => window.removeEventListener('ea-open-legal', onOpen);
}, []);
React.useEffect(() => {
if (!kind) return;
const onKey = (e) => { if (e.key === 'Escape') setKind(null); };
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = prevOverflow; };
}, [kind]);
const checkScroll = React.useCallback(() => {
const el = bodyRef.current;
if (!el) return;
setCanScroll(el.scrollHeight - el.scrollTop - el.clientHeight > 12);
}, []);
React.useEffect(() => {
if (!kind) return;
const t = setTimeout(checkScroll, 50);
window.addEventListener('resize', checkScroll);
return () => { clearTimeout(t); window.removeEventListener('resize', checkScroll); };
}, [kind, checkScroll]);
if (!kind) return null;
const content = LEGAL_CONTENT[kind];
return ReactDOM.createPortal(
{ if (e.target === e.currentTarget) setKind(null); }}>
Free to joinNo card, no commitmentFounding-member perks
);
}
/* ── PROBLEM ──────────────────────────────────────────────────────── */
function useInView(threshold = 0.25) {
const ref = React.useRef(null);
const [inView, setInView] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => { if (entry.isIntersecting) { setInView(true); io.disconnect(); } });
}, { threshold });
io.observe(el);
return () => io.disconnect();
}, [threshold]);
return [ref, inView];
}
function EAProblem() {
const pains = [
'Jobs and reschedules scattered across texts, calls, and WhatsApp.',
'Mornings lost chasing cleaners for status updates.',
'No proof a clean actually happened until a client complains.',
'Invoices written hours after the job is finished.',
'Every new cleaner needs training just to use the software.',
'No clear picture of how the day is really going.'];
const [ref, inView] = useInView(0.2);
return (
The everyday mess
Running a cleaning business shouldn't feel this chaotic.
If your operation runs on group chats, spreadsheets, and memory, something usually slips through the cracks.
Schedule cleans, dispatch crews, and see what's done — all in the browser, no app to install. Here's an early peek at what you'll get access to.
app.luppy.app/jobs
Home›Jobs
All jobs
Filter
Add Job
Today
12
+2 vs yest.
In progress
4
on time
Completed
8
+18% wk
Hours billed
42h
+8h wk
Job
When
Crew
Status
{rows.map((r, i) =>
{r.name.slice(0, 2)}
{r.name}
{r.sub}
{r.when}
{r.crew}
{r.status}
)}
Early preview — screens evolve as we build with our early-access members.
);
}
/* ── WHY DIFFERENT ────────────────────────────────────────────────── */
function EAWhy() {
const cards = [
{ ic: 'solar:download-minimalistic-linear', cls: 'f-primary', t: 'No app to install', d: 'Cleaners open their jobs from a link, right in their phone browser. No app store, no logins to fight.' },
{ ic: 'solar:rocket-2-linear', cls: 'f-success', t: 'Live in a morning', d: 'Add clients, add crew, dispatch your first job by lunch. No onboarding project, no consultants.' },
{ ic: 'solar:users-group-two-rounded-linear', cls: 'f-warning', t: 'Built for the field', d: 'Designed for the people in the van, not just the office. If the crew won\u2019t use it, it doesn\u2019t count.' },
{ ic: 'solar:eye-linear', cls: 'f-info', t: 'Real-time visibility', d: 'See when work starts, pauses and finishes — with photo proof, so a clean is never in doubt.' }];
return (
Why Luppy is different
Simple enough that the whole team actually uses it
Most cleaning software grows complicated until only the office touches it. We're building the opposite — a tool your crew adopts on day one.
{cards.map((c, i) =>
{c.t}
{c.d}
)}
);
}
/* ── WHO IS IT FOR ────────────────────────────────────────────────── */
function EAWho() {
const cards = [
{ ic: 'solar:home-smile-linear', bg: 'var(--color-lightprimary)', fg: 'var(--color-primary)', t: 'Residential cleaning', d: 'Recurring home cleans, tidy schedules, happy repeat clients.' },
{ ic: 'solar:buildings-2-linear', bg: 'var(--color-lightinfo)', fg: '#0891b2', t: 'Commercial cleaning', d: 'Offices, clinics and retail — recurring contracts, proof of work.' },
{ ic: 'solar:key-minimalistic-2-linear', bg: 'var(--color-lightsecondary)', fg: '#0d9488', t: 'Airbnb turnovers', d: 'Fast short-let cleans timed to check-out, checklists per property.' },
{ ic: 'solar:magic-stick-3-linear', bg: 'var(--color-lightwarning)', fg: '#b88406', t: 'Maid services', d: 'Dispatch crews, track hours, invoice the moment a job wraps.' },
{ ic: 'solar:users-group-rounded-linear', bg: 'var(--color-lightsuccess)', fg: 'var(--color-success)', t: 'Growing teams', d: 'Scaling past a handful of cleaners without scaling the chaos.' }];
return (
Who it's for
Made for cleaning businesses of every shape
If you send people to clean places, Luppy is being built for you. We'd love your input while it's early.
I believe technology should remove friction from people's work, not add to it. Luppy is how I'm putting that belief into practice for cleaning businesses.
The FounderLuppy
);
}
/* ── EARLY ACCESS CTA (perks + form) ──────────────────────────────── */
function EAJoin() {
const perks = [
{ ic: 'solar:rocket-2-bold', t: 'First access', d: 'Get in before we open to the public.' },
{ ic: 'solar:tag-price-bold', t: 'Founding-member pricing', d: 'A locked-in rate, just for early backers.' },
{ ic: 'solar:calendar-mark-bold', t: 'Extended free trial', d: 'A generous 30-day trial to settle in.' },
{ ic: 'solar:chat-round-line-bold', t: 'Shape the roadmap', d: 'Tell us what to build. We\u2019ll listen.' },
{ ic: 'solar:test-tube-bold', t: 'Beta invitations', d: 'Try new features before anyone else.' },
{ ic: 'solar:letter-opened-bold', t: 'Build updates', d: 'Honest progress notes as we go — no fluff.' }];
return (
Early access
Join early access
Here's exactly what you get for sharing your email — and exactly what we'll do with it.
{perks.map((p, i) =>
{p.t}{p.d}
)}
Ready to join?
One click opens a short form — name, company and a couple details so we build for the right people.