any2human / frontend /src /App.tsx
idnameraj's picture
Upload 3150 files
c29fb5e verified
Raw
History Blame Contribute Delete
47.2 kB
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;
}}
/>
);
}
/** Official multicolor Google “G” mark for the OAuth button. */
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);
}
/** Keep remaining issues after one fix, shifting offsets past the edit. */
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) // drop overlaps
.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 &amp; 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 &amp; academic writers</h3>
<p>Refine AI-assisted notes into clearer Academic or Formal wording. Follow your institution’s rules.</p>
</article>
<article>
<h3>Freelancers &amp; professionals</h3>
<p>Turn stiff AI emails and reports into confident, natural communication.</p>
</article>
<article>
<h3>Bloggers &amp; SEO writers</h3>
<p>Refresh repetitive AI drafts into readable posts that still keep your meaning.</p>
</article>
<article>
<h3>Social &amp; 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 &amp; 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);
// Guests always use config teaser limit (ignore inflated account payloads / env mistakes)
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);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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>
);
}