| import { useEffect, useState, type FormEvent } from "react";
|
| import { createPortal } from "react-dom";
|
| import {
|
| checkGrammar,
|
| rewriteText,
|
| STRENGTH_MAP,
|
| STRENGTHS,
|
| TONES,
|
| type AccountInfo,
|
| type ApiError,
|
| type GrammarIssue,
|
| type StrengthLabel,
|
| type Tone,
|
| } from "./api";
|
| import { useAuth } from "./auth";
|
| import type { PlanCard } from "./supabase";
|
|
|
| const LOGO_SRC = "/zuzu-logo.png";
|
| const LOGO_FALLBACK = "/favicon.svg";
|
| const MAX_CHARS = 50000;
|
| const GRAMMAR_MAX_CHARS = 12000;
|
|
|
| type ProductId = "writer" | "grammar";
|
|
|
| const PRODUCTS: {
|
| id: ProductId;
|
| name: string;
|
| short: string;
|
| tagline: string;
|
| blurb: string;
|
| status: "live" | "preview";
|
| }[] = [
|
| {
|
| id: "writer",
|
| name: "ZuZu Writer",
|
| short: "Writer",
|
| tagline: "Turn AI drafts into natural wording.",
|
| blurb:
|
| "Rewrite ChatGPT, Gemini, and Claude text in Neutral, Casual, Formal, or Academic tone — classical NLP, no LLM.",
|
| status: "live",
|
| },
|
| {
|
| id: "grammar",
|
| name: "ZuZu Grammar",
|
| short: "Grammar",
|
| tagline: "Catch grammar and spelling before you publish.",
|
| blurb:
|
| "Full sentence grammar, spelling, and punctuation via self-hosted LanguageTool — apply fixes with one click.",
|
| status: "preview",
|
| },
|
| ];
|
|
|
| function BrandLogo({ size = 44, className = "" }: { size?: number; className?: string }) {
|
| return (
|
| <img
|
| src={LOGO_SRC}
|
| alt="ZuZu Writer"
|
| width={size}
|
| height={size}
|
| className={`brand-logo ${className}`.trim()}
|
| decoding="async"
|
| onError={(e) => {
|
| const img = e.currentTarget;
|
| if (img.src.endsWith("favicon.svg")) return;
|
| img.src = LOGO_FALLBACK;
|
| }}
|
| />
|
| );
|
| }
|
|
|
|
|
| function GoogleGMark({ size = 18 }: { size?: number }) {
|
| return (
|
| <svg
|
| className="google-g"
|
| width={size}
|
| height={size}
|
| viewBox="0 0 48 48"
|
| aria-hidden="true"
|
| focusable="false"
|
| >
|
| <path
|
| fill="#EA4335"
|
| d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
|
| />
|
| <path
|
| fill="#4285F4"
|
| d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
|
| />
|
| <path
|
| fill="#FBBC05"
|
| d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
|
| />
|
| <path
|
| fill="#34A853"
|
| d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
|
| />
|
| </svg>
|
| );
|
| }
|
|
|
| const SAMPLE_TEXT =
|
| "Artificial intelligence has significantly transformed the way individuals create written content in recent years. Many professionals now rely on advanced language models to generate initial drafts quickly and efficiently. However, the resulting text can sometimes appear repetitive, overly formal, or lacking a natural human voice. Therefore, it is essential to carefully refine AI-generated material so that it communicates clearly, remains original in wording, and feels authentic to the intended audience.";
|
|
|
| const TONE_HINT: Record<Tone, string> = {
|
| Neutral: "Clear and balanced — light elevate only",
|
| Casual: "Conversational — contractions + downshift lexicon",
|
| Formal: "Professional — elevated wording, no contractions",
|
| Academic: "Scholarly — denser lexicon + discourse markers",
|
| };
|
|
|
| function wordCount(text: string): number {
|
| return text.trim() ? text.trim().split(/\s+/).length : 0;
|
| }
|
|
|
| function ProductSwitcher({
|
| product,
|
| onChange,
|
| }: {
|
| product: ProductId;
|
| onChange: (id: ProductId) => void;
|
| }) {
|
| return (
|
| <div className="product-switcher" role="tablist" aria-label="Products">
|
| {PRODUCTS.map((p) => (
|
| <button
|
| key={p.id}
|
| type="button"
|
| role="tab"
|
| aria-selected={product === p.id}
|
| className={product === p.id ? "active" : ""}
|
| onClick={() => onChange(p.id)}
|
| >
|
| {p.short}
|
| {p.status === "preview" ? <span className="product-pill">Preview</span> : null}
|
| </button>
|
| ))}
|
| </div>
|
| );
|
| }
|
|
|
| function SiteNav({
|
| product,
|
| onSelectProduct,
|
| }: {
|
| product: ProductId;
|
| onSelectProduct: (id: ProductId) => void;
|
| }) {
|
| return (
|
| <nav className="site-nav" aria-label="Site">
|
| <a href="#products">Products</a>
|
| <a href="#plans">Plans</a>
|
| <button type="button" className="nav-product" onClick={() => onSelectProduct("writer")}>
|
| {product === "writer" ? "Open Writer" : "Writer"}
|
| </button>
|
| <button type="button" className="nav-product" onClick={() => onSelectProduct("grammar")}>
|
| {product === "grammar" ? "Open Grammar" : "Grammar"}
|
| </button>
|
| </nav>
|
| );
|
| }
|
|
|
| function applyGrammarFix(text: string, issue: GrammarIssue): string {
|
| if (issue.suggestion == null) return text;
|
| return text.slice(0, issue.start) + issue.suggestion + text.slice(issue.end);
|
| }
|
|
|
|
|
| function remainIssuesAfterFix(issues: GrammarIssue[], applied: GrammarIssue): GrammarIssue[] {
|
| if (applied.suggestion == null) {
|
| return issues.filter((i) => i.id !== applied.id);
|
| }
|
| const oldLen = Math.max(0, applied.end - applied.start);
|
| const delta = applied.suggestion.length - oldLen;
|
| return issues
|
| .filter((i) => i.id !== applied.id)
|
| .filter((i) => i.end <= applied.start || i.start >= applied.end)
|
| .map((i) => {
|
| if (i.start >= applied.end) {
|
| return {
|
| ...i,
|
| start: i.start + delta,
|
| end: i.end + delta,
|
| id: `${i.id}-s${delta}`,
|
| };
|
| }
|
| return i;
|
| });
|
| }
|
|
|
| function applyAllGrammarFixes(text: string, issues: GrammarIssue[]): string {
|
| let next = text;
|
| const ordered = [...issues].sort((a, b) => b.start - a.start);
|
| for (const issue of ordered) {
|
| if (issue.suggestion == null) continue;
|
| next = applyGrammarFix(next, issue);
|
| }
|
| return next;
|
| }
|
|
|
| const GRAMMAR_SAMPLE =
|
| "teh quick brown fox jump over the lazy dog. i think this sentance is seperate from the other one and it it needs fixing.";
|
|
|
| const GRAMMAR_LANGUAGES: { id: string; label: string }[] = [
|
| { id: "en-US", label: "English (US)" },
|
| { id: "en-GB", label: "English (UK)" },
|
| { id: "en-AU", label: "English (AU)" },
|
| { id: "en-CA", label: "English (CA)" },
|
| { id: "en-ZA", label: "English (ZA)" },
|
| { id: "en-NZ", label: "English (NZ)" },
|
| ];
|
|
|
| function AuthModal({
|
| open,
|
| onClose,
|
| initialMode = "signup",
|
| title,
|
| }: {
|
| open: boolean;
|
| onClose: () => void;
|
| initialMode?: "signin" | "signup";
|
| title?: string;
|
| }) {
|
| const { signInWithPassword, signUp, signInWithGoogle } = useAuth();
|
| const [mode, setMode] = useState<"signin" | "signup">(initialMode);
|
| const [email, setEmail] = useState("");
|
| const [password, setPassword] = useState("");
|
| const [busy, setBusy] = useState(false);
|
| const [message, setMessage] = useState("");
|
| const [error, setError] = useState("");
|
|
|
| useEffect(() => {
|
| if (open) {
|
| setMode(initialMode);
|
| setError("");
|
| setMessage("");
|
| const prev = document.body.style.overflow;
|
| document.body.style.overflow = "hidden";
|
| return () => {
|
| document.body.style.overflow = prev;
|
| };
|
| }
|
| return undefined;
|
| }, [open, initialMode]);
|
|
|
| if (!open) return null;
|
|
|
| async function onSubmit(e: FormEvent) {
|
| e.preventDefault();
|
| setBusy(true);
|
| setError("");
|
| setMessage("");
|
| try {
|
| if (mode === "signin") {
|
| await signInWithPassword(email.trim(), password);
|
| onClose();
|
| } else {
|
| const result = await signUp(email.trim(), password);
|
| if (result === "check_email") {
|
| setMessage("Check your email to confirm your account, then sign in.");
|
| } else {
|
| onClose();
|
| }
|
| }
|
| } catch (err) {
|
| setError(err instanceof Error ? err.message : "Authentication failed.");
|
| } finally {
|
| setBusy(false);
|
| }
|
| }
|
|
|
| return createPortal(
|
| <div className="modal-backdrop" role="presentation" onClick={onClose}>
|
| <div
|
| className="auth-card modal-card"
|
| role="dialog"
|
| aria-modal="true"
|
| aria-label={title || "Sign in"}
|
| onClick={(e) => e.stopPropagation()}
|
| >
|
| <button type="button" className="modal-close" onClick={onClose} aria-label="Close">
|
| ×
|
| </button>
|
| <div className="auth-brand">
|
| <BrandLogo size={40} />
|
| <div>
|
| <p className="auth-brand-name">ZuZu Writer</p>
|
| <h2>{title || (mode === "signin" ? "Sign in" : "Create free account")}</h2>
|
| </div>
|
| </div>
|
| <p className="auth-lead">
|
| Unlock longer rewrites and daily limits. Google or email — takes a minute.
|
| </p>
|
|
|
| <div className="segment auth-tabs" role="group" aria-label="Auth mode">
|
| <button
|
| type="button"
|
| className={mode === "signin" ? "active" : ""}
|
| onClick={() => setMode("signin")}
|
| >
|
| Sign in
|
| </button>
|
| <button
|
| type="button"
|
| className={mode === "signup" ? "active" : ""}
|
| onClick={() => setMode("signup")}
|
| >
|
| Sign up
|
| </button>
|
| </div>
|
|
|
| <form className="auth-form" onSubmit={(e) => void onSubmit(e)}>
|
| <label>
|
| Email
|
| <input
|
| type="email"
|
| autoComplete="email"
|
| value={email}
|
| onChange={(e) => setEmail(e.target.value)}
|
| required
|
| />
|
| </label>
|
| <label>
|
| Password
|
| <input
|
| type="password"
|
| autoComplete={mode === "signin" ? "current-password" : "new-password"}
|
| value={password}
|
| onChange={(e) => setPassword(e.target.value)}
|
| minLength={6}
|
| required
|
| />
|
| </label>
|
| <button type="submit" className="btn btn-primary auth-submit" disabled={busy}>
|
| {busy ? "Please wait…" : mode === "signin" ? "Sign in" : "Create free account"}
|
| </button>
|
| </form>
|
|
|
| <div className="auth-divider">or</div>
|
|
|
| <button
|
| type="button"
|
| className="btn btn-quiet auth-google"
|
| disabled={busy}
|
| onClick={() => void signInWithGoogle().catch((err) => setError(err.message))}
|
| >
|
| <GoogleGMark />
|
| <span>Continue with Google</span>
|
| </button>
|
|
|
| {error ? <p className="auth-error">{error}</p> : null}
|
| {message ? <p className="auth-ok">{message}</p> : null}
|
| </div>
|
| </div>,
|
| document.body,
|
| );
|
| }
|
|
|
| function UpgradeCard({
|
| account,
|
| plans,
|
| guestMaxWords,
|
| onSignUp,
|
| onSignIn,
|
| }: {
|
| account: AccountInfo | null;
|
| plans: PlanCard[];
|
| guestMaxWords: number;
|
| onSignUp: () => void;
|
| onSignIn: () => void;
|
| }) {
|
| const planId = account?.plan.id ?? "guest";
|
| if (planId === "pro" || planId === "plus") return null;
|
|
|
| const free = plans.find((p) => p.id === "free");
|
| const pro = plans.find((p) => p.id === "pro");
|
| const isGuest = planId === "guest" || account?.role === "guest" || !account?.email;
|
| const previewWords = isGuest ? guestMaxWords : account?.plan.max_words_per_request ?? 100;
|
| const previewRewrites = account?.plan.daily_rewrites ?? 1;
|
|
|
| return (
|
| <section className="upgrade-card" aria-label="Upgrade plans">
|
| <div className="upgrade-copy">
|
| <h3>
|
| {isGuest
|
| ? "Liked the rewrite? Unlock more with a free account"
|
| : "Need longer drafts every day? Go Pro"}
|
| </h3>
|
| <p>
|
| {isGuest
|
| ? `Preview is capped at ${previewWords} words and ${previewRewrites} rewrite/day. Sign up free for higher limits — or Pro for serious daily use.`
|
| : `You're on ${account?.plan.name ?? "Free"}. Pro gives up to ${pro?.max_words_per_request?.toLocaleString() ?? "2,000"} words per rewrite and ${pro?.daily_rewrites ?? 50} rewrites/day.`}
|
| </p>
|
| </div>
|
| <div className="upgrade-plans">
|
| {isGuest && free ? (
|
| <div className="plan-pill">
|
| <strong>Free</strong>
|
| <span>
|
| {free.daily_rewrites}/day · {free.max_words_per_request} words
|
| </span>
|
| <span className="plan-price">₹0</span>
|
| </div>
|
| ) : null}
|
| {pro ? (
|
| <div className="plan-pill plan-pill-pro">
|
| <strong>Pro</strong>
|
| <span>
|
| {pro.daily_rewrites}/day · {pro.max_words_per_request.toLocaleString()} words
|
| </span>
|
| <span className="plan-price">₹{pro.price_inr_monthly}/mo</span>
|
| </div>
|
| ) : null}
|
| </div>
|
| <div className="upgrade-actions">
|
| {isGuest ? (
|
| <>
|
| <button type="button" className="btn btn-primary" onClick={onSignUp}>
|
| Sign up free
|
| </button>
|
| <button type="button" className="btn btn-quiet" onClick={onSignIn}>
|
| Sign in
|
| </button>
|
| </>
|
| ) : (
|
| <button type="button" className="btn btn-primary" disabled title="Stripe/Razorpay next">
|
| Pro ₹{pro?.price_inr_monthly ?? 199}/mo — payments soon
|
| </button>
|
| )}
|
| </div>
|
| {!isGuest ? (
|
| <p className="upgrade-note">
|
| Until checkout is live, an admin can set <code>plan_id = pro</code> on your profile in
|
| Supabase.
|
| </p>
|
| ) : null}
|
| </section>
|
| );
|
| }
|
|
|
| function LandingSections({
|
| plans,
|
| onSignUp,
|
| authEnabled,
|
| onSelectProduct,
|
| }: {
|
| plans: PlanCard[];
|
| onSignUp: () => void;
|
| authEnabled: boolean;
|
| onSelectProduct: (id: ProductId) => void;
|
| }) {
|
| const free = plans.find((p) => p.id === "free");
|
| const pro = plans.find((p) => p.id === "pro");
|
| const plus = plans.find((p) => p.id === "plus");
|
|
|
| return (
|
| <div className="landing">
|
| <section className="land-block" id="products">
|
| <h2>Our products</h2>
|
| <p className="land-lead">Two focused tools under ZuZu — pick the job you need today.</p>
|
| <div className="product-grid">
|
| {PRODUCTS.map((p) => (
|
| <article key={p.id} className={`product-card product-card-${p.id}`}>
|
| <div className="product-card-top">
|
| <h3>{p.name}</h3>
|
| {p.status === "preview" ? <span className="product-pill">Preview</span> : null}
|
| </div>
|
| <p className="product-tagline">{p.tagline}</p>
|
| <p>{p.blurb}</p>
|
| <ul>
|
| {p.id === "writer" ? (
|
| <>
|
| <li>Tone: Neutral, Casual, Formal, Academic</li>
|
| <li>Offline classical NLP rewrite engine</li>
|
| <li>Free preview, then Free / Pro / Plus plans</li>
|
| </>
|
| ) : (
|
| <>
|
| <li>Full sentence grammar via LanguageTool</li>
|
| <li>Spelling, punctuation & style suggestions</li>
|
| <li>Click to apply fixes · works with your ZuZu account</li>
|
| </>
|
| )}
|
| </ul>
|
| <button
|
| type="button"
|
| className="btn btn-primary"
|
| onClick={() => {
|
| onSelectProduct(p.id);
|
| window.scrollTo({ top: 0, behavior: "smooth" });
|
| }}
|
| >
|
| Open {p.short}
|
| </button>
|
| </article>
|
| ))}
|
| </div>
|
| </section>
|
|
|
| <section className="land-block">
|
| <h2>How ZuZu works</h2>
|
| <p className="land-lead">Use Writer to humanize AI drafts, then Grammar to polish before you publish.</p>
|
| <ol className="steps">
|
| <li>
|
| <span className="step-num">1</span>
|
| <div>
|
| <strong>Choose a product</strong>
|
| <p>Switch between Writer and Grammar from the top of the page.</p>
|
| </div>
|
| </li>
|
| <li>
|
| <span className="step-num">2</span>
|
| <div>
|
| <strong>Paste your draft</strong>
|
| <p>Drop in AI or human text and run Rewrite or Check grammar.</p>
|
| </div>
|
| </li>
|
| <li>
|
| <span className="step-num">3</span>
|
| <div>
|
| <strong>Review, then unlock more</strong>
|
| <p>Try a short preview, sign up for Free, go Pro or Plus when you write every day.</p>
|
| </div>
|
| </li>
|
| </ol>
|
| </section>
|
|
|
| <section className="land-block">
|
| <h2>Who it’s for</h2>
|
| <p className="land-lead">Built for people who draft with AI and publish as themselves.</p>
|
| <div className="audience-grid">
|
| <article>
|
| <h3>Students & academic writers</h3>
|
| <p>Refine AI-assisted notes into clearer Academic or Formal wording. Follow your institution’s rules.</p>
|
| </article>
|
| <article>
|
| <h3>Freelancers & professionals</h3>
|
| <p>Turn stiff AI emails and reports into confident, natural communication.</p>
|
| </article>
|
| <article>
|
| <h3>Bloggers & SEO writers</h3>
|
| <p>Refresh repetitive AI drafts into readable posts that still keep your meaning.</p>
|
| </article>
|
| <article>
|
| <h3>Social & content teams</h3>
|
| <p>Humanize captions and scripts so they sound like your brand, not a model.</p>
|
| </article>
|
| </div>
|
| </section>
|
|
|
| <section className="land-block" id="plans">
|
| <h2>Simple plans</h2>
|
| <p className="land-lead">
|
| One account for Writer today — Grammar preview is included. Checkout for Pro/Plus coming soon.
|
| </p>
|
| <div className="pricing-grid">
|
| <article className="price-card">
|
| <h3>Free</h3>
|
| <p className="price-amount">₹0</p>
|
| <ul>
|
| <li>{free?.max_words_per_request ?? 400} words / rewrite</li>
|
| <li>{free?.daily_rewrites ?? 5} rewrites / day</li>
|
| <li>Grammar preview included</li>
|
| </ul>
|
| {authEnabled ? (
|
| <button type="button" className="btn btn-quiet" onClick={onSignUp}>
|
| Create free account
|
| </button>
|
| ) : null}
|
| </article>
|
| <article className="price-card price-card-pro">
|
| <h3>Pro</h3>
|
| <p className="price-amount">
|
| ₹{pro?.price_inr_monthly ?? 199}
|
| <span>/mo</span>
|
| </p>
|
| <ul>
|
| <li>{(pro?.max_words_per_request ?? 2000).toLocaleString()} words / rewrite</li>
|
| <li>{pro?.daily_rewrites ?? 50} rewrites / day</li>
|
| <li>Best for daily AI drafts</li>
|
| </ul>
|
| <p className="price-soon">Checkout coming soon</p>
|
| </article>
|
| <article className="price-card price-card-plus">
|
| <h3>Plus</h3>
|
| <p className="price-amount">
|
| ₹{plus?.price_inr_monthly ?? 499}
|
| <span>/mo</span>
|
| </p>
|
| <ul>
|
| <li>{(plus?.max_words_per_request ?? 5000).toLocaleString()} words / rewrite</li>
|
| <li>{plus?.daily_rewrites ?? 200} rewrites / day</li>
|
| <li>Heavy use & longer documents</li>
|
| </ul>
|
| <p className="price-soon">Checkout coming soon</p>
|
| </article>
|
| </div>
|
| <p className="plans-note">
|
| Visitors can try a short free Writer preview on the homepage before signing up.
|
| </p>
|
| </section>
|
|
|
| <footer className="site-footer">
|
| <p>Review every rewrite and grammar suggestion before you share or publish.</p>
|
| <p className="footer-brand">
|
| <BrandLogo size={28} />
|
| <span>ZuZu</span>
|
| </p>
|
| </footer>
|
| </div>
|
| );
|
| }
|
|
|
| export default function App() {
|
| const {
|
| ready,
|
| authEnabled,
|
| session,
|
| account,
|
| setAccount,
|
| signOut,
|
| plans,
|
| guestMaxWords,
|
| refreshAccount,
|
| idleSignedOut,
|
| clearIdleNotice,
|
| sessionIdleMinutes,
|
| } = useAuth();
|
| const [input, setInput] = useState("");
|
| const [output, setOutput] = useState("");
|
| const [tone, setTone] = useState<Tone>("Neutral");
|
| const [strength, setStrength] = useState<StrengthLabel>("Normal");
|
| const [preserveLength, setPreserveLength] = useState(true);
|
| const [mlPolish, setMlPolish] = useState(false);
|
| const [loading, setLoading] = useState(false);
|
| const [error, setError] = useState("");
|
| const [meta, setMeta] = useState("");
|
| const [copied, setCopied] = useState(false);
|
| const [freshOut, setFreshOut] = useState(false);
|
| const [showUpgrade, setShowUpgrade] = useState(false);
|
| const [authOpen, setAuthOpen] = useState(false);
|
| const [authMode, setAuthMode] = useState<"signin" | "signup">("signup");
|
| const [authTitle, setAuthTitle] = useState<string | undefined>();
|
| const [product, setProduct] = useState<ProductId>("writer");
|
| const [grammarText, setGrammarText] = useState("");
|
| const [grammarLanguage, setGrammarLanguage] = useState("en-US");
|
| const [grammarIssues, setGrammarIssues] = useState<GrammarIssue[]>([]);
|
| const [grammarNote, setGrammarNote] = useState("");
|
| const [grammarEngine, setGrammarEngine] = useState("");
|
| const [grammarLoading, setGrammarLoading] = useState(false);
|
| const [grammarError, setGrammarError] = useState("");
|
| const [grammarMeta, setGrammarMeta] = useState("");
|
|
|
| const activeProduct = PRODUCTS.find((p) => p.id === product) ?? PRODUCTS[0];
|
| const isGuest = Boolean(authEnabled && !session);
|
|
|
| const maxWords = isGuest
|
| ? guestMaxWords
|
| : account?.plan.max_words_per_request ?? 50000;
|
|
|
| useEffect(() => {
|
| if (!copied) return;
|
| const t = window.setTimeout(() => setCopied(false), 1600);
|
| return () => window.clearTimeout(t);
|
| }, [copied]);
|
|
|
| useEffect(() => {
|
| if (session) {
|
| setAuthOpen(false);
|
| void refreshAccount();
|
| }
|
| }, [session, refreshAccount]);
|
|
|
| useEffect(() => {
|
| if (isGuest) setShowUpgrade(true);
|
| }, [isGuest]);
|
|
|
| useEffect(() => {
|
| const onKey = (e: KeyboardEvent) => {
|
| if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
| e.preventDefault();
|
| if (product === "writer") void onRewrite();
|
| else void onGrammarCheck();
|
| }
|
| };
|
| window.addEventListener("keydown", onKey);
|
| return () => window.removeEventListener("keydown", onKey);
|
|
|
| }, [input, tone, strength, preserveLength, mlPolish, loading, session, product, grammarText, grammarLoading, grammarLanguage]);
|
|
|
| function openAuth(mode: "signin" | "signup", title?: string) {
|
| setAuthMode(mode);
|
| setAuthTitle(title);
|
| setAuthOpen(true);
|
| }
|
|
|
| function selectProduct(id: ProductId) {
|
| setProduct(id);
|
| setError("");
|
| setGrammarError("");
|
| }
|
|
|
| function loadSample() {
|
| setInput(SAMPLE_TEXT);
|
| setError("");
|
| setMeta("Sample loaded — hit Rewrite to try it.");
|
| }
|
|
|
| function loadGrammarSample() {
|
| setGrammarText(GRAMMAR_SAMPLE);
|
| setGrammarIssues([]);
|
| setGrammarError("");
|
| setGrammarMeta("Sample loaded — hit Check grammar.");
|
| setGrammarNote("");
|
| }
|
|
|
| async function onGrammarCheck() {
|
| const text = grammarText.trim();
|
| if (!text) {
|
| setGrammarError("Paste some text first — or try the sample.");
|
| return;
|
| }
|
| if (text.length > GRAMMAR_MAX_CHARS) {
|
| setGrammarError(
|
| `Text is too long for grammar check (${text.length.toLocaleString()} chars). Max is ${GRAMMAR_MAX_CHARS.toLocaleString()} — shorten or split into sections.`,
|
| );
|
| return;
|
| }
|
| setGrammarLoading(true);
|
| setGrammarError("");
|
| setGrammarMeta("Checking…");
|
| setGrammarEngine("");
|
| try {
|
| const result = await checkGrammar(text, {
|
| language: grammarLanguage,
|
| accessToken: session?.access_token,
|
| });
|
| setGrammarIssues(result.issues);
|
| setGrammarNote(result.note ?? "");
|
| setGrammarEngine(result.engine || "");
|
| setGrammarMeta(
|
| result.issues.length
|
| ? `${result.issues.length} issue${result.issues.length === 1 ? "" : "s"} · ${result.input_words} words · ${result.language ?? grammarLanguage}`
|
| : `No issues found · ${result.input_words} words · ${result.language ?? grammarLanguage}`,
|
| );
|
| } catch (err) {
|
| const apiErr = err as ApiError;
|
| setGrammarError(apiErr.message || "Grammar check failed.");
|
| setGrammarMeta("");
|
| setGrammarEngine("");
|
| setGrammarIssues([]);
|
| } finally {
|
| setGrammarLoading(false);
|
| }
|
| }
|
|
|
| function onApplyGrammarIssue(issue: GrammarIssue) {
|
| setGrammarText((prev) => applyGrammarFix(prev, issue));
|
| const remaining = remainIssuesAfterFix(grammarIssues, issue);
|
| setGrammarIssues(remaining);
|
| setGrammarMeta(
|
| remaining.length
|
| ? `Fix applied · ${remaining.length} issue${remaining.length === 1 ? "" : "s"} left`
|
| : "Fix applied · no issues left — run Check grammar again to confirm.",
|
| );
|
| }
|
|
|
| function onApplyAllGrammar() {
|
| if (!grammarIssues.length) return;
|
| setGrammarText((prev) => applyAllGrammarFixes(prev, grammarIssues));
|
| setGrammarIssues([]);
|
| setGrammarMeta("All suggested fixes applied — run Check grammar again to confirm.");
|
| }
|
|
|
| async function onRewrite() {
|
| const text = input.trim();
|
| if (!text) {
|
| setError("Paste some text first — or try the sample.");
|
| return;
|
| }
|
| if (text.length > MAX_CHARS) {
|
| setError(`Text is too long (${text.length.toLocaleString()} chars).`);
|
| return;
|
| }
|
| const words = wordCount(text);
|
| if (authEnabled && words > maxWords) {
|
| setError(
|
| `This text has ${words} words. ${isGuest ? "Preview" : account?.plan.name ?? "Your plan"} allows ${maxWords} words per rewrite.`,
|
| );
|
| setShowUpgrade(true);
|
| if (isGuest) openAuth("signup", "Sign up to rewrite longer text");
|
| return;
|
| }
|
|
|
| setLoading(true);
|
| setError("");
|
| setMeta("Rewriting…");
|
| setFreshOut(false);
|
| try {
|
| const result = await rewriteText(
|
| {
|
| text,
|
| tone,
|
| strength: STRENGTH_MAP[strength],
|
| preserve_length: preserveLength,
|
| ml_polish: mlPolish,
|
| },
|
| session?.access_token,
|
| );
|
| setOutput(result.rewrite);
|
| setFreshOut(true);
|
| if (result.account) setAccount(result.account);
|
| const left = result.account?.usage?.remaining_rewrites;
|
| const quota =
|
| left != null ? ` · ${left} rewrite${left === 1 ? "" : "s"} left today` : "";
|
| const lexCount = result.meta.lexical_refined ?? 0;
|
| const polishNote = mlPolish
|
| ? ` · polish on · ${lexCount} synonym-touched sentence${lexCount === 1 ? "" : "s"}`
|
| : ` · polish off · ${lexCount} synonym-touched sentence${lexCount === 1 ? "" : "s"}`;
|
| setMeta(
|
| `${result.meta.input_words.toLocaleString()} → ${result.meta.output_words.toLocaleString()} words · ${result.meta.seconds}s${polishNote}${quota}`,
|
| );
|
| if (
|
| authEnabled &&
|
| result.account &&
|
| (result.account.plan.id === "guest" || result.account.plan.id === "free")
|
| ) {
|
| setShowUpgrade(true);
|
| }
|
| } catch (err) {
|
| const apiErr = err as ApiError;
|
| setError(apiErr.message || "Rewrite failed.");
|
| setMeta("");
|
| if (apiErr.code === "limit") {
|
| setShowUpgrade(true);
|
| if (isGuest) openAuth("signup", "Free preview used — sign up for more");
|
| }
|
| } finally {
|
| setLoading(false);
|
| }
|
| }
|
|
|
| async function onCopy() {
|
| if (!output.trim()) return;
|
| try {
|
| await navigator.clipboard.writeText(output);
|
| setCopied(true);
|
| } catch {
|
| setError("Could not copy to clipboard.");
|
| }
|
| }
|
|
|
| function onDownload() {
|
| if (!output.trim()) return;
|
| const blob = new Blob([output], { type: "text/plain;charset=utf-8" });
|
| const url = URL.createObjectURL(blob);
|
| const a = document.createElement("a");
|
| a.href = url;
|
| a.download = "zuzu-writer.txt";
|
| a.click();
|
| URL.revokeObjectURL(url);
|
| }
|
|
|
| const inWords = wordCount(input);
|
| const outWords = wordCount(output);
|
| const overCap = authEnabled && inWords > maxWords;
|
| const meterPct = authEnabled ? Math.min(100, Math.round((inWords / Math.max(maxWords, 1)) * 100)) : 0;
|
|
|
| if (!ready) {
|
| return (
|
| <div className="app shell">
|
| <p className="hint">Loading…</p>
|
| </div>
|
| );
|
| }
|
|
|
| return (
|
| <div className="app shell">
|
| <div className="topbar">
|
| <header className="brand">
|
| <div className="brand-lockup">
|
| <BrandLogo size={56} className="brand-logo-hero" />
|
| <div className="brand-text">
|
| <p className="brand-mark">ZuZu</p>
|
| <h1>{activeProduct.name}</h1>
|
| </div>
|
| </div>
|
| <p className="brand-tag">{activeProduct.tagline}</p>
|
| <SiteNav product={product} onSelectProduct={selectProduct} />
|
| </header>
|
| <div className="top-meta">
|
| {session && account ? (
|
| <div className="account-panel">
|
| <div className="account-panel-main">
|
| <span className="account-plan">{account.plan.name}</span>
|
| <span className="account-quota">
|
| {account.usage.remaining_rewrites}/{account.plan.daily_rewrites} left today
|
| </span>
|
| <span className="account-email">{account.email}</span>
|
| </div>
|
| <button type="button" className="btn btn-quiet btn-compact" onClick={() => void signOut()}>
|
| Sign out
|
| </button>
|
| </div>
|
| ) : authEnabled ? (
|
| <div className="account-panel">
|
| <div className="account-panel-main">
|
| <span className="account-plan">Preview</span>
|
| <span className="account-quota">
|
| {account
|
| ? `${account.usage.remaining_rewrites} of ${account.plan.daily_rewrites} free today`
|
| : `Up to ${guestMaxWords} words`}
|
| </span>
|
| </div>
|
| <div className="auth-inline">
|
| <button
|
| type="button"
|
| className="btn btn-quiet btn-compact"
|
| onClick={() => openAuth("signin", "Sign in")}
|
| >
|
| Sign in
|
| </button>
|
| <button
|
| type="button"
|
| className="btn btn-primary btn-compact"
|
| onClick={() => openAuth("signup", "Create free account")}
|
| >
|
| Sign up
|
| </button>
|
| </div>
|
| </div>
|
| ) : (
|
| <div className="account-panel account-panel-quiet">
|
| <span className="account-quota">
|
| {product === "writer" ? `${tone} · ${strength}` : "Grammar preview"}
|
| </span>
|
| <span className="kbd-hint">⌘/Ctrl + Enter</span>
|
| </div>
|
| )}
|
| </div>
|
| </div>
|
|
|
| <ProductSwitcher product={product} onChange={selectProduct} />
|
|
|
| {idleSignedOut ? (
|
| <div className="teaser-banner idle-banner" role="status">
|
| <span>Signed out after {sessionIdleMinutes} minutes of inactivity.</span>
|
| <button
|
| type="button"
|
| className="btn btn-quiet btn-compact"
|
| onClick={() => {
|
| clearIdleNotice();
|
| openAuth("signin", "Sign in again");
|
| }}
|
| >
|
| Sign in again
|
| </button>
|
| </div>
|
| ) : null}
|
|
|
| {isGuest && !idleSignedOut && product === "writer" ? (
|
| <div className="teaser-banner teaser-cta">
|
| <div className="teaser-copy">
|
| <span className="teaser-label">Free Preview</span>
|
| <p>
|
| Try up to <strong>{maxWords} words</strong>, {account?.plan.daily_rewrites ?? 1}{" "}
|
| rewrite/day — then unlock Free for longer drafts.
|
| </p>
|
| </div>
|
| <button
|
| type="button"
|
| className="btn btn-primary"
|
| onClick={() => openAuth("signup", "Create free account")}
|
| >
|
| Unlock Free plan
|
| </button>
|
| </div>
|
| ) : null}
|
|
|
| {product === "grammar" ? (
|
| <div className="stage grammar-stage">
|
| <div className="toolbar">
|
| <div className="toolbar-controls">
|
| <div className="tool-group">
|
| <span>Language</span>
|
| <div className="segment grammar-lang" role="group" aria-label="Grammar language">
|
| {GRAMMAR_LANGUAGES.map((lang) => (
|
| <button
|
| key={lang.id}
|
| type="button"
|
| title={lang.label}
|
| className={grammarLanguage === lang.id ? "active" : ""}
|
| onClick={() => {
|
| setGrammarLanguage(lang.id);
|
| setGrammarIssues([]);
|
| setGrammarMeta("");
|
| setGrammarNote("");
|
| }}
|
| disabled={grammarLoading}
|
| >
|
| {lang.id}
|
| </button>
|
| ))}
|
| </div>
|
| </div>
|
| <p className="grammar-lead">
|
| Self-hosted LanguageTool checks full sentence grammar for{" "}
|
| <strong>{GRAMMAR_LANGUAGES.find((l) => l.id === grammarLanguage)?.label ?? grammarLanguage}</strong>
|
| . Long text is checked in small chunks. If LanguageTool is offline, basic local rules are used instead.
|
| </p>
|
| {grammarEngine ? (
|
| <span
|
| className={`engine-badge engine-${grammarEngine}`}
|
| title={grammarNote || undefined}
|
| >
|
| Engine: {grammarEngine === "languagetool" ? "LanguageTool" : grammarEngine === "rules" ? "Local rules" : grammarEngine}
|
| </span>
|
| ) : null}
|
| </div>
|
| <div className="toolbar-actions">
|
| <button
|
| type="button"
|
| className="btn btn-quiet"
|
| onClick={() => {
|
| setGrammarText("");
|
| setGrammarIssues([]);
|
| setGrammarMeta("");
|
| setGrammarError("");
|
| setGrammarNote("");
|
| setGrammarEngine("");
|
| }}
|
| disabled={grammarLoading}
|
| >
|
| Clear
|
| </button>
|
| <button
|
| type="button"
|
| className="btn btn-quiet"
|
| onClick={loadGrammarSample}
|
| disabled={grammarLoading}
|
| >
|
| Try sample
|
| </button>
|
| {grammarIssues.length ? (
|
| <button
|
| type="button"
|
| className="btn btn-quiet"
|
| onClick={onApplyAllGrammar}
|
| disabled={grammarLoading}
|
| >
|
| Apply all
|
| </button>
|
| ) : null}
|
| <button
|
| type="button"
|
| className="btn btn-primary btn-rewrite"
|
| onClick={() => void onGrammarCheck()}
|
| disabled={grammarLoading}
|
| >
|
| {grammarLoading ? "Checking…" : "Check grammar"}
|
| </button>
|
| </div>
|
| </div>
|
|
|
| <section className="editors grammar-editors">
|
| <div className="pane">
|
| <div className="pane-head">
|
| <h2>Your text</h2>
|
| </div>
|
| <textarea
|
| value={grammarText}
|
| onChange={(e) => {
|
| setGrammarText(e.target.value);
|
| setGrammarIssues([]);
|
| }}
|
| placeholder="Paste a draft to check grammar and spelling…"
|
| spellCheck
|
| />
|
| </div>
|
| <div className="pane pane-out grammar-issues-pane">
|
| <div className="pane-head">
|
| <h2>Issues</h2>
|
| <span className="issue-count">
|
| {grammarIssues.length
|
| ? `${grammarIssues.length} found`
|
| : grammarMeta
|
| ? "Clean"
|
| : "—"}
|
| </span>
|
| </div>
|
| {grammarIssues.length ? (
|
| <ul className="issue-list">
|
| {grammarIssues.map((issue) => (
|
| <li key={issue.id} className={`issue-item cat-${issue.category}`}>
|
| <div className="issue-main">
|
| <span className="issue-cat">{issue.category}</span>
|
| <p>{issue.message}</p>
|
| <code className="issue-snippet">
|
| {grammarText.slice(issue.start, issue.end) || "…"}
|
| {issue.suggestion != null ? ` → ${issue.suggestion}` : ""}
|
| </code>
|
| </div>
|
| {issue.suggestion != null ? (
|
| <button
|
| type="button"
|
| className="btn btn-quiet btn-tiny"
|
| onClick={() => onApplyGrammarIssue(issue)}
|
| >
|
| Apply
|
| </button>
|
| ) : null}
|
| </li>
|
| ))}
|
| </ul>
|
| ) : (
|
| <p className="grammar-empty">
|
| {grammarNote || "Run Check grammar to see suggestions here."}
|
| </p>
|
| )}
|
| </div>
|
| </section>
|
|
|
| <div className="statusbar">
|
| <div className={grammarError ? "error" : grammarLoading ? "loading" : undefined}>
|
| {grammarError || grammarMeta || "Paste text → Check grammar"}
|
| </div>
|
| <div className="counts">
|
| <span>{wordCount(grammarText)} words</span>
|
| </div>
|
| </div>
|
| </div>
|
| ) : (
|
| <div className="stage">
|
| <div className="toolbar">
|
| <div className="toolbar-controls">
|
| <div className="tool-group">
|
| <span>Tone</span>
|
| <div className="segment" role="group" aria-label="Tone">
|
| {TONES.map((t) => (
|
| <button
|
| key={t}
|
| type="button"
|
| title={TONE_HINT[t]}
|
| className={tone === t ? "active" : ""}
|
| onClick={() => setTone(t)}
|
| >
|
| {t}
|
| </button>
|
| ))}
|
| </div>
|
| <span className="tool-hint">{TONE_HINT[tone]}</span>
|
| </div>
|
|
|
| <div className="tool-group">
|
| <span>Strength</span>
|
| <div className="segment" role="group" aria-label="Strength">
|
| {STRENGTHS.map((s) => (
|
| <button
|
| key={s}
|
| type="button"
|
| className={strength === s ? "active" : ""}
|
| onClick={() => setStrength(s)}
|
| >
|
| {s}
|
| </button>
|
| ))}
|
| </div>
|
| </div>
|
|
|
| <label className="check">
|
| <input
|
| type="checkbox"
|
| checked={preserveLength}
|
| onChange={(e) => setPreserveLength(e.target.checked)}
|
| />
|
| Match length
|
| </label>
|
|
|
| <label
|
| className="check"
|
| title="Apply stronger synonym refinement on any sentence using denser WordNet-backed word swaps"
|
| >
|
| <input
|
| type="checkbox"
|
| checked={mlPolish}
|
| onChange={(e) => {
|
| setMlPolish(e.target.checked);
|
| setFreshOut(false);
|
| setMeta(
|
| e.target.checked
|
| ? "Extra word polish on — click Rewrite to apply denser synonym changes."
|
| : "Extra word polish off — click Rewrite to compare.",
|
| );
|
| }}
|
| />
|
| Extra word polish
|
| </label>
|
| </div>
|
|
|
| <div className="toolbar-actions">
|
| <button
|
| type="button"
|
| className="btn btn-quiet"
|
| onClick={() => {
|
| setInput("");
|
| setOutput("");
|
| setMeta("");
|
| setError("");
|
| setFreshOut(false);
|
| }}
|
| disabled={loading}
|
| >
|
| Clear
|
| </button>
|
| <button
|
| type="button"
|
| className="btn btn-primary btn-rewrite"
|
| onClick={() => void onRewrite()}
|
| disabled={loading}
|
| >
|
| {loading ? "Rewriting…" : "Rewrite"}
|
| </button>
|
| </div>
|
| </div>
|
|
|
| <section className="editors">
|
| <div className="pane">
|
| <div className="pane-head">
|
| <h2>Original</h2>
|
| <div className="pane-actions">
|
| <button
|
| type="button"
|
| className="btn btn-quiet btn-tiny"
|
| onClick={loadSample}
|
| disabled={loading}
|
| >
|
| Try sample
|
| </button>
|
| </div>
|
| </div>
|
| <textarea
|
| value={input}
|
| onChange={(e) => setInput(e.target.value)}
|
| placeholder={
|
| isGuest
|
| ? `Paste a short AI draft (max ${maxWords} words on Preview)…`
|
| : "Paste your AI draft here…"
|
| }
|
| spellCheck
|
| />
|
| </div>
|
|
|
| <div className={`pane pane-out${freshOut ? " writing" : ""}`}>
|
| <div className="pane-head">
|
| <h2>Rewrite</h2>
|
| <div className="pane-actions">
|
| <button
|
| type="button"
|
| className="btn btn-quiet btn-tiny"
|
| onClick={() => void onCopy()}
|
| disabled={!output.trim()}
|
| >
|
| {copied ? "Copied" : "Copy"}
|
| </button>
|
| <button
|
| type="button"
|
| className="btn btn-quiet btn-tiny"
|
| onClick={onDownload}
|
| disabled={!output.trim()}
|
| >
|
| Download
|
| </button>
|
| </div>
|
| </div>
|
| <textarea
|
| value={output}
|
| onChange={(e) => {
|
| setOutput(e.target.value);
|
| setFreshOut(false);
|
| }}
|
| placeholder="Your rewrite appears here…"
|
| spellCheck
|
| />
|
| </div>
|
| </section>
|
|
|
| {authEnabled ? (
|
| <div className="word-meter" aria-hidden>
|
| <div
|
| className={`word-meter-fill${overCap ? " over" : meterPct > 80 ? " hot" : ""}`}
|
| style={{ width: `${meterPct}%` }}
|
| />
|
| </div>
|
| ) : null}
|
|
|
| <div className="statusbar">
|
| <div className={error ? "error" : loading ? "loading" : undefined}>
|
| {error || meta || "Paste AI text → pick a tone → Rewrite"}
|
| </div>
|
| <div className="counts">
|
| <span className={overCap ? "over-limit" : undefined}>
|
| {inWords}
|
| {authEnabled ? ` / ${maxWords}` : ""} words in
|
| </span>
|
| <span>{outWords} words out</span>
|
| </div>
|
| </div>
|
| </div>
|
| )}
|
|
|
| {showUpgrade && authEnabled && product === "writer" ? (
|
| <UpgradeCard
|
| account={account}
|
| plans={plans}
|
| guestMaxWords={guestMaxWords}
|
| onSignUp={() => openAuth("signup", "Create free account")}
|
| onSignIn={() => openAuth("signin", "Sign in")}
|
| />
|
| ) : null}
|
|
|
| <p className="hint">
|
| {product === "grammar"
|
| ? "Review every suggestion before you publish."
|
| : "Review the rewrite before you share or publish it."}
|
| </p>
|
|
|
| <LandingSections
|
| plans={plans}
|
| authEnabled={authEnabled}
|
| onSignUp={() => openAuth("signup", "Create free account")}
|
| onSelectProduct={selectProduct}
|
| />
|
|
|
| {authEnabled ? (
|
| <AuthModal
|
| open={authOpen}
|
| onClose={() => setAuthOpen(false)}
|
| initialMode={authMode}
|
| title={authTitle}
|
| />
|
| ) : null}
|
| </div>
|
| );
|
| }
|
|
|