import React, { useState, useEffect, useMemo, useRef } from "react"; // ============================================================ // floy — Pricing calculator (English) · detailed, per-niche // Setup: the person sets the price of EVERY factor of their business once // (base fee, price per bedroom/bathroom, each add-on, each service...). // Quote: they answer the specific questions of the job (how many bedrooms, // deep clean or not, clean the oven?, distance, deadline...) and the price // builds itself. Every niche has its own detailed questions. // Look: pure black, Poppins, wine #9C2E32, gold #C9A24B on the price. // ============================================================ const WINE = "#9C2E32"; const GOLD = "#C9A24B"; const CREAM = "#F4EDE2"; // Factor types: service (pick one, has base price) · base (flat fee) · // count (priced per unit, ask how many) · choice (single-select multiplier) · // addon (toggle + qty, priced each). const NICHES = { cleaning: { label: "Cleaning", travel: true, prep: false, factors: [ { type: "base", key: "trip", label: "Base visit fee", price: 40 }, { type: "count", key: "bed", label: "Bedrooms", unit: "bedroom", price: 25 }, { type: "count", key: "bath", label: "Bathrooms", unit: "bathroom", price: 35 }, { type: "count", key: "kitchen", label: "Kitchens", unit: "kitchen", price: 40 }, { type: "count", key: "other", label: "Other rooms (living, office, hall)", unit: "room", price: 20 }, { type: "choice", key: "type", label: "Type of cleaning", options: [ { label: "Standard", mult: 1 }, { label: "Deep clean", mult: 1.5 }, { label: "Move-in / move-out", mult: 2 } ] }, { type: "addon", key: "oven", label: "Inside oven / stove", price: 30 }, { type: "addon", key: "fridge", label: "Inside fridge", price: 25 }, { type: "addon", key: "windows", label: "Interior windows", price: 40 }, { type: "addon", key: "cabinets", label: "Inside cabinets", price: 30 }, { type: "addon", key: "baseboards", label: "Baseboards", price: 25 }, { type: "addon", key: "laundry", label: "Laundry (wash & fold)", price: 20 }, ], }, organization: { label: "Personal organizer", travel: true, prep: false, factors: [ { type: "base", key: "consult", label: "Consultation / plan fee", price: 50 }, { type: "count", key: "closet", label: "Closets / wardrobes", unit: "closet", price: 120 }, { type: "count", key: "room", label: "Full rooms", unit: "room", price: 150 }, { type: "count", key: "pantry", label: "Kitchen / pantry", unit: "space", price: 200 }, { type: "count", key: "garage", label: "Garage", unit: "garage", price: 250 }, { type: "choice", key: "level", label: "Level of work", options: [ { label: "Declutter only", mult: 1 }, { label: "Organize + systems", mult: 1.4 }, { label: "Full makeover", mult: 1.8 } ] }, { type: "addon", key: "shopping", label: "Product sourcing / shopping", price: 60 }, { type: "addon", key: "haul", label: "Donation haul-away", price: 40 }, { type: "addon", key: "labels", label: "Labeling system", price: 30 }, { type: "addon", key: "maint", label: "Follow-up maintenance visit", price: 50 }, ], }, photography: { label: "Photography", travel: true, prep: true, prepLabel: "Editing hours", factors: [ { type: "service", key: "svc", label: "Session", options: [ { key: "mini", label: "Mini session (up to 30 min)", unit: "session", price: 200 }, { key: "std", label: "Standard session (1 hour)", unit: "session", price: 350 }, { key: "event", label: "Event coverage (per hour)", unit: "hour", price: 200 } ] }, { type: "count", key: "extraPhotos", label: "Extra edited photos", unit: "photo", price: 15 }, { type: "count", key: "people", label: "Additional people", unit: "person", price: 25 }, { type: "choice", key: "usage", label: "Usage rights", options: [ { label: "Personal", mult: 1 }, { label: "Commercial", mult: 1.5 }, { label: "Full buyout", mult: 2 } ] }, { type: "addon", key: "loc2", label: "Second location", price: 75 }, { type: "addon", key: "rush", label: "Rush 48h delivery", price: 100 }, { type: "addon", key: "hmu", label: "Hair / makeup coordination", price: 50 }, { type: "addon", key: "prints", label: "Prints package", price: 60 }, ], }, content: { label: "Content & video", travel: true, prep: true, prepLabel: "Filming + editing hours", factors: [ { type: "service", key: "svc", label: "What you're delivering", options: [ { key: "reels", label: "Reels package (per month)", unit: "package", price: 850 }, { key: "video", label: "Produced video (each)", unit: "video", price: 250 }, { key: "day", label: "Filming day", unit: "day", price: 250 } ] }, { type: "count", key: "extraVideos", label: "Extra edited videos", unit: "video", price: 150 }, { type: "count", key: "extraHours", label: "Extra filming hours", unit: "hour", price: 100 }, { type: "choice", key: "level", label: "Production level", options: [ { label: "Simple", mult: 1 }, { label: "Standard", mult: 1.3 }, { label: "Cinematic", mult: 1.8 } ] }, { type: "choice", key: "usage", label: "Usage", options: [ { label: "Organic / social", mult: 1 }, { label: "Paid ads", mult: 1.3 }, { label: "Full commercial", mult: 1.6 } ] }, { type: "addon", key: "photos", label: "Photo add-on (10 photos)", price: 250 }, { type: "addon", key: "captions", label: "Captions / voiceover", price: 50 }, { type: "addon", key: "revision", label: "Extra revision round", price: 75 }, ], }, beauty: { label: "Beauty (nails, makeup, hair)", travel: true, prep: false, factors: [ { type: "service", key: "svc", label: "Service", options: [ { key: "mani", label: "Manicure", unit: "client", price: 40 }, { key: "nails", label: "Gel / nail design", unit: "client", price: 60 }, { key: "makeup", label: "Makeup", unit: "client", price: 120 }, { key: "hair", label: "Hair styling", unit: "client", price: 90 }, { key: "bridal", label: "Bridal package", unit: "client", price: 350 } ] }, { type: "choice", key: "complex", label: "Complexity", options: [ { label: "Simple", mult: 1 }, { label: "Detailed", mult: 1.3 }, { label: "Elaborate", mult: 1.7 } ] }, { type: "addon", key: "home", label: "Home visit", price: 40 }, { type: "addon", key: "guest", label: "Extra guest", price: 60 }, { type: "addon", key: "trial", label: "Trial session", price: 50 }, ], }, baking: { label: "Baking & confectionery", travel: false, prep: false, factors: [ { type: "service", key: "svc", label: "What you're selling", options: [ { key: "hundred", label: "Sweets (per hundred)", unit: "hundred", price: 120 }, { key: "unit", label: "Individual sweets (per unit)", unit: "unit", price: 1.5 }, { key: "cake", label: "Custom cake (each)", unit: "cake", price: 80 }, { key: "cupcake", label: "Cupcakes (each)", unit: "cupcake", price: 3 } ] }, { type: "count", key: "tiers", label: "Extra cake tiers", unit: "tier", price: 40 }, { type: "choice", key: "complex", label: "Complexity", options: [ { label: "Simple", mult: 1 }, { label: "Custom / themed", mult: 1.4 }, { label: "Sculpted / elaborate", mult: 1.9 } ] }, { type: "addon", key: "delivery", label: "Delivery", price: 20 }, { type: "addon", key: "topper", label: "Custom topper", price: 15 }, { type: "addon", key: "diet", label: "Gluten-free / vegan", price: 15 }, { type: "addon", key: "setup", label: "Venue setup", price: 50 }, ], }, other: { label: "Other", travel: true, prep: false, custom: true, factors: [ { type: "service", key: "svc", label: "Your services", options: [{ key: "s1", label: "", unit: "", price: 0 }] }, ], }, }; const URGENCY = [ { label: "Standard timeline", pct: 0 }, { label: "Tight deadline", pct: 0.2 }, { label: "Rush (ASAP)", pct: 0.4 }, ]; const money = (n) => "$" + (Math.round(n * 100) / 100).toLocaleString("en-US", { maximumFractionDigits: 0 }); const STEP = { INTRO: 0, AREA: 1, PRICES: 2, EXTRAS: 3, COSTS: 4, DONE: 5, Q_SERVICE: 6, Q_SIZE: 7, Q_ADDONS: 8, Q_LOGISTICS: 9, RESULT: 10 }; export default function FloyCalculator() { const [step, setStep] = useState(STEP.INTRO); useEffect(() => { const l = document.createElement("link"); l.rel = "stylesheet"; l.href = "https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap"; document.head.appendChild(l); return () => { try { document.head.removeChild(l); } catch (e) {} }; }, []); // Profile (prices set once) const [nicheKey, setNicheKey] = useState(null); const [nicheName, setNicheName] = useState(""); const [services, setServices] = useState([]); // [{key,label,unit,price}] const [bases, setBases] = useState([]); // [{key,label,price}] const [counts, setCounts] = useState([]); // [{key,label,unit,price}] const [addons, setAddons] = useState([]); // [{key,label,price}] const [travelRate, setTravelRate] = useState(0.7); const [hourly, setHourly] = useState(25); const [tax, setTax] = useState(20); const [card, setCard] = useState(3); const [margin, setMargin] = useState(30); const niche = nicheKey ? NICHES[nicheKey] : null; const choices = niche ? niche.factors.filter((f) => f.type === "choice") : []; const businessName = nicheKey === "other" ? (nicheName || "Your business") : (niche?.label || ""); const pickNiche = (k) => { const n = NICHES[k]; const svc = n.factors.find((f) => f.type === "service"); setServices(svc ? svc.options.map((o) => ({ ...o })) : []); setBases(n.factors.filter((f) => f.type === "base").map((f) => ({ ...f }))); setCounts(n.factors.filter((f) => f.type === "count").map((f) => ({ ...f }))); setAddons(n.factors.filter((f) => f.type === "addon").map((f) => ({ ...f }))); setTravelRate(0.7); setNicheKey(k); resetQuote(); }; // Quote answers const [serviceIdx, setServiceIdx] = useState(0); const [mainQty, setMainQty] = useState(1); const [countAns, setCountAns] = useState({}); const [choiceAns, setChoiceAns] = useState({}); const [addonAns, setAddonAns] = useState({}); const [visits, setVisits] = useState(1); const [miles, setMiles] = useState(0); const [urg, setUrg] = useState(0); const [prepH, setPrepH] = useState(0); const resetQuote = () => { setServiceIdx(0); setMainQty(1); setCountAns({}); setChoiceAns({}); setAddonAns({}); setVisits(1); setMiles(0); setUrg(0); setPrepH(0); }; const validServices = services.filter((s) => s.label.trim() !== ""); const hasSize = counts.length > 0 || choices.length > 0; const service = validServices[serviceIdx] || validServices[0]; // Dynamic quote step order (skip what the niche doesn't have) const quoteSteps = useMemo(() => { const arr = []; if (validServices.length) arr.push(STEP.Q_SERVICE); if (hasSize) arr.push(STEP.Q_SIZE); if (addons.length) arr.push(STEP.Q_ADDONS); arr.push(STEP.Q_LOGISTICS); arr.push(STEP.RESULT); return arr; }, [validServices.length, hasSize, addons.length]); const qNext = () => { const i = quoteSteps.indexOf(step); if (i >= 0 && i < quoteSteps.length - 1) setStep(quoteSteps[i + 1]); }; const qBack = () => { const i = quoteSteps.indexOf(step); if (i > 0) setStep(quoteSteps[i - 1]); else setStep(STEP.DONE); }; // ---------- THE MATH ---------- const calc = useMemo(() => { if (!niche) return null; const serviceBase = service ? (Number(service.price) || 0) * mainQty : 0; const countsSum = counts.reduce((s, f) => s + (Number(f.price) || 0) * (countAns[f.key] || 0), 0); const baseSum = bases.reduce((s, f) => s + (Number(f.price) || 0), 0); let core = serviceBase + countsSum + baseSum; choices.forEach((f) => { const idx = choiceAns[f.key] ?? 0; core *= f.options[idx].mult; }); core *= (1 + URGENCY[urg].pct); const addonsSum = addons.reduce((s, f) => s + (Number(f.price) || 0) * (addonAns[f.key] || 0), 0); const travel = niche.travel ? miles * travelRate * 2 : 0; const prep = niche.prep ? prepH * hourly : 0; const perJob = core + addonsSum + travel + prep; const costBase = perJob * Math.max(1, visits); const taxReserve = costBase * (tax / 100); const cardFee = costBase * (card / 100); const recommended = (costBase + taxReserve + cardFee) * (1 + margin / 100); const minimum = (costBase + taxReserve + cardFee) * 1.1; const premium = recommended * 1.35; return { core, addonsSum, travel, prep, taxReserve, cardFee, recommended, minimum, premium, visits }; }, [niche, service, mainQty, counts, countAns, bases, choices, choiceAns, addons, addonAns, urg, miles, travelRate, prepH, hourly, visits, tax, card, margin]); const quoteText = useMemo(() => { if (!calc) return ""; const name = service ? service.label : businessName; const L = []; L.push(`Hi! Here's the quote for your ${name.toLowerCase()}.`); L.push(""); L.push(`${name}${mainQty > 1 ? ` — ${mainQty}x` : ""}${visits > 1 ? ` · ${visits} visits` : ""}`); L.push(`Total: ${money(calc.recommended)}`); L.push(""); const inc = []; if (calc.addonsSum > 0) inc.push("the add-ons"); if (calc.travel > 0) inc.push("travel"); if (calc.prep > 0) inc.push("editing"); if (inc.length) L.push(`This already includes ${inc.join(", ")}.`); L.push("To lock in your date, a 50% deposit confirms the booking."); L.push("Any questions, just message me here."); return L.join("\n"); }, [calc, service, businessName, mainQty, visits]); const S = styles; const progress = Math.min(step / STEP.RESULT, 1); return (
floy
{step === STEP.INTRO && (

Set it up once. Quote in seconds.

Answer a few questions about your business — your prices, what you include, what costs extra. After that, every quote is just the job's details, and floy builds a fair price with a ready-to-send message.

setStep(STEP.AREA)}>Set up my business
)} {step === STEP.AREA && ( My business

What do you do?

{Object.entries(NICHES).map(([k, n]) => ( ))}
{nicheKey === "other" && ( )}
); } function hover(e, on, selected) { if (selected) return; e.currentTarget.style.borderColor = on ? WINE : "#2a2a2a"; e.currentTarget.style.background = on ? "rgba(156,46,50,0.12)" : "transparent"; } function Screen({ children }) { return
{children}
; } function Eyebrow({ children }) { return
{children}
; } function Result({ calc, name, quoteText, onAdjust, onNew }) { const [copied, setCopied] = useState(false); const taRef = useRef(null); const S = styles; const copy = () => { try { navigator.clipboard.writeText(quoteText); } catch (e) { if (taRef.current) { taRef.current.select(); document.execCommand("copy"); } } setCopied(true); setTimeout(() => setCopied(false), 1800); }; return (

For {name.toLowerCase()}

{money(calc.recommended)}

recommended price

How this price is built
{calc.addonsSum > 0 && } {calc.travel > 0 && } {calc.prep > 0 && }
Quote ready to send