19 KiB
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
fetchAPI 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: 1pxover a hairline background). - Buttons: uppercase, letterspacing
0.16–0.22em, 10px font-size,--radiuscorners. Primary =--fgbackground /--bgtext; 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::beforewith the fractal-noise SVG data-URI,opacity: 0.035,mix-blend-mode: multiply. - Fade-up reveals:
.reveal { opacity: 0; transform: translateY(14px); }→.is-in; staggeredtransition-delayvia IntersectionObserver. - Logo:
Mal<span class="e">e</span>nia— Cormorant Garamond, uppercase,letter-spacing: 0.35em; theeisvar(--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.25and slight negativeletter-spacing. - Body: Tenor Sans.
- CRITICAL — Cyrillic fonts: the self-hosted
.design/fonts/*.woff2subsets haveunicode-rangefor Latin Extended only (noU+0400–04FF) and will render Russian text with fallback glyphs. The interface is Russian, so load fonts from Google Fonts exactly as the.refpages do:https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Tenor+Sans&display=swap(withpreconnectlinks). 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 usecredentials. - Tokens stored via a
useLocalStorage-style hook under keysmalenia.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=credentialsonce; 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; throwsErrorwith the backenddetailfor 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(orreturnTo). 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)
- Load pricing from
GET /plans/on mount (usePlanshook). Do not hardcode. - Device cost is linear:
deviceCost = device_price * devices. No per-seat declining curves, no volume multipliers. - Addon cost is the sum of selected addons'
price. - Duration: options are 1 / 3 / 6 / 12 months mapped to
duration_daysas30 / 90 / 180 / 365. The.refdiscount 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. free_thresholdon an addon: present in the backend model with unspecified semantics — ask the backend owner before wiring any rule. Until confirmed, render addons at their flatpriceand do not guess.- Total display =
deviceCost + addonCost. Show the monthly and the full-order amount (total * months) in the breakdown.
Bug notes (do not reproduce)
.refreferenced DOM nodes (#bd-total,#bd-period-row) that were missing from the HTML, crashingrender()for multi-month durations.- Pricing
priceFor()declining per-seat curve contradicts backenddevice_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 topayment_link(when non-null) in the same tab; fall back to a "order created" confirmation state showingorder_id/amount_to_paywhen the link is null. - Show
amount_to_payprominently sincebonus_paidmay reduce it belowtotal_amount.
Account (/account) — .ref/account.html
- Hero: «Ваш аккаунт» + username (Cormorant, serif), sub-line from subscription data.
- Stat strip (4 columns,
gap: 1pxhairline grid; 2 cols tablet, 1 col mobile): Username, Telegram ID (@…or empty state), Devices linked (n / limit), Renews / subscription expiry. Data fromGET /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,
--fgbackground,--bgtext, 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 fromunknown. - 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-labelon 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.tslevel, 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/resultfrom 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 |