v0.0.01b
This commit is contained in:
423
AGENTS.md
Normal file
423
AGENTS.md
Normal 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 |
|
||||
Reference in New Issue
Block a user