423 lines
19 KiB
Markdown
423 lines
19 KiB
Markdown
# 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 | |