Arslan1997 commited on
Commit
a7da6ce
·
1 Parent(s): 8e2a6eb

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
.env.example CHANGED
@@ -6,6 +6,19 @@ API_URL=http://localhost:8000
6
  CORS_ORIGINS=http://localhost:5173
7
  INTERNAL_TOKEN=replace-with-random-token
8
  BILLING_ENABLED=false
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  # ---- Auth (JWT). Revocation bumps users.token_version, so tokens can be long lived.
11
  JWT_SECRET=replace-with-64-random-chars
 
6
  CORS_ORIGINS=http://localhost:5173
7
  INTERNAL_TOKEN=replace-with-random-token
8
  BILLING_ENABLED=false
9
+ # Stripe. Checkout, the customer portal and webhooks. Prices are found or created by lookup key
10
+ # (notestack_<plan>_<cycle>) from backend/app/services/plans.py; set STRIPE_PRICE_* to pin existing ones.
11
+ # Webhook endpoint: {API_URL}/api/billing/webhook with events checkout.session.completed,
12
+ # checkout.session.async_payment_succeeded, checkout.session.async_payment_failed,
13
+ # customer.subscription.created, customer.subscription.updated, customer.subscription.deleted,
14
+ # customer.subscription.paused, customer.subscription.resumed, invoice.paid, invoice.payment_failed,
15
+ # invoice.payment_action_required, charge.dispute.created.
16
+ STRIPE_SECRET_KEY=
17
+ STRIPE_WEBHOOK_SECRET=
18
+ STRIPE_PRICE_WRITER_MONTHLY=
19
+ STRIPE_PRICE_WRITER_ANNUAL=
20
+ STRIPE_PRICE_STUDIO_MONTHLY=
21
+ STRIPE_PRICE_STUDIO_ANNUAL=
22
 
23
  # ---- Auth (JWT). Revocation bumps users.token_version, so tokens can be long lived.
24
  JWT_SECRET=replace-with-64-random-chars
backend/app/services/stripe_billing.py CHANGED
@@ -331,9 +331,11 @@ def handle_event(db: Session, event) -> str:
331
  if kind.startswith("invoice."):
332
  details = _get(_get(obj, "parent", {}), "subscription_details", {}) # newer API versions
333
  sub_id = _get(obj, "subscription") or _get(details, "subscription")
334
- ws = _workspace_for(db, {"id": sub_id} if sub_id else None, customer_id=_get(obj, "customer"))
335
- if ws and sub_id:
336
- sync_subscription(db, ws, sc.v1.subscriptions.retrieve(sub_id))
 
 
337
  if first and kind in {"invoice.payment_action_required", "invoice.payment_failed"}:
338
  # The first invoice of a Checkout is handled on the Checkout page itself.
339
  if _get(obj, "billing_reason") != "subscription_create":
 
331
  if kind.startswith("invoice."):
332
  details = _get(_get(obj, "parent", {}), "subscription_details", {}) # newer API versions
333
  sub_id = _get(obj, "subscription") or _get(details, "subscription")
334
+ # Read the subscription from Stripe: its metadata names the workspace even if an earlier event was missed.
335
+ stripe_sub = sc.v1.subscriptions.retrieve(sub_id) if sub_id else None
336
+ ws = _workspace_for(db, stripe_sub, customer_id=_get(obj, "customer"))
337
+ if ws and stripe_sub is not None:
338
+ sync_subscription(db, ws, stripe_sub)
339
  if first and kind in {"invoice.payment_action_required", "invoice.payment_failed"}:
340
  # The first invoice of a Checkout is handled on the Checkout page itself.
341
  if _get(obj, "billing_reason") != "subscription_create":
backend/tests/test_billing.py CHANGED
@@ -52,7 +52,8 @@ def test_free_plan_nudges_and_out_of_fuel_402(client, auth, billing_on, db_sessi
52
 
53
 
54
  def test_checkout_guards(client, auth, billing_on, db_session):
55
- assert client.post("/api/billing/checkout", headers=auth, json={"plan": "writer"}).status_code == 501
 
56
  assert client.post("/api/billing/checkout", headers=auth, json={"plan": "free"}).status_code == 422
57
  ws = db_session.query(Workspace).one()
58
  apply_subscription(db_session, ws.id, plan="studio", status="active", customer_id="cus_1")
 
52
 
53
 
54
  def test_checkout_guards(client, auth, billing_on, db_session):
55
+ r = client.post("/api/billing/checkout", headers=auth, json={"plan": "writer"})
56
+ assert r.status_code == 503 and r.json()["detail"]["code"] == "stripe_not_configured" # no STRIPE_SECRET_KEY
57
  assert client.post("/api/billing/checkout", headers=auth, json={"plan": "free"}).status_code == 422
58
  ws = db_session.query(Workspace).one()
59
  apply_subscription(db_session, ws.id, plan="studio", status="active", customer_id="cus_1")
backend/tests/test_stripe_billing.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stripe webhooks and checkout with a fake Stripe: signatures, retries, out of order events, 3DS, grace."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import time
7
+ import uuid
8
+
9
+ import pytest
10
+ from sqlalchemy import select
11
+
12
+ from app.config import settings
13
+ from app.models import Subscription, Workspace
14
+ from app.services import stripe_billing
15
+ from app.services.email import ConsoleEmailProvider
16
+ from app.services.plans import effective_plan
17
+ from tests.conftest import last_code
18
+
19
+ SECRET = "whsec_test_secret"
20
+
21
+
22
+ class FakeStripe:
23
+ """Just enough of StripeClient.v1 for the billing flows."""
24
+
25
+ def __init__(self):
26
+ self.subs: dict[str, dict] = {}
27
+ self.calls: list[tuple] = []
28
+ self.pending = False # next plan change needs 3DS
29
+ outer = self
30
+
31
+ class Subs:
32
+ def retrieve(self, sid, params=None):
33
+ return outer.subs[sid]
34
+
35
+ def update(self, sid, params):
36
+ outer.calls.append(("update", sid, params))
37
+ sub = outer.subs[sid]
38
+ invoice = {"status": "open" if outer.pending else "paid",
39
+ "hosted_invoice_url": "https://invoice.stripe.test/i_1"}
40
+ if outer.pending:
41
+ return {**sub, "pending_update": {"subscription_items": params["items"]}, "latest_invoice": invoice}
42
+ sub["items"]["data"][0]["price"] = {"id": params["items"][0]["price"],
43
+ "lookup_key": "notestack_studio_monthly"}
44
+ return {**sub, "pending_update": None, "latest_invoice": invoice}
45
+
46
+ class Prices:
47
+ def list(self, params):
48
+ return type("L", (), {"data": [type("P", (), {"id": f"price_{params['lookup_keys'][0]}"})()]})()
49
+
50
+ class Customers:
51
+ def create(self, params, options=None):
52
+ outer.calls.append(("customer", params))
53
+ return type("C", (), {"id": "cus_1"})()
54
+
55
+ class Sessions:
56
+ def create(self, params):
57
+ outer.calls.append(("checkout", params))
58
+ return type("S", (), {"url": "https://checkout.stripe.test/s_1"})()
59
+
60
+ def retrieve(self, sid, params=None):
61
+ return outer.session
62
+
63
+ class V1:
64
+ subscriptions = Subs()
65
+ prices = Prices()
66
+ customers = Customers()
67
+ checkout = type("Checkout", (), {"sessions": Sessions()})()
68
+
69
+ self.v1 = V1()
70
+ self.session: dict = {}
71
+
72
+ def add_sub(self, sid, workspace_id, plan="writer", status="active", period_end=None):
73
+ self.subs[sid] = {
74
+ "id": sid, "customer": "cus_1", "status": status,
75
+ "metadata": {"workspace_id": str(workspace_id), "plan": plan},
76
+ "items": {"data": [{
77
+ "id": "si_1",
78
+ "price": {"id": f"price_{plan}", "lookup_key": f"notestack_{plan}_monthly"},
79
+ "current_period_end": period_end or int(time.time()) + 30 * 86400,
80
+ }]},
81
+ }
82
+
83
+
84
+ @pytest.fixture()
85
+ def stripe_on(monkeypatch):
86
+ fake = FakeStripe()
87
+ monkeypatch.setattr(settings, "billing_enabled", True)
88
+ monkeypatch.setattr(settings, "stripe_secret_key", "sk_test_x")
89
+ monkeypatch.setattr(settings, "stripe_webhook_secret", SECRET)
90
+ monkeypatch.setattr(stripe_billing, "client", lambda: fake)
91
+ return fake
92
+
93
+
94
+ @pytest.fixture()
95
+ def owner(client, db_session):
96
+ client.post("/api/auth/email/register/start", json={"email": "grace@example.com", "password": "stardust-42",
97
+ "name": "Grace Hopper"})
98
+ r = client.post("/api/auth/email/register/verify", json={"email": "grace@example.com", "code": last_code()})
99
+ ws = db_session.scalar(select(Workspace))
100
+ return {"Authorization": f"Bearer {r.json()['access_token']}"}, ws
101
+
102
+
103
+ def send(client, event: dict, secret: str = SECRET):
104
+ payload = json.dumps(event)
105
+ t = int(time.time())
106
+ sig = hmac.new(secret.encode(), f"{t}.{payload}".encode(), hashlib.sha256).hexdigest()
107
+ return client.post("/api/billing/webhook", content=payload,
108
+ headers={"stripe-signature": f"t={t},v1={sig}", "content-type": "application/json"})
109
+
110
+
111
+ def event(kind: str, obj: dict) -> dict:
112
+ return {"id": f"evt_{uuid.uuid4().hex}", "object": "event", "type": kind, "data": {"object": obj}}
113
+
114
+
115
+ def plan_of(db, ws) -> str:
116
+ db.expire_all()
117
+ return effective_plan(db, ws).id
118
+
119
+
120
+ def test_webhook_rejects_bad_signature(client, stripe_on, owner):
121
+ r = send(client, event("customer.subscription.updated", {"id": "sub_x"}), secret="whsec_wrong")
122
+ assert r.status_code == 400
123
+
124
+
125
+ def test_checkout_completed_then_retry_and_out_of_order(client, stripe_on, owner, db_session):
126
+ _, ws = owner
127
+ stripe_on.add_sub("sub_1", ws.id, plan="writer")
128
+ done = event("checkout.session.completed", {"mode": "subscription", "subscription": "sub_1",
129
+ "client_reference_id": str(ws.id)})
130
+ assert send(client, done).json()["result"] == "synced"
131
+ assert plan_of(db_session, ws) == "writer"
132
+ # Stripe retries the same delivery: still fine, nothing doubles.
133
+ assert send(client, done).status_code == 200
134
+ assert db_session.scalar(select(Subscription).where(Subscription.workspace_id == ws.id)).plan == "writer"
135
+ # An older "incomplete" payload arrives late: we re-read Stripe, which says active.
136
+ stale = {**stripe_on.subs["sub_1"], "status": "incomplete"}
137
+ send(client, event("customer.subscription.updated", stale))
138
+ assert plan_of(db_session, ws) == "writer"
139
+
140
+
141
+ def test_past_due_keeps_plan_and_emails_once_then_cancel_drops_to_free(client, stripe_on, owner, db_session):
142
+ _, ws = owner
143
+ stripe_on.add_sub("sub_2", ws.id, plan="studio")
144
+ send(client, event("customer.subscription.created", stripe_on.subs["sub_2"]))
145
+ stripe_on.subs["sub_2"]["status"] = "past_due"
146
+ ConsoleEmailProvider.sent.clear()
147
+ failed = event("invoice.payment_failed", {
148
+ "subscription": "sub_2", "customer": "cus_1", "customer_email": "grace@example.com",
149
+ "billing_reason": "subscription_cycle", "hosted_invoice_url": "https://invoice.stripe.test/i_2"})
150
+ send(client, failed)
151
+ send(client, failed) # retried delivery
152
+ assert plan_of(db_session, ws) == "studio" # grace while Stripe retries
153
+ assert len([m for m in ConsoleEmailProvider.sent if "payment failed" in m.subject.lower()]) == 1
154
+ stripe_on.subs["sub_2"]["status"] = "canceled"
155
+ send(client, event("customer.subscription.deleted", stripe_on.subs["sub_2"]))
156
+ assert plan_of(db_session, ws) == "free"
157
+
158
+
159
+ def test_renewal_needing_3ds_emails_confirm_link(client, stripe_on, owner, db_session):
160
+ _, ws = owner
161
+ stripe_on.add_sub("sub_3", ws.id)
162
+ ConsoleEmailProvider.sent.clear()
163
+ send(client, event("invoice.payment_action_required", {
164
+ "subscription": "sub_3", "customer": "cus_1", "customer_email": "grace@example.com",
165
+ "billing_reason": "subscription_cycle", "hosted_invoice_url": "https://invoice.stripe.test/i_3"}))
166
+ mail = [m for m in ConsoleEmailProvider.sent if "confirm" in m.subject.lower()]
167
+ assert len(mail) == 1 and "https://invoice.stripe.test/i_3" in mail[0].text
168
+ assert plan_of(db_session, ws) == "writer"
169
+
170
+
171
+ def test_checkout_creates_one_customer_and_confirm_applies_plan(client, stripe_on, owner, db_session):
172
+ headers, ws = owner
173
+ r = client.post("/api/billing/checkout", json={"plan": "writer", "cycle": "annual"}, headers=headers)
174
+ assert r.json()["url"] == "https://checkout.stripe.test/s_1"
175
+ kinds = [c[0] for c in stripe_on.calls]
176
+ assert kinds == ["customer", "checkout"]
177
+ session = stripe_on.calls[1][1]
178
+ assert session["customer"] == "cus_1" and session["client_reference_id"] == str(ws.id)
179
+ assert "{CHECKOUT_SESSION_ID}" in session["success_url"]
180
+ assert session["line_items"][0]["price"] == "price_notestack_writer_annual"
181
+ # Back on the success page before any webhook: confirm applies the plan.
182
+ stripe_on.add_sub("sub_4", ws.id, plan="writer")
183
+ stripe_on.session = {"client_reference_id": str(ws.id), "status": "complete",
184
+ "subscription": stripe_on.subs["sub_4"]}
185
+ r = client.post("/api/billing/confirm", json={"session_id": "cs_1"}, headers=headers)
186
+ assert r.json()["plan"]["id"] == "writer"
187
+ # A second checkout while subscribed changes the plan in place instead of a second subscription.
188
+ r = client.post("/api/billing/checkout", json={"plan": "studio", "cycle": "monthly"}, headers=headers)
189
+ assert r.json()["url"].endswith("/app?checkout=success")
190
+ assert [c[0] for c in stripe_on.calls].count("customer") == 1
191
+ assert plan_of(db_session, ws) == "studio"
192
+
193
+
194
+ def test_upgrade_needing_3ds_sends_user_to_invoice(client, stripe_on, owner, db_session):
195
+ headers, ws = owner
196
+ stripe_on.add_sub("sub_5", ws.id, plan="writer")
197
+ send(client, event("customer.subscription.created", stripe_on.subs["sub_5"]))
198
+ stripe_on.pending = True
199
+ r = client.post("/api/billing/checkout", json={"plan": "studio", "cycle": "monthly"}, headers=headers)
200
+ assert r.json()["url"] == "https://invoice.stripe.test/i_1"
201
+ assert plan_of(db_session, ws) == "writer" # switches only once the payment succeeds
deploy/env.prod.example CHANGED
@@ -65,3 +65,16 @@ LINKEDIN_CLIENT_ID=
65
  LINKEDIN_CLIENT_SECRET=
66
 
67
  BILLING_ENABLED=false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  LINKEDIN_CLIENT_SECRET=
66
 
67
  BILLING_ENABLED=false
68
+ # Stripe. Checkout, the customer portal and webhooks. Prices are found or created by lookup key
69
+ # (notestack_<plan>_<cycle>) from backend/app/services/plans.py; set STRIPE_PRICE_* to pin existing ones.
70
+ # Webhook endpoint: {API_URL}/api/billing/webhook with events checkout.session.completed,
71
+ # checkout.session.async_payment_succeeded, checkout.session.async_payment_failed,
72
+ # customer.subscription.created, customer.subscription.updated, customer.subscription.deleted,
73
+ # customer.subscription.paused, customer.subscription.resumed, invoice.paid, invoice.payment_failed,
74
+ # invoice.payment_action_required, charge.dispute.created.
75
+ STRIPE_SECRET_KEY=
76
+ STRIPE_WEBHOOK_SECRET=
77
+ STRIPE_PRICE_WRITER_MONTHLY=
78
+ STRIPE_PRICE_WRITER_ANNUAL=
79
+ STRIPE_PRICE_STUDIO_MONTHLY=
80
+ STRIPE_PRICE_STUDIO_ANNUAL=
frontend/src/api/endpoints.ts CHANGED
@@ -187,4 +187,5 @@ export const billingApi = {
187
  status: () => api<BillingStatus>("/api/billing/status"),
188
  checkout: (plan: string, cycle: BillingCycle) => post<{ url: string }>("/api/billing/checkout", { plan, cycle }),
189
  portal: () => post<{ url: string }>("/api/billing/portal"),
 
190
  };
 
187
  status: () => api<BillingStatus>("/api/billing/status"),
188
  checkout: (plan: string, cycle: BillingCycle) => post<{ url: string }>("/api/billing/checkout", { plan, cycle }),
189
  portal: () => post<{ url: string }>("/api/billing/portal"),
190
+ confirm: (sessionId: string) => post<BillingStatus>("/api/billing/confirm", { session_id: sessionId }),
191
  };
frontend/src/components/UpgradeModal.tsx CHANGED
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
3
  import type { BillingCycle } from "../api/endpoints";
4
  import type { Meter, PlanInfo } from "../api/types";
5
  import { useUpgrade } from "../hooks/useUpgrade";
6
- import { CheckIcon, RocketIcon } from "./icons/Icons";
7
  import { annualMonthly, annualTotal, money } from "./PricingTiers";
8
 
9
  const ORDER = ["free", "writer", "studio"];
@@ -62,9 +62,6 @@ function Scene({ mode }: { mode: "explore" | "limit" | "welcome" }) {
62
  <circle cx="106" cy="76" r="3" className="upg-crater" />
63
  <path d="M8 104a92 20 0 0 0 184 0" className="upg-ring front" />
64
  </svg>
65
- <span className="upg-rocket">
66
- <RocketIcon size={22} />
67
- </span>
68
  </div>
69
  </div>
70
  );
@@ -93,6 +90,11 @@ export default function UpgradeModal() {
93
  const [cycle, setCycle] = useState<BillingCycle>("annual");
94
  const panel = useRef<HTMLDivElement>(null);
95
 
 
 
 
 
 
96
  useEffect(() => {
97
  if (!modal) return;
98
  const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
@@ -165,7 +167,7 @@ export default function UpgradeModal() {
165
  <>
166
  <div className="upg-cycle">
167
  <div className="cycle-toggle" role="radiogroup" aria-label="Billing cycle">
168
- {(["monthly", "annual"] as const).map((c) => (
169
  <button key={c} type="button" role="radio" aria-checked={cycle === c} className={cycle === c ? "on" : ""} onClick={() => setCycle(c)}>
170
  {c === "monthly" ? "Monthly" : "Annual"}
171
  {c === "annual" && offers[0]?.annual_savings_pct > 0 && <span className="save mono">Save {offers[0].annual_savings_pct}%</span>}
 
3
  import type { BillingCycle } from "../api/endpoints";
4
  import type { Meter, PlanInfo } from "../api/types";
5
  import { useUpgrade } from "../hooks/useUpgrade";
6
+ import { CheckIcon } from "./icons/Icons";
7
  import { annualMonthly, annualTotal, money } from "./PricingTiers";
8
 
9
  const ORDER = ["free", "writer", "studio"];
 
62
  <circle cx="106" cy="76" r="3" className="upg-crater" />
63
  <path d="M8 104a92 20 0 0 0 184 0" className="upg-ring front" />
64
  </svg>
 
 
 
65
  </div>
66
  </div>
67
  );
 
90
  const [cycle, setCycle] = useState<BillingCycle>("annual");
91
  const panel = useRef<HTMLDivElement>(null);
92
 
93
+ // Every open starts on annual.
94
+ useEffect(() => {
95
+ if (modal) setCycle("annual");
96
+ }, [modal]);
97
+
98
  useEffect(() => {
99
  if (!modal) return;
100
  const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
 
167
  <>
168
  <div className="upg-cycle">
169
  <div className="cycle-toggle" role="radiogroup" aria-label="Billing cycle">
170
+ {(["annual", "monthly"] as const).map((c) => (
171
  <button key={c} type="button" role="radio" aria-checked={cycle === c} className={cycle === c ? "on" : ""} onClick={() => setCycle(c)}>
172
  {c === "monthly" ? "Monthly" : "Annual"}
173
  {c === "annual" && offers[0]?.annual_savings_pct > 0 && <span className="save mono">Save {offers[0].annual_savings_pct}%</span>}
frontend/src/hooks/useUpgrade.tsx CHANGED
@@ -107,10 +107,14 @@ export function UpgradeProvider({ children }: { children: ReactNode }) {
107
  if (upgrade) setModal({ mode: "explore", highlight: upgrade === "1" ? null : upgrade });
108
  if (checkoutResult === "success") {
109
  setModal({ mode: "welcome" });
110
- refresh();
 
 
 
111
  }
112
  params.delete("upgrade");
113
  params.delete("checkout");
 
114
  const rest = params.toString();
115
  navigate({ pathname: location.pathname, search: rest ? `?${rest}` : "" }, { replace: true });
116
  openingDone.current = true;
 
107
  if (upgrade) setModal({ mode: "explore", highlight: upgrade === "1" ? null : upgrade });
108
  if (checkoutResult === "success") {
109
  setModal({ mode: "welcome" });
110
+ // Apply the purchase now instead of waiting for Stripe's webhook, then show the new plan.
111
+ const sessionId = params.get("session_id");
112
+ if (sessionId) billingApi.confirm(sessionId).catch(() => undefined).finally(refresh);
113
+ else refresh();
114
  }
115
  params.delete("upgrade");
116
  params.delete("checkout");
117
+ params.delete("session_id");
118
  const rest = params.toString();
119
  navigate({ pathname: location.pathname, search: rest ? `?${rest}` : "" }, { replace: true });
120
  openingDone.current = true;
frontend/src/lib/highlight.tsx ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+
3
+ /** Tiny syntax highlighter for chat code blocks: one tokenizing regex per language family, React spans
4
+ * out (never HTML). Good enough for reading, not a parser. Colors live in pages.css (.tok-*). */
5
+
6
+ const KEYWORDS: Record<string, string> = {
7
+ js: "async await break case catch class const continue default delete do else export extends false finally for from function if import in instanceof let new null of return static super switch this throw true try typeof undefined var void while yield interface type enum implements readonly as satisfies keyof public private protected",
8
+ py: "and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return self True try while with yield match case",
9
+ sh: "if then else elif fi for while do done case esac in function return export local echo exit set unset source cd sudo",
10
+ sql: "select from where and or not insert into values update set delete create table index view join left right inner outer on group by order having limit offset as distinct union all null is in like between case when then else end primary key foreign references default returning with",
11
+ go: "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false",
12
+ rust: "as async await break const continue crate else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while dyn Some None Ok Err",
13
+ c: "abstract boolean break byte case catch char class const continue default do double else enum extends final finally float for if implements import instanceof int interface long new null package private protected public return short static super switch this throw throws try void volatile while true false struct typedef unsigned sizeof include define namespace using template",
14
+ yaml: "true false null yes no on off",
15
+ css: "important media keyframes from to import supports",
16
+ };
17
+
18
+ const FAMILY: Record<string, string> = {
19
+ js: "js", jsx: "js", ts: "js", tsx: "js", javascript: "js", typescript: "js", mjs: "js", cjs: "js", json: "json",
20
+ py: "py", python: "py",
21
+ sh: "sh", bash: "sh", shell: "sh", zsh: "sh", console: "sh", powershell: "sh", ps1: "sh",
22
+ sql: "sql", postgres: "sql", sqlite: "sql",
23
+ go: "go", golang: "go",
24
+ rs: "rust", rust: "rust",
25
+ java: "c", c: "c", cpp: "c", "c++": "c", cs: "c", csharp: "c", kotlin: "c", swift: "c", php: "c",
26
+ yaml: "yaml", yml: "yaml", toml: "yaml", ini: "yaml",
27
+ css: "css", scss: "css",
28
+ html: "html", xml: "html", svg: "html", vue: "html",
29
+ };
30
+
31
+ const HASH_COMMENTS = new Set(["py", "sh", "yaml"]);
32
+
33
+ function tokenizer(family: string): RegExp {
34
+ if (family === "html") {
35
+ return /(<!--[\s\S]*?-->)|(<\/?[\w:-]+)|("[^"]*"|'[^']*')|([\w:-]+(?==))|(\/?>)/g;
36
+ }
37
+ const comment = HASH_COMMENTS.has(family)
38
+ ? String.raw`#[^\n]*`
39
+ : family === "sql"
40
+ ? String.raw`--[^\n]*|\/\*[\s\S]*?\*\/`
41
+ : String.raw`\/\/[^\n]*|\/\*[\s\S]*?\*\/`;
42
+ const strings = String.raw`"""[\s\S]*?"""|'''[\s\S]*?'''|` + "`(?:\\\\.|[^`\\\\])*`" + String.raw`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'`;
43
+ return new RegExp(
44
+ `(${comment})|(${strings})|(\\b\\d[\\d_]*(?:\\.\\d+)?(?:e[+-]?\\d+)?\\b|\\b0x[\\da-f]+\\b)|([A-Za-z_$][\\w$]*)(?=\\s*\\()|([A-Za-z_$@][\\w$-]*)`,
45
+ "gi",
46
+ );
47
+ }
48
+
49
+ export function languageLabel(lang: string): string {
50
+ const l = lang.toLowerCase();
51
+ return l ? l : "text";
52
+ }
53
+
54
+ export function highlight(code: string, lang: string): ReactNode[] {
55
+ const family = FAMILY[lang.toLowerCase()];
56
+ if (!family) return [code];
57
+ const out: ReactNode[] = [];
58
+ let last = 0;
59
+ let k = 0;
60
+ if (family === "json") {
61
+ for (const m of code.matchAll(/("(?:\\.|[^"\\])*")(\s*:)?|(\b-?\d+(?:\.\d+)?(?:e[+-]?\d+)?\b)|\b(true|false|null)\b/gi)) {
62
+ const i = m.index ?? 0;
63
+ if (i > last) out.push(code.slice(last, i));
64
+ if (m[1]) {
65
+ out.push(<span key={k++} className={m[2] ? "tok-prop" : "tok-str"}>{m[1]}</span>);
66
+ if (m[2]) out.push(m[2]);
67
+ } else out.push(<span key={k++} className={m[3] ? "tok-num" : "tok-kw"}>{m[0]}</span>);
68
+ last = i + m[0].length;
69
+ }
70
+ if (last < code.length) out.push(code.slice(last));
71
+ return out;
72
+ }
73
+ const words = new Set((KEYWORDS[family] ?? "").split(" "));
74
+ const re = tokenizer(family);
75
+ for (const m of code.matchAll(re)) {
76
+ const i = m.index ?? 0;
77
+ if (i > last) out.push(code.slice(last, i));
78
+ const [whole, a, b, c, d, e] = m;
79
+ let cls: string | null = null;
80
+ if (family === "html") cls = a ? "tok-com" : b ? "tok-kw" : c ? "tok-str" : d ? "tok-prop" : e ? "tok-kw" : null;
81
+ else if (a) cls = "tok-com";
82
+ else if (b) cls = "tok-str";
83
+ else if (c) cls = "tok-num";
84
+ else if (d) cls = words.has(family === "sql" ? d.toLowerCase() : d) ? "tok-kw" : "tok-fn";
85
+ else if (e) cls = words.has(family === "sql" ? e.toLowerCase() : e) ? "tok-kw" : /^[A-Z][A-Za-z0-9]+$/.test(e) ? "tok-type" : null;
86
+ out.push(cls ? <span key={k++} className={cls}>{whole}</span> : whole);
87
+ last = i + whole.length;
88
+ }
89
+ if (last < code.length) out.push(code.slice(last));
90
+ return out;
91
+ }
frontend/src/styles/upgrade.css CHANGED
@@ -64,7 +64,7 @@
64
  margin: 0;
65
  }
66
 
67
- /* Scene: drifting stars, a ringed planet and a rocket on orbit */
68
  .upg-scene {
69
  position: relative;
70
  height: 190px;
@@ -141,22 +141,6 @@
141
  stroke: rgba(255, 255, 255, 0.15);
142
  }
143
 
144
- /* The rocket flies an ellipse around the planet (the ring's plane). */
145
- .upg-rocket {
146
- position: absolute;
147
- top: 0;
148
- left: 0;
149
- display: grid;
150
- place-items: center;
151
- width: 26px;
152
- height: 26px;
153
- color: var(--white);
154
- filter: drop-shadow(0 0 6px var(--blue));
155
- offset-path: path("M -20 92 a 105 32 0 1 0 210 0 a 105 32 0 1 0 -210 0");
156
- offset-rotate: 0deg;
157
- animation: upg-fly 9s linear infinite;
158
- }
159
-
160
  /* Explore: warp streaks racing past */
161
  .upg-warp span {
162
  position: absolute;
@@ -189,17 +173,11 @@
189
  .upg-warp span:nth-child(13) { --a: 337deg; animation-delay: 1.4s; }
190
  .upg-warp span:nth-child(14) { --a: 355deg; animation-delay: 2s; }
191
 
192
- /* Limit: the rocket sputters, the planet dims */
193
  .upg-limit .upg-planet {
194
  filter: drop-shadow(0 0 10px var(--blue-20)) saturate(0.6);
195
  }
196
 
197
- .upg-limit .upg-rocket {
198
- animation:
199
- upg-fly 28s linear infinite,
200
- upg-sputter 1.4s steps(2, jump-none) infinite;
201
- }
202
-
203
  /* Welcome: a burst of sparkles */
204
  .upg-burst span {
205
  position: absolute;
@@ -570,10 +548,6 @@
570
  50% { transform: translateY(-5px); }
571
  }
572
 
573
- @keyframes upg-fly {
574
- to { offset-distance: 100%; }
575
- }
576
-
577
  @keyframes upg-warp {
578
  0% { opacity: 0; transform: rotate(var(--a)) translateX(10px) scaleX(0.2); }
579
  30% { opacity: 1; }
@@ -585,10 +559,6 @@
585
  100% { opacity: 0; transform: rotate(var(--a)) translateX(150px) scale(0.3); }
586
  }
587
 
588
- @keyframes upg-sputter {
589
- 50% { opacity: 0.35; }
590
- }
591
-
592
  @keyframes upg-blink {
593
  50% { opacity: 0.3; }
594
  }
 
64
  margin: 0;
65
  }
66
 
67
+ /* Scene: drifting stars and a ringed planet */
68
  .upg-scene {
69
  position: relative;
70
  height: 190px;
 
141
  stroke: rgba(255, 255, 255, 0.15);
142
  }
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  /* Explore: warp streaks racing past */
145
  .upg-warp span {
146
  position: absolute;
 
173
  .upg-warp span:nth-child(13) { --a: 337deg; animation-delay: 1.4s; }
174
  .upg-warp span:nth-child(14) { --a: 355deg; animation-delay: 2s; }
175
 
176
+ /* Limit: the planet dims */
177
  .upg-limit .upg-planet {
178
  filter: drop-shadow(0 0 10px var(--blue-20)) saturate(0.6);
179
  }
180
 
 
 
 
 
 
 
181
  /* Welcome: a burst of sparkles */
182
  .upg-burst span {
183
  position: absolute;
 
548
  50% { transform: translateY(-5px); }
549
  }
550
 
 
 
 
 
551
  @keyframes upg-warp {
552
  0% { opacity: 0; transform: rotate(var(--a)) translateX(10px) scaleX(0.2); }
553
  30% { opacity: 1; }
 
559
  100% { opacity: 0; transform: rotate(var(--a)) translateX(150px) scale(0.3); }
560
  }
561
 
 
 
 
 
562
  @keyframes upg-blink {
563
  50% { opacity: 0.3; }
564
  }