idnameraj commited on
Commit
4c5fda9
·
verified ·
1 Parent(s): b387e01

Upload 64 files

Browse files
README.md CHANGED
@@ -92,6 +92,7 @@ Add Supabase secrets, then add the Space URL to Supabase Auth redirect URLs.
92
  ## Notes
93
 
94
  - Core rewrite engine is classical NLP (spaCy + WordNet + rules).
 
95
  - Admin: manage `profiles` / `plans` in the Supabase Table Editor.
96
  - Stripe can set `plan_id` later; see setup doc.
97
  - Review rewritten text before publishing.
 
92
  ## Notes
93
 
94
  - Core rewrite engine is classical NLP (spaCy + WordNet + rules).
95
+ - With Supabase enabled: **guest preview** on the homepage → sign up for Free → Pro later.
96
  - Admin: manage `profiles` / `plans` in the Supabase Table Editor.
97
  - Stripe can set `plan_id` later; see setup doc.
98
  - Review rewritten text before publishing.
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/auth/__pycache__/supabase_client.cpython-311.pyc CHANGED
Binary files a/app/auth/__pycache__/supabase_client.cpython-311.pyc and b/app/auth/__pycache__/supabase_client.cpython-311.pyc differ
 
app/auth/supabase_client.py CHANGED
@@ -151,8 +151,17 @@ def rest_post(path: str, body: dict[str, Any] | list[dict[str, Any]], *, upsert:
151
 
152
 
153
  def auth_public_config() -> dict[str, Any]:
 
 
 
154
  return {
155
  "enabled": AUTH_ENABLED,
156
  "supabase_url": SUPABASE_URL if AUTH_ENABLED else "",
157
  "supabase_anon_key": SUPABASE_ANON_KEY if AUTH_ENABLED else "",
 
 
 
 
 
 
158
  }
 
151
 
152
 
153
  def auth_public_config() -> dict[str, Any]:
154
+ from app.billing.quota import plans_catalog
155
+ from app.config import GUEST_DAILY_REWRITES, GUEST_DAILY_WORD_CAP, GUEST_MAX_WORDS
156
+
157
  return {
158
  "enabled": AUTH_ENABLED,
159
  "supabase_url": SUPABASE_URL if AUTH_ENABLED else "",
160
  "supabase_anon_key": SUPABASE_ANON_KEY if AUTH_ENABLED else "",
161
+ "guest": {
162
+ "daily_rewrites": GUEST_DAILY_REWRITES,
163
+ "max_words_per_request": GUEST_MAX_WORDS,
164
+ "daily_word_cap": GUEST_DAILY_WORD_CAP,
165
+ },
166
+ "plans": plans_catalog() if AUTH_ENABLED else plans_catalog(),
167
  }
app/billing/__pycache__/quota.cpython-311.pyc CHANGED
Binary files a/app/billing/__pycache__/quota.cpython-311.pyc and b/app/billing/__pycache__/quota.cpython-311.pyc differ
 
app/billing/quota.py CHANGED
@@ -1,14 +1,25 @@
1
- """Plan limits + daily usage (Supabase Postgres via PostgREST)."""
2
 
3
  from __future__ import annotations
4
 
 
 
5
  from dataclasses import dataclass
6
  from datetime import date, datetime, timezone
7
 
8
  from fastapi import HTTPException, status
9
 
10
  from app.auth.supabase_client import AuthUser, rest_get, rest_patch, rest_post
11
- from app.config import AUTH_ENABLED
 
 
 
 
 
 
 
 
 
12
 
13
 
14
  @dataclass
@@ -40,10 +51,141 @@ class AccountState:
40
  remaining_words: int
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  def _utc_today() -> str:
44
  return datetime.now(timezone.utc).date().isoformat()
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  def _ensure_profile(user: AuthUser) -> dict:
48
  rows = rest_get(
49
  "profiles",
@@ -55,7 +197,6 @@ def _ensure_profile(user: AuthUser) -> dict:
55
  if rows:
56
  return rows[0]
57
 
58
- # Trigger may lag; create profile defensively
59
  created = rest_post(
60
  "profiles",
61
  {
@@ -98,7 +239,6 @@ def _load_plan(plan_id: str) -> PlanLimits:
98
  },
99
  )
100
  if not rows:
101
- # Hard fallback if schema not applied yet
102
  return PlanLimits("free", "Free", 5, 400, 1500, 0)
103
  row = rows[0]
104
  return PlanLimits(
@@ -152,86 +292,101 @@ def get_account_state(user: AuthUser) -> AccountState:
152
  )
153
 
154
 
155
- def assert_can_rewrite(user: AuthUser | None, input_words: int) -> AccountState | None:
156
- """Enforce plan limits when auth is on. Returns account state for response meta."""
157
- if not AUTH_ENABLED or user is None:
158
- return None
159
-
160
- account = get_account_state(user)
161
- plan = account.plan
162
- usage = account.usage
163
-
164
  if input_words > plan.max_words_per_request:
165
  raise HTTPException(
166
- status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
167
  detail=(
168
- f"Your {plan.plan_name} plan allows up to "
169
- f"{plan.max_words_per_request:,} words per rewrite "
170
- f"(this text has {input_words:,}). Upgrade for higher limits."
171
  ),
172
  )
173
-
174
  if usage.rewrite_count >= plan.daily_rewrites:
175
  raise HTTPException(
176
  status_code=status.HTTP_429_TOO_MANY_REQUESTS,
177
  detail=(
178
- f"Daily rewrite limit reached ({plan.daily_rewrites} on {plan.plan_name}). "
179
- "Try again tomorrow or upgrade to Pro."
180
  ),
181
  )
182
-
183
  if usage.word_count + input_words > plan.daily_word_cap:
184
  raise HTTPException(
185
  status_code=status.HTTP_429_TOO_MANY_REQUESTS,
186
  detail=(
187
- f"Daily word limit reached ({plan.daily_word_cap:,} on {plan.plan_name}). "
188
- "Try again tomorrow or upgrade to Pro."
189
  ),
190
  )
191
 
192
- return account
193
-
194
 
195
- def record_rewrite(user: AuthUser | None, input_words: int) -> AccountState | None:
196
- if not AUTH_ENABLED or user is None:
 
 
 
 
 
 
197
  return None
198
 
199
- today = _utc_today()
200
- usage = _load_usage(user.id)
201
- new_rewrites = usage.rewrite_count + 1
202
- new_words = usage.word_count + input_words
203
 
204
- existing = rest_get(
205
- "usage_daily",
206
- {
207
- "user_id": f"eq.{user.id}",
208
- "usage_date": f"eq.{today}",
209
- "select": "user_id",
210
- "limit": "1",
211
- },
212
- )
213
- if existing:
214
- rest_patch(
 
 
 
 
 
 
 
 
215
  "usage_daily",
216
- {"rewrite_count": new_rewrites, "word_count": new_words},
217
  {
218
  "user_id": f"eq.{user.id}",
219
  "usage_date": f"eq.{today}",
 
 
220
  },
221
  )
222
- else:
223
- rest_post(
224
- "usage_daily",
225
- {
226
- "user_id": user.id,
227
- "usage_date": today,
228
- "rewrite_count": new_rewrites,
229
- "word_count": new_words,
230
- },
231
- upsert=True,
232
- )
233
-
234
- return get_account_state(user)
 
 
 
 
 
 
 
 
 
 
235
 
236
 
237
  def account_payload(account: AccountState | None) -> dict | None:
 
1
+ """Plan limits + daily usage (signed-in via Supabase, guests via IP)."""
2
 
3
  from __future__ import annotations
4
 
5
+ import hashlib
6
+ import threading
7
  from dataclasses import dataclass
8
  from datetime import date, datetime, timezone
9
 
10
  from fastapi import HTTPException, status
11
 
12
  from app.auth.supabase_client import AuthUser, rest_get, rest_patch, rest_post
13
+ from app.config import (
14
+ AUTH_ENABLED,
15
+ GUEST_DAILY_REWRITES,
16
+ GUEST_DAILY_WORD_CAP,
17
+ GUEST_MAX_WORDS,
18
+ )
19
+
20
+ # In-memory guest usage: resets on redeploy (fine for free hosting teaser)
21
+ _guest_lock = threading.Lock()
22
+ _guest_usage: dict[str, dict[str, int | str]] = {}
23
 
24
 
25
  @dataclass
 
51
  remaining_words: int
52
 
53
 
54
+ def guest_plan() -> PlanLimits:
55
+ return PlanLimits(
56
+ plan_id="guest",
57
+ plan_name="Preview",
58
+ daily_rewrites=GUEST_DAILY_REWRITES,
59
+ max_words_per_request=GUEST_MAX_WORDS,
60
+ daily_word_cap=GUEST_DAILY_WORD_CAP,
61
+ price_inr_monthly=0,
62
+ )
63
+
64
+
65
+ def fallback_plans_catalog() -> list[dict]:
66
+ """Public plan cards for upgrade UI (DB preferred when auth is on)."""
67
+ return [
68
+ {
69
+ "id": "guest",
70
+ "name": "Preview",
71
+ "daily_rewrites": GUEST_DAILY_REWRITES,
72
+ "max_words_per_request": GUEST_MAX_WORDS,
73
+ "daily_word_cap": GUEST_DAILY_WORD_CAP,
74
+ "price_inr_monthly": 0,
75
+ "blurb": "Try on the homepage — no account needed.",
76
+ },
77
+ {
78
+ "id": "free",
79
+ "name": "Free",
80
+ "daily_rewrites": 5,
81
+ "max_words_per_request": 400,
82
+ "daily_word_cap": 1500,
83
+ "price_inr_monthly": 0,
84
+ "blurb": "Sign up for daily rewrites and longer drafts.",
85
+ },
86
+ {
87
+ "id": "pro",
88
+ "name": "Pro",
89
+ "daily_rewrites": 50,
90
+ "max_words_per_request": 2000,
91
+ "daily_word_cap": 15000,
92
+ "price_inr_monthly": 199,
93
+ "blurb": "For everyday AI drafts — more words, more rewrites.",
94
+ },
95
+ {
96
+ "id": "plus",
97
+ "name": "Plus",
98
+ "daily_rewrites": 200,
99
+ "max_words_per_request": 5000,
100
+ "daily_word_cap": 50000,
101
+ "price_inr_monthly": 499,
102
+ "blurb": "Heavy daily writing and longer documents.",
103
+ },
104
+ ]
105
+
106
+
107
+ def plans_catalog() -> list[dict]:
108
+ base = {p["id"]: p for p in fallback_plans_catalog()}
109
+ if not AUTH_ENABLED:
110
+ return list(base.values())
111
+ try:
112
+ rows = rest_get(
113
+ "plans",
114
+ {
115
+ "select": "id,name,daily_rewrites,max_words_per_request,daily_word_cap,price_inr_monthly,active",
116
+ "active": "eq.true",
117
+ "order": "price_inr_monthly.asc",
118
+ },
119
+ )
120
+ for row in rows or []:
121
+ pid = row["id"]
122
+ base[pid] = {
123
+ "id": pid,
124
+ "name": row["name"],
125
+ "daily_rewrites": int(row["daily_rewrites"]),
126
+ "max_words_per_request": int(row["max_words_per_request"]),
127
+ "daily_word_cap": int(row["daily_word_cap"]),
128
+ "price_inr_monthly": int(row.get("price_inr_monthly") or 0),
129
+ "blurb": base.get(pid, {}).get("blurb", ""),
130
+ }
131
+ except Exception: # noqa: BLE001 — keep fallbacks if DB unreachable
132
+ pass
133
+ # Keep guest card first for marketing
134
+ order = ["guest", "free", "pro", "plus"]
135
+ out = [base[k] for k in order if k in base]
136
+ for k, v in base.items():
137
+ if k not in order:
138
+ out.append(v)
139
+ return out
140
+
141
+
142
  def _utc_today() -> str:
143
  return datetime.now(timezone.utc).date().isoformat()
144
 
145
 
146
+ def guest_key_from_ip(ip: str) -> str:
147
+ raw = (ip or "unknown").strip()
148
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
149
+
150
+
151
+ def _guest_snapshot(key: str) -> UsageSnapshot:
152
+ today = _utc_today()
153
+ with _guest_lock:
154
+ row = _guest_usage.get(key)
155
+ if not row or row.get("date") != today:
156
+ return UsageSnapshot(0, 0)
157
+ return UsageSnapshot(int(row["rewrites"]), int(row["words"]))
158
+
159
+
160
+ def get_guest_state(client_ip: str) -> AccountState:
161
+ plan = guest_plan()
162
+ usage = _guest_snapshot(guest_key_from_ip(client_ip))
163
+ return AccountState(
164
+ user_id="guest",
165
+ email=None,
166
+ display_name="Guest",
167
+ role="guest",
168
+ status="active",
169
+ plan=plan,
170
+ usage=usage,
171
+ remaining_rewrites=max(0, plan.daily_rewrites - usage.rewrite_count),
172
+ remaining_words=max(0, plan.daily_word_cap - usage.word_count),
173
+ )
174
+
175
+
176
+ def record_guest_rewrite(client_ip: str, input_words: int) -> AccountState:
177
+ key = guest_key_from_ip(client_ip)
178
+ today = _utc_today()
179
+ with _guest_lock:
180
+ row = _guest_usage.get(key)
181
+ if not row or row.get("date") != today:
182
+ row = {"date": today, "rewrites": 0, "words": 0}
183
+ row["rewrites"] = int(row["rewrites"]) + 1
184
+ row["words"] = int(row["words"]) + input_words
185
+ _guest_usage[key] = row
186
+ return get_guest_state(client_ip)
187
+
188
+
189
  def _ensure_profile(user: AuthUser) -> dict:
190
  rows = rest_get(
191
  "profiles",
 
197
  if rows:
198
  return rows[0]
199
 
 
200
  created = rest_post(
201
  "profiles",
202
  {
 
239
  },
240
  )
241
  if not rows:
 
242
  return PlanLimits("free", "Free", 5, 400, 1500, 0)
243
  row = rows[0]
244
  return PlanLimits(
 
292
  )
293
 
294
 
295
+ def _enforce(plan: PlanLimits, usage: UsageSnapshot, input_words: int, *, signup_hint: bool) -> None:
296
+ upgrade = (
297
+ "Sign up free for 400 words and 5 rewrites/day, or go Pro for more."
298
+ if signup_hint
299
+ else "Upgrade to Pro for higher limits."
300
+ )
 
 
 
301
  if input_words > plan.max_words_per_request:
302
  raise HTTPException(
303
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
304
  detail=(
305
+ f"{plan.plan_name} allows up to {plan.max_words_per_request:,} words per rewrite "
306
+ f"(this text has {input_words:,}). {upgrade}"
 
307
  ),
308
  )
 
309
  if usage.rewrite_count >= plan.daily_rewrites:
310
  raise HTTPException(
311
  status_code=status.HTTP_429_TOO_MANY_REQUESTS,
312
  detail=(
313
+ f"Daily rewrite limit reached ({plan.daily_rewrites} on {plan.plan_name}). {upgrade}"
 
314
  ),
315
  )
 
316
  if usage.word_count + input_words > plan.daily_word_cap:
317
  raise HTTPException(
318
  status_code=status.HTTP_429_TOO_MANY_REQUESTS,
319
  detail=(
320
+ f"Daily word limit reached ({plan.daily_word_cap:,} on {plan.plan_name}). {upgrade}"
 
321
  ),
322
  )
323
 
 
 
324
 
325
+ def assert_can_rewrite(
326
+ user: AuthUser | None,
327
+ input_words: int,
328
+ *,
329
+ client_ip: str = "",
330
+ ) -> AccountState | None:
331
+ """Enforce guest or plan limits when auth is enabled."""
332
+ if not AUTH_ENABLED:
333
  return None
334
 
335
+ if user is not None:
336
+ account = get_account_state(user)
337
+ _enforce(account.plan, account.usage, input_words, signup_hint=False)
338
+ return account
339
 
340
+ guest = get_guest_state(client_ip)
341
+ _enforce(guest.plan, guest.usage, input_words, signup_hint=True)
342
+ return guest
343
+
344
+
345
+ def record_rewrite(
346
+ user: AuthUser | None,
347
+ input_words: int,
348
+ *,
349
+ client_ip: str = "",
350
+ ) -> AccountState | None:
351
+ if not AUTH_ENABLED:
352
+ return None
353
+ if user is not None:
354
+ today = _utc_today()
355
+ usage = _load_usage(user.id)
356
+ new_rewrites = usage.rewrite_count + 1
357
+ new_words = usage.word_count + input_words
358
+ existing = rest_get(
359
  "usage_daily",
 
360
  {
361
  "user_id": f"eq.{user.id}",
362
  "usage_date": f"eq.{today}",
363
+ "select": "user_id",
364
+ "limit": "1",
365
  },
366
  )
367
+ if existing:
368
+ rest_patch(
369
+ "usage_daily",
370
+ {"rewrite_count": new_rewrites, "word_count": new_words},
371
+ {
372
+ "user_id": f"eq.{user.id}",
373
+ "usage_date": f"eq.{today}",
374
+ },
375
+ )
376
+ else:
377
+ rest_post(
378
+ "usage_daily",
379
+ {
380
+ "user_id": user.id,
381
+ "usage_date": today,
382
+ "rewrite_count": new_rewrites,
383
+ "word_count": new_words,
384
+ },
385
+ upsert=True,
386
+ )
387
+ return get_account_state(user)
388
+
389
+ return record_guest_rewrite(client_ip, input_words)
390
 
391
 
392
  def account_payload(account: AccountState | None) -> dict | None:
app/config.py CHANGED
@@ -28,3 +28,8 @@ SUPABASE_SERVICE_ROLE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY") or ""
28
  SUPABASE_JWT_SECRET = os.environ.get("SUPABASE_JWT_SECRET") or ""
29
 
30
  AUTH_ENABLED = bool(SUPABASE_URL and SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY)
 
 
 
 
 
 
28
  SUPABASE_JWT_SECRET = os.environ.get("SUPABASE_JWT_SECRET") or ""
29
 
30
  AUTH_ENABLED = bool(SUPABASE_URL and SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY)
31
+
32
+ # Guest / homepage teaser (when auth is enabled and user is not signed in)
33
+ GUEST_DAILY_REWRITES = int(os.environ.get("GUEST_DAILY_REWRITES", "1") or "1")
34
+ GUEST_MAX_WORDS = int(os.environ.get("GUEST_MAX_WORDS", "100") or "100")
35
+ GUEST_DAILY_WORD_CAP = int(os.environ.get("GUEST_DAILY_WORD_CAP", "100") or "100")
app/main.py CHANGED
@@ -9,15 +9,21 @@ ROOT = Path(__file__).resolve().parent.parent
9
  if str(ROOT) not in sys.path:
10
  sys.path.insert(0, str(ROOT))
11
 
12
- from fastapi import Depends, FastAPI, HTTPException
13
  from fastapi.middleware.cors import CORSMiddleware
14
  from fastapi.responses import FileResponse
15
  from fastapi.staticfiles import StaticFiles
16
  from pydantic import BaseModel, Field
17
 
18
- from app.auth.deps import require_user
19
  from app.auth.supabase_client import AuthUser, auth_public_config
20
- from app.billing.quota import account_payload, assert_can_rewrite, get_account_state, record_rewrite
 
 
 
 
 
 
21
  from app.bootstrap import ensure_resources
22
  from app.config import APP_TITLE, AUTH_ENABLED, MAX_CHARS
23
  from app.pipeline.nlp import spacy_available
@@ -28,7 +34,7 @@ ensure_resources()
28
 
29
  STATIC_DIR = ROOT / "frontend" / "dist"
30
 
31
- app = FastAPI(title=APP_TITLE, version="2.1.0")
32
 
33
  app.add_middleware(
34
  CORSMiddleware,
@@ -51,6 +57,15 @@ class SimilarityRequest(BaseModel):
51
  reference: str
52
 
53
 
 
 
 
 
 
 
 
 
 
54
  @app.get("/health")
55
  def health():
56
  engines = ["rules", "wordnet", "mechanics"]
@@ -66,22 +81,30 @@ def health():
66
 
67
  @app.get("/v1/auth/config")
68
  def api_auth_config():
69
- """Public Supabase keys for the browser (anon key is safe to expose)."""
70
  return auth_public_config()
71
 
72
 
73
  @app.get("/v1/me")
74
- def api_me(user: AuthUser | None = Depends(require_user)):
 
 
 
75
  if not AUTH_ENABLED:
76
  return {"auth_enabled": False, "account": None}
77
- assert user is not None
 
 
 
 
78
  return {"auth_enabled": True, "account": account_payload(get_account_state(user))}
79
 
80
 
81
  @app.post("/v1/rewrite")
82
  def api_rewrite(
83
  body: RewriteRequest,
84
- user: AuthUser | None = Depends(require_user),
 
85
  ):
86
  text = body.text.strip()
87
  if not text:
@@ -92,8 +115,9 @@ def api_rewrite(
92
  detail=f"Text is too long ({len(text):,} chars; max {MAX_CHARS:,}).",
93
  )
94
 
 
95
  input_words = len(text.split())
96
- assert_can_rewrite(user, input_words)
97
 
98
  try:
99
  result = rewrite_text(
@@ -105,7 +129,7 @@ def api_rewrite(
105
  except ValueError as exc:
106
  raise HTTPException(status_code=400, detail=str(exc)) from exc
107
 
108
- account = record_rewrite(user, result.input_words)
109
 
110
  return {
111
  "rewrite": result.text,
@@ -126,9 +150,9 @@ def api_rewrite(
126
  @app.post("/v1/similarity")
127
  def api_similarity(
128
  body: SimilarityRequest,
129
- user: AuthUser | None = Depends(require_user),
130
  ):
131
- _ = user # auth gate only when enabled
132
  sim = similarity_check(body.rewrite, body.reference)
133
  return {
134
  "overlap_pct": sim.overlap_pct,
 
9
  if str(ROOT) not in sys.path:
10
  sys.path.insert(0, str(ROOT))
11
 
12
+ from fastapi import Depends, FastAPI, HTTPException, Request
13
  from fastapi.middleware.cors import CORSMiddleware
14
  from fastapi.responses import FileResponse
15
  from fastapi.staticfiles import StaticFiles
16
  from pydantic import BaseModel, Field
17
 
18
+ from app.auth.deps import optional_user
19
  from app.auth.supabase_client import AuthUser, auth_public_config
20
+ from app.billing.quota import (
21
+ account_payload,
22
+ assert_can_rewrite,
23
+ get_account_state,
24
+ get_guest_state,
25
+ record_rewrite,
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
 
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
  reference: str
58
 
59
 
60
+ def _client_ip(request: Request) -> str:
61
+ forwarded = request.headers.get("x-forwarded-for") or ""
62
+ if forwarded:
63
+ return forwarded.split(",")[0].strip()
64
+ if request.client and request.client.host:
65
+ return request.client.host
66
+ return "unknown"
67
+
68
+
69
  @app.get("/health")
70
  def health():
71
  engines = ["rules", "wordnet", "mechanics"]
 
81
 
82
  @app.get("/v1/auth/config")
83
  def api_auth_config():
84
+ """Public Supabase keys + plan cards for the UI (anon key is safe to expose)."""
85
  return auth_public_config()
86
 
87
 
88
  @app.get("/v1/me")
89
+ def api_me(
90
+ request: Request,
91
+ user: AuthUser | None = Depends(optional_user),
92
+ ):
93
  if not AUTH_ENABLED:
94
  return {"auth_enabled": False, "account": None}
95
+ if user is None:
96
+ return {
97
+ "auth_enabled": True,
98
+ "account": account_payload(get_guest_state(_client_ip(request))),
99
+ }
100
  return {"auth_enabled": True, "account": account_payload(get_account_state(user))}
101
 
102
 
103
  @app.post("/v1/rewrite")
104
  def api_rewrite(
105
  body: RewriteRequest,
106
+ request: Request,
107
+ user: AuthUser | None = Depends(optional_user),
108
  ):
109
  text = body.text.strip()
110
  if not text:
 
115
  detail=f"Text is too long ({len(text):,} chars; max {MAX_CHARS:,}).",
116
  )
117
 
118
+ ip = _client_ip(request)
119
  input_words = len(text.split())
120
+ assert_can_rewrite(user, input_words, client_ip=ip)
121
 
122
  try:
123
  result = rewrite_text(
 
129
  except ValueError as exc:
130
  raise HTTPException(status_code=400, detail=str(exc)) from exc
131
 
132
+ account = record_rewrite(user, result.input_words, client_ip=ip)
133
 
134
  return {
135
  "rewrite": result.text,
 
150
  @app.post("/v1/similarity")
151
  def api_similarity(
152
  body: SimilarityRequest,
153
+ user: AuthUser | None = Depends(optional_user),
154
  ):
155
+ _ = user
156
  sim = similarity_check(body.rewrite, body.reference)
157
  return {
158
  "overlap_pct": sim.overlap_pct,
frontend/dist/assets/index.css CHANGED
@@ -526,6 +526,148 @@ textarea {
526
  font-size: 0.88rem;
527
  }
528
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  @media (max-width: 920px) {
530
  .topbar {
531
  flex-direction: column;
 
526
  font-size: 0.88rem;
527
  }
528
 
529
+ .teaser-banner {
530
+ margin: -0.5rem 0 1.1rem;
531
+ padding: 0.75rem 1rem;
532
+ border-radius: 12px;
533
+ background: var(--accent-soft);
534
+ color: var(--ink-soft);
535
+ font-size: 0.92rem;
536
+ line-height: 1.4;
537
+ }
538
+
539
+ .teaser-banner strong {
540
+ color: var(--accent);
541
+ }
542
+
543
+ .auth-inline {
544
+ display: flex;
545
+ align-items: center;
546
+ gap: 0.35rem;
547
+ margin-top: 0.25rem;
548
+ }
549
+
550
+ .btn-compact {
551
+ padding: 0.4rem 0.75rem;
552
+ font-size: 0.8rem;
553
+ }
554
+
555
+ .counts .over-limit {
556
+ color: var(--warn);
557
+ font-weight: 600;
558
+ }
559
+
560
+ .modal-backdrop {
561
+ position: fixed;
562
+ inset: 0;
563
+ z-index: 40;
564
+ display: grid;
565
+ place-items: center;
566
+ padding: 1rem;
567
+ background: rgba(18, 28, 26, 0.45);
568
+ backdrop-filter: blur(4px);
569
+ animation: enter 0.25s ease both;
570
+ }
571
+
572
+ .modal-card {
573
+ position: relative;
574
+ max-height: min(90vh, 640px);
575
+ overflow: auto;
576
+ }
577
+
578
+ .modal-card h2 {
579
+ margin: 0 1.5rem 0 0;
580
+ font-family: var(--font-display);
581
+ font-size: 1.75rem;
582
+ letter-spacing: -0.02em;
583
+ }
584
+
585
+ .modal-close {
586
+ position: absolute;
587
+ top: 0.75rem;
588
+ right: 0.85rem;
589
+ border: 0;
590
+ background: transparent;
591
+ color: var(--muted);
592
+ font-size: 1.5rem;
593
+ line-height: 1;
594
+ cursor: pointer;
595
+ }
596
+
597
+ .upgrade-card {
598
+ margin-top: 1.25rem;
599
+ padding: 1.25rem 1.35rem;
600
+ border: 1px solid var(--panel-edge);
601
+ border-radius: 18px;
602
+ background: rgba(251, 252, 251, 0.85);
603
+ box-shadow: var(--shadow-soft);
604
+ animation: fade-up 0.45s ease both;
605
+ }
606
+
607
+ .upgrade-copy h3 {
608
+ margin: 0;
609
+ font-family: var(--font-display);
610
+ font-size: 1.35rem;
611
+ letter-spacing: -0.02em;
612
+ }
613
+
614
+ .upgrade-copy p {
615
+ margin: 0.45rem 0 0;
616
+ color: var(--ink-soft);
617
+ font-size: 0.95rem;
618
+ line-height: 1.45;
619
+ max-width: 40rem;
620
+ }
621
+
622
+ .upgrade-plans {
623
+ display: flex;
624
+ flex-wrap: wrap;
625
+ gap: 0.65rem;
626
+ margin-top: 1rem;
627
+ }
628
+
629
+ .plan-pill {
630
+ display: flex;
631
+ flex-direction: column;
632
+ gap: 0.15rem;
633
+ min-width: 9.5rem;
634
+ padding: 0.7rem 0.85rem;
635
+ border-radius: 12px;
636
+ border: 1px solid var(--panel-edge);
637
+ background: rgba(232, 239, 236, 0.55);
638
+ font-size: 0.82rem;
639
+ color: var(--ink-soft);
640
+ }
641
+
642
+ .plan-pill strong {
643
+ color: var(--ink);
644
+ font-size: 0.9rem;
645
+ }
646
+
647
+ .plan-pill-pro {
648
+ border-color: rgba(26, 92, 74, 0.35);
649
+ background: var(--accent-soft);
650
+ }
651
+
652
+ .plan-price {
653
+ font-weight: 700;
654
+ color: var(--accent);
655
+ margin-top: 0.15rem;
656
+ }
657
+
658
+ .upgrade-actions {
659
+ display: flex;
660
+ flex-wrap: wrap;
661
+ gap: 0.5rem;
662
+ margin-top: 1rem;
663
+ }
664
+
665
+ .upgrade-note {
666
+ margin: 0.75rem 0 0;
667
+ color: var(--muted);
668
+ font-size: 0.78rem;
669
+ }
670
+
671
  @media (max-width: 920px) {
672
  .topbar {
673
  flex-direction: column;
frontend/dist/assets/index.js CHANGED
@@ -25,9 +25,9 @@ async function loadAuthConfig() {
25
  const res = await fetch("/v1/auth/config");
26
  cachedConfig = res.ok
27
  ? await res.json()
28
- : { enabled: false, supabase_url: "", supabase_anon_key: "" };
29
  } catch {
30
- cachedConfig = { enabled: false, supabase_url: "", supabase_anon_key: "" };
31
  }
32
  return cachedConfig;
33
  }
@@ -36,11 +36,7 @@ async function initSupabase() {
36
  const config = await loadAuthConfig();
37
  if (config.enabled && config.supabase_url && config.supabase_anon_key) {
38
  supabaseClient = createClient(config.supabase_url, config.supabase_anon_key, {
39
- auth: {
40
- persistSession: true,
41
- autoRefreshToken: true,
42
- detectSessionInUrl: true,
43
- },
44
  });
45
  } else {
46
  supabaseClient = null;
@@ -55,9 +51,7 @@ function wordCount(text) {
55
  function detailMessage(detail, fallback) {
56
  if (typeof detail === "string") return detail;
57
  if (Array.isArray(detail)) {
58
- return detail
59
- .map((d) => (d && typeof d === "object" && d.msg ? String(d.msg) : String(d)))
60
- .join(" ");
61
  }
62
  return fallback;
63
  }
@@ -65,28 +59,25 @@ function detailMessage(detail, fallback) {
65
  async function rewriteText(payload, accessToken) {
66
  const headers = { "Content-Type": "application/json" };
67
  if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
68
- const res = await fetch("/v1/rewrite", {
69
- method: "POST",
70
- headers,
71
- body: JSON.stringify(payload),
72
- });
73
  if (!res.ok) {
74
  let detail = "Rewrite failed.";
75
  try {
76
  const data = await res.json();
77
  detail = data.detail || detail;
78
- } catch {
79
- /* ignore */
80
- }
81
- throw new Error(detailMessage(detail, "Rewrite failed."));
 
82
  }
83
  return res.json();
84
  }
85
 
86
  async function fetchMe(accessToken) {
87
- const res = await fetch("/v1/me", {
88
- headers: { Authorization: `Bearer ${accessToken}` },
89
- });
90
  if (!res.ok) throw new Error("Could not load account.");
91
  return res.json();
92
  }
@@ -98,14 +89,16 @@ function AuthProvider({ children }) {
98
  const [authEnabled, setAuthEnabled] = useState(false);
99
  const [session, setSession] = useState(null);
100
  const [account, setAccount] = useState(null);
 
 
101
 
102
  const refreshAccount = useCallback(async () => {
103
- if (!authEnabled || !session) {
104
  setAccount(null);
105
  return;
106
  }
107
  try {
108
- const me = await fetchMe(session.access_token);
109
  setAccount(me.account);
110
  } catch {
111
  setAccount(null);
@@ -117,62 +110,51 @@ function AuthProvider({ children }) {
117
  (async () => {
118
  const config = await initSupabase();
119
  setAuthEnabled(!!config.enabled);
 
 
120
  if (!config.enabled || !supabaseClient) {
121
  setReady(true);
122
  return;
123
  }
124
  const { data } = await supabaseClient.auth.getSession();
125
  setSession(data.session);
126
- const { data: listener } = supabaseClient.auth.onAuthStateChange((_e, next) => {
127
- setSession(next);
128
- });
129
  unsub = () => listener.subscription.unsubscribe();
130
  setReady(true);
131
  })();
132
- return () => {
133
- if (unsub) unsub();
134
- };
135
  }, []);
136
 
137
- useEffect(() => {
138
- refreshAccount();
139
- }, [refreshAccount]);
140
-
141
- const value = useMemo(
142
- () => ({
143
- ready,
144
- authEnabled,
145
- session,
146
- account,
147
- setAccount,
148
- refreshAccount,
149
- async signInWithPassword(email, password) {
150
- if (!supabaseClient) throw new Error("Auth is not configured.");
151
- const { error } = await supabaseClient.auth.signInWithPassword({ email, password });
152
- if (error) throw error;
153
- },
154
- async signUp(email, password) {
155
- if (!supabaseClient) throw new Error("Auth is not configured.");
156
- const { data, error } = await supabaseClient.auth.signUp({ email, password });
157
- if (error) throw error;
158
- return data.session ? "signed_in" : "check_email";
159
- },
160
- async signInWithGoogle() {
161
- if (!supabaseClient) throw new Error("Auth is not configured.");
162
- const { error } = await supabaseClient.auth.signInWithOAuth({
163
- provider: "google",
164
- options: { redirectTo: window.location.origin },
165
- });
166
- if (error) throw error;
167
- },
168
- async signOut() {
169
- if (!supabaseClient) return;
170
- await supabaseClient.auth.signOut();
171
- setAccount(null);
172
- },
173
- }),
174
- [ready, authEnabled, session, account, refreshAccount],
175
- );
176
 
177
  return h(AuthContext.Provider, { value }, children);
178
  }
@@ -183,15 +165,25 @@ function useAuth() {
183
  return ctx;
184
  }
185
 
186
- function AuthScreen() {
187
  const { signInWithPassword, signUp, signInWithGoogle } = useAuth();
188
- const [mode, setMode] = useState("signin");
189
  const [email, setEmail] = useState("");
190
  const [password, setPassword] = useState("");
191
  const [busy, setBusy] = useState(false);
192
  const [message, setMessage] = useState("");
193
  const [error, setError] = useState("");
194
 
 
 
 
 
 
 
 
 
 
 
195
  async function onSubmit(e) {
196
  e.preventDefault();
197
  setBusy(true);
@@ -200,11 +192,11 @@ function AuthScreen() {
200
  try {
201
  if (mode === "signin") {
202
  await signInWithPassword(email.trim(), password);
 
203
  } else {
204
  const result = await signUp(email.trim(), password);
205
- if (result === "check_email") {
206
- setMessage("Check your email to confirm your account, then sign in.");
207
- }
208
  }
209
  } catch (err) {
210
  setError(err instanceof Error ? err.message : "Authentication failed.");
@@ -213,49 +205,31 @@ function AuthScreen() {
213
  }
214
  }
215
 
216
- return h("div", { className: "auth-shell" },
217
- h("div", { className: "auth-card" },
218
- h("h1", null, "PlainRewrite"),
219
- h("p", { className: "auth-lead" },
220
- "From AI-generated to plagiarism-safe — rewrite in a voice that feels real.",
221
- ),
222
- h("div", { className: "segment auth-tabs", role: "group", "aria-label": "Auth mode" },
223
- h("button", {
224
- type: "button",
225
- className: mode === "signin" ? "active" : "",
226
- onClick: () => setMode("signin"),
227
- }, "Sign in"),
228
- h("button", {
229
- type: "button",
230
- className: mode === "signup" ? "active" : "",
231
- onClick: () => setMode("signup"),
232
- }, "Sign up"),
233
  ),
234
- h("form", { className: "auth-form", onSubmit: onSubmit },
235
- h("label", null, "Email",
236
- h("input", {
237
- type: "email",
238
- autoComplete: "email",
239
- value: email,
240
- onChange: (e) => setEmail(e.target.value),
241
- required: true,
242
- }),
243
- ),
244
- h("label", null, "Password",
245
- h("input", {
246
- type: "password",
247
- autoComplete: mode === "signin" ? "current-password" : "new-password",
248
- value: password,
249
- onChange: (e) => setPassword(e.target.value),
250
- minLength: 6,
251
- required: true,
252
- }),
253
- ),
254
- h("button", {
255
- type: "submit",
256
- className: "btn btn-primary auth-submit",
257
- disabled: busy,
258
- }, busy ? "Please wait…" : mode === "signin" ? "Sign in" : "Create account"),
259
  ),
260
  h("div", { className: "auth-divider" }, "or"),
261
  h("button", {
@@ -270,8 +244,61 @@ function AuthScreen() {
270
  );
271
  }
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  function App() {
274
- const { ready, authEnabled, session, account, setAccount, signOut } = useAuth();
 
 
275
  const [input, setInput] = useState("");
276
  const [output, setOutput] = useState("");
277
  const [tone, setTone] = useState("Neutral");
@@ -282,6 +309,13 @@ function App() {
282
  const [meta, setMeta] = useState("");
283
  const [copied, setCopied] = useState(false);
284
  const [freshOut, setFreshOut] = useState(false);
 
 
 
 
 
 
 
285
 
286
  useEffect(() => {
287
  if (!copied) return undefined;
@@ -289,6 +323,13 @@ function App() {
289
  return () => window.clearTimeout(t);
290
  }, [copied]);
291
 
 
 
 
 
 
 
 
292
  useEffect(() => {
293
  const onKey = (e) => {
294
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
@@ -300,44 +341,54 @@ function App() {
300
  return () => window.removeEventListener("keydown", onKey);
301
  });
302
 
 
 
 
 
 
 
303
  async function onRewrite() {
304
  const text = input.trim();
305
- if (!text) {
306
- setError("Paste some text first.");
307
- return;
308
- }
309
  if (text.length > MAX_CHARS) {
310
  setError(`Text is too long (${text.length.toLocaleString()} chars).`);
311
  return;
312
  }
 
 
 
 
 
 
 
313
 
314
  setLoading(true);
315
  setError("");
316
  setMeta("Rewriting…");
317
  setFreshOut(false);
318
  try {
319
- const result = await rewriteText(
320
- {
321
- text,
322
- tone,
323
- strength: STRENGTH_MAP[strength],
324
- preserve_length: preserveLength,
325
- },
326
- session && session.access_token,
327
- );
328
  setOutput(result.rewrite);
329
  setFreshOut(true);
330
  if (result.account) setAccount(result.account);
331
- const quota =
332
- result.account && result.account.usage
333
- ? ` · ${result.account.usage.remaining_rewrites} rewrites left today`
334
- : "";
335
- setMeta(
336
- `${result.meta.input_words.toLocaleString()} → ${result.meta.output_words.toLocaleString()} words · ${result.meta.seconds}s${quota}`,
337
- );
338
  } catch (err) {
339
  setError(err instanceof Error ? err.message : "Rewrite failed.");
340
  setMeta("");
 
 
 
 
341
  } finally {
342
  setLoading(false);
343
  }
@@ -366,14 +417,9 @@ function App() {
366
 
367
  const inWords = wordCount(input);
368
  const outWords = wordCount(output);
 
369
 
370
- if (!ready) {
371
- return h("div", { className: "app shell" }, h("p", { className: "hint" }, "Loading…"));
372
- }
373
-
374
- if (authEnabled && !session) {
375
- return h(AuthScreen);
376
- }
377
 
378
  return h("div", { className: "app shell" },
379
  h("div", { className: "topbar" },
@@ -382,74 +428,63 @@ function App() {
382
  h("p", null, "From AI-generated to plagiarism-safe — rewrite in a voice that feels real."),
383
  ),
384
  h("div", { className: "top-meta" },
385
- account
386
  ? h("div", { className: "account-chip" },
387
  h("div", { className: "account-plan" }, account.plan.name),
388
  h("div", null, `${account.usage.remaining_rewrites}/${account.plan.daily_rewrites} rewrites today`),
389
  h("div", { className: "account-email" }, account.email),
390
- h("button", {
391
- type: "button",
392
- className: "ghost-btn",
393
- onClick: () => signOut(),
394
- }, "Sign out"),
395
  )
396
- : h(Fragment, null, `${tone} · ${strength}`, h("br"), "⌘/Ctrl + Enter"),
 
 
 
 
 
 
 
 
 
 
 
397
  ),
398
  ),
 
 
 
 
 
 
399
  h("div", { className: "stage" },
400
  h("div", { className: "toolbar" },
401
  h("div", { className: "tool-group" },
402
  h("span", null, "Tone"),
403
- h("div", { className: "segment", role: "group", "aria-label": "Tone" },
404
- TONES.map((t) =>
405
- h("button", {
406
- key: t,
407
- type: "button",
408
- className: tone === t ? "active" : "",
409
- onClick: () => setTone(t),
410
- }, t),
411
- ),
412
  ),
413
  ),
414
  h("div", { className: "tool-group" },
415
  h("span", null, "Strength"),
416
- h("div", { className: "segment", role: "group", "aria-label": "Strength" },
417
- STRENGTHS.map((s) =>
418
- h("button", {
419
- key: s,
420
- type: "button",
421
- className: strength === s ? "active" : "",
422
- onClick: () => setStrength(s),
423
- }, s),
424
- ),
425
  ),
426
  ),
427
  h("label", { className: "check" },
428
- h("input", {
429
- type: "checkbox",
430
- checked: preserveLength,
431
- onChange: (e) => setPreserveLength(e.target.checked),
432
- }),
433
  " Match length",
434
  ),
435
  h("div", { className: "toolbar-actions" },
436
  h("button", {
437
  type: "button",
438
  className: "btn btn-quiet",
439
- onClick: () => {
440
- setInput("");
441
- setOutput("");
442
- setMeta("");
443
- setError("");
444
- setFreshOut(false);
445
- },
446
  disabled: loading,
 
447
  }, "Clear"),
448
  h("button", {
449
  type: "button",
450
  className: "btn btn-primary",
451
- onClick: () => onRewrite(),
452
  disabled: loading,
 
453
  }, loading ? "Rewriting…" : "Rewrite"),
454
  ),
455
  ),
@@ -459,7 +494,7 @@ function App() {
459
  h("textarea", {
460
  value: input,
461
  onChange: (e) => setInput(e.target.value),
462
- placeholder: "Paste your draft here…",
463
  spellCheck: true,
464
  }),
465
  ),
@@ -467,45 +502,44 @@ function App() {
467
  h("div", { className: "pane-head" },
468
  h("h2", null, "Rewrite"),
469
  h("div", { className: "pane-actions" },
470
- h("button", {
471
- type: "button",
472
- className: "ghost-btn",
473
- onClick: () => onCopy(),
474
- disabled: !output.trim(),
475
- }, copied ? "Copied" : "Copy"),
476
- h("button", {
477
- type: "button",
478
- className: "ghost-btn",
479
- onClick: onDownload,
480
- disabled: !output.trim(),
481
- }, "Download"),
482
  ),
483
  ),
484
  h("textarea", {
485
  value: output,
486
- onChange: (e) => {
487
- setOutput(e.target.value);
488
- setFreshOut(false);
489
- },
490
  placeholder: "Your rewrite appears here…",
491
  spellCheck: true,
492
  }),
493
  ),
494
  ),
495
  h("div", { className: "statusbar" },
496
- h("div", { className: error ? "error" : loading ? "loading" : undefined },
497
- error || meta || "Ready to rewrite",
498
- ),
499
  h("div", { className: "counts" },
500
- h("span", null, `${inWords} words in`),
 
501
  h("span", null, `${outWords} words out`),
502
- account
503
- ? h("span", null, `max ${account.plan.max_words_per_request.toLocaleString()} / rewrite`)
504
- : null,
505
  ),
506
  ),
507
  ),
 
 
 
 
 
 
 
 
508
  h("p", { className: "hint" }, "Review the rewrite before you share or publish it."),
 
 
 
 
 
 
 
 
509
  );
510
  }
511
 
 
25
  const res = await fetch("/v1/auth/config");
26
  cachedConfig = res.ok
27
  ? await res.json()
28
+ : { enabled: false, supabase_url: "", supabase_anon_key: "", plans: [], guest: { max_words_per_request: 100, daily_rewrites: 1 } };
29
  } catch {
30
+ cachedConfig = { enabled: false, supabase_url: "", supabase_anon_key: "", plans: [], guest: { max_words_per_request: 100, daily_rewrites: 1 } };
31
  }
32
  return cachedConfig;
33
  }
 
36
  const config = await loadAuthConfig();
37
  if (config.enabled && config.supabase_url && config.supabase_anon_key) {
38
  supabaseClient = createClient(config.supabase_url, config.supabase_anon_key, {
39
+ auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true },
 
 
 
 
40
  });
41
  } else {
42
  supabaseClient = null;
 
51
  function detailMessage(detail, fallback) {
52
  if (typeof detail === "string") return detail;
53
  if (Array.isArray(detail)) {
54
+ return detail.map((d) => (d && typeof d === "object" && d.msg ? String(d.msg) : String(d))).join(" ");
 
 
55
  }
56
  return fallback;
57
  }
 
59
  async function rewriteText(payload, accessToken) {
60
  const headers = { "Content-Type": "application/json" };
61
  if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
62
+ const res = await fetch("/v1/rewrite", { method: "POST", headers, body: JSON.stringify(payload) });
 
 
 
 
63
  if (!res.ok) {
64
  let detail = "Rewrite failed.";
65
  try {
66
  const data = await res.json();
67
  detail = data.detail || detail;
68
+ } catch { /* ignore */ }
69
+ const err = new Error(detailMessage(detail, "Rewrite failed."));
70
+ err.status = res.status;
71
+ if (res.status === 401 || res.status === 429 || res.status === 413) err.code = "limit";
72
+ throw err;
73
  }
74
  return res.json();
75
  }
76
 
77
  async function fetchMe(accessToken) {
78
+ const headers = {};
79
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
80
+ const res = await fetch("/v1/me", { headers });
81
  if (!res.ok) throw new Error("Could not load account.");
82
  return res.json();
83
  }
 
89
  const [authEnabled, setAuthEnabled] = useState(false);
90
  const [session, setSession] = useState(null);
91
  const [account, setAccount] = useState(null);
92
+ const [plans, setPlans] = useState([]);
93
+ const [guestMaxWords, setGuestMaxWords] = useState(100);
94
 
95
  const refreshAccount = useCallback(async () => {
96
+ if (!authEnabled) {
97
  setAccount(null);
98
  return;
99
  }
100
  try {
101
+ const me = await fetchMe(session && session.access_token);
102
  setAccount(me.account);
103
  } catch {
104
  setAccount(null);
 
110
  (async () => {
111
  const config = await initSupabase();
112
  setAuthEnabled(!!config.enabled);
113
+ setPlans(config.plans || []);
114
+ setGuestMaxWords((config.guest && config.guest.max_words_per_request) || 100);
115
  if (!config.enabled || !supabaseClient) {
116
  setReady(true);
117
  return;
118
  }
119
  const { data } = await supabaseClient.auth.getSession();
120
  setSession(data.session);
121
+ const { data: listener } = supabaseClient.auth.onAuthStateChange((_e, next) => setSession(next));
 
 
122
  unsub = () => listener.subscription.unsubscribe();
123
  setReady(true);
124
  })();
125
+ return () => { if (unsub) unsub(); };
 
 
126
  }, []);
127
 
128
+ useEffect(() => { refreshAccount(); }, [refreshAccount]);
129
+
130
+ const value = useMemo(() => ({
131
+ ready, authEnabled, session, account, plans, guestMaxWords, setAccount, refreshAccount,
132
+ async signInWithPassword(email, password) {
133
+ if (!supabaseClient) throw new Error("Auth is not configured.");
134
+ const { error } = await supabaseClient.auth.signInWithPassword({ email, password });
135
+ if (error) throw error;
136
+ },
137
+ async signUp(email, password) {
138
+ if (!supabaseClient) throw new Error("Auth is not configured.");
139
+ const { data, error } = await supabaseClient.auth.signUp({ email, password });
140
+ if (error) throw error;
141
+ return data.session ? "signed_in" : "check_email";
142
+ },
143
+ async signInWithGoogle() {
144
+ if (!supabaseClient) throw new Error("Auth is not configured.");
145
+ const { error } = await supabaseClient.auth.signInWithOAuth({
146
+ provider: "google",
147
+ options: { redirectTo: window.location.origin },
148
+ });
149
+ if (error) throw error;
150
+ },
151
+ async signOut() {
152
+ if (!supabaseClient) return;
153
+ await supabaseClient.auth.signOut();
154
+ setAccount(null);
155
+ await refreshAccount();
156
+ },
157
+ }), [ready, authEnabled, session, account, plans, guestMaxWords, refreshAccount]);
 
 
 
 
 
 
 
 
 
158
 
159
  return h(AuthContext.Provider, { value }, children);
160
  }
 
165
  return ctx;
166
  }
167
 
168
+ function AuthModal({ open, onClose, initialMode, title }) {
169
  const { signInWithPassword, signUp, signInWithGoogle } = useAuth();
170
+ const [mode, setMode] = useState(initialMode || "signup");
171
  const [email, setEmail] = useState("");
172
  const [password, setPassword] = useState("");
173
  const [busy, setBusy] = useState(false);
174
  const [message, setMessage] = useState("");
175
  const [error, setError] = useState("");
176
 
177
+ useEffect(() => {
178
+ if (open) {
179
+ setMode(initialMode || "signup");
180
+ setError("");
181
+ setMessage("");
182
+ }
183
+ }, [open, initialMode]);
184
+
185
+ if (!open) return null;
186
+
187
  async function onSubmit(e) {
188
  e.preventDefault();
189
  setBusy(true);
 
192
  try {
193
  if (mode === "signin") {
194
  await signInWithPassword(email.trim(), password);
195
+ onClose();
196
  } else {
197
  const result = await signUp(email.trim(), password);
198
+ if (result === "check_email") setMessage("Check your email to confirm your account, then sign in.");
199
+ else onClose();
 
200
  }
201
  } catch (err) {
202
  setError(err instanceof Error ? err.message : "Authentication failed.");
 
205
  }
206
  }
207
 
208
+ return h("div", { className: "modal-backdrop", onClick: onClose },
209
+ h("div", {
210
+ className: "auth-card modal-card",
211
+ role: "dialog",
212
+ "aria-modal": "true",
213
+ onClick: (e) => e.stopPropagation(),
214
+ },
215
+ h("button", { type: "button", className: "modal-close", onClick: onClose, "aria-label": "Close" }, "×"),
216
+ h("h2", null, title || (mode === "signin" ? "Sign in" : "Create free account")),
217
+ h("p", { className: "auth-lead" }, "Unlock longer rewrites and daily limits. Google or email — takes a minute."),
218
+ h("div", { className: "segment auth-tabs", role: "group" },
219
+ h("button", { type: "button", className: mode === "signin" ? "active" : "", onClick: () => setMode("signin") }, "Sign in"),
220
+ h("button", { type: "button", className: mode === "signup" ? "active" : "", onClick: () => setMode("signup") }, "Sign up"),
 
 
 
 
221
  ),
222
+ h("form", { className: "auth-form", onSubmit },
223
+ h("label", null, "Email", h("input", { type: "email", value: email, onChange: (e) => setEmail(e.target.value), required: true })),
224
+ h("label", null, "Password", h("input", {
225
+ type: "password",
226
+ value: password,
227
+ onChange: (e) => setPassword(e.target.value),
228
+ minLength: 6,
229
+ required: true,
230
+ })),
231
+ h("button", { type: "submit", className: "btn btn-primary auth-submit", disabled: busy },
232
+ busy ? "Please wait…" : mode === "signin" ? "Sign in" : "Create free account"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  ),
234
  h("div", { className: "auth-divider" }, "or"),
235
  h("button", {
 
244
  );
245
  }
246
 
247
+ function UpgradeCard({ account, plans, onSignUp, onSignIn }) {
248
+ const planId = (account && account.plan && account.plan.id) || "guest";
249
+ if (planId === "pro" || planId === "plus") return null;
250
+ const free = plans.find((p) => p.id === "free");
251
+ const pro = plans.find((p) => p.id === "pro");
252
+ const isGuest = planId === "guest" || (account && account.role === "guest");
253
+
254
+ return h("section", { className: "upgrade-card", "aria-label": "Upgrade plans" },
255
+ h("div", { className: "upgrade-copy" },
256
+ h("h3", null, isGuest
257
+ ? "Liked the rewrite? Unlock more with a free account"
258
+ : "Need longer drafts every day? Go Pro"),
259
+ h("p", null, isGuest
260
+ ? `Preview is capped at ${(account && account.plan && account.plan.max_words_per_request) || 100} words and ${(account && account.plan && account.plan.daily_rewrites) || 1} rewrite/day. Sign up free for higher limits — or Pro for serious daily use.`
261
+ : `You're on ${(account && account.plan && account.plan.name) || "Free"}. Pro gives up to ${(pro && pro.max_words_per_request ? pro.max_words_per_request.toLocaleString() : "2,000")} words per rewrite and ${(pro && pro.daily_rewrites) || 50} rewrites/day.`),
262
+ ),
263
+ h("div", { className: "upgrade-plans" },
264
+ isGuest && free
265
+ ? h("div", { className: "plan-pill" },
266
+ h("strong", null, "Free"),
267
+ h("span", null, `${free.daily_rewrites}/day · ${free.max_words_per_request} words`),
268
+ h("span", { className: "plan-price" }, "₹0"),
269
+ )
270
+ : null,
271
+ pro
272
+ ? h("div", { className: "plan-pill plan-pill-pro" },
273
+ h("strong", null, "Pro"),
274
+ h("span", null, `${pro.daily_rewrites}/day · ${pro.max_words_per_request.toLocaleString()} words`),
275
+ h("span", { className: "plan-price" }, `₹${pro.price_inr_monthly}/mo`),
276
+ )
277
+ : null,
278
+ ),
279
+ h("div", { className: "upgrade-actions" },
280
+ isGuest
281
+ ? h(Fragment, null,
282
+ h("button", { type: "button", className: "btn btn-primary", onClick: onSignUp }, "Sign up free"),
283
+ h("button", { type: "button", className: "btn btn-quiet", onClick: onSignIn }, "Sign in"),
284
+ )
285
+ : h("button", {
286
+ type: "button",
287
+ className: "btn btn-primary",
288
+ disabled: true,
289
+ title: "Stripe/Razorpay next",
290
+ }, `Pro ₹${(pro && pro.price_inr_monthly) || 199}/mo — payments soon`),
291
+ ),
292
+ !isGuest
293
+ ? h("p", { className: "upgrade-note" }, "Until checkout is live, an admin can set plan_id = pro on your profile in Supabase.")
294
+ : null,
295
+ );
296
+ }
297
+
298
  function App() {
299
+ const {
300
+ ready, authEnabled, session, account, setAccount, signOut, plans, guestMaxWords, refreshAccount,
301
+ } = useAuth();
302
  const [input, setInput] = useState("");
303
  const [output, setOutput] = useState("");
304
  const [tone, setTone] = useState("Neutral");
 
309
  const [meta, setMeta] = useState("");
310
  const [copied, setCopied] = useState(false);
311
  const [freshOut, setFreshOut] = useState(false);
312
+ const [showUpgrade, setShowUpgrade] = useState(false);
313
+ const [authOpen, setAuthOpen] = useState(false);
314
+ const [authMode, setAuthMode] = useState("signup");
315
+ const [authTitle, setAuthTitle] = useState(undefined);
316
+
317
+ const isGuest = !!(authEnabled && !session);
318
+ const maxWords = (account && account.plan && account.plan.max_words_per_request) || (isGuest ? guestMaxWords : 50000);
319
 
320
  useEffect(() => {
321
  if (!copied) return undefined;
 
323
  return () => window.clearTimeout(t);
324
  }, [copied]);
325
 
326
+ useEffect(() => {
327
+ if (session) {
328
+ setAuthOpen(false);
329
+ refreshAccount();
330
+ }
331
+ }, [session, refreshAccount]);
332
+
333
  useEffect(() => {
334
  const onKey = (e) => {
335
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
 
341
  return () => window.removeEventListener("keydown", onKey);
342
  });
343
 
344
+ function openAuth(mode, title) {
345
+ setAuthMode(mode);
346
+ setAuthTitle(title);
347
+ setAuthOpen(true);
348
+ }
349
+
350
  async function onRewrite() {
351
  const text = input.trim();
352
+ if (!text) { setError("Paste some text first."); return; }
 
 
 
353
  if (text.length > MAX_CHARS) {
354
  setError(`Text is too long (${text.length.toLocaleString()} chars).`);
355
  return;
356
  }
357
+ const words = wordCount(text);
358
+ if (authEnabled && words > maxWords) {
359
+ setError(`This text has ${words} words. ${isGuest ? "Preview" : ((account && account.plan && account.plan.name) || "Your plan")} allows ${maxWords} words per rewrite.`);
360
+ setShowUpgrade(true);
361
+ if (isGuest) openAuth("signup", "Sign up to rewrite longer text");
362
+ return;
363
+ }
364
 
365
  setLoading(true);
366
  setError("");
367
  setMeta("Rewriting…");
368
  setFreshOut(false);
369
  try {
370
+ const result = await rewriteText({
371
+ text,
372
+ tone,
373
+ strength: STRENGTH_MAP[strength],
374
+ preserve_length: preserveLength,
375
+ }, session && session.access_token);
 
 
 
376
  setOutput(result.rewrite);
377
  setFreshOut(true);
378
  if (result.account) setAccount(result.account);
379
+ const left = result.account && result.account.usage && result.account.usage.remaining_rewrites;
380
+ const quota = left != null ? ` · ${left} rewrite${left === 1 ? "" : "s"} left today` : "";
381
+ setMeta(`${result.meta.input_words.toLocaleString()} ${result.meta.output_words.toLocaleString()} words · ${result.meta.seconds}s${quota}`);
382
+ if (authEnabled && result.account && (result.account.plan.id === "guest" || result.account.plan.id === "free")) {
383
+ setShowUpgrade(true);
384
+ }
 
385
  } catch (err) {
386
  setError(err instanceof Error ? err.message : "Rewrite failed.");
387
  setMeta("");
388
+ if (err && err.code === "limit") {
389
+ setShowUpgrade(true);
390
+ if (isGuest) openAuth("signup", "Free preview used — sign up for more");
391
+ }
392
  } finally {
393
  setLoading(false);
394
  }
 
417
 
418
  const inWords = wordCount(input);
419
  const outWords = wordCount(output);
420
+ const overGuestCap = isGuest && inWords > maxWords;
421
 
422
+ if (!ready) return h("div", { className: "app shell" }, h("p", { className: "hint" }, "Loading…"));
 
 
 
 
 
 
423
 
424
  return h("div", { className: "app shell" },
425
  h("div", { className: "topbar" },
 
428
  h("p", null, "From AI-generated to plagiarism-safe — rewrite in a voice that feels real."),
429
  ),
430
  h("div", { className: "top-meta" },
431
+ session && account
432
  ? h("div", { className: "account-chip" },
433
  h("div", { className: "account-plan" }, account.plan.name),
434
  h("div", null, `${account.usage.remaining_rewrites}/${account.plan.daily_rewrites} rewrites today`),
435
  h("div", { className: "account-email" }, account.email),
436
+ h("button", { type: "button", className: "ghost-btn", onClick: () => signOut() }, "Sign out"),
 
 
 
 
437
  )
438
+ : authEnabled
439
+ ? h("div", { className: "account-chip" },
440
+ h("div", { className: "account-plan" }, "Preview"),
441
+ h("div", null, account
442
+ ? `${account.usage.remaining_rewrites}/${account.plan.daily_rewrites} free rewrite today`
443
+ : `Try ${guestMaxWords} words free`),
444
+ h("div", { className: "auth-inline" },
445
+ h("button", { type: "button", className: "ghost-btn", onClick: () => openAuth("signin", "Sign in") }, "Sign in"),
446
+ h("button", { type: "button", className: "btn btn-primary btn-compact", onClick: () => openAuth("signup", "Create free account") }, "Sign up"),
447
+ ),
448
+ )
449
+ : h(Fragment, null, `${tone} · ${strength}`, h("br"), "⌘/Ctrl + Enter"),
450
  ),
451
  ),
452
+ isGuest
453
+ ? h("p", { className: "teaser-banner" },
454
+ "Try a short rewrite free — up to ", h("strong", null, `${maxWords} words`),
455
+ `, ${(account && account.plan && account.plan.daily_rewrites) || 1} per day. Sign up for more length and daily rewrites.`,
456
+ )
457
+ : null,
458
  h("div", { className: "stage" },
459
  h("div", { className: "toolbar" },
460
  h("div", { className: "tool-group" },
461
  h("span", null, "Tone"),
462
+ h("div", { className: "segment", role: "group" },
463
+ TONES.map((t) => h("button", { key: t, type: "button", className: tone === t ? "active" : "", onClick: () => setTone(t) }, t)),
 
 
 
 
 
 
 
464
  ),
465
  ),
466
  h("div", { className: "tool-group" },
467
  h("span", null, "Strength"),
468
+ h("div", { className: "segment", role: "group" },
469
+ STRENGTHS.map((s) => h("button", { key: s, type: "button", className: strength === s ? "active" : "", onClick: () => setStrength(s) }, s)),
 
 
 
 
 
 
 
470
  ),
471
  ),
472
  h("label", { className: "check" },
473
+ h("input", { type: "checkbox", checked: preserveLength, onChange: (e) => setPreserveLength(e.target.checked) }),
 
 
 
 
474
  " Match length",
475
  ),
476
  h("div", { className: "toolbar-actions" },
477
  h("button", {
478
  type: "button",
479
  className: "btn btn-quiet",
 
 
 
 
 
 
 
480
  disabled: loading,
481
+ onClick: () => { setInput(""); setOutput(""); setMeta(""); setError(""); setFreshOut(false); },
482
  }, "Clear"),
483
  h("button", {
484
  type: "button",
485
  className: "btn btn-primary",
 
486
  disabled: loading,
487
+ onClick: () => onRewrite(),
488
  }, loading ? "Rewriting…" : "Rewrite"),
489
  ),
490
  ),
 
494
  h("textarea", {
495
  value: input,
496
  onChange: (e) => setInput(e.target.value),
497
+ placeholder: isGuest ? `Paste a short AI draft (up to ${maxWords} words)…` : "Paste your draft here…",
498
  spellCheck: true,
499
  }),
500
  ),
 
502
  h("div", { className: "pane-head" },
503
  h("h2", null, "Rewrite"),
504
  h("div", { className: "pane-actions" },
505
+ h("button", { type: "button", className: "ghost-btn", onClick: () => onCopy(), disabled: !output.trim() }, copied ? "Copied" : "Copy"),
506
+ h("button", { type: "button", className: "ghost-btn", onClick: onDownload, disabled: !output.trim() }, "Download"),
 
 
 
 
 
 
 
 
 
 
507
  ),
508
  ),
509
  h("textarea", {
510
  value: output,
511
+ onChange: (e) => { setOutput(e.target.value); setFreshOut(false); },
 
 
 
512
  placeholder: "Your rewrite appears here…",
513
  spellCheck: true,
514
  }),
515
  ),
516
  ),
517
  h("div", { className: "statusbar" },
518
+ h("div", { className: error ? "error" : loading ? "loading" : undefined }, error || meta || "Ready to rewrite"),
 
 
519
  h("div", { className: "counts" },
520
+ h("span", { className: overGuestCap ? "over-limit" : undefined },
521
+ `${inWords}${authEnabled ? ` / ${maxWords}` : ""} words in`),
522
  h("span", null, `${outWords} words out`),
 
 
 
523
  ),
524
  ),
525
  ),
526
+ showUpgrade && authEnabled
527
+ ? h(UpgradeCard, {
528
+ account,
529
+ plans,
530
+ onSignUp: () => openAuth("signup", "Create free account"),
531
+ onSignIn: () => openAuth("signin", "Sign in"),
532
+ })
533
+ : null,
534
  h("p", { className: "hint" }, "Review the rewrite before you share or publish it."),
535
+ authEnabled
536
+ ? h(AuthModal, {
537
+ open: authOpen,
538
+ onClose: () => setAuthOpen(false),
539
+ initialMode: authMode,
540
+ title: authTitle,
541
+ })
542
+ : null,
543
  );
544
  }
545
 
frontend/src/App.tsx CHANGED
@@ -4,10 +4,13 @@ import {
4
  STRENGTH_MAP,
5
  STRENGTHS,
6
  TONES,
 
 
7
  type StrengthLabel,
8
  type Tone,
9
  } from "./api";
10
  import { useAuth } from "./auth";
 
11
 
12
  const MAX_CHARS = 50000;
13
 
@@ -15,15 +18,35 @@ function wordCount(text: string): number {
15
  return text.trim() ? text.trim().split(/\s+/).length : 0;
16
  }
17
 
18
- function AuthScreen() {
 
 
 
 
 
 
 
 
 
 
19
  const { signInWithPassword, signUp, signInWithGoogle } = useAuth();
20
- const [mode, setMode] = useState<"signin" | "signup">("signin");
21
  const [email, setEmail] = useState("");
22
  const [password, setPassword] = useState("");
23
  const [busy, setBusy] = useState(false);
24
  const [message, setMessage] = useState("");
25
  const [error, setError] = useState("");
26
 
 
 
 
 
 
 
 
 
 
 
27
  async function onSubmit(e: FormEvent) {
28
  e.preventDefault();
29
  setBusy(true);
@@ -32,10 +55,13 @@ function AuthScreen() {
32
  try {
33
  if (mode === "signin") {
34
  await signInWithPassword(email.trim(), password);
 
35
  } else {
36
  const result = await signUp(email.trim(), password);
37
  if (result === "check_email") {
38
  setMessage("Check your email to confirm your account, then sign in.");
 
 
39
  }
40
  }
41
  } catch (err) {
@@ -46,11 +72,20 @@ function AuthScreen() {
46
  }
47
 
48
  return (
49
- <div className="auth-shell">
50
- <div className="auth-card">
51
- <h1>PlainRewrite</h1>
 
 
 
 
 
 
 
 
 
52
  <p className="auth-lead">
53
- From AI-generated to plagiarism-safe rewrite in a voice that feels real.
54
  </p>
55
 
56
  <div className="segment auth-tabs" role="group" aria-label="Auth mode">
@@ -93,7 +128,7 @@ function AuthScreen() {
93
  />
94
  </label>
95
  <button type="submit" className="btn btn-primary auth-submit" disabled={busy}>
96
- {busy ? "Please wait…" : mode === "signin" ? "Sign in" : "Create account"}
97
  </button>
98
  </form>
99
 
@@ -115,8 +150,96 @@ function AuthScreen() {
115
  );
116
  }
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  export default function App() {
119
- const { ready, authEnabled, session, account, setAccount, signOut } = useAuth();
 
 
 
 
 
 
 
 
 
 
120
  const [input, setInput] = useState("");
121
  const [output, setOutput] = useState("");
122
  const [tone, setTone] = useState<Tone>("Neutral");
@@ -127,6 +250,13 @@ export default function App() {
127
  const [meta, setMeta] = useState("");
128
  const [copied, setCopied] = useState(false);
129
  const [freshOut, setFreshOut] = useState(false);
 
 
 
 
 
 
 
130
 
131
  useEffect(() => {
132
  if (!copied) return;
@@ -134,6 +264,13 @@ export default function App() {
134
  return () => window.clearTimeout(t);
135
  }, [copied]);
136
 
 
 
 
 
 
 
 
137
  useEffect(() => {
138
  const onKey = (e: KeyboardEvent) => {
139
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
@@ -146,6 +283,12 @@ export default function App() {
146
  // eslint-disable-next-line react-hooks/exhaustive-deps
147
  }, [input, tone, strength, preserveLength, loading, session]);
148
 
 
 
 
 
 
 
149
  async function onRewrite() {
150
  const text = input.trim();
151
  if (!text) {
@@ -156,6 +299,15 @@ export default function App() {
156
  setError(`Text is too long (${text.length.toLocaleString()} chars).`);
157
  return;
158
  }
 
 
 
 
 
 
 
 
 
159
 
160
  setLoading(true);
161
  setError("");
@@ -174,16 +326,27 @@ export default function App() {
174
  setOutput(result.rewrite);
175
  setFreshOut(true);
176
  if (result.account) setAccount(result.account);
 
177
  const quota =
178
- result.account?.usage != null
179
- ? ` · ${result.account.usage.remaining_rewrites} rewrites left today`
180
- : "";
181
  setMeta(
182
  `${result.meta.input_words.toLocaleString()} → ${result.meta.output_words.toLocaleString()} words · ${result.meta.seconds}s${quota}`,
183
  );
 
 
 
 
 
 
 
184
  } catch (err) {
185
- setError(err instanceof Error ? err.message : "Rewrite failed.");
 
186
  setMeta("");
 
 
 
 
187
  } finally {
188
  setLoading(false);
189
  }
@@ -212,6 +375,7 @@ export default function App() {
212
 
213
  const inWords = wordCount(input);
214
  const outWords = wordCount(output);
 
215
 
216
  if (!ready) {
217
  return (
@@ -221,10 +385,6 @@ export default function App() {
221
  );
222
  }
223
 
224
- if (authEnabled && !session) {
225
- return <AuthScreen />;
226
- }
227
-
228
  return (
229
  <div className="app shell">
230
  <div className="topbar">
@@ -233,7 +393,7 @@ export default function App() {
233
  <p>From AI-generated to plagiarism-safe — rewrite in a voice that feels real.</p>
234
  </header>
235
  <div className="top-meta">
236
- {account ? (
237
  <div className="account-chip">
238
  <div className="account-plan">{account.plan.name}</div>
239
  <div>
@@ -244,6 +404,31 @@ export default function App() {
244
  Sign out
245
  </button>
246
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  ) : (
248
  <>
249
  {tone} · {strength}
@@ -254,6 +439,13 @@ export default function App() {
254
  </div>
255
  </div>
256
 
 
 
 
 
 
 
 
257
  <div className="stage">
258
  <div className="toolbar">
259
  <div className="tool-group">
@@ -331,7 +523,11 @@ export default function App() {
331
  <textarea
332
  value={input}
333
  onChange={(e) => setInput(e.target.value)}
334
- placeholder="Paste your draft here…"
 
 
 
 
335
  spellCheck
336
  />
337
  </div>
@@ -375,18 +571,39 @@ export default function App() {
375
  {error || meta || "Ready to rewrite"}
376
  </div>
377
  <div className="counts">
378
- <span>{inWords} words in</span>
 
 
 
379
  <span>{outWords} words out</span>
380
- {account ? (
381
- <span>
382
- max {account.plan.max_words_per_request.toLocaleString()} / rewrite
383
- </span>
384
- ) : null}
385
  </div>
386
  </div>
387
  </div>
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  <p className="hint">Review the rewrite before you share or publish it.</p>
 
 
 
 
 
 
 
 
 
390
  </div>
391
  );
392
  }
 
4
  STRENGTH_MAP,
5
  STRENGTHS,
6
  TONES,
7
+ type AccountInfo,
8
+ type ApiError,
9
  type StrengthLabel,
10
  type Tone,
11
  } from "./api";
12
  import { useAuth } from "./auth";
13
+ import type { PlanCard } from "./supabase";
14
 
15
  const MAX_CHARS = 50000;
16
 
 
18
  return text.trim() ? text.trim().split(/\s+/).length : 0;
19
  }
20
 
21
+ function AuthModal({
22
+ open,
23
+ onClose,
24
+ initialMode = "signup",
25
+ title,
26
+ }: {
27
+ open: boolean;
28
+ onClose: () => void;
29
+ initialMode?: "signin" | "signup";
30
+ title?: string;
31
+ }) {
32
  const { signInWithPassword, signUp, signInWithGoogle } = useAuth();
33
+ const [mode, setMode] = useState<"signin" | "signup">(initialMode);
34
  const [email, setEmail] = useState("");
35
  const [password, setPassword] = useState("");
36
  const [busy, setBusy] = useState(false);
37
  const [message, setMessage] = useState("");
38
  const [error, setError] = useState("");
39
 
40
+ useEffect(() => {
41
+ if (open) {
42
+ setMode(initialMode);
43
+ setError("");
44
+ setMessage("");
45
+ }
46
+ }, [open, initialMode]);
47
+
48
+ if (!open) return null;
49
+
50
  async function onSubmit(e: FormEvent) {
51
  e.preventDefault();
52
  setBusy(true);
 
55
  try {
56
  if (mode === "signin") {
57
  await signInWithPassword(email.trim(), password);
58
+ onClose();
59
  } else {
60
  const result = await signUp(email.trim(), password);
61
  if (result === "check_email") {
62
  setMessage("Check your email to confirm your account, then sign in.");
63
+ } else {
64
+ onClose();
65
  }
66
  }
67
  } catch (err) {
 
72
  }
73
 
74
  return (
75
+ <div className="modal-backdrop" role="presentation" onClick={onClose}>
76
+ <div
77
+ className="auth-card modal-card"
78
+ role="dialog"
79
+ aria-modal="true"
80
+ aria-label={title || "Sign in"}
81
+ onClick={(e) => e.stopPropagation()}
82
+ >
83
+ <button type="button" className="modal-close" onClick={onClose} aria-label="Close">
84
+ ×
85
+ </button>
86
+ <h2>{title || (mode === "signin" ? "Sign in" : "Create free account")}</h2>
87
  <p className="auth-lead">
88
+ Unlock longer rewrites and daily limits. Google or email takes a minute.
89
  </p>
90
 
91
  <div className="segment auth-tabs" role="group" aria-label="Auth mode">
 
128
  />
129
  </label>
130
  <button type="submit" className="btn btn-primary auth-submit" disabled={busy}>
131
+ {busy ? "Please wait…" : mode === "signin" ? "Sign in" : "Create free account"}
132
  </button>
133
  </form>
134
 
 
150
  );
151
  }
152
 
153
+ function UpgradeCard({
154
+ account,
155
+ plans,
156
+ onSignUp,
157
+ onSignIn,
158
+ }: {
159
+ account: AccountInfo | null;
160
+ plans: PlanCard[];
161
+ onSignUp: () => void;
162
+ onSignIn: () => void;
163
+ }) {
164
+ const planId = account?.plan.id ?? "guest";
165
+ if (planId === "pro" || planId === "plus") return null;
166
+
167
+ const free = plans.find((p) => p.id === "free");
168
+ const pro = plans.find((p) => p.id === "pro");
169
+ const isGuest = planId === "guest" || account?.role === "guest";
170
+
171
+ return (
172
+ <section className="upgrade-card" aria-label="Upgrade plans">
173
+ <div className="upgrade-copy">
174
+ <h3>
175
+ {isGuest
176
+ ? "Liked the rewrite? Unlock more with a free account"
177
+ : "Need longer drafts every day? Go Pro"}
178
+ </h3>
179
+ <p>
180
+ {isGuest
181
+ ? `Preview is capped at ${account?.plan.max_words_per_request ?? 100} words and ${account?.plan.daily_rewrites ?? 1} rewrite/day. Sign up free for higher limits — or Pro for serious daily use.`
182
+ : `You're on ${account?.plan.name ?? "Free"}. Pro gives up to ${pro?.max_words_per_request?.toLocaleString() ?? "2,000"} words per rewrite and ${pro?.daily_rewrites ?? 50} rewrites/day.`}
183
+ </p>
184
+ </div>
185
+ <div className="upgrade-plans">
186
+ {isGuest && free ? (
187
+ <div className="plan-pill">
188
+ <strong>Free</strong>
189
+ <span>
190
+ {free.daily_rewrites}/day · {free.max_words_per_request} words
191
+ </span>
192
+ <span className="plan-price">₹0</span>
193
+ </div>
194
+ ) : null}
195
+ {pro ? (
196
+ <div className="plan-pill plan-pill-pro">
197
+ <strong>Pro</strong>
198
+ <span>
199
+ {pro.daily_rewrites}/day · {pro.max_words_per_request.toLocaleString()} words
200
+ </span>
201
+ <span className="plan-price">₹{pro.price_inr_monthly}/mo</span>
202
+ </div>
203
+ ) : null}
204
+ </div>
205
+ <div className="upgrade-actions">
206
+ {isGuest ? (
207
+ <>
208
+ <button type="button" className="btn btn-primary" onClick={onSignUp}>
209
+ Sign up free
210
+ </button>
211
+ <button type="button" className="btn btn-quiet" onClick={onSignIn}>
212
+ Sign in
213
+ </button>
214
+ </>
215
+ ) : (
216
+ <button type="button" className="btn btn-primary" disabled title="Stripe/Razorpay next">
217
+ Pro ₹{pro?.price_inr_monthly ?? 199}/mo — payments soon
218
+ </button>
219
+ )}
220
+ </div>
221
+ {!isGuest ? (
222
+ <p className="upgrade-note">
223
+ Until checkout is live, an admin can set <code>plan_id = pro</code> on your profile in
224
+ Supabase.
225
+ </p>
226
+ ) : null}
227
+ </section>
228
+ );
229
+ }
230
+
231
  export default function App() {
232
+ const {
233
+ ready,
234
+ authEnabled,
235
+ session,
236
+ account,
237
+ setAccount,
238
+ signOut,
239
+ plans,
240
+ guestMaxWords,
241
+ refreshAccount,
242
+ } = useAuth();
243
  const [input, setInput] = useState("");
244
  const [output, setOutput] = useState("");
245
  const [tone, setTone] = useState<Tone>("Neutral");
 
250
  const [meta, setMeta] = useState("");
251
  const [copied, setCopied] = useState(false);
252
  const [freshOut, setFreshOut] = useState(false);
253
+ const [showUpgrade, setShowUpgrade] = useState(false);
254
+ const [authOpen, setAuthOpen] = useState(false);
255
+ const [authMode, setAuthMode] = useState<"signin" | "signup">("signup");
256
+ const [authTitle, setAuthTitle] = useState<string | undefined>();
257
+
258
+ const isGuest = Boolean(authEnabled && !session);
259
+ const maxWords = account?.plan.max_words_per_request ?? (isGuest ? guestMaxWords : 50000);
260
 
261
  useEffect(() => {
262
  if (!copied) return;
 
264
  return () => window.clearTimeout(t);
265
  }, [copied]);
266
 
267
+ useEffect(() => {
268
+ if (session) {
269
+ setAuthOpen(false);
270
+ void refreshAccount();
271
+ }
272
+ }, [session, refreshAccount]);
273
+
274
  useEffect(() => {
275
  const onKey = (e: KeyboardEvent) => {
276
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
 
283
  // eslint-disable-next-line react-hooks/exhaustive-deps
284
  }, [input, tone, strength, preserveLength, loading, session]);
285
 
286
+ function openAuth(mode: "signin" | "signup", title?: string) {
287
+ setAuthMode(mode);
288
+ setAuthTitle(title);
289
+ setAuthOpen(true);
290
+ }
291
+
292
  async function onRewrite() {
293
  const text = input.trim();
294
  if (!text) {
 
299
  setError(`Text is too long (${text.length.toLocaleString()} chars).`);
300
  return;
301
  }
302
+ const words = wordCount(text);
303
+ if (authEnabled && words > maxWords) {
304
+ setError(
305
+ `This text has ${words} words. ${isGuest ? "Preview" : account?.plan.name ?? "Your plan"} allows ${maxWords} words per rewrite.`,
306
+ );
307
+ setShowUpgrade(true);
308
+ if (isGuest) openAuth("signup", "Sign up to rewrite longer text");
309
+ return;
310
+ }
311
 
312
  setLoading(true);
313
  setError("");
 
326
  setOutput(result.rewrite);
327
  setFreshOut(true);
328
  if (result.account) setAccount(result.account);
329
+ const left = result.account?.usage?.remaining_rewrites;
330
  const quota =
331
+ left != null ? ` · ${left} rewrite${left === 1 ? "" : "s"} left today` : "";
 
 
332
  setMeta(
333
  `${result.meta.input_words.toLocaleString()} → ${result.meta.output_words.toLocaleString()} words · ${result.meta.seconds}s${quota}`,
334
  );
335
+ if (
336
+ authEnabled &&
337
+ result.account &&
338
+ (result.account.plan.id === "guest" || result.account.plan.id === "free")
339
+ ) {
340
+ setShowUpgrade(true);
341
+ }
342
  } catch (err) {
343
+ const apiErr = err as ApiError;
344
+ setError(apiErr.message || "Rewrite failed.");
345
  setMeta("");
346
+ if (apiErr.code === "limit") {
347
+ setShowUpgrade(true);
348
+ if (isGuest) openAuth("signup", "Free preview used — sign up for more");
349
+ }
350
  } finally {
351
  setLoading(false);
352
  }
 
375
 
376
  const inWords = wordCount(input);
377
  const outWords = wordCount(output);
378
+ const overGuestCap = isGuest && inWords > maxWords;
379
 
380
  if (!ready) {
381
  return (
 
385
  );
386
  }
387
 
 
 
 
 
388
  return (
389
  <div className="app shell">
390
  <div className="topbar">
 
393
  <p>From AI-generated to plagiarism-safe — rewrite in a voice that feels real.</p>
394
  </header>
395
  <div className="top-meta">
396
+ {session && account ? (
397
  <div className="account-chip">
398
  <div className="account-plan">{account.plan.name}</div>
399
  <div>
 
404
  Sign out
405
  </button>
406
  </div>
407
+ ) : authEnabled ? (
408
+ <div className="account-chip">
409
+ <div className="account-plan">Preview</div>
410
+ <div>
411
+ {account
412
+ ? `${account.usage.remaining_rewrites}/${account.plan.daily_rewrites} free rewrite today`
413
+ : `Try ${guestMaxWords} words free`}
414
+ </div>
415
+ <div className="auth-inline">
416
+ <button
417
+ type="button"
418
+ className="ghost-btn"
419
+ onClick={() => openAuth("signin", "Sign in")}
420
+ >
421
+ Sign in
422
+ </button>
423
+ <button
424
+ type="button"
425
+ className="btn btn-primary btn-compact"
426
+ onClick={() => openAuth("signup", "Create free account")}
427
+ >
428
+ Sign up
429
+ </button>
430
+ </div>
431
+ </div>
432
  ) : (
433
  <>
434
  {tone} · {strength}
 
439
  </div>
440
  </div>
441
 
442
+ {isGuest ? (
443
+ <p className="teaser-banner">
444
+ Try a short rewrite free — up to <strong>{maxWords} words</strong>,{" "}
445
+ {account?.plan.daily_rewrites ?? 1} per day. Sign up for more length and daily rewrites.
446
+ </p>
447
+ ) : null}
448
+
449
  <div className="stage">
450
  <div className="toolbar">
451
  <div className="tool-group">
 
523
  <textarea
524
  value={input}
525
  onChange={(e) => setInput(e.target.value)}
526
+ placeholder={
527
+ isGuest
528
+ ? `Paste a short AI draft (up to ${maxWords} words)…`
529
+ : "Paste your draft here…"
530
+ }
531
  spellCheck
532
  />
533
  </div>
 
571
  {error || meta || "Ready to rewrite"}
572
  </div>
573
  <div className="counts">
574
+ <span className={overGuestCap ? "over-limit" : undefined}>
575
+ {inWords}
576
+ {authEnabled ? ` / ${maxWords}` : ""} words in
577
+ </span>
578
  <span>{outWords} words out</span>
 
 
 
 
 
579
  </div>
580
  </div>
581
  </div>
582
 
583
+ {showUpgrade && authEnabled ? (
584
+ <UpgradeCard
585
+ account={account}
586
+ plans={plans}
587
+ onSignUp={() =>
588
+ openAuth(
589
+ session ? "signup" : "signup",
590
+ session ? "Upgrade to Pro" : "Create free account",
591
+ )
592
+ }
593
+ onSignIn={() => openAuth("signin", "Sign in")}
594
+ />
595
+ ) : null}
596
+
597
  <p className="hint">Review the rewrite before you share or publish it.</p>
598
+
599
+ {authEnabled ? (
600
+ <AuthModal
601
+ open={authOpen}
602
+ onClose={() => setAuthOpen(false)}
603
+ initialMode={authMode}
604
+ title={authTitle}
605
+ />
606
+ ) : null}
607
  </div>
608
  );
609
  }
frontend/src/api.ts CHANGED
@@ -45,10 +45,14 @@ export type RewriteResponse = {
45
  account?: AccountInfo | null;
46
  };
47
 
 
 
48
  function detailMessage(detail: unknown, fallback: string): string {
49
  if (typeof detail === "string") return detail;
50
  if (Array.isArray(detail)) {
51
- return detail.map((d) => (typeof d === "object" && d && "msg" in d ? String(d.msg) : String(d))).join(" ");
 
 
52
  }
53
  return fallback;
54
  }
@@ -78,18 +82,23 @@ export async function rewriteText(
78
  } catch {
79
  /* ignore */
80
  }
81
- throw new Error(detailMessage(detail, "Rewrite failed."));
 
 
 
 
 
82
  }
83
  return res.json();
84
  }
85
 
86
- export async function fetchMe(accessToken: string): Promise<{
87
  auth_enabled: boolean;
88
  account: AccountInfo | null;
89
  }> {
90
- const res = await fetch("/v1/me", {
91
- headers: { Authorization: `Bearer ${accessToken}` },
92
- });
93
  if (!res.ok) {
94
  throw new Error("Could not load account.");
95
  }
 
45
  account?: AccountInfo | null;
46
  };
47
 
48
+ export type ApiError = Error & { status?: number; code?: string };
49
+
50
  function detailMessage(detail: unknown, fallback: string): string {
51
  if (typeof detail === "string") return detail;
52
  if (Array.isArray(detail)) {
53
+ return detail
54
+ .map((d) => (typeof d === "object" && d && "msg" in d ? String(d.msg) : String(d)))
55
+ .join(" ");
56
  }
57
  return fallback;
58
  }
 
82
  } catch {
83
  /* ignore */
84
  }
85
+ const err = new Error(detailMessage(detail, "Rewrite failed.")) as ApiError;
86
+ err.status = res.status;
87
+ if (res.status === 401 || res.status === 429 || res.status === 413) {
88
+ err.code = "limit";
89
+ }
90
+ throw err;
91
  }
92
  return res.json();
93
  }
94
 
95
+ export async function fetchMe(accessToken?: string | null): Promise<{
96
  auth_enabled: boolean;
97
  account: AccountInfo | null;
98
  }> {
99
+ const headers: Record<string, string> = {};
100
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
101
+ const res = await fetch("/v1/me", { headers });
102
  if (!res.ok) {
103
  throw new Error("Could not load account.");
104
  }
frontend/src/auth.tsx CHANGED
@@ -9,7 +9,12 @@ import {
9
  } from "react";
10
  import type { Session, User } from "@supabase/supabase-js";
11
  import { fetchMe, type AccountInfo } from "./api";
12
- import { getSupabase, initSupabase } from "./supabase";
 
 
 
 
 
13
 
14
  type AuthContextValue = {
15
  ready: boolean;
@@ -17,6 +22,8 @@ type AuthContextValue = {
17
  session: Session | null;
18
  user: User | null;
19
  account: AccountInfo | null;
 
 
20
  refreshAccount: () => Promise<void>;
21
  setAccount: (account: AccountInfo | null) => void;
22
  signInWithPassword: (email: string, password: string) => Promise<void>;
@@ -32,14 +39,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
32
  const [authEnabled, setAuthEnabled] = useState(false);
33
  const [session, setSession] = useState<Session | null>(null);
34
  const [account, setAccount] = useState<AccountInfo | null>(null);
 
 
35
 
36
  const refreshAccount = useCallback(async () => {
37
- if (!authEnabled || !session) {
38
  setAccount(null);
39
  return;
40
  }
41
  try {
42
- const me = await fetchMe(session.access_token);
43
  setAccount(me.account);
44
  } catch {
45
  setAccount(null);
@@ -51,6 +60,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
51
  void (async () => {
52
  const config = await initSupabase();
53
  setAuthEnabled(config.enabled);
 
 
54
  const sb = getSupabase();
55
  if (!config.enabled || !sb) {
56
  setReady(true);
@@ -78,6 +89,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
78
  session,
79
  user: session?.user ?? null,
80
  account,
 
 
81
  refreshAccount,
82
  setAccount,
83
  async signInWithPassword(email, password) {
@@ -108,9 +121,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
108
  if (!sb) return;
109
  await sb.auth.signOut();
110
  setAccount(null);
 
111
  },
112
  }),
113
- [ready, authEnabled, session, account, refreshAccount],
114
  );
115
 
116
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
 
9
  } from "react";
10
  import type { Session, User } from "@supabase/supabase-js";
11
  import { fetchMe, type AccountInfo } from "./api";
12
+ import {
13
+ getCachedAuthConfig,
14
+ getSupabase,
15
+ initSupabase,
16
+ type PlanCard,
17
+ } from "./supabase";
18
 
19
  type AuthContextValue = {
20
  ready: boolean;
 
22
  session: Session | null;
23
  user: User | null;
24
  account: AccountInfo | null;
25
+ plans: PlanCard[];
26
+ guestMaxWords: number;
27
  refreshAccount: () => Promise<void>;
28
  setAccount: (account: AccountInfo | null) => void;
29
  signInWithPassword: (email: string, password: string) => Promise<void>;
 
39
  const [authEnabled, setAuthEnabled] = useState(false);
40
  const [session, setSession] = useState<Session | null>(null);
41
  const [account, setAccount] = useState<AccountInfo | null>(null);
42
+ const [plans, setPlans] = useState<PlanCard[]>([]);
43
+ const [guestMaxWords, setGuestMaxWords] = useState(100);
44
 
45
  const refreshAccount = useCallback(async () => {
46
+ if (!authEnabled) {
47
  setAccount(null);
48
  return;
49
  }
50
  try {
51
+ const me = await fetchMe(session?.access_token);
52
  setAccount(me.account);
53
  } catch {
54
  setAccount(null);
 
60
  void (async () => {
61
  const config = await initSupabase();
62
  setAuthEnabled(config.enabled);
63
+ setPlans(config.plans ?? []);
64
+ setGuestMaxWords(config.guest?.max_words_per_request ?? 100);
65
  const sb = getSupabase();
66
  if (!config.enabled || !sb) {
67
  setReady(true);
 
89
  session,
90
  user: session?.user ?? null,
91
  account,
92
+ plans: plans.length ? plans : getCachedAuthConfig()?.plans ?? [],
93
+ guestMaxWords,
94
  refreshAccount,
95
  setAccount,
96
  async signInWithPassword(email, password) {
 
121
  if (!sb) return;
122
  await sb.auth.signOut();
123
  setAccount(null);
124
+ await refreshAccount();
125
  },
126
  }),
127
+ [ready, authEnabled, session, account, plans, guestMaxWords, refreshAccount],
128
  );
129
 
130
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
frontend/src/index.css CHANGED
@@ -526,6 +526,148 @@ textarea {
526
  font-size: 0.88rem;
527
  }
528
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  @media (max-width: 920px) {
530
  .topbar {
531
  flex-direction: column;
 
526
  font-size: 0.88rem;
527
  }
528
 
529
+ .teaser-banner {
530
+ margin: -0.5rem 0 1.1rem;
531
+ padding: 0.75rem 1rem;
532
+ border-radius: 12px;
533
+ background: var(--accent-soft);
534
+ color: var(--ink-soft);
535
+ font-size: 0.92rem;
536
+ line-height: 1.4;
537
+ }
538
+
539
+ .teaser-banner strong {
540
+ color: var(--accent);
541
+ }
542
+
543
+ .auth-inline {
544
+ display: flex;
545
+ align-items: center;
546
+ gap: 0.35rem;
547
+ margin-top: 0.25rem;
548
+ }
549
+
550
+ .btn-compact {
551
+ padding: 0.4rem 0.75rem;
552
+ font-size: 0.8rem;
553
+ }
554
+
555
+ .counts .over-limit {
556
+ color: var(--warn);
557
+ font-weight: 600;
558
+ }
559
+
560
+ .modal-backdrop {
561
+ position: fixed;
562
+ inset: 0;
563
+ z-index: 40;
564
+ display: grid;
565
+ place-items: center;
566
+ padding: 1rem;
567
+ background: rgba(18, 28, 26, 0.45);
568
+ backdrop-filter: blur(4px);
569
+ animation: enter 0.25s ease both;
570
+ }
571
+
572
+ .modal-card {
573
+ position: relative;
574
+ max-height: min(90vh, 640px);
575
+ overflow: auto;
576
+ }
577
+
578
+ .modal-card h2 {
579
+ margin: 0 1.5rem 0 0;
580
+ font-family: var(--font-display);
581
+ font-size: 1.75rem;
582
+ letter-spacing: -0.02em;
583
+ }
584
+
585
+ .modal-close {
586
+ position: absolute;
587
+ top: 0.75rem;
588
+ right: 0.85rem;
589
+ border: 0;
590
+ background: transparent;
591
+ color: var(--muted);
592
+ font-size: 1.5rem;
593
+ line-height: 1;
594
+ cursor: pointer;
595
+ }
596
+
597
+ .upgrade-card {
598
+ margin-top: 1.25rem;
599
+ padding: 1.25rem 1.35rem;
600
+ border: 1px solid var(--panel-edge);
601
+ border-radius: 18px;
602
+ background: rgba(251, 252, 251, 0.85);
603
+ box-shadow: var(--shadow-soft);
604
+ animation: fade-up 0.45s ease both;
605
+ }
606
+
607
+ .upgrade-copy h3 {
608
+ margin: 0;
609
+ font-family: var(--font-display);
610
+ font-size: 1.35rem;
611
+ letter-spacing: -0.02em;
612
+ }
613
+
614
+ .upgrade-copy p {
615
+ margin: 0.45rem 0 0;
616
+ color: var(--ink-soft);
617
+ font-size: 0.95rem;
618
+ line-height: 1.45;
619
+ max-width: 40rem;
620
+ }
621
+
622
+ .upgrade-plans {
623
+ display: flex;
624
+ flex-wrap: wrap;
625
+ gap: 0.65rem;
626
+ margin-top: 1rem;
627
+ }
628
+
629
+ .plan-pill {
630
+ display: flex;
631
+ flex-direction: column;
632
+ gap: 0.15rem;
633
+ min-width: 9.5rem;
634
+ padding: 0.7rem 0.85rem;
635
+ border-radius: 12px;
636
+ border: 1px solid var(--panel-edge);
637
+ background: rgba(232, 239, 236, 0.55);
638
+ font-size: 0.82rem;
639
+ color: var(--ink-soft);
640
+ }
641
+
642
+ .plan-pill strong {
643
+ color: var(--ink);
644
+ font-size: 0.9rem;
645
+ }
646
+
647
+ .plan-pill-pro {
648
+ border-color: rgba(26, 92, 74, 0.35);
649
+ background: var(--accent-soft);
650
+ }
651
+
652
+ .plan-price {
653
+ font-weight: 700;
654
+ color: var(--accent);
655
+ margin-top: 0.15rem;
656
+ }
657
+
658
+ .upgrade-actions {
659
+ display: flex;
660
+ flex-wrap: wrap;
661
+ gap: 0.5rem;
662
+ margin-top: 1rem;
663
+ }
664
+
665
+ .upgrade-note {
666
+ margin: 0.75rem 0 0;
667
+ color: var(--muted);
668
+ font-size: 0.78rem;
669
+ }
670
+
671
  @media (max-width: 920px) {
672
  .topbar {
673
  flex-direction: column;
frontend/src/supabase.ts CHANGED
@@ -1,9 +1,25 @@
1
  import { createClient, type Session, type SupabaseClient } from "@supabase/supabase-js";
2
 
 
 
 
 
 
 
 
 
 
 
3
  export type AuthConfig = {
4
  enabled: boolean;
5
  supabase_url: string;
6
  supabase_anon_key: string;
 
 
 
 
 
 
7
  };
8
 
9
  let client: SupabaseClient | null = null;
@@ -20,6 +36,10 @@ export async function loadAuthConfig(): Promise<AuthConfig> {
20
  return cachedConfig;
21
  }
22
 
 
 
 
 
23
  export function getSupabase(): SupabaseClient | null {
24
  return client;
25
  }
 
1
  import { createClient, type Session, type SupabaseClient } from "@supabase/supabase-js";
2
 
3
+ export type PlanCard = {
4
+ id: string;
5
+ name: string;
6
+ daily_rewrites: number;
7
+ max_words_per_request: number;
8
+ daily_word_cap: number;
9
+ price_inr_monthly: number;
10
+ blurb?: string;
11
+ };
12
+
13
  export type AuthConfig = {
14
  enabled: boolean;
15
  supabase_url: string;
16
  supabase_anon_key: string;
17
+ guest?: {
18
+ daily_rewrites: number;
19
+ max_words_per_request: number;
20
+ daily_word_cap: number;
21
+ };
22
+ plans?: PlanCard[];
23
  };
24
 
25
  let client: SupabaseClient | null = null;
 
36
  return cachedConfig;
37
  }
38
 
39
+ export function getCachedAuthConfig(): AuthConfig | null {
40
+ return cachedConfig;
41
+ }
42
+
43
  export function getSupabase(): SupabaseClient | null {
44
  return client;
45
  }