Upload 64 files
Browse files- README.md +1 -0
- app/__pycache__/config.cpython-311.pyc +0 -0
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/auth/__pycache__/supabase_client.cpython-311.pyc +0 -0
- app/auth/supabase_client.py +9 -0
- app/billing/__pycache__/quota.cpython-311.pyc +0 -0
- app/billing/quota.py +211 -56
- app/config.py +5 -0
- app/main.py +36 -12
- frontend/dist/assets/index.css +142 -0
- frontend/dist/assets/index.js +240 -206
- frontend/src/App.tsx +241 -24
- frontend/src/api.ts +15 -6
- frontend/src/auth.tsx +18 -4
- frontend/src/index.css +142 -0
- frontend/src/supabase.ts +20 -0
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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 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.
|
| 167 |
detail=(
|
| 168 |
-
f"
|
| 169 |
-
f"{
|
| 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
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
return None
|
| 198 |
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 19 |
from app.auth.supabase_client import AuthUser, auth_public_config
|
| 20 |
-
from app.billing.quota import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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
|
| 70 |
return auth_public_config()
|
| 71 |
|
| 72 |
|
| 73 |
@app.get("/v1/me")
|
| 74 |
-
def api_me(
|
|
|
|
|
|
|
|
|
|
| 75 |
if not AUTH_ENABLED:
|
| 76 |
return {"auth_enabled": False, "account": None}
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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(
|
| 130 |
):
|
| 131 |
-
_ = user
|
| 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 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
| 82 |
}
|
| 83 |
return res.json();
|
| 84 |
}
|
| 85 |
|
| 86 |
async function fetchMe(accessToken) {
|
| 87 |
-
const
|
| 88 |
-
|
| 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
|
| 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 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 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
|
| 187 |
const { signInWithPassword, signUp, signInWithGoogle } = useAuth();
|
| 188 |
-
const [mode, setMode] = useState("
|
| 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 |
-
|
| 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: "
|
| 217 |
-
h("div", {
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
),
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 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
|
| 235 |
-
h("label", null, "Email",
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 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 {
|
|
|
|
|
|
|
| 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 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
},
|
| 326 |
-
session && session.access_token,
|
| 327 |
-
);
|
| 328 |
setOutput(result.rewrite);
|
| 329 |
setFreshOut(true);
|
| 330 |
if (result.account) setAccount(result.account);
|
| 331 |
-
const
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 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 |
-
:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 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"
|
| 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 |
-
|
| 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",
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const { signInWithPassword, signUp, signInWithGoogle } = useAuth();
|
| 20 |
-
const [mode, setMode] = useState<"signin" | "signup">(
|
| 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="
|
| 50 |
-
<div
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
<p className="auth-lead">
|
| 53 |
-
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
return fallback;
|
| 54 |
}
|
|
@@ -78,18 +82,23 @@ export async function rewriteText(
|
|
| 78 |
} catch {
|
| 79 |
/* ignore */
|
| 80 |
}
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 91 |
-
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
}
|