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

423 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`)
```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 |