Files
malenia-frontend/AGENTS.md
2026-08-10 12:01:03 +07:00

19 KiB
Raw Permalink Blame History

MaleniaVPN — Frontend Agent Guidance

Project Overview

MaleniaVPN (malenia) is a VPN purchase service built around a pay-for-device model: one flat rate per device ("seat"), no tiers. The frontend is a Russian-language web app living in frontend/ on top of a FastAPI backend. This file is the canonical guidance for AI assistants working on it.

The service is run by a two-person studio. The brand is minimalist and archival (cream/ink/gold, extreme whitespace), but the voice is honest, ironic and sometimes snarky. Keep the UI elegant; keep the copy human.

Screens

Screen Route Access Source of truth
Landing / Public .ref/landing.html (existing hosted page)
Plan calculator /plan Public (auth needed only to confirm an order) .ref/calculator.html
Login / Signup /login Public .ref/login.html
Account /account Protected (auth required) .ref/account.html

Authoritative Source Directories (read-only)

Never edit these; they are reference material.

Path What it is
.info/openapi.json The backend API contract. All types below mirror it.
.info/ToV.md Company values and tone of voice (Russian).
.design/DESIGN.md, .design/brand.json Visual identity: palette, typography, layout posture.
.design/fonts/ Self-hosted .woff2 + fonts.css. Latin-only subsets — see Typography.
.design/logos/ favicon-0.ico — the favicon.
.ref/*.html Ready-made page references: full markup, styles and behaviors. Refactor into React, do not copy verbatim.

The .design/system/ folder (tokens, kit.html, artifacts) is brand-system material; at page build time use the canonical tokens below rather than the --brand-* derived tokens.

Tech Stack

  • Vite + React 19 + TypeScript ("strict": true)
  • React Router v6
  • Zustand — auth tokens and UI state only, never server-fetched data
  • Native fetch API client (no axios)
  • Vanilla CSS with custom properties (no Tailwind, no CSS-in-JS, no CSS frameworks)
  • Vitest + React Testing Library for tests
  • ESLint (typescript/react/hooks) + Prettier (print width 100, single quotes, trailing commas, semicolons)

Design System

Canonical CSS tokens (src/styles/tokens.css)

Token Value Usage
--bg #faf7f2 Page background (cream)
--surface #f5f0e8 Cards, raised blocks (warm white)
--fg #1a1610 Primary text, primary buttons (ink)
--muted #3d3428 Secondary text (ink-soft)
--fg-muted #7a6e5f Tertiary text, captions (ink-muted)
--accent #c9a84c Highlights, active states, emblems (gold)
--accent-light #e8ca7a Hover glow, gold-light
--accent-dim #8b6914 Emphasis inside quote text
--hairline rgba(26, 22, 16, 0.10) Faint structural rules, grid gaps
--hairline-strong rgba(26, 22, 16, 0.18) Input underlines, card borders
--border rgba(201, 168, 76, 0.25) Landing header/footer rules
--font-display 'Cormorant Garamond', Georgia, serif Headings, numerals
--font-body 'Tenor Sans', 'Helvetica Neue', Arial, sans-serif Body, labels, buttons
--radius 8px All corners
--baseline 8px Spacing unit

Layout rules

  • 8px baseline grid for spacing and padding; generous padding everywhere.
  • Extreme whitespace + centered alignment. One primary purpose per screen.
  • 1px hairlines for structural division (horizontal rules, stat strips divided by grid gap: 1px over a hairline background).
  • Buttons: uppercase, letterspacing 0.16–0.22em, 10px font-size, --radius corners. Primary = --fg background / --bg text; hover → --accent. Secondary = transparent, 1px solid var(--hairline-strong).
  • Inputs: underline style (border-bottom: 1px solid var(--hairline-strong)), transparent background, gold animated focus line (scaleX), no boxed inputs.

Signature UI details (all screens)

  • Custom cursor: gold dot (6px) + trailing ring (32px) that grows to 48px over interactive elements. Hide on touch devices (@media (hover: none), (pointer: coarse)) — these also need * { cursor: auto !important; }.
  • Grain overlay: fixed body::before with the fractal-noise SVG data-URI, opacity: 0.035, mix-blend-mode: multiply.
  • Fade-up reveals: .reveal { opacity: 0; transform: translateY(14px); } → .is-in; staggered transition-delay via IntersectionObserver.
  • Logo: Mal<span class="e">e</span>nia — Cormorant Garamond, uppercase, letter-spacing: 0.35em; the e is var(--accent).
  • Emblem: the 90×90 geometric SVG (ring + diamond + cardinal marks), gold stroke.
  • Respect prefers-reduced-motion: disable cursor animation, grain is fine (static), use plain opacity transitions without translate.

Typography

  • Display/headings: Cormorant Garamond (weights 400, 500, 600 + italics). Large sizes with line-height 1.1–1.25 and slight negative letter-spacing.
  • Body: Tenor Sans.
  • CRITICAL — Cyrillic fonts: the self-hosted .design/fonts/*.woff2 subsets have unicode-range for Latin Extended only (no U+0400–04FF) and will render Russian text with fallback glyphs. The interface is Russian, so load fonts from Google Fonts exactly as the .ref pages do: https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Tenor+Sans&display=swap (with preconnect links). Self-hosting is only acceptable after adding Cyrillic subsets.

Brand Voice & Copy (from .info/ToV.md)

  • All interface copy is Russian. No placeholders left in English.
  • Tone: restrained, archival and minimal on the surface; honest, ironic, occasionally snarky underneath. Plain wording, no corporate clichés, no hype ("revolutionize", "unleash the power", "best-in-class" are banned).
  • Service attitude: "включил и забыл" (set-and-forget), transparency about incidents, engineering-first, anti-censorship.
  • Tagline: «Свобода. Практика. Простота.»
  • Landing quote block: rotate randomly through the Russian quotes array from .ref/landing.html; render the second line as an italic <em>.

Routing (src/App.tsx)

Path Element Notes
/ Landing Existing marketing page
/login LoginPage Credentials only. ?returnTo= preserved for post-auth redirect
/plan PlanPage Calculator + confirm → checkout
/account AccountPage Protected: redirect to /login when unauthenticated

Backend API (mirrors .info/openapi.json — the only source of truth)

Base: backend routes have no /api/ prefix. In dev, vite.config.ts proxies /auth, /plans, /orders, /users, /payments to http://127.0.0.1:8000. In prod the reverse proxy serves same-origin. Never hardcode absolute URLs.

Types (src/types.ts)

type Provider = 'credentials' | 'telegram' | 'api';

interface UserInfo {
  username: string | null;
  telegram_id: string | null;
  referal_code: string;
}

interface LoginResponse {          // POST /auth/login
  access_token: string;
  refresh_token: string;
  expires_at: number;              // epoch seconds
  user: UserInfo;
}

interface Tokens {                 // POST /auth/refresh
  access_token: string;
  refresh_token: string;
  expires_at: number;
}

interface AddonData {              // inside GET /plans/
  id: string;
  name: string;
  price: number;
  free_threshold: number;
}

interface PricingPlans {           // GET /plans/
  device_price: number;
  addons: AddonData[];
}

interface OrderDetails {           // POST /orders/checkout body
  devices: number;
  addons: string[];                // addon ids
  duration_days: number;
}

interface CheckoutResponse {       // POST /orders/checkout → 201
  order_id: string;
  total_amount: number;
  bonus_paid: number;
  amount_to_pay: number;
  payment_link: string | null;
}

interface Device {                 // GET /users/subscription/hwid
  os: string | null;
  model: string | null;
  client: string | null;
  hwid: string;
}

Endpoint table

Method Path Auth Purpose
POST /auth/signup – Register, body {provider, username?, password?, telegram_id?, referal_code?}
POST /auth/login – Login, body {provider:'credentials', username, password}
POST /auth/refresh?refresh_token=&iss= – New token pair; iss is the provider
GET /plans/ – Pricing: device_price + addons
POST /orders/checkout yes Create order → payment_link
GET /users/me yes UserInfo
GET /users/subscription yes Subscription state (schema is {} — shape to be confirmed at implementation)
GET /users/subscription/hwid yes Device[]
DELETE /users/subscription/hwid?hwid= yes Unlink a device
POST /payments/pally/result – Payment callback — backend-internal, frontend never calls it

Auth flow

  • Credentials only. Sign up / sign in are username + password. Telegram auth is explicitly out of scope for now (linking ships later). The API accepts provider: telegram, but the frontend must only use credentials.
  • Tokens stored via a useLocalStorage-style hook under keys malenia.access, malenia.refresh, malenia.expires_at (epoch seconds).
  • The API client injects Authorization: Bearer <access> on every request.
  • On 401: try POST /auth/refresh?refresh_token=…&iss=credentials once; on success retry the original request, on failure clear tokens and redirect to /login. Guard against refresh loops.
  • Signup may include referal_code — expose an optional field on the signup form and pass it through.

API client conventions

  • Generic request<T>() helper; throws Error with the backend detail for non-2xx.
  • 201 responses return parsed JSON; no 204s in this API.
  • Errors surface as toast notifications (inline errors only for form validation).

Pages — implementation notes (from .ref/)

Landing (/) — .ref/landing.html

Keep the overall structure and design. Apply the existing page's corrections:

  • Header gains navigation links (<nav>): e.g. «Оформить» → /plan, «Канал» → https://t.me/maleniavpn (external), «Поддержка» → https://t.me/maleniasupportbot (external). Logo links to /. Keep the hairline rule + tagline layout.
  • CTA buttons (hero): primary «Оформить» → /plan; secondary «Канал» → Telegram channel (external link, keep the Telegram icon). Support link may live in the header/footer.
  • Keep: emblem, rotating quote block, divider-diamond, footer, fade-up sequence.
  • This is the existing hosted page — preserve its copy and content, only wire the corrected navigation/CTAs.

Login / Signup (/login) — .ref/login.html

  • Single page with sign-in and sign-up modes (toggle), or cleanly split states.
  • No Telegram button and no "Forgot password?" link (no such endpoint exists; credentials-only auth). Remove the ghost-telegram affordance entirely.
  • Sign-in: provider: 'credentials', username, password, show/hide password toggle, "remember" checkbox (drives refresh persistence).
  • Sign-up: username, password, optional referal_code.
  • On success → /account (or returnTo). Show auth errors via the page's status line plus toast.

Plan calculator (/plan) — .ref/calculator.html

Keep the visual language: readout, rhombus device slider (range 3–12, drag + keyboard + pointer capture), ticks, duration grid, breakdown, addon list, CTA. Rewrite the pricing logic — the .ref has known bugs and placeholder numbers.

Rewritten pricing (authoritative)

  1. Load pricing from GET /plans/ on mount (usePlans hook). Do not hardcode.
  2. Device cost is linear: deviceCost = device_price * devices. No per-seat declining curves, no volume multipliers.
  3. Addon cost is the sum of selected addons' price.
  4. Duration: options are 1 / 3 / 6 / 12 months mapped to duration_days as 30 / 90 / 180 / 365. The .ref discount multipliers (mult 0.95/0.90/0.80) are design-only placeholders — the backend data has no discount field, so do not invent one. If a "выгода" badge is shown, it must be derived from real backend data only; otherwise drop it.
  5. free_threshold on an addon: present in the backend model with unspecified semantics — ask the backend owner before wiring any rule. Until confirmed, render addons at their flat price and do not guess.
  6. Total display = deviceCost + addonCost. Show the monthly and the full-order amount (total * months) in the breakdown.

Bug notes (do not reproduce)

  • .ref referenced DOM nodes (#bd-total, #bd-period-row) that were missing from the HTML, crashing render() for multi-month durations.
  • Pricing priceFor() declining per-seat curve contradicts backend device_price.
  • The addons array was a hardcoded placeholder.

Checkout flow

  • CTA «Оформить» → if no token, redirect to /login?returnTo=/plan (preserve the chosen config in state/URL). If authed → POST /orders/checkout { devices, addons, duration_days }.
  • On 201: redirect the user to payment_link (when non-null) in the same tab; fall back to a "order created" confirmation state showing order_id / amount_to_pay when the link is null.
  • Show amount_to_pay prominently since bonus_paid may reduce it below total_amount.

Account (/account) — .ref/account.html

  • Hero: «Ваш аккаунт» + username (Cormorant, serif), sub-line from subscription data.
  • Stat strip (4 columns, gap: 1px hairline grid; 2 cols tablet, 1 col mobile): Username, Telegram ID (@… or empty state), Devices linked (n / limit), Renews / subscription expiry. Data from GET /users/me + GET /users/subscription.
  • Devices grid from GET /users/subscription/hwid. Card: OS glyph (monoline SVGs — macos/ios/android/windows/linux from .ref), model, tags (OS label · client · joined), delete button.
  • Delete flow: confirmation modal («Удалить это устройство?», shows device model), then DELETE /users/subscription/hwid?hwid=…, toast on success, grid re-rendered. Empty state: «Нет подключённых устройств» + instructions.
  • Sign out in the footer: clear tokens → /.
  • Toast: fixed bottom-center, --fg background, --bg text, auto-dismiss ~2.2s.

State Management (Zustand)

Store shape {
  auth: {
    access: string | null;
    refresh: string | null;
    expiresAt: number | null;
    user: UserInfo | null;
    setSession(tokens, user), updateUser(user), clear();
  }
}
  • Server data (plans, devices, subscription) is fetched in hooks inside components — never stored in the store.
  • Persist tokens to localStorage; hydrate on boot; empty store = unauthenticated.

Project Structure

frontend/
├── AGENTS.md
├── .design/  .info/  .ref/       # read-only reference
├── index.html                      # mount + Google Fonts links + favicon
├── public/
│   ├── favicon.ico                 # from .design/logos/favicon-0.ico
│   └── fonts/                      # optional self-hosted (latin-only — see above)
├── src/
│   ├── main.tsx
│   ├── App.tsx                     # Router, auth guard
│   ├── types.ts                    # mirrors openapi (above)
│   ├── api/client.ts               # fetch wrapper + token injection + refresh
│   ├── hooks/
│   │   ├── useLocalStorage.ts
│   │   ├── useAuth.ts
│   │   ├── usePlans.ts
│   │   └── useDevices.ts
│   ├── store/useStore.ts
│   ├── lib/format.ts               # money/pluralization helpers
│   ├── components/
│   │   ├── Cursor.tsx              # dot + ring
│   │   ├── Emblem.tsx / Logo.tsx
│   │   ├── Reveal.tsx
│   │   ├── TopBar.tsx              # landing header w/ nav + CTA links
│   │   ├── Shell.tsx               # app topbar + content (plan/account)
│   │   ├── DeviceSlider.tsx
│   │   ├── DurationSelect.tsx
│   │   ├── AddonList.tsx
│   │   ├── Breakdown.tsx
│   │   ├── StatStrip.tsx
│   │   ├── DeviceCard.tsx
│   │   ├── Modal.tsx / Toast.tsx
│   │   └── Skeleton.tsx
│   ├── pages/
│   │   ├── Landing.tsx
│   │   ├── LoginPage.tsx
│   │   ├── PlanPage.tsx
│   │   └── AccountPage.tsx
│   └── styles/
│       ├── tokens.css              # canonical tokens (table above)
│       ├── base.css                # reset, grain overlay, cursor, reveal
│       ├── components.css
│       └── pages.css

Conventions

  • TypeScript strict; all props/state/responses explicitly typed; no any; narrow with type guards from unknown.
  • No business logic in components — extract pricing/formatting into lib/ or hooks.
  • Form submit buttons show a disabled "Сохранение…" state while pending.
  • Loading → <Skeleton /> matching the content shape.
  • Errors → toast (auto-dismiss ~4s). Critical auth errors redirect to /login.
  • Semantics: <nav>, <main>, <section>, <article>, aria-label on the slider and icon-only buttons; keyboard support already in .ref (arrows/Home/End, Escape closes modal).
  • Interface copy must be in Russian (see Voice above).

Testing (Vitest + React Testing Library)

  • api/client.ts — request construction, auth header, refresh-on-401, error mapping.
  • Pricing logic (lib) — device × price, addon sums, duration→days mapping, totals.
  • LoginPage — sign-in/sign-up submit + validation, redirect after auth.
  • PlanPage — slider/duration/addon state, checkout payload, payment_link redirect.
  • AccountPage — device render, empty state, delete → confirm modal → DELETE call → toast.
  • Mock at the api/client.ts level, not per-call.

Anti-Patterns

  • No server-fetched data in Zustand. No business logic in components/routers.
  • No Tailwind/Bootstrap/CSS frameworks. No inline <style> in React components.
  • No hardcoded prices, no per-seat discount curves, no invented promo multipliers.
  • No English UI strings left in the app.
  • Don't edit .design/, .info/, .ref/ — reference only.
  • Don't call /payments/pally/result from the frontend.

Key Files Reference

File Purpose
AGENTS.md This file
.info/openapi.json Backend API contract
.info/ToV.md Voice & tone
.design/DESIGN.md, brand.json Brand identity
.ref/landing.html Landing page reference + quotes
.ref/login.html Auth page reference
.ref/calculator.html Plan/calculator reference (pricing logic is buggy — rewrite per above)
.ref/account.html Account page reference
src/api/client.ts Central API client
src/types.ts Backend-mirroring types
src/styles/tokens.css Design tokens