This commit is contained in:
2026-08-10 12:01:03 +07:00
commit 265b1202ad
95 changed files with 27718 additions and 0 deletions

423
AGENTS.md Normal file
View File

@@ -0,0 +1,423 @@
# 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 |