idnameraj commited on
Commit
2d0fe75
·
verified ·
1 Parent(s): eccf140

Upload 79 files

Browse files
README.md CHANGED
@@ -74,8 +74,14 @@ Open http://127.0.0.1:5173 (proxies API to port 8000).
74
  - `GET /v1/auth/config`
75
  - `GET /v1/me` (Bearer token when auth enabled)
76
  - `POST /v1/rewrite` (Bearer token when auth enabled)
 
77
  - `POST /v1/similarity`
78
 
 
 
 
 
 
79
  ## Docker
80
 
81
  ```bash
 
74
  - `GET /v1/auth/config`
75
  - `GET /v1/me` (Bearer token when auth enabled)
76
  - `POST /v1/rewrite` (Bearer token when auth enabled)
77
+ - `POST /v1/grammar`
78
  - `POST /v1/similarity`
79
 
80
+ ## Products
81
+
82
+ - **ZuZu Writer** — tone-aware classical NLP rewrite
83
+ - **ZuZu Grammar** — preview grammar / spelling / punctuation checker
84
+
85
  ## Docker
86
 
87
  ```bash
app/__pycache__/__init__.cpython-311.pyc CHANGED
Binary files a/app/__pycache__/__init__.cpython-311.pyc and b/app/__pycache__/__init__.cpython-311.pyc differ
 
app/__pycache__/config.cpython-311.pyc CHANGED
Binary files a/app/__pycache__/config.cpython-311.pyc and b/app/__pycache__/config.cpython-311.pyc differ
 
app/__pycache__/main.cpython-311.pyc CHANGED
Binary files a/app/__pycache__/main.cpython-311.pyc and b/app/__pycache__/main.cpython-311.pyc differ
 
app/main.py CHANGED
@@ -26,6 +26,7 @@ from app.billing.quota import (
26
  )
27
  from app.bootstrap import ensure_resources
28
  from app.config import APP_TITLE, AUTH_ENABLED, MAX_CHARS
 
29
  from app.pipeline.nlp import spacy_available
30
  from app.pipeline.orchestrator import rewrite_text, similarity_check
31
  from app.pipeline.tones import normalize_tone
@@ -34,7 +35,7 @@ ensure_resources()
34
 
35
  STATIC_DIR = ROOT / "frontend" / "dist"
36
 
37
- app = FastAPI(title=APP_TITLE, version="2.2.0")
38
 
39
  app.add_middleware(
40
  CORSMiddleware,
@@ -57,6 +58,10 @@ class SimilarityRequest(BaseModel):
57
  reference: str
58
 
59
 
 
 
 
 
60
  def _client_ip(request: Request) -> str:
61
  forwarded = request.headers.get("x-forwarded-for") or ""
62
  if forwarded:
@@ -163,6 +168,24 @@ def api_similarity(
163
  }
164
 
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  def _register_frontend() -> None:
167
  if not STATIC_DIR.is_dir():
168
  return
@@ -201,6 +224,13 @@ def _register_frontend() -> None:
201
  return FileResponse(png, media_type="image/png")
202
  raise HTTPException(status_code=404)
203
 
 
 
 
 
 
 
 
204
  @app.get("/apple-touch-icon.png")
205
  def apple_touch_icon():
206
  png = STATIC_DIR / "apple-touch-icon.png"
 
26
  )
27
  from app.bootstrap import ensure_resources
28
  from app.config import APP_TITLE, AUTH_ENABLED, MAX_CHARS
29
+ from app.pipeline.grammar import check_grammar
30
  from app.pipeline.nlp import spacy_available
31
  from app.pipeline.orchestrator import rewrite_text, similarity_check
32
  from app.pipeline.tones import normalize_tone
 
35
 
36
  STATIC_DIR = ROOT / "frontend" / "dist"
37
 
38
+ app = FastAPI(title=APP_TITLE, version="2.3.0")
39
 
40
  app.add_middleware(
41
  CORSMiddleware,
 
58
  reference: str
59
 
60
 
61
+ class GrammarRequest(BaseModel):
62
+ text: str = Field(..., min_length=1)
63
+
64
+
65
  def _client_ip(request: Request) -> str:
66
  forwarded = request.headers.get("x-forwarded-for") or ""
67
  if forwarded:
 
168
  }
169
 
170
 
171
+ @app.post("/v1/grammar")
172
+ def api_grammar(
173
+ body: GrammarRequest,
174
+ user: AuthUser | None = Depends(optional_user),
175
+ ):
176
+ """ZuZu Grammar — classical rule checks (preview engine)."""
177
+ _ = user
178
+ text = (body.text or "").strip()
179
+ if not text:
180
+ raise HTTPException(status_code=400, detail="Paste some text to check.")
181
+ if len(text) > MAX_CHARS:
182
+ raise HTTPException(
183
+ status_code=413,
184
+ detail=f"Text is too long ({len(text):,} chars). Max is {MAX_CHARS:,}.",
185
+ )
186
+ return check_grammar(text)
187
+
188
+
189
  def _register_frontend() -> None:
190
  if not STATIC_DIR.is_dir():
191
  return
 
224
  return FileResponse(png, media_type="image/png")
225
  raise HTTPException(status_code=404)
226
 
227
+ @app.get("/zuzu-logo.png")
228
+ def logo_png():
229
+ png = STATIC_DIR / "zuzu-logo.png"
230
+ if png.is_file():
231
+ return FileResponse(png, media_type="image/png")
232
+ raise HTTPException(status_code=404)
233
+
234
  @app.get("/apple-touch-icon.png")
235
  def apple_touch_icon():
236
  png = STATIC_DIR / "apple-touch-icon.png"
app/pipeline/__pycache__/grammar.cpython-311.pyc ADDED
Binary file (8.5 kB). View file
 
app/pipeline/grammar.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lightweight classical grammar / mechanics checker (no LLM)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import asdict, dataclass
7
+
8
+
9
+ @dataclass
10
+ class GrammarIssue:
11
+ id: str
12
+ start: int
13
+ end: int
14
+ message: str
15
+ suggestion: str | None
16
+ category: str # spelling | grammar | punctuation | style
17
+
18
+
19
+ def _word_count(text: str) -> int:
20
+ return len(text.split()) if text.strip() else 0
21
+
22
+
23
+ def check_grammar(text: str) -> dict:
24
+ """Return issues with offsets into the original text."""
25
+ issues: list[GrammarIssue] = []
26
+ if not text or not text.strip():
27
+ return {"issues": [], "input_words": 0, "engine": "rules"}
28
+
29
+ # Double (or more) spaces
30
+ for m in re.finditer(r" {2,}", text):
31
+ issues.append(
32
+ GrammarIssue(
33
+ id=f"spaces-{m.start()}",
34
+ start=m.start(),
35
+ end=m.end(),
36
+ message="Extra spaces.",
37
+ suggestion=" ",
38
+ category="punctuation",
39
+ )
40
+ )
41
+
42
+ # Space before punctuation
43
+ for m in re.finditer(r"\s+([,.!?;:])", text):
44
+ issues.append(
45
+ GrammarIssue(
46
+ id=f"space-punct-{m.start()}",
47
+ start=m.start(),
48
+ end=m.end(),
49
+ message="Remove the space before punctuation.",
50
+ suggestion=m.group(1),
51
+ category="punctuation",
52
+ )
53
+ )
54
+
55
+ # Missing space after punctuation (except decimals / ellipsis-ish)
56
+ for m in re.finditer(r"([,.!?;:])([A-Za-z])", text):
57
+ issues.append(
58
+ GrammarIssue(
59
+ id=f"punct-space-{m.start()}",
60
+ start=m.start(),
61
+ end=m.end(),
62
+ message="Add a space after punctuation.",
63
+ suggestion=f"{m.group(1)} {m.group(2)}",
64
+ category="punctuation",
65
+ )
66
+ )
67
+
68
+ # Repeated consecutive words (case-insensitive)
69
+ for m in re.finditer(r"\b([A-Za-z']+)\s+\1\b", text, flags=re.IGNORECASE):
70
+ issues.append(
71
+ GrammarIssue(
72
+ id=f"repeat-{m.start()}",
73
+ start=m.start(),
74
+ end=m.end(),
75
+ message="Repeated word.",
76
+ suggestion=m.group(1),
77
+ category="grammar",
78
+ )
79
+ )
80
+
81
+ # Standalone lowercase "i"
82
+ for m in re.finditer(r"(?<![A-Za-z])i(?![A-Za-z])", text):
83
+ issues.append(
84
+ GrammarIssue(
85
+ id=f"cap-i-{m.start()}",
86
+ start=m.start(),
87
+ end=m.end(),
88
+ message='Capitalize the pronoun "I".',
89
+ suggestion="I",
90
+ category="grammar",
91
+ )
92
+ )
93
+
94
+ # Sentence start should be capitalized (after . ! ? or start of text)
95
+ for m in re.finditer(r"(?:^|[.!?]\s+)([a-z])", text):
96
+ issues.append(
97
+ GrammarIssue(
98
+ id=f"sent-cap-{m.start(1)}",
99
+ start=m.start(1),
100
+ end=m.end(1),
101
+ message="Sentences usually start with a capital letter.",
102
+ suggestion=m.group(1).upper(),
103
+ category="grammar",
104
+ )
105
+ )
106
+
107
+ # Common confused pairs (simple lexicon)
108
+ swaps = {
109
+ r"\bteh\b": ("the", "Possible typo."),
110
+ r"\badn\b": ("and", "Possible typo."),
111
+ r"\byoru\b": ("your", "Possible typo."),
112
+ r"\brecieve\b": ("receive", "Spelling."),
113
+ r"\bseperate\b": ("separate", "Spelling."),
114
+ r"\boccurence\b": ("occurrence", "Spelling."),
115
+ r"\bdefinately\b": ("definitely", "Spelling."),
116
+ r"\bactully\b": ("actually", "Spelling."),
117
+ r"\bwether\b": ("whether", "Spelling."),
118
+ r"\bthier\b": ("their", "Spelling."),
119
+ }
120
+ for pattern, (fix, msg) in swaps.items():
121
+ for m in re.finditer(pattern, text, flags=re.IGNORECASE):
122
+ # Preserve simple case for first letter
123
+ repl = fix if m.group(0)[0].islower() else fix[:1].upper() + fix[1:]
124
+ issues.append(
125
+ GrammarIssue(
126
+ id=f"spell-{m.start()}-{fix}",
127
+ start=m.start(),
128
+ end=m.end(),
129
+ message=msg,
130
+ suggestion=repl,
131
+ category="spelling",
132
+ )
133
+ )
134
+
135
+ # a/an before vowel sound (very rough: vowel letter)
136
+ for m in re.finditer(r"\b([Aa])\s+([aeiouAEIOU]\w*)\b", text):
137
+ article = "an" if m.group(1).islower() else "An"
138
+ issues.append(
139
+ GrammarIssue(
140
+ id=f"a-an-{m.start()}",
141
+ start=m.start(),
142
+ end=m.start() + len(m.group(1)),
143
+ message='Use "an" before a vowel sound.',
144
+ suggestion=article,
145
+ category="grammar",
146
+ )
147
+ )
148
+ for m in re.finditer(r"\b([Aa]n)\s+([bcdfghjklmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ]\w*)\b", text):
149
+ # skip silent-h / vowel-sound exceptions later; keep conservative for consonants
150
+ word = m.group(2).lower()
151
+ if word.startswith(("hour", "honest", "honor", "honour", "heir")):
152
+ continue
153
+ article = "a" if m.group(1)[0].islower() else "A"
154
+ issues.append(
155
+ GrammarIssue(
156
+ id=f"an-a-{m.start()}",
157
+ start=m.start(),
158
+ end=m.start() + len(m.group(1)),
159
+ message='Use "a" before a consonant sound.',
160
+ suggestion=article,
161
+ category="grammar",
162
+ )
163
+ )
164
+
165
+ # Prefer more specific / longer matches when ranges overlap
166
+ priority = {"spelling": 0, "grammar": 1, "punctuation": 2, "style": 3}
167
+ issues.sort(key=lambda i: (i.start, -(i.end - i.start), priority.get(i.category, 9)))
168
+ filtered: list[GrammarIssue] = []
169
+ last_end = -1
170
+ for issue in issues:
171
+ if issue.start < last_end:
172
+ continue
173
+ filtered.append(issue)
174
+ last_end = issue.end
175
+
176
+ return {
177
+ "issues": [asdict(i) for i in filtered],
178
+ "input_words": _word_count(text),
179
+ "engine": "rules",
180
+ "note": "Preview grammar rules. Fuller LanguageTool-style checks coming next.",
181
+ }
frontend/dist/assets/index.css CHANGED
@@ -168,7 +168,234 @@ textarea {
168
  align-items: flex-end;
169
  justify-content: space-between;
170
  gap: 1.5rem;
171
- margin-bottom: 1.35rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  }
173
 
174
  .brand {
 
168
  align-items: flex-end;
169
  justify-content: space-between;
170
  gap: 1.5rem;
171
+ margin-bottom: 0.85rem;
172
+ }
173
+
174
+ .site-nav {
175
+ display: flex;
176
+ flex-wrap: wrap;
177
+ gap: 0.65rem 1rem;
178
+ margin-top: 0.85rem;
179
+ align-items: center;
180
+ }
181
+
182
+ .site-nav a,
183
+ .site-nav .nav-product {
184
+ font-family: var(--font-body);
185
+ font-size: 0.82rem;
186
+ font-weight: 600;
187
+ color: var(--ink-soft);
188
+ text-decoration: none;
189
+ background: none;
190
+ border: none;
191
+ padding: 0;
192
+ cursor: pointer;
193
+ }
194
+
195
+ .site-nav a:hover,
196
+ .site-nav .nav-product:hover {
197
+ color: var(--accent);
198
+ }
199
+
200
+ .product-switcher {
201
+ display: inline-flex;
202
+ flex-wrap: wrap;
203
+ gap: 0.35rem;
204
+ margin-bottom: 1rem;
205
+ padding: 0.3rem;
206
+ border-radius: 14px;
207
+ border: 1px solid var(--panel-edge);
208
+ background: rgba(255, 255, 255, 0.72);
209
+ box-shadow: 0 6px 18px rgba(15, 36, 28, 0.04);
210
+ }
211
+
212
+ .product-switcher button {
213
+ display: inline-flex;
214
+ align-items: center;
215
+ gap: 0.4rem;
216
+ border: none;
217
+ background: transparent;
218
+ color: var(--ink-soft);
219
+ font-family: var(--font-body);
220
+ font-size: 0.9rem;
221
+ font-weight: 600;
222
+ padding: 0.55rem 0.95rem;
223
+ border-radius: 10px;
224
+ cursor: pointer;
225
+ }
226
+
227
+ .product-switcher button.active {
228
+ background: linear-gradient(165deg, #0f7a5f, #0b614c);
229
+ color: #fff8f0;
230
+ box-shadow: 0 6px 16px rgba(15, 122, 95, 0.22);
231
+ }
232
+
233
+ .product-switcher button.active .product-pill {
234
+ background: rgba(255, 248, 240, 0.2);
235
+ color: #fff8f0;
236
+ }
237
+
238
+ .product-pill {
239
+ display: inline-flex;
240
+ align-items: center;
241
+ font-size: 0.65rem;
242
+ font-weight: 700;
243
+ letter-spacing: 0.06em;
244
+ text-transform: uppercase;
245
+ padding: 0.15rem 0.4rem;
246
+ border-radius: 999px;
247
+ background: rgba(226, 90, 60, 0.12);
248
+ color: var(--coral);
249
+ }
250
+
251
+ .product-grid {
252
+ display: grid;
253
+ grid-template-columns: repeat(2, minmax(0, 1fr));
254
+ gap: 1rem;
255
+ }
256
+
257
+ .product-card {
258
+ display: flex;
259
+ flex-direction: column;
260
+ gap: 0.65rem;
261
+ padding: 1.25rem 1.2rem;
262
+ border-radius: 18px;
263
+ border: 1px solid var(--panel-edge);
264
+ background: linear-gradient(165deg, rgba(255, 255, 255, 0.96), rgba(232, 245, 239, 0.75));
265
+ }
266
+
267
+ .product-card-grammar {
268
+ background: linear-gradient(165deg, rgba(255, 255, 255, 0.96), rgba(255, 236, 228, 0.8));
269
+ border-color: rgba(226, 90, 60, 0.22);
270
+ }
271
+
272
+ .product-card-top {
273
+ display: flex;
274
+ align-items: center;
275
+ justify-content: space-between;
276
+ gap: 0.5rem;
277
+ }
278
+
279
+ .product-card h3 {
280
+ margin: 0;
281
+ font-size: 1.2rem;
282
+ }
283
+
284
+ .product-tagline {
285
+ margin: 0;
286
+ font-family: var(--font-display);
287
+ font-size: 1.05rem;
288
+ letter-spacing: -0.02em;
289
+ color: var(--ink);
290
+ }
291
+
292
+ .product-card p {
293
+ margin: 0;
294
+ color: var(--ink-soft);
295
+ font-size: 0.9rem;
296
+ line-height: 1.5;
297
+ }
298
+
299
+ .product-card ul {
300
+ margin: 0;
301
+ padding-left: 1.1rem;
302
+ color: var(--ink-soft);
303
+ font-size: 0.86rem;
304
+ line-height: 1.55;
305
+ flex: 1;
306
+ }
307
+
308
+ .product-card .btn {
309
+ align-self: flex-start;
310
+ margin-top: 0.35rem;
311
+ }
312
+
313
+ .grammar-lead {
314
+ margin: 0;
315
+ color: var(--muted);
316
+ font-size: 0.88rem;
317
+ max-width: 36rem;
318
+ line-height: 1.45;
319
+ }
320
+
321
+ .grammar-issues-pane {
322
+ min-height: 18rem;
323
+ }
324
+
325
+ .issue-count {
326
+ font-size: 0.78rem;
327
+ color: var(--muted);
328
+ font-weight: 600;
329
+ }
330
+
331
+ .issue-list {
332
+ list-style: none;
333
+ margin: 0;
334
+ padding: 0.75rem;
335
+ display: flex;
336
+ flex-direction: column;
337
+ gap: 0.65rem;
338
+ overflow: auto;
339
+ max-height: 22rem;
340
+ }
341
+
342
+ .issue-item {
343
+ display: flex;
344
+ align-items: flex-start;
345
+ justify-content: space-between;
346
+ gap: 0.75rem;
347
+ padding: 0.75rem 0.85rem;
348
+ border-radius: 12px;
349
+ border: 1px solid var(--panel-edge);
350
+ background: rgba(255, 255, 255, 0.8);
351
+ }
352
+
353
+ .issue-cat {
354
+ display: inline-block;
355
+ font-size: 0.68rem;
356
+ font-weight: 700;
357
+ letter-spacing: 0.06em;
358
+ text-transform: uppercase;
359
+ color: var(--accent);
360
+ margin-bottom: 0.25rem;
361
+ }
362
+
363
+ .issue-item.cat-spelling .issue-cat {
364
+ color: var(--coral);
365
+ }
366
+
367
+ .issue-item.cat-punctuation .issue-cat {
368
+ color: #2a6f97;
369
+ }
370
+
371
+ .issue-main p {
372
+ margin: 0 0 0.35rem;
373
+ font-size: 0.88rem;
374
+ color: var(--ink);
375
+ }
376
+
377
+ .issue-snippet {
378
+ display: block;
379
+ font-size: 0.78rem;
380
+ color: var(--ink-soft);
381
+ background: rgba(15, 36, 28, 0.04);
382
+ padding: 0.25rem 0.4rem;
383
+ border-radius: 6px;
384
+ white-space: pre-wrap;
385
+ }
386
+
387
+ .grammar-empty {
388
+ margin: 0;
389
+ padding: 1.25rem;
390
+ color: var(--muted);
391
+ font-size: 0.9rem;
392
+ line-height: 1.5;
393
+ }
394
+
395
+ @media (max-width: 820px) {
396
+ .product-grid {
397
+ grid-template-columns: 1fr;
398
+ }
399
  }
400
 
401
  .brand {
frontend/dist/assets/index.js CHANGED
@@ -347,6 +347,77 @@ function AuthModal({ open, onClose, initialMode, title }) {
347
  );
348
  }
349
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  function UpgradeCard({ account, plans, onSignUp, onSignIn }) {
351
  const planId = (account && account.plan && account.plan.id) || "guest";
352
  if (planId === "pro" || planId === "plus") return null;
@@ -398,64 +469,66 @@ function UpgradeCard({ account, plans, onSignUp, onSignIn }) {
398
  );
399
  }
400
 
401
- function LandingSections({ plans, onSignUp, authEnabled }) {
402
  const free = plans.find((p) => p.id === "free");
403
  const pro = plans.find((p) => p.id === "pro");
404
  const plus = plans.find((p) => p.id === "plus");
405
 
406
  return h("div", { className: "landing" },
407
- h("section", { className: "land-block" },
408
- h("h2", null, "How to use ZuZu Writer"),
409
- h("p", { className: "land-lead" }, "Three steps from AI draft to natural wording."),
410
- h("ol", { className: "steps" },
411
- h("li", null,
412
- h("span", { className: "step-num" }, "1"),
413
- h("div", null,
414
- h("strong", null, "Paste your AI text"),
415
- h("p", null, "Drop in a ChatGPT, Gemini, or Claude draft on the left."),
416
- ),
417
- ),
418
- h("li", null,
419
- h("span", { className: "step-num" }, "2"),
420
- h("div", null,
421
- h("strong", null, "Pick tone & strength"),
422
- h("p", null, "Neutral, Casual, Formal, or Academic — match the voice you need."),
423
  ),
424
- ),
425
- h("li", null,
426
- h("span", { className: "step-num" }, "3"),
427
- h("div", null,
428
- h("strong", null, "Rewrite, then unlock more"),
429
- h("p", null, "Try a short preview, sign up for Free, go Pro or Plus when you write every day."),
 
 
 
 
 
 
 
 
430
  ),
431
- ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  ),
433
  ),
434
  h("section", { className: "land-block" },
435
- h("h2", null, "Who its for"),
436
  h("p", { className: "land-lead" }, "Built for people who draft with AI and publish as themselves."),
437
  h("div", { className: "audience-grid" },
438
- h("article", null,
439
- h("h3", null, "Students & academic writers"),
440
- h("p", null, "Refine AI-assisted notes into clearer Academic or Formal wording. Follow your institution’s rules."),
441
- ),
442
- h("article", null,
443
- h("h3", null, "Freelancers & professionals"),
444
- h("p", null, "Turn stiff AI emails and reports into confident, natural communication."),
445
- ),
446
- h("article", null,
447
- h("h3", null, "Bloggers & SEO writers"),
448
- h("p", null, "Refresh repetitive AI drafts into readable posts that still keep your meaning."),
449
- ),
450
- h("article", null,
451
- h("h3", null, "Social & content teams"),
452
- h("p", null, "Humanize captions and scripts so they sound like your brand, not a model."),
453
- ),
454
  ),
455
  ),
456
  h("section", { className: "land-block", id: "plans" },
457
  h("h2", null, "Simple plans"),
458
- h("p", { className: "land-lead" }, "Start on Free. Upgrade to Pro or Plus when you need more words every day."),
459
  h("div", { className: "pricing-grid" },
460
  h("article", { className: "price-card" },
461
  h("h3", null, "Free"),
@@ -463,18 +536,13 @@ function LandingSections({ plans, onSignUp, authEnabled }) {
463
  h("ul", null,
464
  h("li", null, `${(free && free.max_words_per_request) || 400} words / rewrite`),
465
  h("li", null, `${(free && free.daily_rewrites) || 5} rewrites / day`),
466
- h("li", null, "Google or email signup"),
467
  ),
468
- authEnabled
469
- ? h("button", { type: "button", className: "btn btn-quiet", onClick: onSignUp }, "Create free account")
470
- : null,
471
  ),
472
  h("article", { className: "price-card price-card-pro" },
473
  h("h3", null, "Pro"),
474
- h("p", { className: "price-amount" },
475
- `₹${(pro && pro.price_inr_monthly) || 199}`,
476
- h("span", null, "/mo"),
477
- ),
478
  h("ul", null,
479
  h("li", null, `${((pro && pro.max_words_per_request) || 2000).toLocaleString()} words / rewrite`),
480
  h("li", null, `${(pro && pro.daily_rewrites) || 50} rewrites / day`),
@@ -484,10 +552,7 @@ function LandingSections({ plans, onSignUp, authEnabled }) {
484
  ),
485
  h("article", { className: "price-card price-card-plus" },
486
  h("h3", null, "Plus"),
487
- h("p", { className: "price-amount" },
488
- `₹${(plus && plus.price_inr_monthly) || 499}`,
489
- h("span", null, "/mo"),
490
- ),
491
  h("ul", null,
492
  h("li", null, `${((plus && plus.max_words_per_request) || 5000).toLocaleString()} words / rewrite`),
493
  h("li", null, `${(plus && plus.daily_rewrites) || 200} rewrites / day`),
@@ -496,14 +561,11 @@ function LandingSections({ plans, onSignUp, authEnabled }) {
496
  h("p", { className: "price-soon" }, "Checkout coming soon"),
497
  ),
498
  ),
499
- h("p", { className: "plans-note" }, "Visitors can try a short free preview on the homepage before signing up."),
500
  ),
501
  h("footer", { className: "site-footer" },
502
- h("p", null, "Review every rewrite before you share or publish. Follow your school or workplace AI policy."),
503
- h("p", { className: "footer-brand" },
504
- h(BrandLogo, { size: 28 }),
505
- h("span", null, "ZuZu Writer"),
506
- ),
507
  ),
508
  );
509
  }
@@ -527,7 +589,15 @@ function App() {
527
  const [authOpen, setAuthOpen] = useState(false);
528
  const [authMode, setAuthMode] = useState("signup");
529
  const [authTitle, setAuthTitle] = useState(undefined);
530
-
 
 
 
 
 
 
 
 
531
  const isGuest = !!(authEnabled && !session);
532
  const maxWords = (account && account.plan && account.plan.max_words_per_request) || (isGuest ? guestMaxWords : 50000);
533
 
@@ -548,7 +618,8 @@ function App() {
548
  const onKey = (e) => {
549
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
550
  e.preventDefault();
551
- onRewrite();
 
552
  }
553
  };
554
  window.addEventListener("keydown", onKey);
@@ -561,6 +632,49 @@ function App() {
561
  setAuthOpen(true);
562
  }
563
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  async function onRewrite() {
565
  const text = input.trim();
566
  if (!text) { setError("Paste some text first."); return; }
@@ -642,10 +756,11 @@ function App() {
642
  h(BrandLogo, { size: 56, className: "brand-logo-hero" }),
643
  h("div", { className: "brand-text" },
644
  h("p", { className: "brand-mark" }, "ZuZu"),
645
- h("h1", null, "ZuZu Writer"),
646
  ),
647
  ),
648
- h("p", { className: "brand-tag" }, "From AI-generated to plagiarism-safe — rewrite in a voice that feels real."),
 
649
  ),
650
  h("div", { className: "top-meta" },
651
  session && account
@@ -669,7 +784,8 @@ function App() {
669
  : h(Fragment, null, `${tone} · ${strength}`, h("br"), "⌘/Ctrl + Enter"),
670
  ),
671
  ),
672
- isGuest && !idleSignedOut
 
673
  ? h("p", { className: "teaser-banner" },
674
  "Try a short rewrite free — up to ", h("strong", null, `${maxWords} words`),
675
  `, ${(account && account.plan && account.plan.daily_rewrites) || 1} per day. Sign up for more length and daily rewrites.`,
@@ -688,7 +804,58 @@ function App() {
688
  }, "Sign in again"),
689
  )
690
  : null,
691
- h("div", { className: "stage" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
692
  h("div", { className: "toolbar" },
693
  h("div", { className: "tool-group" },
694
  h("span", null, "Tone"),
@@ -756,7 +923,7 @@ function App() {
756
  ),
757
  ),
758
  ),
759
- showUpgrade && authEnabled
760
  ? h(UpgradeCard, {
761
  account,
762
  plans,
@@ -764,11 +931,12 @@ function App() {
764
  onSignIn: () => openAuth("signin", "Sign in"),
765
  })
766
  : null,
767
- h("p", { className: "hint" }, "Review the rewrite before you share or publish it."),
768
  h(LandingSections, {
769
  plans,
770
  authEnabled,
771
  onSignUp: () => openAuth("signup", "Create free account"),
 
772
  }),
773
  authEnabled
774
  ? h(AuthModal, {
 
347
  );
348
  }
349
 
350
+
351
+ const PRODUCTS = [
352
+ {
353
+ id: "writer",
354
+ name: "ZuZu Writer",
355
+ short: "Writer",
356
+ tagline: "Turn AI drafts into natural wording.",
357
+ blurb: "Rewrite ChatGPT, Gemini, and Claude text in Neutral, Casual, Formal, or Academic tone — classical NLP, no LLM.",
358
+ status: "live",
359
+ },
360
+ {
361
+ id: "grammar",
362
+ name: "ZuZu Grammar",
363
+ short: "Grammar",
364
+ tagline: "Catch grammar and spelling before you publish.",
365
+ blurb: "Scan drafts for grammar, punctuation, and common spelling issues, then apply fixes with one click.",
366
+ status: "preview",
367
+ },
368
+ ];
369
+
370
+ 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.";
371
+
372
+ async function checkGrammar(text, accessToken) {
373
+ const headers = { "Content-Type": "application/json" };
374
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
375
+ const res = await fetch("/v1/grammar", { method: "POST", headers, body: JSON.stringify({ text }) });
376
+ if (!res.ok) {
377
+ let detail = "Grammar check failed.";
378
+ try { const data = await res.json(); detail = data.detail || detail; } catch (_) {}
379
+ throw new Error(typeof detail === "string" ? detail : "Grammar check failed.");
380
+ }
381
+ return res.json();
382
+ }
383
+
384
+ function applyGrammarFix(text, issue) {
385
+ if (issue.suggestion == null) return text;
386
+ return text.slice(0, issue.start) + issue.suggestion + text.slice(issue.end);
387
+ }
388
+
389
+ function applyAllGrammarFixes(text, issues) {
390
+ let next = text;
391
+ const ordered = [...issues].sort((a, b) => b.start - a.start);
392
+ for (const issue of ordered) {
393
+ if (issue.suggestion == null) continue;
394
+ next = applyGrammarFix(next, issue);
395
+ }
396
+ return next;
397
+ }
398
+
399
+ function ProductSwitcher({ product, onChange }) {
400
+ return h("div", { className: "product-switcher", role: "tablist", "aria-label": "Products" },
401
+ PRODUCTS.map((p) => h("button", {
402
+ key: p.id,
403
+ type: "button",
404
+ role: "tab",
405
+ "aria-selected": product === p.id,
406
+ className: product === p.id ? "active" : "",
407
+ onClick: () => onChange(p.id),
408
+ }, p.short, p.status === "preview" ? h("span", { className: "product-pill" }, "Preview") : null)),
409
+ );
410
+ }
411
+
412
+ function SiteNav({ product, onSelectProduct }) {
413
+ return h("nav", { className: "site-nav", "aria-label": "Site" },
414
+ h("a", { href: "#products" }, "Products"),
415
+ h("a", { href: "#plans" }, "Plans"),
416
+ h("button", { type: "button", className: "nav-product", onClick: () => onSelectProduct("writer") }, product === "writer" ? "Open Writer" : "Writer"),
417
+ h("button", { type: "button", className: "nav-product", onClick: () => onSelectProduct("grammar") }, product === "grammar" ? "Open Grammar" : "Grammar"),
418
+ );
419
+ }
420
+
421
  function UpgradeCard({ account, plans, onSignUp, onSignIn }) {
422
  const planId = (account && account.plan && account.plan.id) || "guest";
423
  if (planId === "pro" || planId === "plus") return null;
 
469
  );
470
  }
471
 
472
+ function LandingSections({ plans, onSignUp, authEnabled, onSelectProduct }) {
473
  const free = plans.find((p) => p.id === "free");
474
  const pro = plans.find((p) => p.id === "pro");
475
  const plus = plans.find((p) => p.id === "plus");
476
 
477
  return h("div", { className: "landing" },
478
+ h("section", { className: "land-block", id: "products" },
479
+ h("h2", null, "Our products"),
480
+ h("p", { className: "land-lead" }, "Two focused tools under ZuZu pick the job you need today."),
481
+ h("div", { className: "product-grid" },
482
+ PRODUCTS.map((p) => h("article", { key: p.id, className: `product-card product-card-${p.id}` },
483
+ h("div", { className: "product-card-top" },
484
+ h("h3", null, p.name),
485
+ p.status === "preview" ? h("span", { className: "product-pill" }, "Preview") : null,
 
 
 
 
 
 
 
 
486
  ),
487
+ h("p", { className: "product-tagline" }, p.tagline),
488
+ h("p", null, p.blurb),
489
+ h("ul", null,
490
+ ...(p.id === "writer"
491
+ ? [
492
+ h("li", { key: "w1" }, "Tone: Neutral, Casual, Formal, Academic"),
493
+ h("li", { key: "w2" }, "Offline classical NLP rewrite engine"),
494
+ h("li", { key: "w3" }, "Free preview, then Free / Pro / Plus plans"),
495
+ ]
496
+ : [
497
+ h("li", { key: "g1" }, "Grammar, punctuation & common spelling"),
498
+ h("li", { key: "g2" }, "Click to apply suggested fixes"),
499
+ h("li", { key: "g3" }, "Works alongside Writer in the same account"),
500
+ ]),
501
  ),
502
+ h("button", {
503
+ type: "button",
504
+ className: "btn btn-primary",
505
+ onClick: () => { onSelectProduct(p.id); window.scrollTo({ top: 0, behavior: "smooth" }); },
506
+ }, `Open ${p.short}`),
507
+ )),
508
+ ),
509
+ ),
510
+ h("section", { className: "land-block" },
511
+ h("h2", null, "How ZuZu works"),
512
+ h("p", { className: "land-lead" }, "Use Writer to humanize AI drafts, then Grammar to polish before you publish."),
513
+ h("ol", { className: "steps" },
514
+ h("li", null, h("span", { className: "step-num" }, "1"), h("div", null, h("strong", null, "Choose a product"), h("p", null, "Switch between Writer and Grammar from the top of the page."))),
515
+ h("li", null, h("span", { className: "step-num" }, "2"), h("div", null, h("strong", null, "Paste your draft"), h("p", null, "Drop in AI or human text and run Rewrite or Check grammar."))),
516
+ h("li", null, h("span", { className: "step-num" }, "3"), h("div", null, h("strong", null, "Review, then unlock more"), h("p", null, "Try a short preview, sign up for Free, go Pro or Plus when you write every day."))),
517
  ),
518
  ),
519
  h("section", { className: "land-block" },
520
+ h("h2", null, "Who it's for"),
521
  h("p", { className: "land-lead" }, "Built for people who draft with AI and publish as themselves."),
522
  h("div", { className: "audience-grid" },
523
+ h("article", null, h("h3", null, "Students & academic writers"), h("p", null, "Refine AI-assisted notes into clearer Academic or Formal wording. Follow your institution's rules.")),
524
+ h("article", null, h("h3", null, "Freelancers & professionals"), h("p", null, "Turn stiff AI emails and reports into confident, natural communication.")),
525
+ h("article", null, h("h3", null, "Bloggers & SEO writers"), h("p", null, "Refresh repetitive AI drafts into readable posts that still keep your meaning.")),
526
+ h("article", null, h("h3", null, "Social & content teams"), h("p", null, "Humanize captions and scripts so they sound like your brand, not a model.")),
 
 
 
 
 
 
 
 
 
 
 
 
527
  ),
528
  ),
529
  h("section", { className: "land-block", id: "plans" },
530
  h("h2", null, "Simple plans"),
531
+ h("p", { className: "land-lead" }, "One account for Writer today Grammar preview is included. Checkout for Pro/Plus coming soon."),
532
  h("div", { className: "pricing-grid" },
533
  h("article", { className: "price-card" },
534
  h("h3", null, "Free"),
 
536
  h("ul", null,
537
  h("li", null, `${(free && free.max_words_per_request) || 400} words / rewrite`),
538
  h("li", null, `${(free && free.daily_rewrites) || 5} rewrites / day`),
539
+ h("li", null, "Grammar preview included"),
540
  ),
541
+ authEnabled ? h("button", { type: "button", className: "btn btn-quiet", onClick: onSignUp }, "Create free account") : null,
 
 
542
  ),
543
  h("article", { className: "price-card price-card-pro" },
544
  h("h3", null, "Pro"),
545
+ h("p", { className: "price-amount" }, `₹${(pro && pro.price_inr_monthly) || 199}`, h("span", null, "/mo")),
 
 
 
546
  h("ul", null,
547
  h("li", null, `${((pro && pro.max_words_per_request) || 2000).toLocaleString()} words / rewrite`),
548
  h("li", null, `${(pro && pro.daily_rewrites) || 50} rewrites / day`),
 
552
  ),
553
  h("article", { className: "price-card price-card-plus" },
554
  h("h3", null, "Plus"),
555
+ h("p", { className: "price-amount" }, `₹${(plus && plus.price_inr_monthly) || 499}`, h("span", null, "/mo")),
 
 
 
556
  h("ul", null,
557
  h("li", null, `${((plus && plus.max_words_per_request) || 5000).toLocaleString()} words / rewrite`),
558
  h("li", null, `${(plus && plus.daily_rewrites) || 200} rewrites / day`),
 
561
  h("p", { className: "price-soon" }, "Checkout coming soon"),
562
  ),
563
  ),
564
+ h("p", { className: "plans-note" }, "Visitors can try a short free Writer preview on the homepage before signing up."),
565
  ),
566
  h("footer", { className: "site-footer" },
567
+ h("p", null, "Review every rewrite and grammar suggestion before you share or publish."),
568
+ h("p", { className: "footer-brand" }, h(BrandLogo, { size: 28 }), h("span", null, "ZuZu")),
 
 
 
569
  ),
570
  );
571
  }
 
589
  const [authOpen, setAuthOpen] = useState(false);
590
  const [authMode, setAuthMode] = useState("signup");
591
  const [authTitle, setAuthTitle] = useState(undefined);
592
+ const [product, setProduct] = useState("writer");
593
+ const [grammarText, setGrammarText] = useState("");
594
+ const [grammarIssues, setGrammarIssues] = useState([]);
595
+ const [grammarNote, setGrammarNote] = useState("");
596
+ const [grammarLoading, setGrammarLoading] = useState(false);
597
+ const [grammarError, setGrammarError] = useState("");
598
+ const [grammarMeta, setGrammarMeta] = useState("");
599
+
600
+ const activeProduct = PRODUCTS.find((p) => p.id === product) || PRODUCTS[0];
601
  const isGuest = !!(authEnabled && !session);
602
  const maxWords = (account && account.plan && account.plan.max_words_per_request) || (isGuest ? guestMaxWords : 50000);
603
 
 
618
  const onKey = (e) => {
619
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
620
  e.preventDefault();
621
+ if (product === "writer") onRewrite();
622
+ else onGrammarCheck();
623
  }
624
  };
625
  window.addEventListener("keydown", onKey);
 
632
  setAuthOpen(true);
633
  }
634
 
635
+ function selectProduct(id) {
636
+ setProduct(id);
637
+ setError("");
638
+ setGrammarError("");
639
+ }
640
+
641
+ async function onGrammarCheck() {
642
+ const text = grammarText.trim();
643
+ if (!text) { setGrammarError("Paste some text first — or try the sample."); return; }
644
+ if (text.length > MAX_CHARS) { setGrammarError(`Text is too long (${text.length.toLocaleString()} chars).`); return; }
645
+ setGrammarLoading(true);
646
+ setGrammarError("");
647
+ setGrammarMeta("Checking…");
648
+ try {
649
+ const result = await checkGrammar(text, session && session.access_token);
650
+ setGrammarIssues(result.issues || []);
651
+ setGrammarNote(result.note || "");
652
+ const n = (result.issues || []).length;
653
+ setGrammarMeta(n ? `${n} issue${n === 1 ? "" : "s"} · ${result.input_words} words` : `No issues found · ${result.input_words} words`);
654
+ } catch (err) {
655
+ setGrammarError(err instanceof Error ? err.message : "Grammar check failed.");
656
+ setGrammarMeta("");
657
+ setGrammarIssues([]);
658
+ } finally {
659
+ setGrammarLoading(false);
660
+ }
661
+ }
662
+
663
+ function onApplyGrammarIssue(issue) {
664
+ setGrammarText((prev) => applyGrammarFix(prev, issue));
665
+ setGrammarIssues([]);
666
+ setGrammarMeta("Fix applied — run Check grammar again to refresh.");
667
+ setGrammarNote("");
668
+ }
669
+
670
+ function onApplyAllGrammar() {
671
+ if (!grammarIssues.length) return;
672
+ setGrammarText((prev) => applyAllGrammarFixes(prev, grammarIssues));
673
+ setGrammarIssues([]);
674
+ setGrammarMeta("All suggested fixes applied — run Check grammar again to refresh.");
675
+ setGrammarNote("");
676
+ }
677
+
678
  async function onRewrite() {
679
  const text = input.trim();
680
  if (!text) { setError("Paste some text first."); return; }
 
756
  h(BrandLogo, { size: 56, className: "brand-logo-hero" }),
757
  h("div", { className: "brand-text" },
758
  h("p", { className: "brand-mark" }, "ZuZu"),
759
+ h("h1", null, activeProduct.name),
760
  ),
761
  ),
762
+ h("p", { className: "brand-tag" }, activeProduct.tagline),
763
+ h(SiteNav, { product, onSelectProduct: selectProduct }),
764
  ),
765
  h("div", { className: "top-meta" },
766
  session && account
 
784
  : h(Fragment, null, `${tone} · ${strength}`, h("br"), "⌘/Ctrl + Enter"),
785
  ),
786
  ),
787
+ h(ProductSwitcher, { product, onChange: selectProduct }),
788
+ isGuest && !idleSignedOut && product === "writer"
789
  ? h("p", { className: "teaser-banner" },
790
  "Try a short rewrite free — up to ", h("strong", null, `${maxWords} words`),
791
  `, ${(account && account.plan && account.plan.daily_rewrites) || 1} per day. Sign up for more length and daily rewrites.`,
 
804
  }, "Sign in again"),
805
  )
806
  : null,
807
+ product === "grammar"
808
+ ? h("div", { className: "stage grammar-stage" },
809
+ h("div", { className: "toolbar" },
810
+ h("div", { className: "toolbar-controls" },
811
+ h("p", { className: "grammar-lead" }, "Preview rule engine — grammar, punctuation, and common spelling. Fuller checks coming next."),
812
+ ),
813
+ h("div", { className: "toolbar-actions" },
814
+ h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: () => { setGrammarText(""); setGrammarIssues([]); setGrammarMeta(""); setGrammarError(""); setGrammarNote(""); } }, "Clear"),
815
+ h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: () => { setGrammarText(GRAMMAR_SAMPLE); setGrammarIssues([]); setGrammarError(""); setGrammarMeta("Sample loaded — hit Check grammar."); setGrammarNote(""); } }, "Try sample"),
816
+ grammarIssues.length ? h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: onApplyAllGrammar }, "Apply all") : null,
817
+ h("button", { type: "button", className: "btn btn-primary btn-rewrite", disabled: grammarLoading, onClick: () => onGrammarCheck() }, grammarLoading ? "Checking…" : "Check grammar"),
818
+ ),
819
+ ),
820
+ h("section", { className: "editors grammar-editors" },
821
+ h("div", { className: "pane" },
822
+ h("div", { className: "pane-head" }, h("h2", null, "Your text")),
823
+ h("textarea", {
824
+ value: grammarText,
825
+ onChange: (e) => { setGrammarText(e.target.value); setGrammarIssues([]); },
826
+ placeholder: "Paste a draft to check grammar and spelling…",
827
+ spellCheck: true,
828
+ }),
829
+ ),
830
+ h("div", { className: "pane pane-out grammar-issues-pane" },
831
+ h("div", { className: "pane-head" },
832
+ h("h2", null, "Issues"),
833
+ h("span", { className: "issue-count" }, grammarIssues.length ? `${grammarIssues.length} found` : (grammarMeta ? "Clean" : "—")),
834
+ ),
835
+ grammarIssues.length
836
+ ? h("ul", { className: "issue-list" },
837
+ grammarIssues.map((issue) => h("li", { key: issue.id, className: `issue-item cat-${issue.category}` },
838
+ h("div", { className: "issue-main" },
839
+ h("span", { className: "issue-cat" }, issue.category),
840
+ h("p", null, issue.message),
841
+ h("code", { className: "issue-snippet" },
842
+ (grammarText.slice(issue.start, issue.end) || "…") + (issue.suggestion != null ? ` → ${issue.suggestion}` : ""),
843
+ ),
844
+ ),
845
+ issue.suggestion != null
846
+ ? h("button", { type: "button", className: "btn btn-quiet btn-tiny", onClick: () => onApplyGrammarIssue(issue) }, "Apply")
847
+ : null,
848
+ )),
849
+ )
850
+ : h("p", { className: "grammar-empty" }, grammarNote || "Run Check grammar to see suggestions here."),
851
+ ),
852
+ ),
853
+ h("div", { className: "statusbar" },
854
+ h("div", { className: grammarError ? "error" : grammarLoading ? "loading" : undefined }, grammarError || grammarMeta || "Paste text → Check grammar"),
855
+ h("div", { className: "counts" }, h("span", null, `${wordCount(grammarText)} words`)),
856
+ ),
857
+ )
858
+ : h("div", { className: "stage" },
859
  h("div", { className: "toolbar" },
860
  h("div", { className: "tool-group" },
861
  h("span", null, "Tone"),
 
923
  ),
924
  ),
925
  ),
926
+ showUpgrade && authEnabled && product === "writer"
927
  ? h(UpgradeCard, {
928
  account,
929
  plans,
 
931
  onSignIn: () => openAuth("signin", "Sign in"),
932
  })
933
  : null,
934
+ h("p", { className: "hint" }, product === "grammar" ? "Review every suggestion before you publish." : "Review the rewrite before you share or publish it."),
935
  h(LandingSections, {
936
  plans,
937
  authEnabled,
938
  onSignUp: () => openAuth("signup", "Create free account"),
939
+ onSelectProduct: selectProduct,
940
  }),
941
  authEnabled
942
  ? h(AuthModal, {
frontend/dist/index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>ZuZu Writer</title>
7
  <link rel="icon" href="/zuzu-icon-512.png" type="image/png" sizes="any" />
8
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
9
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>ZuZu</title>
7
  <link rel="icon" href="/zuzu-icon-512.png" type="image/png" sizes="any" />
8
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
9
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
frontend/index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>ZuZu Writer</title>
7
  <link rel="icon" href="/zuzu-icon-512.png" type="image/png" sizes="any" />
8
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
9
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>ZuZu</title>
7
  <link rel="icon" href="/zuzu-icon-512.png" type="image/png" sizes="any" />
8
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
9
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
frontend/src/App.tsx CHANGED
@@ -1,12 +1,14 @@
1
  import { useEffect, useState, type FormEvent } from "react";
2
  import { createPortal } from "react-dom";
3
  import {
 
4
  rewriteText,
5
  STRENGTH_MAP,
6
  STRENGTHS,
7
  TONES,
8
  type AccountInfo,
9
  type ApiError,
 
10
  type StrengthLabel,
11
  type Tone,
12
  } from "./api";
@@ -17,6 +19,36 @@ const LOGO_SRC = "/zuzu-logo.png";
17
  const LOGO_FALLBACK = "/favicon.svg";
18
  const MAX_CHARS = 50000;
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  function BrandLogo({ size = 44, className = "" }: { size?: number; className?: string }) {
21
  return (
22
  <img
@@ -80,6 +112,71 @@ function wordCount(text: string): number {
80
  return text.trim() ? text.trim().split(/\s+/).length : 0;
81
  }
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  function AuthModal({
84
  open,
85
  onClose,
@@ -312,10 +409,12 @@ function LandingSections({
312
  plans,
313
  onSignUp,
314
  authEnabled,
 
315
  }: {
316
  plans: PlanCard[];
317
  onSignUp: () => void;
318
  authEnabled: boolean;
 
319
  }) {
320
  const free = plans.find((p) => p.id === "free");
321
  const pro = plans.find((p) => p.id === "pro");
@@ -323,28 +422,70 @@ function LandingSections({
323
 
324
  return (
325
  <div className="landing">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  <section className="land-block">
327
- <h2>How to use ZuZu Writer</h2>
328
- <p className="land-lead">Three steps from AI draft to natural wording.</p>
329
  <ol className="steps">
330
  <li>
331
  <span className="step-num">1</span>
332
  <div>
333
- <strong>Paste your AI text</strong>
334
- <p>Drop in a ChatGPT, Gemini, or Claude draft on the left.</p>
335
  </div>
336
  </li>
337
  <li>
338
  <span className="step-num">2</span>
339
  <div>
340
- <strong>Pick tone &amp; strength</strong>
341
- <p>Neutral, Casual, Formal, or Academic match the voice you need.</p>
342
  </div>
343
  </li>
344
  <li>
345
  <span className="step-num">3</span>
346
  <div>
347
- <strong>Rewrite, then unlock more</strong>
348
  <p>Try a short preview, sign up for Free, go Pro or Plus when you write every day.</p>
349
  </div>
350
  </li>
@@ -376,7 +517,9 @@ function LandingSections({
376
 
377
  <section className="land-block" id="plans">
378
  <h2>Simple plans</h2>
379
- <p className="land-lead">Start on Free. Upgrade to Pro or Plus when you need more words every day.</p>
 
 
380
  <div className="pricing-grid">
381
  <article className="price-card">
382
  <h3>Free</h3>
@@ -384,7 +527,7 @@ function LandingSections({
384
  <ul>
385
  <li>{free?.max_words_per_request ?? 400} words / rewrite</li>
386
  <li>{free?.daily_rewrites ?? 5} rewrites / day</li>
387
- <li>Google or email signup</li>
388
  </ul>
389
  {authEnabled ? (
390
  <button type="button" className="btn btn-quiet" onClick={onSignUp}>
@@ -420,15 +563,15 @@ function LandingSections({
420
  </article>
421
  </div>
422
  <p className="plans-note">
423
- Visitors can try a short free preview on the homepage before signing up.
424
  </p>
425
  </section>
426
 
427
  <footer className="site-footer">
428
- <p>Review every rewrite before you share or publish. Follow your school or workplace AI policy.</p>
429
  <p className="footer-brand">
430
  <BrandLogo size={28} />
431
- <span>ZuZu Writer</span>
432
  </p>
433
  </footer>
434
  </div>
@@ -464,7 +607,15 @@ export default function App() {
464
  const [authOpen, setAuthOpen] = useState(false);
465
  const [authMode, setAuthMode] = useState<"signin" | "signup">("signup");
466
  const [authTitle, setAuthTitle] = useState<string | undefined>();
 
 
 
 
 
 
 
467
 
 
468
  const isGuest = Boolean(authEnabled && !session);
469
  // Guests always use config teaser limit (ignore inflated account payloads / env mistakes)
470
  const maxWords = isGuest
@@ -492,13 +643,14 @@ export default function App() {
492
  const onKey = (e: KeyboardEvent) => {
493
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
494
  e.preventDefault();
495
- void onRewrite();
 
496
  }
497
  };
498
  window.addEventListener("keydown", onKey);
499
  return () => window.removeEventListener("keydown", onKey);
500
  // eslint-disable-next-line react-hooks/exhaustive-deps
501
- }, [input, tone, strength, preserveLength, loading, session]);
502
 
503
  function openAuth(mode: "signin" | "signup", title?: string) {
504
  setAuthMode(mode);
@@ -506,12 +658,73 @@ export default function App() {
506
  setAuthOpen(true);
507
  }
508
 
 
 
 
 
 
 
509
  function loadSample() {
510
  setInput(SAMPLE_TEXT);
511
  setError("");
512
  setMeta("Sample loaded — hit Rewrite to try it.");
513
  }
514
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  async function onRewrite() {
516
  const text = input.trim();
517
  if (!text) {
@@ -617,12 +830,11 @@ export default function App() {
617
  <BrandLogo size={56} className="brand-logo-hero" />
618
  <div className="brand-text">
619
  <p className="brand-mark">ZuZu</p>
620
- <h1>ZuZu Writer</h1>
621
  </div>
622
  </div>
623
- <p className="brand-tag">
624
- From AI-generated to plagiarism-safe — rewrite in a voice that feels real.
625
- </p>
626
  </header>
627
  <div className="top-meta">
628
  {session && account ? (
@@ -668,7 +880,7 @@ export default function App() {
668
  ) : (
669
  <div className="account-panel account-panel-quiet">
670
  <span className="account-quota">
671
- {tone} · {strength}
672
  </span>
673
  <span className="kbd-hint">⌘/Ctrl + Enter</span>
674
  </div>
@@ -676,6 +888,8 @@ export default function App() {
676
  </div>
677
  </div>
678
 
 
 
679
  {idleSignedOut ? (
680
  <div className="teaser-banner idle-banner" role="status">
681
  <span>Signed out after {sessionIdleMinutes} minutes of inactivity.</span>
@@ -692,7 +906,7 @@ export default function App() {
692
  </div>
693
  ) : null}
694
 
695
- {isGuest && !idleSignedOut ? (
696
  <div className="teaser-banner teaser-cta">
697
  <div className="teaser-copy">
698
  <span className="teaser-label">Free Preview</span>
@@ -711,164 +925,286 @@ export default function App() {
711
  </div>
712
  ) : null}
713
 
714
- <div className="stage">
715
- <div className="toolbar">
716
- <div className="toolbar-controls">
717
- <div className="tool-group">
718
- <span>Tone</span>
719
- <div className="segment" role="group" aria-label="Tone">
720
- {TONES.map((t) => (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
721
  <button
722
- key={t}
723
  type="button"
724
- title={TONE_HINT[t]}
725
- className={tone === t ? "active" : ""}
726
- onClick={() => setTone(t)}
727
  >
728
- {t}
729
  </button>
730
- ))}
731
  </div>
732
- <span className="tool-hint">{TONE_HINT[tone]}</span>
 
 
 
 
 
 
 
 
 
733
  </div>
734
 
735
- <div className="tool-group">
736
- <span>Strength</span>
737
- <div className="segment" role="group" aria-label="Strength">
738
- {STRENGTHS.map((s) => (
739
  <button
740
- key={s}
741
  type="button"
742
- className={strength === s ? "active" : ""}
743
- onClick={() => setStrength(s)}
 
744
  >
745
- {s}
746
  </button>
747
- ))}
 
 
 
 
 
 
 
 
748
  </div>
 
 
 
 
 
 
 
 
 
749
  </div>
 
750
 
751
- <label className="check">
752
- <input
753
- type="checkbox"
754
- checked={preserveLength}
755
- onChange={(e) => setPreserveLength(e.target.checked)}
756
  />
757
- Match length
758
- </label>
759
- </div>
760
-
761
- <div className="toolbar-actions">
762
- <button
763
- type="button"
764
- className="btn btn-quiet"
765
- onClick={() => {
766
- setInput("");
767
- setOutput("");
768
- setMeta("");
769
- setError("");
770
- setFreshOut(false);
771
- }}
772
- disabled={loading}
773
- >
774
- Clear
775
- </button>
776
- <button
777
- type="button"
778
- className="btn btn-primary btn-rewrite"
779
- onClick={() => void onRewrite()}
780
- disabled={loading}
781
- >
782
- {loading ? "Rewriting…" : "Rewrite"}
783
- </button>
784
- </div>
785
- </div>
786
-
787
- <section className="editors">
788
- <div className="pane">
789
- <div className="pane-head">
790
- <h2>Original</h2>
791
- <div className="pane-actions">
792
- <button
793
- type="button"
794
- className="btn btn-quiet btn-tiny"
795
- onClick={loadSample}
796
- disabled={loading}
797
- >
798
- Try sample
799
- </button>
800
- </div>
801
  </div>
802
- <textarea
803
- value={input}
804
- onChange={(e) => setInput(e.target.value)}
805
- placeholder={
806
- isGuest
807
- ? `Paste a short AI draft (max ${maxWords} words on Preview)…`
808
- : "Paste your AI draft here…"
809
- }
810
- spellCheck
811
- />
812
- </div>
813
 
814
- <div className={`pane pane-out${freshOut ? " writing" : ""}`}>
815
- <div className="pane-head">
816
- <h2>Rewrite</h2>
817
- <div className="pane-actions">
818
- <button
819
- type="button"
820
- className="btn btn-quiet btn-tiny"
821
- onClick={() => void onCopy()}
822
- disabled={!output.trim()}
823
- >
824
- {copied ? "Copied" : "Copy"}
825
- </button>
826
- <button
827
- type="button"
828
- className="btn btn-quiet btn-tiny"
829
- onClick={onDownload}
830
- disabled={!output.trim()}
831
- >
832
- Download
833
- </button>
834
- </div>
835
  </div>
836
- <textarea
837
- value={output}
838
- onChange={(e) => {
839
- setOutput(e.target.value);
840
- setFreshOut(false);
841
- }}
842
- placeholder="Your rewrite appears here…"
843
- spellCheck
844
- />
845
- </div>
846
- </section>
847
-
848
- {authEnabled ? (
849
- <div className="word-meter" aria-hidden>
850
- <div
851
- className={`word-meter-fill${overCap ? " over" : meterPct > 80 ? " hot" : ""}`}
852
- style={{ width: `${meterPct}%` }}
853
- />
854
- </div>
855
- ) : null}
856
-
857
- <div className="statusbar">
858
- <div className={error ? "error" : loading ? "loading" : undefined}>
859
- {error || meta || "Paste AI text → pick a tone → Rewrite"}
860
- </div>
861
- <div className="counts">
862
- <span className={overCap ? "over-limit" : undefined}>
863
- {inWords}
864
- {authEnabled ? ` / ${maxWords}` : ""} words in
865
- </span>
866
- <span>{outWords} words out</span>
867
  </div>
868
  </div>
869
- </div>
870
 
871
- {showUpgrade && authEnabled ? (
872
  <UpgradeCard
873
  account={account}
874
  plans={plans}
@@ -878,12 +1214,17 @@ export default function App() {
878
  />
879
  ) : null}
880
 
881
- <p className="hint">Review the rewrite before you share or publish it.</p>
 
 
 
 
882
 
883
  <LandingSections
884
  plans={plans}
885
  authEnabled={authEnabled}
886
  onSignUp={() => openAuth("signup", "Create free account")}
 
887
  />
888
 
889
  {authEnabled ? (
 
1
  import { useEffect, useState, type FormEvent } from "react";
2
  import { createPortal } from "react-dom";
3
  import {
4
+ checkGrammar,
5
  rewriteText,
6
  STRENGTH_MAP,
7
  STRENGTHS,
8
  TONES,
9
  type AccountInfo,
10
  type ApiError,
11
+ type GrammarIssue,
12
  type StrengthLabel,
13
  type Tone,
14
  } from "./api";
 
19
  const LOGO_FALLBACK = "/favicon.svg";
20
  const MAX_CHARS = 50000;
21
 
22
+ type ProductId = "writer" | "grammar";
23
+
24
+ const PRODUCTS: {
25
+ id: ProductId;
26
+ name: string;
27
+ short: string;
28
+ tagline: string;
29
+ blurb: string;
30
+ status: "live" | "preview";
31
+ }[] = [
32
+ {
33
+ id: "writer",
34
+ name: "ZuZu Writer",
35
+ short: "Writer",
36
+ tagline: "Turn AI drafts into natural wording.",
37
+ blurb:
38
+ "Rewrite ChatGPT, Gemini, and Claude text in Neutral, Casual, Formal, or Academic tone — classical NLP, no LLM.",
39
+ status: "live",
40
+ },
41
+ {
42
+ id: "grammar",
43
+ name: "ZuZu Grammar",
44
+ short: "Grammar",
45
+ tagline: "Catch grammar and spelling before you publish.",
46
+ blurb:
47
+ "Scan drafts for grammar, punctuation, and common spelling issues, then apply fixes with one click.",
48
+ status: "preview",
49
+ },
50
+ ];
51
+
52
  function BrandLogo({ size = 44, className = "" }: { size?: number; className?: string }) {
53
  return (
54
  <img
 
112
  return text.trim() ? text.trim().split(/\s+/).length : 0;
113
  }
114
 
115
+ function ProductSwitcher({
116
+ product,
117
+ onChange,
118
+ }: {
119
+ product: ProductId;
120
+ onChange: (id: ProductId) => void;
121
+ }) {
122
+ return (
123
+ <div className="product-switcher" role="tablist" aria-label="Products">
124
+ {PRODUCTS.map((p) => (
125
+ <button
126
+ key={p.id}
127
+ type="button"
128
+ role="tab"
129
+ aria-selected={product === p.id}
130
+ className={product === p.id ? "active" : ""}
131
+ onClick={() => onChange(p.id)}
132
+ >
133
+ {p.short}
134
+ {p.status === "preview" ? <span className="product-pill">Preview</span> : null}
135
+ </button>
136
+ ))}
137
+ </div>
138
+ );
139
+ }
140
+
141
+ function SiteNav({
142
+ product,
143
+ onSelectProduct,
144
+ }: {
145
+ product: ProductId;
146
+ onSelectProduct: (id: ProductId) => void;
147
+ }) {
148
+ return (
149
+ <nav className="site-nav" aria-label="Site">
150
+ <a href="#products">Products</a>
151
+ <a href="#plans">Plans</a>
152
+ <button type="button" className="nav-product" onClick={() => onSelectProduct("writer")}>
153
+ {product === "writer" ? "Open Writer" : "Writer"}
154
+ </button>
155
+ <button type="button" className="nav-product" onClick={() => onSelectProduct("grammar")}>
156
+ {product === "grammar" ? "Open Grammar" : "Grammar"}
157
+ </button>
158
+ </nav>
159
+ );
160
+ }
161
+
162
+ function applyGrammarFix(text: string, issue: GrammarIssue): string {
163
+ if (issue.suggestion == null) return text;
164
+ return text.slice(0, issue.start) + issue.suggestion + text.slice(issue.end);
165
+ }
166
+
167
+ function applyAllGrammarFixes(text: string, issues: GrammarIssue[]): string {
168
+ let next = text;
169
+ const ordered = [...issues].sort((a, b) => b.start - a.start);
170
+ for (const issue of ordered) {
171
+ if (issue.suggestion == null) continue;
172
+ next = applyGrammarFix(next, issue);
173
+ }
174
+ return next;
175
+ }
176
+
177
+ const GRAMMAR_SAMPLE =
178
+ "teh quick brown fox jump over the lazy dog. i think this sentance is seperate from the other one and it it needs fixing.";
179
+
180
  function AuthModal({
181
  open,
182
  onClose,
 
409
  plans,
410
  onSignUp,
411
  authEnabled,
412
+ onSelectProduct,
413
  }: {
414
  plans: PlanCard[];
415
  onSignUp: () => void;
416
  authEnabled: boolean;
417
+ onSelectProduct: (id: ProductId) => void;
418
  }) {
419
  const free = plans.find((p) => p.id === "free");
420
  const pro = plans.find((p) => p.id === "pro");
 
422
 
423
  return (
424
  <div className="landing">
425
+ <section className="land-block" id="products">
426
+ <h2>Our products</h2>
427
+ <p className="land-lead">Two focused tools under ZuZu — pick the job you need today.</p>
428
+ <div className="product-grid">
429
+ {PRODUCTS.map((p) => (
430
+ <article key={p.id} className={`product-card product-card-${p.id}`}>
431
+ <div className="product-card-top">
432
+ <h3>{p.name}</h3>
433
+ {p.status === "preview" ? <span className="product-pill">Preview</span> : null}
434
+ </div>
435
+ <p className="product-tagline">{p.tagline}</p>
436
+ <p>{p.blurb}</p>
437
+ <ul>
438
+ {p.id === "writer" ? (
439
+ <>
440
+ <li>Tone: Neutral, Casual, Formal, Academic</li>
441
+ <li>Offline classical NLP rewrite engine</li>
442
+ <li>Free preview, then Free / Pro / Plus plans</li>
443
+ </>
444
+ ) : (
445
+ <>
446
+ <li>Grammar, punctuation &amp; common spelling</li>
447
+ <li>Click to apply suggested fixes</li>
448
+ <li>Works alongside Writer in the same account</li>
449
+ </>
450
+ )}
451
+ </ul>
452
+ <button
453
+ type="button"
454
+ className="btn btn-primary"
455
+ onClick={() => {
456
+ onSelectProduct(p.id);
457
+ window.scrollTo({ top: 0, behavior: "smooth" });
458
+ }}
459
+ >
460
+ Open {p.short}
461
+ </button>
462
+ </article>
463
+ ))}
464
+ </div>
465
+ </section>
466
+
467
  <section className="land-block">
468
+ <h2>How ZuZu works</h2>
469
+ <p className="land-lead">Use Writer to humanize AI drafts, then Grammar to polish before you publish.</p>
470
  <ol className="steps">
471
  <li>
472
  <span className="step-num">1</span>
473
  <div>
474
+ <strong>Choose a product</strong>
475
+ <p>Switch between Writer and Grammar from the top of the page.</p>
476
  </div>
477
  </li>
478
  <li>
479
  <span className="step-num">2</span>
480
  <div>
481
+ <strong>Paste your draft</strong>
482
+ <p>Drop in AI or human text and run Rewrite or Check grammar.</p>
483
  </div>
484
  </li>
485
  <li>
486
  <span className="step-num">3</span>
487
  <div>
488
+ <strong>Review, then unlock more</strong>
489
  <p>Try a short preview, sign up for Free, go Pro or Plus when you write every day.</p>
490
  </div>
491
  </li>
 
517
 
518
  <section className="land-block" id="plans">
519
  <h2>Simple plans</h2>
520
+ <p className="land-lead">
521
+ One account for Writer today — Grammar preview is included. Checkout for Pro/Plus coming soon.
522
+ </p>
523
  <div className="pricing-grid">
524
  <article className="price-card">
525
  <h3>Free</h3>
 
527
  <ul>
528
  <li>{free?.max_words_per_request ?? 400} words / rewrite</li>
529
  <li>{free?.daily_rewrites ?? 5} rewrites / day</li>
530
+ <li>Grammar preview included</li>
531
  </ul>
532
  {authEnabled ? (
533
  <button type="button" className="btn btn-quiet" onClick={onSignUp}>
 
563
  </article>
564
  </div>
565
  <p className="plans-note">
566
+ Visitors can try a short free Writer preview on the homepage before signing up.
567
  </p>
568
  </section>
569
 
570
  <footer className="site-footer">
571
+ <p>Review every rewrite and grammar suggestion before you share or publish.</p>
572
  <p className="footer-brand">
573
  <BrandLogo size={28} />
574
+ <span>ZuZu</span>
575
  </p>
576
  </footer>
577
  </div>
 
607
  const [authOpen, setAuthOpen] = useState(false);
608
  const [authMode, setAuthMode] = useState<"signin" | "signup">("signup");
609
  const [authTitle, setAuthTitle] = useState<string | undefined>();
610
+ const [product, setProduct] = useState<ProductId>("writer");
611
+ const [grammarText, setGrammarText] = useState("");
612
+ const [grammarIssues, setGrammarIssues] = useState<GrammarIssue[]>([]);
613
+ const [grammarNote, setGrammarNote] = useState("");
614
+ const [grammarLoading, setGrammarLoading] = useState(false);
615
+ const [grammarError, setGrammarError] = useState("");
616
+ const [grammarMeta, setGrammarMeta] = useState("");
617
 
618
+ const activeProduct = PRODUCTS.find((p) => p.id === product) ?? PRODUCTS[0];
619
  const isGuest = Boolean(authEnabled && !session);
620
  // Guests always use config teaser limit (ignore inflated account payloads / env mistakes)
621
  const maxWords = isGuest
 
643
  const onKey = (e: KeyboardEvent) => {
644
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
645
  e.preventDefault();
646
+ if (product === "writer") void onRewrite();
647
+ else void onGrammarCheck();
648
  }
649
  };
650
  window.addEventListener("keydown", onKey);
651
  return () => window.removeEventListener("keydown", onKey);
652
  // eslint-disable-next-line react-hooks/exhaustive-deps
653
+ }, [input, tone, strength, preserveLength, loading, session, product, grammarText, grammarLoading]);
654
 
655
  function openAuth(mode: "signin" | "signup", title?: string) {
656
  setAuthMode(mode);
 
658
  setAuthOpen(true);
659
  }
660
 
661
+ function selectProduct(id: ProductId) {
662
+ setProduct(id);
663
+ setError("");
664
+ setGrammarError("");
665
+ }
666
+
667
  function loadSample() {
668
  setInput(SAMPLE_TEXT);
669
  setError("");
670
  setMeta("Sample loaded — hit Rewrite to try it.");
671
  }
672
 
673
+ function loadGrammarSample() {
674
+ setGrammarText(GRAMMAR_SAMPLE);
675
+ setGrammarIssues([]);
676
+ setGrammarError("");
677
+ setGrammarMeta("Sample loaded — hit Check grammar.");
678
+ setGrammarNote("");
679
+ }
680
+
681
+ async function onGrammarCheck() {
682
+ const text = grammarText.trim();
683
+ if (!text) {
684
+ setGrammarError("Paste some text first — or try the sample.");
685
+ return;
686
+ }
687
+ if (text.length > MAX_CHARS) {
688
+ setGrammarError(`Text is too long (${text.length.toLocaleString()} chars).`);
689
+ return;
690
+ }
691
+ setGrammarLoading(true);
692
+ setGrammarError("");
693
+ setGrammarMeta("Checking…");
694
+ try {
695
+ const result = await checkGrammar(text, session?.access_token);
696
+ setGrammarIssues(result.issues);
697
+ setGrammarNote(result.note ?? "");
698
+ setGrammarMeta(
699
+ result.issues.length
700
+ ? `${result.issues.length} issue${result.issues.length === 1 ? "" : "s"} · ${result.input_words} words`
701
+ : `No issues found · ${result.input_words} words`,
702
+ );
703
+ } catch (err) {
704
+ const apiErr = err as ApiError;
705
+ setGrammarError(apiErr.message || "Grammar check failed.");
706
+ setGrammarMeta("");
707
+ setGrammarIssues([]);
708
+ } finally {
709
+ setGrammarLoading(false);
710
+ }
711
+ }
712
+
713
+ function onApplyGrammarIssue(issue: GrammarIssue) {
714
+ setGrammarText((prev) => applyGrammarFix(prev, issue));
715
+ setGrammarIssues([]);
716
+ setGrammarMeta("Fix applied — run Check grammar again to refresh.");
717
+ setGrammarNote("");
718
+ }
719
+
720
+ function onApplyAllGrammar() {
721
+ if (!grammarIssues.length) return;
722
+ setGrammarText((prev) => applyAllGrammarFixes(prev, grammarIssues));
723
+ setGrammarIssues([]);
724
+ setGrammarMeta("All suggested fixes applied — run Check grammar again to refresh.");
725
+ setGrammarNote("");
726
+ }
727
+
728
  async function onRewrite() {
729
  const text = input.trim();
730
  if (!text) {
 
830
  <BrandLogo size={56} className="brand-logo-hero" />
831
  <div className="brand-text">
832
  <p className="brand-mark">ZuZu</p>
833
+ <h1>{activeProduct.name}</h1>
834
  </div>
835
  </div>
836
+ <p className="brand-tag">{activeProduct.tagline}</p>
837
+ <SiteNav product={product} onSelectProduct={selectProduct} />
 
838
  </header>
839
  <div className="top-meta">
840
  {session && account ? (
 
880
  ) : (
881
  <div className="account-panel account-panel-quiet">
882
  <span className="account-quota">
883
+ {product === "writer" ? `${tone} · ${strength}` : "Grammar preview"}
884
  </span>
885
  <span className="kbd-hint">⌘/Ctrl + Enter</span>
886
  </div>
 
888
  </div>
889
  </div>
890
 
891
+ <ProductSwitcher product={product} onChange={selectProduct} />
892
+
893
  {idleSignedOut ? (
894
  <div className="teaser-banner idle-banner" role="status">
895
  <span>Signed out after {sessionIdleMinutes} minutes of inactivity.</span>
 
906
  </div>
907
  ) : null}
908
 
909
+ {isGuest && !idleSignedOut && product === "writer" ? (
910
  <div className="teaser-banner teaser-cta">
911
  <div className="teaser-copy">
912
  <span className="teaser-label">Free Preview</span>
 
925
  </div>
926
  ) : null}
927
 
928
+ {product === "grammar" ? (
929
+ <div className="stage grammar-stage">
930
+ <div className="toolbar">
931
+ <div className="toolbar-controls">
932
+ <p className="grammar-lead">
933
+ Preview rule engine — grammar, punctuation, and common spelling. Fuller checks coming
934
+ next.
935
+ </p>
936
+ </div>
937
+ <div className="toolbar-actions">
938
+ <button
939
+ type="button"
940
+ className="btn btn-quiet"
941
+ onClick={() => {
942
+ setGrammarText("");
943
+ setGrammarIssues([]);
944
+ setGrammarMeta("");
945
+ setGrammarError("");
946
+ setGrammarNote("");
947
+ }}
948
+ disabled={grammarLoading}
949
+ >
950
+ Clear
951
+ </button>
952
+ <button
953
+ type="button"
954
+ className="btn btn-quiet"
955
+ onClick={loadGrammarSample}
956
+ disabled={grammarLoading}
957
+ >
958
+ Try sample
959
+ </button>
960
+ {grammarIssues.length ? (
961
+ <button
962
+ type="button"
963
+ className="btn btn-quiet"
964
+ onClick={onApplyAllGrammar}
965
+ disabled={grammarLoading}
966
+ >
967
+ Apply all
968
+ </button>
969
+ ) : null}
970
+ <button
971
+ type="button"
972
+ className="btn btn-primary btn-rewrite"
973
+ onClick={() => void onGrammarCheck()}
974
+ disabled={grammarLoading}
975
+ >
976
+ {grammarLoading ? "Checking…" : "Check grammar"}
977
+ </button>
978
+ </div>
979
+ </div>
980
+
981
+ <section className="editors grammar-editors">
982
+ <div className="pane">
983
+ <div className="pane-head">
984
+ <h2>Your text</h2>
985
+ </div>
986
+ <textarea
987
+ value={grammarText}
988
+ onChange={(e) => {
989
+ setGrammarText(e.target.value);
990
+ setGrammarIssues([]);
991
+ }}
992
+ placeholder="Paste a draft to check grammar and spelling…"
993
+ spellCheck
994
+ />
995
+ </div>
996
+ <div className="pane pane-out grammar-issues-pane">
997
+ <div className="pane-head">
998
+ <h2>Issues</h2>
999
+ <span className="issue-count">
1000
+ {grammarIssues.length
1001
+ ? `${grammarIssues.length} found`
1002
+ : grammarMeta
1003
+ ? "Clean"
1004
+ : "—"}
1005
+ </span>
1006
+ </div>
1007
+ {grammarIssues.length ? (
1008
+ <ul className="issue-list">
1009
+ {grammarIssues.map((issue) => (
1010
+ <li key={issue.id} className={`issue-item cat-${issue.category}`}>
1011
+ <div className="issue-main">
1012
+ <span className="issue-cat">{issue.category}</span>
1013
+ <p>{issue.message}</p>
1014
+ <code className="issue-snippet">
1015
+ {grammarText.slice(issue.start, issue.end) || "…"}
1016
+ {issue.suggestion != null ? ` → ${issue.suggestion}` : ""}
1017
+ </code>
1018
+ </div>
1019
+ {issue.suggestion != null ? (
1020
+ <button
1021
+ type="button"
1022
+ className="btn btn-quiet btn-tiny"
1023
+ onClick={() => onApplyGrammarIssue(issue)}
1024
+ >
1025
+ Apply
1026
+ </button>
1027
+ ) : null}
1028
+ </li>
1029
+ ))}
1030
+ </ul>
1031
+ ) : (
1032
+ <p className="grammar-empty">
1033
+ {grammarNote || "Run Check grammar to see suggestions here."}
1034
+ </p>
1035
+ )}
1036
+ </div>
1037
+ </section>
1038
+
1039
+ <div className="statusbar">
1040
+ <div className={grammarError ? "error" : grammarLoading ? "loading" : undefined}>
1041
+ {grammarError || grammarMeta || "Paste text → Check grammar"}
1042
+ </div>
1043
+ <div className="counts">
1044
+ <span>{wordCount(grammarText)} words</span>
1045
+ </div>
1046
+ </div>
1047
+ </div>
1048
+ ) : (
1049
+ <div className="stage">
1050
+ <div className="toolbar">
1051
+ <div className="toolbar-controls">
1052
+ <div className="tool-group">
1053
+ <span>Tone</span>
1054
+ <div className="segment" role="group" aria-label="Tone">
1055
+ {TONES.map((t) => (
1056
+ <button
1057
+ key={t}
1058
+ type="button"
1059
+ title={TONE_HINT[t]}
1060
+ className={tone === t ? "active" : ""}
1061
+ onClick={() => setTone(t)}
1062
+ >
1063
+ {t}
1064
+ </button>
1065
+ ))}
1066
+ </div>
1067
+ <span className="tool-hint">{TONE_HINT[tone]}</span>
1068
+ </div>
1069
+
1070
+ <div className="tool-group">
1071
+ <span>Strength</span>
1072
+ <div className="segment" role="group" aria-label="Strength">
1073
+ {STRENGTHS.map((s) => (
1074
+ <button
1075
+ key={s}
1076
+ type="button"
1077
+ className={strength === s ? "active" : ""}
1078
+ onClick={() => setStrength(s)}
1079
+ >
1080
+ {s}
1081
+ </button>
1082
+ ))}
1083
+ </div>
1084
+ </div>
1085
+
1086
+ <label className="check">
1087
+ <input
1088
+ type="checkbox"
1089
+ checked={preserveLength}
1090
+ onChange={(e) => setPreserveLength(e.target.checked)}
1091
+ />
1092
+ Match length
1093
+ </label>
1094
+ </div>
1095
+
1096
+ <div className="toolbar-actions">
1097
+ <button
1098
+ type="button"
1099
+ className="btn btn-quiet"
1100
+ onClick={() => {
1101
+ setInput("");
1102
+ setOutput("");
1103
+ setMeta("");
1104
+ setError("");
1105
+ setFreshOut(false);
1106
+ }}
1107
+ disabled={loading}
1108
+ >
1109
+ Clear
1110
+ </button>
1111
+ <button
1112
+ type="button"
1113
+ className="btn btn-primary btn-rewrite"
1114
+ onClick={() => void onRewrite()}
1115
+ disabled={loading}
1116
+ >
1117
+ {loading ? "Rewriting…" : "Rewrite"}
1118
+ </button>
1119
+ </div>
1120
+ </div>
1121
+
1122
+ <section className="editors">
1123
+ <div className="pane">
1124
+ <div className="pane-head">
1125
+ <h2>Original</h2>
1126
+ <div className="pane-actions">
1127
  <button
 
1128
  type="button"
1129
+ className="btn btn-quiet btn-tiny"
1130
+ onClick={loadSample}
1131
+ disabled={loading}
1132
  >
1133
+ Try sample
1134
  </button>
1135
+ </div>
1136
  </div>
1137
+ <textarea
1138
+ value={input}
1139
+ onChange={(e) => setInput(e.target.value)}
1140
+ placeholder={
1141
+ isGuest
1142
+ ? `Paste a short AI draft (max ${maxWords} words on Preview)…`
1143
+ : "Paste your AI draft here…"
1144
+ }
1145
+ spellCheck
1146
+ />
1147
  </div>
1148
 
1149
+ <div className={`pane pane-out${freshOut ? " writing" : ""}`}>
1150
+ <div className="pane-head">
1151
+ <h2>Rewrite</h2>
1152
+ <div className="pane-actions">
1153
  <button
 
1154
  type="button"
1155
+ className="btn btn-quiet btn-tiny"
1156
+ onClick={() => void onCopy()}
1157
+ disabled={!output.trim()}
1158
  >
1159
+ {copied ? "Copied" : "Copy"}
1160
  </button>
1161
+ <button
1162
+ type="button"
1163
+ className="btn btn-quiet btn-tiny"
1164
+ onClick={onDownload}
1165
+ disabled={!output.trim()}
1166
+ >
1167
+ Download
1168
+ </button>
1169
+ </div>
1170
  </div>
1171
+ <textarea
1172
+ value={output}
1173
+ onChange={(e) => {
1174
+ setOutput(e.target.value);
1175
+ setFreshOut(false);
1176
+ }}
1177
+ placeholder="Your rewrite appears here…"
1178
+ spellCheck
1179
+ />
1180
  </div>
1181
+ </section>
1182
 
1183
+ {authEnabled ? (
1184
+ <div className="word-meter" aria-hidden>
1185
+ <div
1186
+ className={`word-meter-fill${overCap ? " over" : meterPct > 80 ? " hot" : ""}`}
1187
+ style={{ width: `${meterPct}%` }}
1188
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1189
  </div>
1190
+ ) : null}
 
 
 
 
 
 
 
 
 
 
1191
 
1192
+ <div className="statusbar">
1193
+ <div className={error ? "error" : loading ? "loading" : undefined}>
1194
+ {error || meta || "Paste AI text → pick a tone → Rewrite"}
1195
+ </div>
1196
+ <div className="counts">
1197
+ <span className={overCap ? "over-limit" : undefined}>
1198
+ {inWords}
1199
+ {authEnabled ? ` / ${maxWords}` : ""} words in
1200
+ </span>
1201
+ <span>{outWords} words out</span>
 
 
 
 
 
 
 
 
 
 
 
1202
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1203
  </div>
1204
  </div>
1205
+ )}
1206
 
1207
+ {showUpgrade && authEnabled && product === "writer" ? (
1208
  <UpgradeCard
1209
  account={account}
1210
  plans={plans}
 
1214
  />
1215
  ) : null}
1216
 
1217
+ <p className="hint">
1218
+ {product === "grammar"
1219
+ ? "Review every suggestion before you publish."
1220
+ : "Review the rewrite before you share or publish it."}
1221
+ </p>
1222
 
1223
  <LandingSections
1224
  plans={plans}
1225
  authEnabled={authEnabled}
1226
  onSignUp={() => openAuth("signup", "Create free account")}
1227
+ onSelectProduct={selectProduct}
1228
  />
1229
 
1230
  {authEnabled ? (
frontend/src/api.ts CHANGED
@@ -92,6 +92,49 @@ export async function rewriteText(
92
  return res.json();
93
  }
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  export async function fetchMe(accessToken?: string | null): Promise<{
96
  auth_enabled: boolean;
97
  account: AccountInfo | null;
 
92
  return res.json();
93
  }
94
 
95
+ export type GrammarIssue = {
96
+ id: string;
97
+ start: number;
98
+ end: number;
99
+ message: string;
100
+ suggestion: string | null;
101
+ category: string;
102
+ };
103
+
104
+ export type GrammarResponse = {
105
+ issues: GrammarIssue[];
106
+ input_words: number;
107
+ engine: string;
108
+ note?: string;
109
+ };
110
+
111
+ export async function checkGrammar(
112
+ text: string,
113
+ accessToken?: string | null,
114
+ ): Promise<GrammarResponse> {
115
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
116
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
117
+
118
+ const res = await fetch("/v1/grammar", {
119
+ method: "POST",
120
+ headers,
121
+ body: JSON.stringify({ text }),
122
+ });
123
+ if (!res.ok) {
124
+ let detail: unknown = "Grammar check failed.";
125
+ try {
126
+ const data = await res.json();
127
+ detail = data.detail ?? detail;
128
+ } catch {
129
+ /* ignore */
130
+ }
131
+ const err = new Error(detailMessage(detail, "Grammar check failed.")) as ApiError;
132
+ err.status = res.status;
133
+ throw err;
134
+ }
135
+ return res.json();
136
+ }
137
+
138
  export async function fetchMe(accessToken?: string | null): Promise<{
139
  auth_enabled: boolean;
140
  account: AccountInfo | null;
frontend/src/index.css CHANGED
@@ -168,7 +168,234 @@ textarea {
168
  align-items: flex-end;
169
  justify-content: space-between;
170
  gap: 1.5rem;
171
- margin-bottom: 1.35rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  }
173
 
174
  .brand {
 
168
  align-items: flex-end;
169
  justify-content: space-between;
170
  gap: 1.5rem;
171
+ margin-bottom: 0.85rem;
172
+ }
173
+
174
+ .site-nav {
175
+ display: flex;
176
+ flex-wrap: wrap;
177
+ gap: 0.65rem 1rem;
178
+ margin-top: 0.85rem;
179
+ align-items: center;
180
+ }
181
+
182
+ .site-nav a,
183
+ .site-nav .nav-product {
184
+ font-family: var(--font-body);
185
+ font-size: 0.82rem;
186
+ font-weight: 600;
187
+ color: var(--ink-soft);
188
+ text-decoration: none;
189
+ background: none;
190
+ border: none;
191
+ padding: 0;
192
+ cursor: pointer;
193
+ }
194
+
195
+ .site-nav a:hover,
196
+ .site-nav .nav-product:hover {
197
+ color: var(--accent);
198
+ }
199
+
200
+ .product-switcher {
201
+ display: inline-flex;
202
+ flex-wrap: wrap;
203
+ gap: 0.35rem;
204
+ margin-bottom: 1rem;
205
+ padding: 0.3rem;
206
+ border-radius: 14px;
207
+ border: 1px solid var(--panel-edge);
208
+ background: rgba(255, 255, 255, 0.72);
209
+ box-shadow: 0 6px 18px rgba(15, 36, 28, 0.04);
210
+ }
211
+
212
+ .product-switcher button {
213
+ display: inline-flex;
214
+ align-items: center;
215
+ gap: 0.4rem;
216
+ border: none;
217
+ background: transparent;
218
+ color: var(--ink-soft);
219
+ font-family: var(--font-body);
220
+ font-size: 0.9rem;
221
+ font-weight: 600;
222
+ padding: 0.55rem 0.95rem;
223
+ border-radius: 10px;
224
+ cursor: pointer;
225
+ }
226
+
227
+ .product-switcher button.active {
228
+ background: linear-gradient(165deg, #0f7a5f, #0b614c);
229
+ color: #fff8f0;
230
+ box-shadow: 0 6px 16px rgba(15, 122, 95, 0.22);
231
+ }
232
+
233
+ .product-switcher button.active .product-pill {
234
+ background: rgba(255, 248, 240, 0.2);
235
+ color: #fff8f0;
236
+ }
237
+
238
+ .product-pill {
239
+ display: inline-flex;
240
+ align-items: center;
241
+ font-size: 0.65rem;
242
+ font-weight: 700;
243
+ letter-spacing: 0.06em;
244
+ text-transform: uppercase;
245
+ padding: 0.15rem 0.4rem;
246
+ border-radius: 999px;
247
+ background: rgba(226, 90, 60, 0.12);
248
+ color: var(--coral);
249
+ }
250
+
251
+ .product-grid {
252
+ display: grid;
253
+ grid-template-columns: repeat(2, minmax(0, 1fr));
254
+ gap: 1rem;
255
+ }
256
+
257
+ .product-card {
258
+ display: flex;
259
+ flex-direction: column;
260
+ gap: 0.65rem;
261
+ padding: 1.25rem 1.2rem;
262
+ border-radius: 18px;
263
+ border: 1px solid var(--panel-edge);
264
+ background: linear-gradient(165deg, rgba(255, 255, 255, 0.96), rgba(232, 245, 239, 0.75));
265
+ }
266
+
267
+ .product-card-grammar {
268
+ background: linear-gradient(165deg, rgba(255, 255, 255, 0.96), rgba(255, 236, 228, 0.8));
269
+ border-color: rgba(226, 90, 60, 0.22);
270
+ }
271
+
272
+ .product-card-top {
273
+ display: flex;
274
+ align-items: center;
275
+ justify-content: space-between;
276
+ gap: 0.5rem;
277
+ }
278
+
279
+ .product-card h3 {
280
+ margin: 0;
281
+ font-size: 1.2rem;
282
+ }
283
+
284
+ .product-tagline {
285
+ margin: 0;
286
+ font-family: var(--font-display);
287
+ font-size: 1.05rem;
288
+ letter-spacing: -0.02em;
289
+ color: var(--ink);
290
+ }
291
+
292
+ .product-card p {
293
+ margin: 0;
294
+ color: var(--ink-soft);
295
+ font-size: 0.9rem;
296
+ line-height: 1.5;
297
+ }
298
+
299
+ .product-card ul {
300
+ margin: 0;
301
+ padding-left: 1.1rem;
302
+ color: var(--ink-soft);
303
+ font-size: 0.86rem;
304
+ line-height: 1.55;
305
+ flex: 1;
306
+ }
307
+
308
+ .product-card .btn {
309
+ align-self: flex-start;
310
+ margin-top: 0.35rem;
311
+ }
312
+
313
+ .grammar-lead {
314
+ margin: 0;
315
+ color: var(--muted);
316
+ font-size: 0.88rem;
317
+ max-width: 36rem;
318
+ line-height: 1.45;
319
+ }
320
+
321
+ .grammar-issues-pane {
322
+ min-height: 18rem;
323
+ }
324
+
325
+ .issue-count {
326
+ font-size: 0.78rem;
327
+ color: var(--muted);
328
+ font-weight: 600;
329
+ }
330
+
331
+ .issue-list {
332
+ list-style: none;
333
+ margin: 0;
334
+ padding: 0.75rem;
335
+ display: flex;
336
+ flex-direction: column;
337
+ gap: 0.65rem;
338
+ overflow: auto;
339
+ max-height: 22rem;
340
+ }
341
+
342
+ .issue-item {
343
+ display: flex;
344
+ align-items: flex-start;
345
+ justify-content: space-between;
346
+ gap: 0.75rem;
347
+ padding: 0.75rem 0.85rem;
348
+ border-radius: 12px;
349
+ border: 1px solid var(--panel-edge);
350
+ background: rgba(255, 255, 255, 0.8);
351
+ }
352
+
353
+ .issue-cat {
354
+ display: inline-block;
355
+ font-size: 0.68rem;
356
+ font-weight: 700;
357
+ letter-spacing: 0.06em;
358
+ text-transform: uppercase;
359
+ color: var(--accent);
360
+ margin-bottom: 0.25rem;
361
+ }
362
+
363
+ .issue-item.cat-spelling .issue-cat {
364
+ color: var(--coral);
365
+ }
366
+
367
+ .issue-item.cat-punctuation .issue-cat {
368
+ color: #2a6f97;
369
+ }
370
+
371
+ .issue-main p {
372
+ margin: 0 0 0.35rem;
373
+ font-size: 0.88rem;
374
+ color: var(--ink);
375
+ }
376
+
377
+ .issue-snippet {
378
+ display: block;
379
+ font-size: 0.78rem;
380
+ color: var(--ink-soft);
381
+ background: rgba(15, 36, 28, 0.04);
382
+ padding: 0.25rem 0.4rem;
383
+ border-radius: 6px;
384
+ white-space: pre-wrap;
385
+ }
386
+
387
+ .grammar-empty {
388
+ margin: 0;
389
+ padding: 1.25rem;
390
+ color: var(--muted);
391
+ font-size: 0.9rem;
392
+ line-height: 1.5;
393
+ }
394
+
395
+ @media (max-width: 820px) {
396
+ .product-grid {
397
+ grid-template-columns: 1fr;
398
+ }
399
  }
400
 
401
  .brand {
scripts/patch_dist_products.py ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Patch frontend/dist/assets/index.js with multi-product UI."""
2
+ from pathlib import Path
3
+
4
+ path = Path(__file__).resolve().parent.parent / "frontend" / "dist" / "assets" / "index.js"
5
+ text = path.read_text(encoding="utf-8")
6
+
7
+ marker = "function UpgradeCard("
8
+ if "const PRODUCTS =" not in text:
9
+ insert = r'''
10
+ const PRODUCTS = [
11
+ {
12
+ id: "writer",
13
+ name: "ZuZu Writer",
14
+ short: "Writer",
15
+ tagline: "Turn AI drafts into natural wording.",
16
+ blurb: "Rewrite ChatGPT, Gemini, and Claude text in Neutral, Casual, Formal, or Academic tone — classical NLP, no LLM.",
17
+ status: "live",
18
+ },
19
+ {
20
+ id: "grammar",
21
+ name: "ZuZu Grammar",
22
+ short: "Grammar",
23
+ tagline: "Catch grammar and spelling before you publish.",
24
+ blurb: "Scan drafts for grammar, punctuation, and common spelling issues, then apply fixes with one click.",
25
+ status: "preview",
26
+ },
27
+ ];
28
+
29
+ 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.";
30
+
31
+ async function checkGrammar(text, accessToken) {
32
+ const headers = { "Content-Type": "application/json" };
33
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
34
+ const res = await fetch("/v1/grammar", { method: "POST", headers, body: JSON.stringify({ text }) });
35
+ if (!res.ok) {
36
+ let detail = "Grammar check failed.";
37
+ try { const data = await res.json(); detail = data.detail || detail; } catch (_) {}
38
+ throw new Error(typeof detail === "string" ? detail : "Grammar check failed.");
39
+ }
40
+ return res.json();
41
+ }
42
+
43
+ function applyGrammarFix(text, issue) {
44
+ if (issue.suggestion == null) return text;
45
+ return text.slice(0, issue.start) + issue.suggestion + text.slice(issue.end);
46
+ }
47
+
48
+ function applyAllGrammarFixes(text, issues) {
49
+ let next = text;
50
+ const ordered = [...issues].sort((a, b) => b.start - a.start);
51
+ for (const issue of ordered) {
52
+ if (issue.suggestion == null) continue;
53
+ next = applyGrammarFix(next, issue);
54
+ }
55
+ return next;
56
+ }
57
+
58
+ function ProductSwitcher({ product, onChange }) {
59
+ return h("div", { className: "product-switcher", role: "tablist", "aria-label": "Products" },
60
+ PRODUCTS.map((p) => h("button", {
61
+ key: p.id,
62
+ type: "button",
63
+ role: "tab",
64
+ "aria-selected": product === p.id,
65
+ className: product === p.id ? "active" : "",
66
+ onClick: () => onChange(p.id),
67
+ }, p.short, p.status === "preview" ? h("span", { className: "product-pill" }, "Preview") : null)),
68
+ );
69
+ }
70
+
71
+ function SiteNav({ product, onSelectProduct }) {
72
+ return h("nav", { className: "site-nav", "aria-label": "Site" },
73
+ h("a", { href: "#products" }, "Products"),
74
+ h("a", { href: "#plans" }, "Plans"),
75
+ h("button", { type: "button", className: "nav-product", onClick: () => onSelectProduct("writer") }, product === "writer" ? "Open Writer" : "Writer"),
76
+ h("button", { type: "button", className: "nav-product", onClick: () => onSelectProduct("grammar") }, product === "grammar" ? "Open Grammar" : "Grammar"),
77
+ );
78
+ }
79
+
80
+ '''
81
+ text = text.replace(marker, insert + marker, 1)
82
+
83
+ start = text.index("function LandingSections(")
84
+ end = text.index("function App()")
85
+ landing = r'''function LandingSections({ plans, onSignUp, authEnabled, onSelectProduct }) {
86
+ const free = plans.find((p) => p.id === "free");
87
+ const pro = plans.find((p) => p.id === "pro");
88
+ const plus = plans.find((p) => p.id === "plus");
89
+
90
+ return h("div", { className: "landing" },
91
+ h("section", { className: "land-block", id: "products" },
92
+ h("h2", null, "Our products"),
93
+ h("p", { className: "land-lead" }, "Two focused tools under ZuZu — pick the job you need today."),
94
+ h("div", { className: "product-grid" },
95
+ PRODUCTS.map((p) => h("article", { key: p.id, className: `product-card product-card-${p.id}` },
96
+ h("div", { className: "product-card-top" },
97
+ h("h3", null, p.name),
98
+ p.status === "preview" ? h("span", { className: "product-pill" }, "Preview") : null,
99
+ ),
100
+ h("p", { className: "product-tagline" }, p.tagline),
101
+ h("p", null, p.blurb),
102
+ h("ul", null,
103
+ ...(p.id === "writer"
104
+ ? [
105
+ h("li", { key: "w1" }, "Tone: Neutral, Casual, Formal, Academic"),
106
+ h("li", { key: "w2" }, "Offline classical NLP rewrite engine"),
107
+ h("li", { key: "w3" }, "Free preview, then Free / Pro / Plus plans"),
108
+ ]
109
+ : [
110
+ h("li", { key: "g1" }, "Grammar, punctuation & common spelling"),
111
+ h("li", { key: "g2" }, "Click to apply suggested fixes"),
112
+ h("li", { key: "g3" }, "Works alongside Writer in the same account"),
113
+ ]),
114
+ ),
115
+ h("button", {
116
+ type: "button",
117
+ className: "btn btn-primary",
118
+ onClick: () => { onSelectProduct(p.id); window.scrollTo({ top: 0, behavior: "smooth" }); },
119
+ }, `Open ${p.short}`),
120
+ )),
121
+ ),
122
+ ),
123
+ h("section", { className: "land-block" },
124
+ h("h2", null, "How ZuZu works"),
125
+ h("p", { className: "land-lead" }, "Use Writer to humanize AI drafts, then Grammar to polish before you publish."),
126
+ h("ol", { className: "steps" },
127
+ h("li", null, h("span", { className: "step-num" }, "1"), h("div", null, h("strong", null, "Choose a product"), h("p", null, "Switch between Writer and Grammar from the top of the page."))),
128
+ h("li", null, h("span", { className: "step-num" }, "2"), h("div", null, h("strong", null, "Paste your draft"), h("p", null, "Drop in AI or human text and run Rewrite or Check grammar."))),
129
+ h("li", null, h("span", { className: "step-num" }, "3"), h("div", null, h("strong", null, "Review, then unlock more"), h("p", null, "Try a short preview, sign up for Free, go Pro or Plus when you write every day."))),
130
+ ),
131
+ ),
132
+ h("section", { className: "land-block" },
133
+ h("h2", null, "Who it's for"),
134
+ h("p", { className: "land-lead" }, "Built for people who draft with AI and publish as themselves."),
135
+ h("div", { className: "audience-grid" },
136
+ h("article", null, h("h3", null, "Students & academic writers"), h("p", null, "Refine AI-assisted notes into clearer Academic or Formal wording. Follow your institution's rules.")),
137
+ h("article", null, h("h3", null, "Freelancers & professionals"), h("p", null, "Turn stiff AI emails and reports into confident, natural communication.")),
138
+ h("article", null, h("h3", null, "Bloggers & SEO writers"), h("p", null, "Refresh repetitive AI drafts into readable posts that still keep your meaning.")),
139
+ h("article", null, h("h3", null, "Social & content teams"), h("p", null, "Humanize captions and scripts so they sound like your brand, not a model.")),
140
+ ),
141
+ ),
142
+ h("section", { className: "land-block", id: "plans" },
143
+ h("h2", null, "Simple plans"),
144
+ h("p", { className: "land-lead" }, "One account for Writer today — Grammar preview is included. Checkout for Pro/Plus coming soon."),
145
+ h("div", { className: "pricing-grid" },
146
+ h("article", { className: "price-card" },
147
+ h("h3", null, "Free"),
148
+ h("p", { className: "price-amount" }, "₹0"),
149
+ h("ul", null,
150
+ h("li", null, `${(free && free.max_words_per_request) || 400} words / rewrite`),
151
+ h("li", null, `${(free && free.daily_rewrites) || 5} rewrites / day`),
152
+ h("li", null, "Grammar preview included"),
153
+ ),
154
+ authEnabled ? h("button", { type: "button", className: "btn btn-quiet", onClick: onSignUp }, "Create free account") : null,
155
+ ),
156
+ h("article", { className: "price-card price-card-pro" },
157
+ h("h3", null, "Pro"),
158
+ h("p", { className: "price-amount" }, `₹${(pro && pro.price_inr_monthly) || 199}`, h("span", null, "/mo")),
159
+ h("ul", null,
160
+ h("li", null, `${((pro && pro.max_words_per_request) || 2000).toLocaleString()} words / rewrite`),
161
+ h("li", null, `${(pro && pro.daily_rewrites) || 50} rewrites / day`),
162
+ h("li", null, "Best for daily AI drafts"),
163
+ ),
164
+ h("p", { className: "price-soon" }, "Checkout coming soon"),
165
+ ),
166
+ h("article", { className: "price-card price-card-plus" },
167
+ h("h3", null, "Plus"),
168
+ h("p", { className: "price-amount" }, `₹${(plus && plus.price_inr_monthly) || 499}`, h("span", null, "/mo")),
169
+ h("ul", null,
170
+ h("li", null, `${((plus && plus.max_words_per_request) || 5000).toLocaleString()} words / rewrite`),
171
+ h("li", null, `${(plus && plus.daily_rewrites) || 200} rewrites / day`),
172
+ h("li", null, "Heavy use & longer documents"),
173
+ ),
174
+ h("p", { className: "price-soon" }, "Checkout coming soon"),
175
+ ),
176
+ ),
177
+ h("p", { className: "plans-note" }, "Visitors can try a short free Writer preview on the homepage before signing up."),
178
+ ),
179
+ h("footer", { className: "site-footer" },
180
+ h("p", null, "Review every rewrite and grammar suggestion before you share or publish."),
181
+ h("p", { className: "footer-brand" }, h(BrandLogo, { size: 28 }), h("span", null, "ZuZu")),
182
+ ),
183
+ );
184
+ }
185
+
186
+ '''
187
+ text = text[:start] + landing + text[end:]
188
+
189
+ needle = " const [authTitle, setAuthTitle] = useState(undefined);\n\n const isGuest"
190
+ if "const [product, setProduct]" not in text:
191
+ text = text.replace(
192
+ needle,
193
+ """ const [authTitle, setAuthTitle] = useState(undefined);
194
+ const [product, setProduct] = useState("writer");
195
+ const [grammarText, setGrammarText] = useState("");
196
+ const [grammarIssues, setGrammarIssues] = useState([]);
197
+ const [grammarNote, setGrammarNote] = useState("");
198
+ const [grammarLoading, setGrammarLoading] = useState(false);
199
+ const [grammarError, setGrammarError] = useState("");
200
+ const [grammarMeta, setGrammarMeta] = useState("");
201
+
202
+ const activeProduct = PRODUCTS.find((p) => p.id === product) || PRODUCTS[0];
203
+ const isGuest""",
204
+ 1,
205
+ )
206
+
207
+ if "async function onGrammarCheck" not in text:
208
+ text = text.replace(
209
+ " onRewrite();\n",
210
+ ' if (product === "writer") onRewrite();\n else onGrammarCheck();\n',
211
+ 1,
212
+ )
213
+ text = text.replace(
214
+ """ function openAuth(mode, title) {
215
+ setAuthMode(mode);
216
+ setAuthTitle(title);
217
+ setAuthOpen(true);
218
+ }
219
+
220
+ async function onRewrite()""",
221
+ """ function openAuth(mode, title) {
222
+ setAuthMode(mode);
223
+ setAuthTitle(title);
224
+ setAuthOpen(true);
225
+ }
226
+
227
+ function selectProduct(id) {
228
+ setProduct(id);
229
+ setError("");
230
+ setGrammarError("");
231
+ }
232
+
233
+ async function onGrammarCheck() {
234
+ const text = grammarText.trim();
235
+ if (!text) { setGrammarError("Paste some text first — or try the sample."); return; }
236
+ if (text.length > MAX_CHARS) { setGrammarError(`Text is too long (${text.length.toLocaleString()} chars).`); return; }
237
+ setGrammarLoading(true);
238
+ setGrammarError("");
239
+ setGrammarMeta("Checking…");
240
+ try {
241
+ const result = await checkGrammar(text, session && session.access_token);
242
+ setGrammarIssues(result.issues || []);
243
+ setGrammarNote(result.note || "");
244
+ const n = (result.issues || []).length;
245
+ setGrammarMeta(n ? `${n} issue${n === 1 ? "" : "s"} · ${result.input_words} words` : `No issues found · ${result.input_words} words`);
246
+ } catch (err) {
247
+ setGrammarError(err instanceof Error ? err.message : "Grammar check failed.");
248
+ setGrammarMeta("");
249
+ setGrammarIssues([]);
250
+ } finally {
251
+ setGrammarLoading(false);
252
+ }
253
+ }
254
+
255
+ function onApplyGrammarIssue(issue) {
256
+ setGrammarText((prev) => applyGrammarFix(prev, issue));
257
+ setGrammarIssues([]);
258
+ setGrammarMeta("Fix applied — run Check grammar again to refresh.");
259
+ setGrammarNote("");
260
+ }
261
+
262
+ function onApplyAllGrammar() {
263
+ if (!grammarIssues.length) return;
264
+ setGrammarText((prev) => applyAllGrammarFixes(prev, grammarIssues));
265
+ setGrammarIssues([]);
266
+ setGrammarMeta("All suggested fixes applied — run Check grammar again to refresh.");
267
+ setGrammarNote("");
268
+ }
269
+
270
+ async function onRewrite()""",
271
+ 1,
272
+ )
273
+
274
+ old_header = """ h(\"p\", { className: \"brand-tag\" }, \"From AI-generated to plagiarism-safe — rewrite in a voice that feels real.\"),
275
+ ),"""
276
+ new_header = """ h(\"p\", { className: \"brand-tag\" }, activeProduct.tagline),
277
+ h(SiteNav, { product, onSelectProduct: selectProduct }),
278
+ ),"""
279
+ if "activeProduct.tagline" not in text:
280
+ text = text.replace(old_header, new_header, 1)
281
+ text = text.replace('h("h1", null, "ZuZu Writer"),', 'h("h1", null, activeProduct.name),', 1)
282
+
283
+ if "h(ProductSwitcher" not in text:
284
+ text = text.replace(
285
+ " isGuest && !idleSignedOut\n",
286
+ ' h(ProductSwitcher, { product, onChange: selectProduct }),\n isGuest && !idleSignedOut && product === "writer"\n',
287
+ 1,
288
+ )
289
+
290
+ text = text.replace(
291
+ """ h(LandingSections, {
292
+ plans,
293
+ authEnabled,
294
+ onSignUp: () => openAuth("signup", "Create free account"),
295
+ }),""",
296
+ """ h(LandingSections, {
297
+ plans,
298
+ authEnabled,
299
+ onSignUp: () => openAuth("signup", "Create free account"),
300
+ onSelectProduct: selectProduct,
301
+ }),""",
302
+ 1,
303
+ )
304
+
305
+ stage_start = text.find(' h("div", { className: "stage" },')
306
+ stage_end = text.find(" showUpgrade && authEnabled")
307
+ if stage_start != -1 and stage_end != -1 and "grammar-stage" not in text:
308
+ writer_stage = text[stage_start:stage_end]
309
+ grammar_stage = r''' product === "grammar"
310
+ ? h("div", { className: "stage grammar-stage" },
311
+ h("div", { className: "toolbar" },
312
+ h("div", { className: "toolbar-controls" },
313
+ h("p", { className: "grammar-lead" }, "Preview rule engine — grammar, punctuation, and common spelling. Fuller checks coming next."),
314
+ ),
315
+ h("div", { className: "toolbar-actions" },
316
+ h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: () => { setGrammarText(""); setGrammarIssues([]); setGrammarMeta(""); setGrammarError(""); setGrammarNote(""); } }, "Clear"),
317
+ h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: () => { setGrammarText(GRAMMAR_SAMPLE); setGrammarIssues([]); setGrammarError(""); setGrammarMeta("Sample loaded — hit Check grammar."); setGrammarNote(""); } }, "Try sample"),
318
+ grammarIssues.length ? h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: onApplyAllGrammar }, "Apply all") : null,
319
+ h("button", { type: "button", className: "btn btn-primary btn-rewrite", disabled: grammarLoading, onClick: () => onGrammarCheck() }, grammarLoading ? "Checking…" : "Check grammar"),
320
+ ),
321
+ ),
322
+ h("section", { className: "editors grammar-editors" },
323
+ h("div", { className: "pane" },
324
+ h("div", { className: "pane-head" }, h("h2", null, "Your text")),
325
+ h("textarea", {
326
+ value: grammarText,
327
+ onChange: (e) => { setGrammarText(e.target.value); setGrammarIssues([]); },
328
+ placeholder: "Paste a draft to check grammar and spelling…",
329
+ spellCheck: true,
330
+ }),
331
+ ),
332
+ h("div", { className: "pane pane-out grammar-issues-pane" },
333
+ h("div", { className: "pane-head" },
334
+ h("h2", null, "Issues"),
335
+ h("span", { className: "issue-count" }, grammarIssues.length ? `${grammarIssues.length} found` : (grammarMeta ? "Clean" : "—")),
336
+ ),
337
+ grammarIssues.length
338
+ ? h("ul", { className: "issue-list" },
339
+ grammarIssues.map((issue) => h("li", { key: issue.id, className: `issue-item cat-${issue.category}` },
340
+ h("div", { className: "issue-main" },
341
+ h("span", { className: "issue-cat" }, issue.category),
342
+ h("p", null, issue.message),
343
+ h("code", { className: "issue-snippet" },
344
+ (grammarText.slice(issue.start, issue.end) || "…") + (issue.suggestion != null ? ` → ${issue.suggestion}` : ""),
345
+ ),
346
+ ),
347
+ issue.suggestion != null
348
+ ? h("button", { type: "button", className: "btn btn-quiet btn-tiny", onClick: () => onApplyGrammarIssue(issue) }, "Apply")
349
+ : null,
350
+ )),
351
+ )
352
+ : h("p", { className: "grammar-empty" }, grammarNote || "Run Check grammar to see suggestions here."),
353
+ ),
354
+ ),
355
+ h("div", { className: "statusbar" },
356
+ h("div", { className: grammarError ? "error" : grammarLoading ? "loading" : undefined }, grammarError || grammarMeta || "Paste text → Check grammar"),
357
+ h("div", { className: "counts" }, h("span", null, `${wordCount(grammarText)} words`)),
358
+ ),
359
+ )
360
+ : '''
361
+ text = text[:stage_start] + grammar_stage + writer_stage + text[stage_end:]
362
+ text = text.replace(
363
+ " showUpgrade && authEnabled\n",
364
+ ' showUpgrade && authEnabled && product === "writer"\n',
365
+ 1,
366
+ )
367
+ text = text.replace(
368
+ ' h("p", { className: "hint" }, "Review the rewrite before you share or publish it."),',
369
+ ' h("p", { className: "hint" }, product === "grammar" ? "Review every suggestion before you publish." : "Review the rewrite before you share or publish it."),',
370
+ 1,
371
+ )
372
+
373
+ path.write_text(text, encoding="utf-8")
374
+ print("patched", path)
375
+ print("PRODUCTS", "const PRODUCTS =" in text)
376
+ print("grammar-stage", "grammar-stage" in text)
377
+ print("ProductSwitcher", "h(ProductSwitcher" in text)