# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

teeu — a Russian-language marketplace (all product categories). Laravel 13 · PHP 8.3 · MySQL 8 ·
Blade SSR (not SPA) · Tailwind v4 · Alpine 3 · Filament 5 (admin only).

The written spec is `tmp/тз.md`; code and docs reference it as `ТЗ §N` — when a comment says
`(ТЗ §8.5)`, that section is the authority for the behaviour. Per-subsystem docs live in
[`docs/`](docs/) and are kept current: [architecture](docs/architecture.md),
[catalog](docs/catalog.md), [yml-import](docs/yml-import.md),
[marketplace-import](docs/marketplace-import.md), [seller](docs/seller.md),
[cabinet](docs/cabinet.md), [authentication](docs/authentication.md),
[ai-moderation](docs/ai-moderation.md), [queues](docs/queues.md),
[blog](docs/blog.md), [referral](docs/referral.md), [payments](docs/payments.md),
[seller-integrations](docs/seller-integrations.md), [deployment](docs/deployment.md),
[design-inventory](docs/design-inventory.md). Read the relevant doc before changing a subsystem —
they carry decisions that are not obvious from the code.

## Environment (Laragon / Windows)

`php`, `node`, `npm` and `composer` are **not on PATH**. Use the full paths:

- PHP 8.3: `d:\laragon\bin\php\php-8.3.30-Win32-vs16-x64\php.exe` (a 7.4 build also exists — do not use it)
- Composer: `php d:\laragon\bin\composer\composer.phar`
- Node 22: `d:\laragon\bin\nodejs\node-v22\` — prepend it to PATH in the same command
- MySQL 8.4: `127.0.0.1:3306`, user `root` with no password, database `teeu`

No imagick (image pipeline runs on gd via `intervention/image`) and no Redis (queues and cache run on
the `database` driver — never couple business logic to Redis).

## Commands

```bash
d:/laragon/bin/php/php-8.3.30-Win32-vs16-x64/php.exe artisan test
```

```bash
d:/laragon/bin/php/php-8.3.30-Win32-vs16-x64/php.exe artisan test --filter=YmlImportTest
```

Tests run against **sqlite `:memory:`** (`phpunit.xml`), so migrations must stay sqlite-compatible
even though production is MySQL. Suites: `--testsuite=Unit` / `--testsuite=Feature`.

Frontend build (PowerShell — Node must be on PATH for this call):

```bash
$env:Path = "d:\laragon\bin\nodejs\node-v22;" + $env:Path; npm run build
```

Formatting: `vendor/bin/pint`. Pint rewrites `routes/web.php` wholesale (import ordering) — when it
does, revert the unrelated churn and re-apply your own lines in the file's existing style.

Queue worker and scheduler in dev:

```bash
d:/laragon/bin/php/php-8.3.30-Win32-vs16-x64/php.exe artisan queue:work --queue=default,yml-download,yml-processing,notifications,images,ai
```

Deploy to production (teeu.ru) — see [Deployment](#deployment):

```bash
bash bin/deploy.sh
```

## Architecture

### Four zones, two UI stacks

| Zone | Path | UI |
|---|---|---|
| Public marketplace | `/`, `/catalog`, `/product`, `/seller` | hand-written Blade |
| Buyer cabinet | `/account` | hand-written Blade (`layouts/account`) |
| Seller cabinet | `/{SELLER_PANEL_PREFIX}` (default `merchant`) | hand-written Blade, dark theme (`layouts/merchant`) |
| Admin | `/{ADMIN_PANEL_PREFIX}` (default `developer`) | **Filament 5** |

Filament is used for the admin panel **only**. Do not introduce Filament/Livewire components into the
public site or the buyer/seller cabinets. Panel prefixes come from `config('teeu.panels.*')`; a secret
admin slug is noise reduction, never authorization — every zone is guarded by auth + role + policies.

Two Filament traps that have already broken production, both silent until someone clicks:

- **Closure arguments are injected by parameter *name*.** `fn (Builder $query)` works; `fn (Builder $q)`
  gets `null` and the page dies with «call to a member function on null». Every filter in the admin
  was broken this way for months — `AdminFiltersTest` now switches each one on.
- **A `match` over an enum in a column/infolist** takes down the whole page as soon as a rare value
  shows up (an auto-disabled feed on page 2). Put `color()`/`label()` on the enum itself.

### Products come from YML feeds, not from forms

There is no manual product creation. A seller registers one or more YML feeds; `YmlImportService::run`
downloads → parses into the `yml_import_offers` **staging** table → classifies → upserts. A feed can
also be a seller's **Ozon or Wildberries account** (`source_type = ozon|wildberries`, API key instead of
a URL): offers come from a `FeedSource` rather than a file, everything downstream is shared — see
[docs/marketplace-import.md](docs/marketplace-import.md). The WB client knows only read-only methods, and
WB prices (4 requests an hour on a basic token) are collected ahead of the import in
`wildberries_prices`. Key invariants (all covered by tests, all in [docs/yml-import.md](docs/yml-import.md)):

- Reconcile (marking vanished offers `source_missing`) runs **only after a successful authoritative
  pass**. A failed download/parse must never deactivate products.
- `products.import_signature` makes re-imports idempotent; `products.manual_overrides` protects
  seller-edited fields from being overwritten.
- Sellers may not edit title, price or availability of feed products (`ProductRequest` drops them).
- Feed URLs and image URLs both go through `SsrfGuard`; `YmlParser` uses `XMLReader` with
  `LIBXML_NONET` and external entity loading disabled.

Category assignment happens **once per YML category**, not per offer: global alias dictionary
(`category_aliases`) → one AI call → alias stored; per-feed overrides live in `yml_category_maps`.
The dictionary is keyed by **(theme, name)**: a feed's `theme` (`App\Enums\FeedTheme`, one AI call
per feed) disambiguates «Прокладки» — automotive in an auto shop, hygienic in a pharmacy.

A mapping row has **three** meaningful states, and confusing them has already cost 89 000 wrongly
published products: a category id; empty + `classify_per_item` («сборная категория» — classify each
product by its own title); empty («— не показывать —» or unresolved → offers are skipped).
**A value a human left empty is a decision, not a gap** — never treat it as «we don't know yet».

### Single sources of truth

Several rules are deliberately centralised — extend these instead of re-implementing the checks:

- `Product::scopePublic()` / `isPublic()` — the only definition of "visible on the site".
- `ProductPurchaseEligibilityService::check()` — the only definition of "can be bought"; used by
  add-to-cart, cart validation and checkout.
- `OrderTransitionService::apply()` — every status change, with history + event.
- `PaymentService` — the only place the payment status changes (issue / revoke / mark paid).
  Card payments go through the seller's own acquiring (`Services\Payments\YooKassaProvider`);
  we never hold the money.
- Changing a feed setting does **not** reach already-imported products: the offer signature is
  computed from feed data, so import skips unchanged offers. Every such setting needs its own
  re-applier — `FeedMarkupReapplier` (prices), `FeedCategoryReassigner` (categories),
  `FeedAvailabilityReapplier` (stock). Forgetting one looks like «the checkbox does nothing».
- `SettingsService::get()` — runtime settings. Every key is declared in `config/teeu.php` under
  `settings` with a `secret` flag and a config `fallback` path. Values live encrypted in
  `app_settings` and are editable in the admin panel; env is a bootstrap fallback only. **Never
  hardcode an AI model, API key, carrier credential or a landing-page text** — add a settings key.

### Swappable providers

Domain boundaries are interfaces bound in `AppServiceProvider::register()`, so an implementation can
be replaced without touching call sites: `AiProvider` (DeepSeek), `CategoryClassifier`, `GeoProvider`
(Nominatim), `DeliveryTimeProvider` (distance heuristic), `PhoneVerifier` (driver chosen from
`services.phone_verify.driver`), `WebPushSender`.

`CityContext`, `DeliveryScope`, `SellerContext` and `VisitorContext` are **request-scoped** singletons
(`app->scoped`) carrying the current city, per-feed delivery area, active store and visitor cookie.

### Auth

Phone-centric (`+7XXXXXXXXXX`): there is no email+password registration. Flash-call verification plus
OAuth adapters (Yandex live; VK/Sber adapters present). Identities merge onto one user. A user can own
several stores (`seller_memberships`, owner/manager), with the active one resolved by `SellerContext`
and switched via `POST merchant.stores.switch`.

### Events, queues, scheduler

Listeners in `app/Listeners` are wired by **Laravel's event auto-discovery**. Do not add manual
`Event::listen` calls in `AppServiceProvider` — a duplicate registration sends every Telegram alert
twice (there is an explicit comment about this in the provider).

Queue names are in the `QueueName` enum (`default`, `yml-download`, `yml-processing`, `ai`, `images`,
`notifications`). Retry/timeout policy is set **per job**, not globally.

A queue list is a **priority order, not a set**: the worker never reaches the second queue while the
first has work. On production the queues therefore run in separate cron+flock processes — `ai` once
starved feed imports for hours. See [docs/queues.md](docs/queues.md) before touching this. Scheduled commands live in
`routes/console.php`, all `withoutOverlapping()`.

`Gate::before` grants admins every ability; policies still apply to everyone else.

### AI moderation is fail-closed

New user content (product text, reviews, replies, chat messages, seller descriptions) is queued to the
`ai` queue and stays unpublished while moderation is pending or the provider is down.
`teeu:ai:retry-failed` picks up content stranded by an outage. Never publish content by defaulting to
"allow" when the AI layer fails.

Product moderation can be **switched off** by the owner (`ai.moderation.products`) — that is a
deliberate decision to publish without checking, and it is not the same as a failure. With the check
on and the provider silent, the product still waits. Keep those two apart in any change here.

## Conventions

- UI text is hardcoded in Russian in Blade; framework strings come from `lang/ru.json` and
  `lang/ru/*.php`. Comments and docs are Russian or English — match the file you are editing.
- Tailwind v4 has no JS config: design tokens are `@theme` variables in `resources/css/app.css`, and
  Blade is scanned via `@source '../views'`.
- Use `<x-confirm-form>` for destructive confirmations, not native `confirm()`.
- Third-party API keys and tokens (Ozon, Wildberries, ЮKassa, amoCRM) are typed into `<x-secret-input>`,
  never `type="password"`: a password field turns the form into a login form, the browser fills in the
  seller's own teeu login and password — and where an empty field means «keep the stored key», saving
  silently replaces a working key with that password.
- Content the owner already edits on production — help articles, `/sell` texts and cards, FAQ — is
  never overwritten blindly. Changes go through a revision seeder extending `HelpArticleRevision`,
  `LandingTextRevision` or `FaqItemRevision`: fragments are taken from a production dump, rows edited
  by hand are skipped, and the seeder is run on production after the deploy
  ([docs/seller.md](docs/seller.md#правка-контента-который-владелец-уже-правил-на-проде)).
- Editable content (landing texts, help articles, integrations, partners, footer, legal requisites)
  belongs in the admin panel — settings keys, or content tables surfaced by a Filament resource.
- Trusted admin-managed HTML (CRM form embeds, email banner, `site.head_meta`/`site.body_scripts`) is
  rendered unescaped by design; `site.body_scripts` is deliberately never injected into the admin panel.

## Deployment

`bin/deploy.sh` pushes to GitLab, then ships **tracked files only** via `git archive | tar -x`, so the
server `.env` and `storage/` are untouched. It refuses to run on a dirty tree, then prunes files that
disappeared from the repo (guarded by a manifest length threshold), runs migrations and rebuilds all
caches on the server.

Two things routinely break deploys:

- **The server has no Node.** `public/build` is committed. If a change touched Tailwind classes, run
  `npm run build` locally and commit the bundle *before* deploying, or the markup ships without its
  styles.
- **Production cron needs `-d disable_functions=`.** Hestia's CLI php.ini disables `pcntl_*`; without
  the prefix, `schedule:run` fatals *after* taking the `withoutOverlapping` mutex, leaving an orphaned
  lock that silently skips scheduled commands for 24h. Full incident write-up in
  [docs/deployment.md](docs/deployment.md).

`/robots.txt` and `/sitemap*.xml` are Laravel routes — do not place static duplicates in `public/`,
they would shadow the routes.
