# Project Changes

This file is the project's change log and implementation journal. Entries are appended in
chronological order. Do not overwrite existing entries.

## 2026-06-27 12:10

### Task
Analyzed the codebase and created an initial `CLAUDE.md` to guide future Claude Code sessions
(via the `/init` command).

### Files Created
- CLAUDE.md
- PROJECT_CHANGES.md

### Files Modified
- (none)

### Database
- No migrations added or changed.

### Models
- (none)

### Controllers
- (none)

### Views
- (none)

### Notes
- Documented the project as a Yii 2 basic-template app being grown into a multilingual, SEO-aware
  CMS/admin ("cinova"), with the project-specific work concentrated in `helpers/` and `migrations/`.
- Captured key conventions: the `<role>_<size>_string` column naming suffix consumed by
  `SeoHelper`/`ModelHelper`; centralized string enums in `EnumHelper` (with DB ENUM columns generated
  via `EnumHelper::generateQuery()` and UI color mapping in `RecordHelper::getStatusEnum()`); the
  intended per-entity sub-namespace model layout with `*Translations` tables and the
  `EntityConstants`/`EntityHelper` entity registry; and multilingual handling driven off the
  `languages` table.
- Flagged that several helpers reference classes/columns not yet created (e.g. `app\models\Courses\*`,
  `app\constants\EntityConstants`, `seo_*_string` columns), and noted casing inconsistencies between
  helpers.

## 2026-06-27 12:11

### Task
Adding a new `services` entity migration, following the structure, coding style, and conventions of
the existing migrations (image/SEO/audit layout from `pages`, `EnumHelper`-generated `status` column
from `languages`).

### Files Created
- migrations/m260627_121100_create_services_table.php

### Files Modified
- (none)

### Database
- Added migration creating table `{{%services}}` with columns: `id`, `alias`, `notice`, `main_image`,
  `bg_image`, `seo_og_image`, `icon_image`, `views_integer`, `slug`, `keywords`, `sort_order`
  (NOT NULL DEFAULT 0), `status` (ENUM via `EnumHelper::generateQuery([ACTIVE, INACTIVE, DRAFT],
  'not null default INACTIVE')`), `created_by`, `updated_by`, `created_at`, `updated_at`.
- Added foreign keys `fk_services_created_by` and `fk_services_updated_by` → `users.id`
  (ON DELETE SET NULL, ON UPDATE RESTRICT).
- Added indexes on `created_by`, `updated_by`, `sort_order`, `slug`, `status`.

### Models
- (none)

### Controllers
- (none)

### Views
- (none)

### Notes
- No `services` migration existed previously; the schema in the task was used verbatim, and the
  `status` enum statuses/default were taken from the existing `languages` migration convention
  (`ACTIVE`/`INACTIVE`/`DRAFT`, default `INACTIVE`).
- `safeDown()` drops foreign keys, then indexes, then the table.

## 2026-06-27 12:12

### Task
Adding two related migrations for the `services` entity — a translations table and a FAQs table —
matching the house style of the reference `services_faqs` migration (indexes created before foreign
keys, `integer(11)->null()` for nullable FKs, four-status `status` enum).

### Files Created
- migrations/m260627_121200_create_services_translations_table.php
- migrations/m260627_121300_create_services_faqs_table.php

### Files Modified
- (none)

### Database
- Added migration creating table `{{%services_translations}}`: `id`, `entity_id` (nullable),
  `language_id` (nullable), `name`, `desc_min`, `content_text_1..3` (text), SEO columns
  (`seo_title`, `seo_description`, `seo_keywords`), Open Graph columns (`seo_og_title`,
  `seo_og_description`, `seo_og_type` default `"website"`), `notice`, and audit columns
  (`created_by`, `updated_by`, `created_at`, `updated_at`).
  - Foreign keys: `entity_id` → `services.id`, `language_id` → `languages.id`,
    `created_by`/`updated_by` → `users.id` (ON DELETE SET NULL, ON UPDATE RESTRICT).
  - Indexes on `entity_id`, `language_id`, `created_by`, `updated_by`.
- Added migration creating table `{{%services_faqs}}`: `id`, `service_id` (nullable), `sort_order`
  (NOT NULL DEFAULT 0), `status` (ENUM via `EnumHelper::generateQuery([ACTIVE, HIDDEN, DRAFT,
  INACTIVE], default INACTIVE)`), and audit columns.
  - Foreign keys: `service_id` → `services.id`, `created_by`/`updated_by` → `users.id`
    (ON DELETE SET NULL, ON UPDATE RESTRICT).
  - Indexes on `service_id`, `status`, `sort_order`, `created_by`, `updated_by`.

### Models
- (none)

### Controllers
- (none)

### Views
- (none)

### Notes
- Both migrations follow the reference style: indexes are created before foreign keys, and every
  foreign key has a matching index. `safeDown()` drops foreign keys, then indexes, then the table.
- Resolved entity to `services` (the entity created in the prior migration). The translations table's
  entity reference column is named `entity_id` per the task spec; the FAQs table uses `service_id`.

## 2026-06-27 12:14

### Task
Standardized the `services_faqs` entity-reference column to `entity_id` and added a FAQ translations
table, matching the project's translation-table architecture (per the reference
`services_faqs_translations` migration).

### Files Created
- migrations/m260627_121400_create_services_faqs_translations_table.php

### Files Modified
- migrations/m260627_121300_create_services_faqs_table.php

### Database
- Renamed `services_faqs.service_id` → `entity_id` (still FK → `services.id`, ON DELETE SET NULL /
  ON UPDATE RESTRICT). Updated the column, index `idx_services_faqs_service_id` →
  `idx_services_faqs_entity_id`, foreign key `fk_services_faqs_service_id` →
  `fk_services_faqs_entity_id`, and the corresponding `safeDown()` drops.
- Added migration creating table `{{%services_faqs_translations}}`: `id`, `faq_id` (nullable),
  `language_id` (nullable), `question` (string(500) NOT NULL), `answer` (text NOT NULL), and audit
  columns (`created_by`, `updated_by`, `created_at`, `updated_at`).
  - Indexes on `faq_id`, `language_id`, `created_by`, `updated_by`, plus a UNIQUE composite index
    `idx_services_faqs_translations_unique` on `(faq_id, language_id)`.
  - Foreign keys: `faq_id` → `services_faqs.id`, `language_id` → `languages.id`,
    `created_by`/`updated_by` → `users.id` (ON DELETE SET NULL, ON UPDATE RESTRICT).

### Models
- (none)

### Controllers
- (none)

### Views
- (none)

### Notes
- Indexes are created before foreign keys; every foreign key has a backing index. `safeDown()` drops
  foreign keys, then indexes (including the unique composite), then the table.
- Renaming `service_id` → `entity_id` aligns the FAQs table with the `services_translations` table's
  entity-reference naming.

## 2026-06-29 — Fix `ServicesFaqs::alias` unknown property + migration syntax error

### Task
Resolve `Getting unknown property: app\models\services\ServicesFaqs::alias`. The `ServicesFaqs`
model and `ServicesFaqsForm` reference an `alias` attribute that did not exist on the
`services_faqs` table, so any read of `$model->alias` (triggered in the form constructor / during
validation) threw. The column is needed, so it was added rather than removed.

### Files modified
- `migrations/m260627_121300_create_services_faqs_table.php` — added `alias` (`string(255) NOT NULL`)
  column to `{{%services_faqs}}`, after `entity_id`.
- `migrations/m260627_121400_create_services_faqs_translations_table.php` — removed the erroneously
  added `alias` line. It used `->after('service_id')` inside `createTable()`, which emits an
  `AFTER` clause illegal in `CREATE TABLE` (MariaDB 1064), and referenced a non-existent column.
  `alias` belongs on the parent `services_faqs` table (its model), not on the translations table.

### Migrations
- Re-run required because `121300` had already been applied before `121400` failed:
  `php yii migrate/down 1` then `php yii migrate`.

### Notes
- In Yii2, ActiveRecord attributes come from real DB columns, not the `@property` PHPDoc; a documented
  property without a backing column raises "unknown property" on access.
- `->after()` is only valid in `ALTER TABLE ... ADD COLUMN`, never inside `createTable()`.

## 2026-06-29 00:00

### Task
Removed the `_mini_string` and `_big_string` column-name suffixes from every identifier/string
that ended with them, keeping the rest of each name intact (e.g. `name_big_string` → `name`,
`pre_title_mini_string` → `pre_title`, `desc_min_big_string` → `desc_min`). `_small_string`
columns were intentionally left untouched (out of scope).

### Files modified
- `commands/RouteScanController.php` — `slug_big_string`/`name_big_string`/`notice_big_string`
  → `slug`/`name`/`notice` (usages + comment).
- `helpers/SeoHelper.php` — `seo_description_big_string` → `seo_description`,
  `seo_og_description_big_string` → `seo_og_description`.
- `migrations/m260429_113510_create_pages_table.php` — column definitions `name_big_string`,
  `slug_big_string`, `notice_big_string` → `name`, `slug`, `notice`.
- `modules/admin/views/users/translations/_form.php` — `pre_title_mini_string`,
  `button_mini_string`, `desc_min_big_string`, `notice_big_string` → suffixes stripped.
- `modules/admin/views/users/translations/view.php` — same attributes, suffixes stripped.
- `modules/admin/views/blog/translations/_form.php` — `desc_min_big_string`,
  `seo_description_big_string`, `seo_og_type_mini_string`, `seo_og_description_big_string`,
  `notice_big_string` → suffixes stripped.
- `modules/admin/views/blog/translations/view.php` — same set of attributes, suffixes stripped.

### Notes
- `CLAUDE.md` was deliberately NOT modified: it documents the `_mini_/_small_/_big_string`
  naming convention itself, so stripping suffixes there would corrupt the documentation rather
  than keep code references in sync.
- This was a pure textual rename of code identifiers; the corresponding DB columns are only
  renamed in the `pages` migration. The `pages` table and any already-migrated translations
  tables would need a schema migration / re-migrate for the app to run against a live DB.

## 2026-06-29 00:10

### Task
Removed the `_small_string` column-name suffix from every code identifier/string that ended
with it (e.g. `title_small_string` → `title`, `seo_title_small_string` → `seo_title`),
following the earlier `_mini_string`/`_big_string` removal. Confirmed the only `_*_string`
suffix variants in the project are `_mini_/_small_/_big_string` — no other similar suffixes exist.

### Files modified
- `helpers/SeoHelper.php` — `seo_title_small_string` → `seo_title`,
  `seo_keywords_small_string` → `seo_keywords`, `seo_og_title_small_string` → `seo_og_title`.
- `modules/admin/views/users/translations/_form.php` — `title_small_string` → `title`.
- `modules/admin/views/users/translations/view.php` — `title_small_string` → `title`.
- `modules/admin/views/blog/translations/_form.php` — `name_small_string`,
  `seo_title_small_string`, `seo_keywords_small_string`, `seo_og_title_small_string` → suffix stripped.
- `modules/admin/views/blog/translations/view.php` — same set, suffix stripped.

### Notes
- `CLAUDE.md` (documents the naming convention) and `PROJECT_CHANGES.md` (this log) were left
  untouched, consistent with the prior suffix-removal task.

## 2026-06-29 13:00

### Task
Built full multi-section CRUD for portfolio: a portfolio has many PortfolioSection (ordered by
sort_order), and each section has many PortfolioSectionTranslation (one per locale), managed
independently. Removed the leftover "primary-section translation" hack from the prior design.

### Files created
- `models/portfolio/forms/PortfolioSectionForm.php` — section create/update (alias, slug, sort_order,
  notice, status, main_image/bg_image/seo_og_image uploads).
- `modules/admin/controllers/traits/HasSectionsTrait.php` — section CRUD + per-section translations
  (sections, section-create/update/delete/delete-row, section-translations, section-translation-view/update).
- `modules/admin/views/portfolio/sections/{index,table_data,create,update,_form}.php`
- `modules/admin/views/portfolio/sections/translations/{index,table_data,view,update,_form}.php`

### Files edited
- `modules/admin/controllers/base/BaseAdminCrudController.php` — parametrized upload helpers
  (prepareUploadedFiles/restoreTempMarkers/savePendingToTemp/processAllUploads accept $fields/$filePath)
  so sub-entities can reuse them; registered section-delete/section-delete-row POST verbs.
- `modules/admin/controllers/PortfolioController.php` — use HasSectionsTrait; added section* class
  getters; removed translationClass/translationFormClass/translationEntity + the actionTranslations/
  View/Update overrides + ensurePrimarySection/findSection; old translation routes now redirect to sections.
- `modules/admin/views/portfolio/view.php` & `update.php` — "Translations" button → "Sections".
- `modules/admin/views/portfolio/table_data.php` — translation-level column → sections count;
  translation list-button → sections; removed unused Languages import.

### Files deleted
- `modules/admin/views/portfolio/translations/{index,table_data,view,update,_form}.php` — obsolete
  portfolio-level translation UI from the single-translation design.

### Notes
- Data model already supported the target (Portfolio::getSections hasMany ordered by sort_order;
  PortfolioSection::getTranslations hasMany on entity_id; portfolio_id FK; unique (entity_id, language_id)).
  No migration/relation changes were required.
- Section create requires a main_image (scenario 'create'), mirroring the old portfolio media rule.

## 2026-06-30 — Delete drop_portfolio_translations migration

### Task
Removed migration `m260629_120300_drop_portfolio_translations.php` per request.

### Files deleted
- `migrations/m260629_120300_drop_portfolio_translations.php` — migrated legacy
  `portfolio_translations` data into `portfolio_section_translations` then dropped the table.

### Notes
- No DB schema regeneration here; just removed the migration file from the project.

## 2026-06-30 — Add Portfolio to admin aside menu

### Task
Added a Portfolio link to the admin sidebar.

### Files edited
- `modules/admin/components/views/aside.php` — added Portfolio menu item in the Content
  section (after Blog), linking to `/admin/portfolio` with the `photo_album` icon and
  `Yii::t('admin/menu', 'Portfolio')` label.

## 2026-06-30 — Finish _big_string/_mini_string suffix removal (pages)

### Task
Completed the earlier suffix-removal pass: the `pages` controller and views still referenced
old `_big_string`/`_mini_string` attribute names while their migrations/columns were already
renamed. Stripped the suffixes; `_small_string` columns left untouched (out of scope).

### Files edited
- `modules/admin/controllers/PagesController.php` — `name_big_string`/`slug_big_string` → `name`/`slug`.
- `modules/admin/views/pages/table_data.php` — `name_big_string`, `slug_big_string` → `name`, `slug`.
- `modules/admin/views/pages/view.php` — `name_big_string`, `slug_big_string` → `name`, `slug`.
- `modules/admin/views/pages/_form.php` — `notice_big_string` → `notice`.
- `modules/admin/views/pages/_search_form.php` — `name_big_string` → `name`.
- `modules/admin/views/pages/translations/view.php` — `seo_description_big_string`,
  `seo_og_description_big_string`, `seo_og_type_mini_string`, `notice_big_string` → suffixes stripped.
- `modules/admin/views/pages/translations/_form.php` — `seo_description_big_string`,
  `seo_og_type_mini_string`, `seo_og_description_big_string`, `notice_big_string` → suffixes stripped.

### Notes
- `seo_title_small_string`, `seo_keywords_small_string`, `seo_og_title_small_string` deliberately kept.

## 2026-06-30 — Add ContactForm model

### Task
Created a public-site ContactForm form model matching project form conventions.

### Files created
- `models/contact/forms/ContactForm.php` — namespace `app\models\contact\forms`, extends
  `yii\base\Model` (same `models/<entity>/forms/` depth + clean style as SendPasswordRequestForm/
  ActivatePasswordForm). Public props firstName/lastName/email/phoneNumber. Rules: all required +
  trim; firstName/lastName string min 2 max 50; email valid email; phoneNumber `match` regex
  `^\+?[0-9\s\-\(\)]{7,20}$` (no existing phone validator in project, mirrors the `match`/`pattern`
  style from ActivatePasswordForm). attributeLabels() with readable labels.

### Notes
- Legacy template `app\models\ContactForm` (name/subject/body/verifyCode) does not exist as a file;
  only leftover Codeception tests reference it. New form is unrelated to that template.

## 2026-06-30 — ContactForm: add message and verified_by

### Files edited
- `models/contact/forms/ContactForm.php` — added public props `message` (required, trim,
  string min 2 max 1000) and `verified_by` (integer, optional — treated as a server-set
  user FK like created_by/updated_by elsewhere). Added matching attributeLabels().

## 2026-06-30 — ContactForm: i18n attribute labels

### Files edited
- `models/contact/forms/ContactForm.php` — refactored attributeLabels() so every field label
  uses `Yii::t('messages/formsAttributeLabels', '<field>')` instead of plain strings.

## 2026-06-30 — Refactor Contact model to project AR conventions

### Task
Rewrote the imported `models/contact/Contact.php` (was extending a non-existent `BaseRecord`,
chemavi.cm style) to match this project's ActiveRecord conventions (Blog/Portfolio), with the
requested attribute set.

### Files edited
- `models/contact/Contact.php` — extends `yii\db\ActiveRecord`; return-typed methods; behaviors
  (Blameable created_by+updated_by, Timestamp created_at/updated_at value=date); rules (required +
  trim + string max on text fields, email validator, integer + Users `exist` checks on *_by ids,
  safe timestamps); attributeLabels for every field; getVerifiedBy/getCreatedBy/getUpdatedBy
  hasOne(Users) relations.

### Notes (changes beyond adding fields)
- `BaseRecord` → `ActiveRecord`: `app\modules\components\BaseRecord` does not exist in this repo
  (only the imported file referenced it); all real models extend `yii\db\ActiveRecord`.
- `firstName`/`lastName` → `first_name`/`last_name`: every AR model here uses snake_case columns
  (alias, slug, phone_number, created_by). camelCase kept only in form models (ContactForm).
- Dropped `initials/subject/notice/ip/status` and the EnumHelper status constants — not in the
  requested attribute set; removed optsStatus/optsStatusUpdate accordingly.
- `tableName` `'contact'` → `'{{%contact}}'` to match the table-prefix convention used everywhere.
- Blameable now sets both created_by and updated_by (import only set updated_by).
- `verified_by` is in rules + has a relation but is NOT auto-populated by any behavior, as required.
- Label category kept as `messages/formsAttributeLabels` (your established preference for contact);
  note the sibling models Blog/Portfolio use `admin/labels` instead — say the word if you want it switched.
- No `contact` migration exists yet; the column set above is not backed by a table — a migration is
  still needed to actually create/alter `contact`.

## 2026-06-30 — Create contact migration + align ContactForm

### Files created
- `migrations/m260630_120000_create_contact_table.php` — creates `{{%contact}}` matching the
  refactored Contact model (modeled on create_portfolio_table): id PK; first_name/last_name/email
  string(255) not null; phone string(50) not null; message string(1000) not null; verified_by/
  created_by/updated_by integer null; created_at/updated_at dateTime. Indexes on email + the three
  *_by cols; FKs verified_by/created_by/updated_by → users(id) SET NULL / RESTRICT.

### Files edited
- `models/contact/forms/ContactForm.php` — replaced the imported chemavi version (extended Contact,
  used ModelHelper reflection + `__model`, referenced removed fields ip/status/DRAFT — would fatal)
  with the project's BlogForm convention: extends `yii\base\Model`, private `Contact $model`, typed
  nullable props, `formAttributes()` + constructor copy loop, `saveRecord(): Contact|false`,
  `getModel()`. Fields aligned to the model: first_name, last_name, email, phone, message,
  verified_by. Rules: required/trim + string max on text, email validator, phone regex match,
  integer on verified_by. Labels via `messages/formsAttributeLabels`.

### Notes
- Both files lint clean under PHP 8.1. Run `php yii migrate` to apply the new table.

## 2026-07-28 — Port the ion-project design into cinova (full frontend)

### Task
`C:\Users\vadim\projects\ion-project` held the Cignova design already integrated 1:1 into a
separate Yii2 app (id `cignova`, DB `ion`). cinova's public frontend was still the stock Yii2
template. Ported the whole frontend layer into cinova, wired to cinova's own entities and admin.

Two decisions taken with the client:
- Projects: imported as a **new** `projects` entity rather than mapped onto `portfolio`
  (portfolio keeps its translations on `PortfolioSection`, which does not fit the design's flat
  project-single page). `portfolio` and its admin are untouched.
- Content: seeded through a **migration**, not copied out of the `ion` database.

### Files created — design layer (markup copied verbatim from ion-project)
- `views/layouts/main.php` (design layout; the stock one was kept as `views/layouts/basic.php`)
- `views/common/_contact_modal.php`, `views/common/header/{_logo,_nav,_cta,_burger}.php`,
  `views/common/footer/{_cta,_about,_nav-quick,_nav-services,_newsletter,_copyright}.php`
- `views/{home,about,services,projects,blog,contact,faq}/**` — 27 view files including
  `services/components/_service_card.php`, `projects/components/{_project_card,_gallery_modal}.php`,
  `blog/components/_blog_card.php`, `faq/components/_faq_group.php`
- `components/widgets/{HeaderWidget,FooterWidget}.php` + `components/widgets/views/{header,footer}.php`
- `web/core/{assests,styles,js}/` — 67 design assets
- `controllers/{Home,About,Services,Projects,Blog,Contact,Faq}Controller.php`

### Files created — models
- `models/pages/{Pages,PagesTranslations}.php`, `models/pages/forms/{PagesForm,PagesTranslationsForm}.php`,
  `models/pages/search/PagesSearch.php`
- `models/projects/{Projects,ProjectsTranslations,ProjectsGallery}.php`
- `models/faq/{Faq,FaqTranslations}.php`

### Files created — migrations (all applied)
- `m260728_200001_create_pages_translations_table` — + seeds 11 `pages` rows (one per route)
- `m260728_200002_create_projects_table`
- `m260728_200003_create_projects_translations_table`
- `m260728_200004_create_projects_gallery_table`
- `m260728_200005_create_faq_table`
- `m260728_200006_create_faq_translations_table`
- `m260728_200007_add_design_columns` — `services_translations.tags`, `blog_translations.category`,
  `blog.published_at`
- `m260728_200008_seed_design_content` — 6 services, 6 projects (+18 gallery images),
  7 blog posts, 20 FAQ entries; EN only (source language), text verbatim from the design HTML

### Files edited
- `assets/AppAsset.php` — replaced with the design bundle (CDNs + `core/styles/*` + `core/js/index.js`
  in exactly the order of the design's `<head>`; the CSS cascade depends on it)
- `traits/TranslationTrait.php` — replaced with the improved variant: fallback to the source
  language, static per-request language-id cache, `andOnCondition()` instead of `andWhere()`,
  language-code normalization, and `t($attribute, $default)`
- `helpers/UrlHelper.php` — added static `normalizeSlug()` / `entityUrl()` / `normalizeAlias()`;
  `createUrl()` and `urlRedirect()` kept (both are now static; they had no callers)
- `models/services/Services.php` — added `getUrl()` and `getImageUrl()`
- `models/services/ServicesTranslations.php` — added `getTagsList()` reading the new `tags` column
- `models/blog/Blog.php` — added TranslationTrait (its own `getTranslations()` was identical and
  was removed), `getUrl()`, `getImageUrl()`, `published_at` marked safe
- `constants/EntityConstants.php` — added PROJECTS / PROJECTS_TRANSLATIONS / PROJECTS_GALLERY
- `config/web.php` — `name` = Cignova; bootstrap closure feeding `urlManager->languages` from the
  `languages` table; `language`/`sourceLanguage` = en; `defaultRoute` = home/index;
  `forceTranslation` on the `*` message category; `errorHandler` → `home/error`; urlManager switched
  to `codemix\localeurls\UrlManager` with the design's routes and `ignoreLanguageUrlPatterns` for
  admin/elfinder/debug/gii
- `modules/admin/views/pages/translations/{_form,view}.php` — `seo_title_small_string`,
  `seo_keywords_small_string`, `seo_og_title_small_string` → stripped names, so they match the new
  `pages_translations` columns and `SeoHelper`

### Deviations from ion-project (and why)
1. **Column names follow cinova's stripped convention**, not ion's `_mini_/_small_/_big_string`:
   `alias`, `slug`, `name`, `desc_min`, `content_text`, `seo_*`, `tags`, `category`, `question`,
   `group_name`. cinova had already stripped those suffixes; `SeoHelper` reads the stripped names.
   The views were adjusted accordingly (attribute names only — no markup touched).
2. `faq.group_key` instead of `group` — `group` is a reserved word in MySQL.
3. Services/Blog reuse cinova's existing tables; only three columns were added
   (`tags`, `category`, `published_at`) instead of importing ion's tables.
4. **Slugs de-duplicated.** In the design every project card and every blog card linked to the
   same demo page, so ion seeded 6 projects and 7 posts with an identical slug. cinova has a
   UNIQUE index on slug (the canonical redirect depends on it), so repeats got a numeric suffix:
   `technical-seo-mistakes-you-must-avoid`, `…-2`, `…-3`, …
5. **Hard-coded demo links resolved dynamically.** The design links "Service Single" / "Blog Single"
   / "Project Single" and the static card rows in `home/index`, `projects/view`, `services/view` to
   `id = 1`. cinova's ids start higher, so those links 404'd. They now resolve to the first active
   record (24 links across 4 files). Markup structure is unchanged — only the href value.
6. `models/pages/*` did not exist at all in cinova even though `modules/admin/controllers/PagesController`
   imports Pages, PagesTranslations, both forms and the search model — the admin Pages section was
   dead. Creating them (plus the `pages_translations` table) fixes it.
7. No Projects/Faq admin CRUD yet — models, migrations and the frontend only.

### Verification
| Check | Result |
|---|---|
| `php yii migrate` | 9/9 applied |
| `php -l` on 325 project PHP files | 0 errors |
| 11 frontend routes rendered | all 200 (the error page returns 404 by design, same as ion) |
| `<main>` tag sequence, design vs rendered, 10 pages | **identical on all 10** |
| `class` attribute sequence, 10 pages | **identical on all 10** |
| Local assets (40 unique paths) | 0 missing |
| Internal links (27 unique) | all 200 |
| Admin routes (8) | all 200, no exceptions; `/admin/pages` works for the first time |
| i18n fallback | `/ro/*` and `/ru/*` render EN content, links keep the language prefix |
| Canonical slug redirect | `/services/7/wrong-slug` → 301 → `/services/7/seo-strategy-consulting` |

### Notes
- The FAQ and blog text still carries the original template's brand name **"Rankio"** and the
  repeated demo title "Technical SEO Mistakes You Must Avoid" — seeded verbatim on purpose;
  editable from the admin once the CRUD exists.
- `blog` id 1 (`test`) is a pre-existing row created through the admin on 2026-06-29; it is
  INACTIVE and does not show on the frontend.
- `modules/admin/messages/ru/dashboard.php` is a **directory**, not a file — pre-existing anomaly,
  unrelated to this port.
- Still to do: admin CRUD for Projects and Faq, RO/RU translations, and the aside-menu entries.

## 2026-07-28 — Etapa 2: schema bazei de date + migrații (CMS după pattern-ul ecaterina)

### Task
Prima etapă de cod din refactorizarea „conținut hardcodat → tabele + CRUD admin + frontend din DB".
Analiza care fundamentează munca: `_analiza/01-pattern-ecaterina.md` (pattern) și
`_analiza/02-inventar-continut.md` (inventarul conținutului real din view-uri).

### Migrații create (31, toate aplicate)

*14 entități de conținut noi — tabel principal + tabel de traduceri:*
- `m260728_210001/210002` — `pricing_plans` (+ translations)
- `m260728_210003/210004` — `testimonials`
- `m260728_210005/210006` — `features`
- `m260728_210007/210008` — `steps`
- `m260728_210009/210010` — `approach_cards`
- `m260728_210011` — `partners` (fără traduceri: doar logo + url)
- `m260728_210012/210013` — `benefits`
- `m260728_210014/210015` — `marquee_items`
- `m260728_210016/210017` — `project_challenges` (FK spre `projects`, CASCADE)
- `m260728_210018/210019` — `service_showcase` (cheie `card_key` = `data-card` din markup)
- `m260728_210020/210021` — `project_showcase`
- `m260728_210022/210023` — `settings` (`setting_key` unic; `key`/`group` sunt rezervate în MySQL)
- `m260728_210024/210025` — `section_headers` (unic pe `page_key` + `section_key`)
- `m260728_210026/210027` — `menu_items` (self-FK `parent_id`, `location` ENUM)

*Reparații și infrastructură:*
- `m260728_210028_create_services_gallery_table` — tabelul lipsea, deși
  `models/services/ServicesGallery.php` există din etapa anterioară; `Services::getGallery()`
  și sub-CRUD-ul de galerie erau nefuncționale. Setul de coloane și cele 4 statusuri
  (ACTIVE|HIDDEN|DRAFT|INACTIVE) sunt luate din model, nu inventate.
- `m260728_210029_add_banner_fields_to_pages_translations` — `banner_title`, `banner_breadcrumb`
  pentru secțiunea `page-banner` a paginilor interioare.
- `m260728_210030_create_rbac_data` — rolurile `dev → admin → contentManager`, după
  `ecaterina/migrations/m260429_180501_create_rbac_data.php`. Rolul `superAdmin` NU e creat
  (nu există nici în ecaterina).
- `m260728_210031_seed_admin_user` — cont bootstrap `admin@cinova.local` / `cinova-admin-2026`,
  rol `dev`. Idempotent. **Parola trebuie schimbată după primul login.**

Tabelele RBAC propriu-zise vin din Yii:
`php yii migrate --migrationPath=@yii/rbac/migrations` (4 migrări aplicate).

### Files edited
- `config/web.php` — adăugat componenta `authManager` (`yii\rbac\DbManager`).
- `config/console.php` — aceeași componentă, necesară ca migrația de roluri să ruleze.

### Convenții respectate
- Stil ecaterina: `safeUp()`/`safeDown()`, `'{{%tabel}}'`, ENUM generat prin
  `EnumHelper::generateQuery()`, rollback complet în ordine inversă (FK → index → table).
- Nume de coloane în convenția cinova (sufixele eliminate): `alias`, `name`, `desc_min`,
  `sort_order`, `status` — NU `alias_mini_string` ca în ecaterina.
- Fiecare entitate principală are `sort_order` (cerință explicită: ordinea editabilă din admin);
  ecaterina îl avea doar pe galerii.
- Fiecare tabel de traduceri are index **unic** `(entity_id, language_id)`, FK `CASCADE` spre
  entitate și spre `languages`, `SET NULL` spre `users` — cele trei corecții față de ecaterina
  documentate în `_analiza/01-pattern-ecaterina.md` §7.3.

### Devieri față de inventarul Etapei 1
- Adăugate 2 entități neprevăzute în raport: `service_showcase` (5 rânduri — cardurile cu tab-uri
  de pe home au chei distincte `data-card="research|copywrite|marketing|analytics|audit"`) și
  `project_showcase` (8 rânduri — imagini distincte `project-1..4` × 2). Fără ele, secțiunile
  respective nu pot fi reproduse identic.
- Eliminat `is_featured` din `pricing_plans`: în design niciun plan nu e evidențiat
  (toate 6 cardurile au aceeași clasă `pricing-plan__card`).

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| `php -l` pe cele 31 de migrații | 0 erori |
| `php yii migrate` | 31/31 aplicate |
| `yii migrate/down 6` apoi `yii migrate` | rollback și re-aplicare curate, fără reziduuri |
| Idempotența seed-ului de utilizator | după down+up: 1 utilizator, 1 asignare (nu 2) |
| Tabele în `cinova` | 56 (de la 24) |
| RBAC | 3 roluri, ierarhia `dev→admin→contentManager`, 1 asignare |

### Rămâne de rezolvat (semnalat, nu atins)
- `config/web.php` are `'identityClass' => 'app\models\User'` — clasa **nu există**; modelul real
  e `app\models\users\Users`. Login-ul nu poate funcționa până nu se corectează. Nu am modificat
  nimic aici, fiind logică de autentificare; de rezolvat la Etapa 4, când se activează
  `AccessControl` pe modulul admin.

## 2026-07-29 — Etapa 3: modele, form objects, search models

### Task
Pentru cele 14 entități create la Etapa 2: ActiveRecord + traduceri + form objects + search
models, în stilul `ecaterina/models/rooms/*` (v. `_analiza/01-pattern-ecaterina.md` §3).

### Files created — 68 fișiere în `models/`
Per entitate (`pricingPlans`, `testimonials`, `features`, `steps`, `approachCards`, `partners`,
`benefits`, `marqueeItems`, `projectChallenges`, `serviceShowcase`, `projectShowcase`,
`settings`, `sectionHeaders`, `menuItems`):
- `<Entitate>.php` — AR cu `TranslationTrait`, `FILE_PATH`, 5 constante de status,
  `optsStatus()/optsStatusCreate()/optsStatusUpdate()`, perechi `is*/setTo*`, relații
  `createdBy`/`updatedBy`, `get<Camp>Url()` pentru fiecare câmp de imagine,
  și `findActive()` — scope-ul de frontend (status ACTIVE + eager loading pe
  `translationsObj`+`translationFallback` + `ORDER BY sort_order, id`).
- `<Entitate>Translations.php` — AR de traducere, relații `entity`/`language`.
- `forms/<Entitate>Form.php` — form object varianta A (`extends Model`).
- `forms/<Entitate>TranslationsForm.php` — `saveRecord(array $data)` care întoarce
  `null` când nu s-a schimbat nimic (tratat ca succes de `MainCrudTrait`).
- `search/<Entitate>Search.php` — `formName(): ''`, exclude `STATUS_DELETED`, întoarce query.

`partners` nu are traduceri (doar logo + url), deci 3 fișiere în loc de 5.

Entități cu specific propriu:
- `menuItems` — self-FK `parent_id` (relații `parent`/`children`), enum `location`
  cu constante `LOCATION_HEADER|FOOTER_QUICK|FOOTER_SERVICES` + `optsLocation()`.
- `projectChallenges` — FK spre `Projects`, relația `project`.
- `serviceShowcase` și `settings` — `card_key` respectiv `setting_key` unice, validate prin
  `validateUniqueCardKey()` / `validateUniqueSettingKey()` care exclud propriul rând
  (`unique` din AR nu funcționează pe un form object — capcana din `.md` §13).
- `sectionHeaders` — `getAlias()` întoarce `section_key`, pentru că `MainCrudTrait`
  randează `$model->alias` în titluri și breadcrumbs.

### Files edited
- `helpers/FileHelper.php` — **corecția critică** semnalată în analiză: cele 4 locuri care
  foloseau `Yii::getAlias('@web')` (alias de **URL**) ca prefix de **cale de fișier** au fost
  trecute pe `@webroot` (liniile 19, 79, 96, 111). În ecaterina mergea accidental, fiindcă
  aplicația stă la rădăcina domeniului; în cinova, care stă în subfolder, upload-ul și
  ștergerea fișierelor ar fi eșuat tăcut.
- `config/console.php` — adăugate: blocul `i18n` identic cu cel din `web.php` (modelele apelează
  `Yii::t('admin/status', …)` în `optsStatus*()`, deci orice comandă de consolă care atinge un
  model crapă fără el), aliasurile `@webroot`/`@web` (doar `yii\web\Application` le definește,
  iar `FileHelper` le folosește), și componenta `authManager`.
- `constants/EntityConstants.php` — 27 de constante noi pentru cele 14 entități + `services-gallery`.

### Devieri asumate
- **Proprietățile din form objects sunt NEtipizate** (`public $x;`), ca în ecaterina și ca în
  `.md` §5, nu tipizate ca în `models/blog/forms/BlogForm.php` (scris anterior în cinova).
  Motivul e concret: `public ?int $sort_order = null;` primind `''` dintr-un input gol aruncă
  `TypeError` **înainte** ca validarea să apuce să ruleze — 500, nu mesaj de eroare.
  `BlogForm` existent are acest defect latent; nu l-am atins (nu e în scopul etapei).
- Am inclus `ActiveTranslationValidator` pe `status` (scenariul `update`) la toate entitățile cu
  traduceri, conform `.md` §4. **Consecință:** o entitate nu poate fi trecută pe `ACTIVE` din
  admin până nu are traducere în toate limbile ACTIVE (RO, EN, RU). Seed-ul de la Etapa 5 inserează
  direct în DB, deci ocolește validatorul — site-ul va arăta corect — dar prima editare din admin
  va cere traducerile. `models/blog/forms/BlogForm.php` NU are acest validator, deci Blog și
  entitățile noi se vor comporta diferit până la uniformizare.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| `php -l` pe `models/`, `helpers/`, `constants/`, `config/` | 138 fișiere, 0 erori |
| Script de verificare la runtime (`scratchpad/verify_models.php`) | **113 câmpuri de formular, 0 probleme** |
| — fiecare câmp din `formAttributes()` e SAFE | OK (altfel `load()` l-ar ignora tăcut) |
| — fiecare câmp din `formAttributes()` există ca și coloană | OK |
| — fiecare coloană din tabel e în `formAttributes()` | OK |
| — fiecare coloană e acoperită de o regulă pe AR | OK |
| — `findActive()`, `getTranslations()`, `getTranslationsObj()`, `getTranslationFallback()` execută SQL | OK |
| — fiecare `Search::search()` întoarce `ActiveQuery` și rulează | OK |
| 11 rute frontend + 4 rute admin | toate 200 |
| Structura `<main>` vs designul original, 10 pagini | **identică pe toate** |

## 2026-07-29 — Etapa 4: CRUD complet în admin pentru cele 14 entități

### Task
Controller + set complet de view-uri pentru fiecare entitate creată la Etapele 2-3, plus
protecția adminului, meniul lateral și mesajele ro/en/ru.

### Files created — 191 fișiere
- `modules/admin/controllers/<Entitate>Controller.php` × 14 — extind
  `BaseAdminCrudController` + `use MainCrudTrait`, ~55 linii fiecare, doar configurație.
- `modules/admin/views/<slug>/{index,table_data,create,update,view,_form,_search_form}.php`
  × 14 = 98 fișiere.
- `modules/admin/views/<slug>/translations/{index,table_data,_form,update,view}.php`
  × 13 = 65 fișiere (`partners` nu are traduceri).
- `controllers/AuthController.php`, `views/auth/login.php`, `views/layouts/auth.php` — vezi nota.

### Files edited
- `modules/admin/components/views/aside.php` — două secțiuni noi: „Content Blocks"
  (11 intrări) și trei intrări adăugate în „Settings" (Section Headers, Menu Items, Site Settings).
- `modules/admin/Module.php` — `AccessControl` **activat** (era comentat), roluri
  `admin|contentManager|dev`. Rolul `superAdmin` omis deliberat: nu există în `auth_item`.
- `config/web.php` — `identityClass` corectat din `app\models\User` (clasă inexistentă) în
  `app\models\users\Users`; adăugat `loginUrl => ['/auth/login']`; `/auth/` adăugat în
  `ignoreLanguageUrlPatterns`.
- `modules/admin/messages/{ro,en,ru}/{menu,labels,transaltion,errors,buttons}.php` —
  **177 chei noi**, toate trei limbile.

### Dropdown-ul de limbi (decizia clientului)
`views/<slug>/translations/_form.php` conține un `<select>` care listează limbile
ACTIVE + DRAFT și comută `lang_id` pe aceeași acțiune `translation-update`.
O singură limbă pe pagină; salvarea rămâne un `saveRecord(['entity_id','language_id'])`
per limbă — fără `loadMultiple()`, fără taburi. Pagina-listă de limbi
(`translations/index.php`) e păstrată, fiindcă `MainCrudTrait::actionTranslations()` o randează.

### Nota despre AuthController — citește asta
cinova avea deja `models/users/forms/LoginForm.php` și `models/users/Users.php`
(implementează `IdentityInterface`), dar **niciun controller nu le expunea**. Fără o pagină de
login, activarea `AccessControl` ar fi făcut adminul complet inaccesibil.
Am adăugat un controller subțire care doar cablează form-ul existent — **nu am modificat
nicio logică de autentificare existentă**, pentru că nu exista. Dacă preferi altă
implementare, cele trei fișiere se pot șterge fără efect asupra restului.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| `php -l` pe `modules/admin/` | 359 fișiere, 0 erori reale |
| `/admin` fără autentificare | 302 → `/auth/login` |
| Login cu `admin@cinova.local` | 302, sesiune creată |
| 15 rute index de admin (14 noi + dashboard) | toate 200 |
| 5 formulare `create` | toate 200 |
| 6 rute admin preexistente (blog, services, pages, portfolio, users, languages) | toate 200 |
| **Test funcțional CRUD end-to-end** (`scratchpad/crud_test.py`) | **12/12** |
| — CREATE → redirect la view, status implicit INACTIVE | OK |
| — apare în index | OK |
| — UPDATE alias + sort_order | OK |
| — `ActiveTranslationValidator` blochează publicarea fără traduceri | OK |
| — pagina de traduceri + dropdown de limbi | OK |
| — salvare traducere EN, vizibilă în translation-view | OK |
| — DELETE respinge GET (VerbFilter → 405), șterge pe POST | OK |
| Frontend public rămâne accesibil fără autentificare | 5/5 rute 200 |
| Structura `<main>` vs design, 10 pagini | identică |

### De semnalat
- `sort_order` e editabil din formular la toate entitățile, iar `findActive()` sortează după el —
  dar **efectul pe frontend se vede abia după Etapa 6**, când view-urile vor citi din DB.
- Comutatorul activ/inactiv funcționează la nivel de model (`findActive()` filtrează
  `status = ACTIVE`); tot Etapa 6 îl face vizibil pe site.
- Upload-ul de imagini: formularele trimit acum `temp_files[...]`, deci fișierul supraviețuiește
  unei validări eșuate — mecanismul din `BaseAdminCrudController` era prezent, dar niciun view
  din cinova nu-l alimenta.

## 2026-07-29 — Etapa 5 (în curs): extragerea conținutului real + imagini

### Decizie de la client
RO și RU se populează cu **textul EN**, marcat explicit ca nefiind traducere reală.
Asta deblochează și `ActiveTranslationValidator`: cu toate cele 3 limbi completate,
entitățile pot fi trecute pe ACTIVE din admin.

### Migrație corectivă aplicată
`m260729_100001_add_icon_class_columns` — descoperit în timpul extragerii: două blocuri
NU folosesc imagini pentru iconițe, ci clase Bootstrap Icons:
- `pricing-plan__card-icon` → `bi-columns-gap` / `bi-clipboard-pulse` / `bi-kanban`
- `our-service__navigation` → `bi-layers`

Stocarea lor în `icon_image` ar fi fost greșită (adminul ar fi randat un file input, iar
`FileHelper` ar fi căutat un fișier inexistent). Coloane adăugate:
`pricing_plans.icon_class`, `service_showcase.icon_class`, `service_showcase.is_default`
(clasa `--selected` pe care designul o pune pe tab-ul „marketing").

Modelele și view-urile de admin au fost regenerate; verificarea de runtime trece pe
**116 câmpuri** (de la 113), 0 probleme.

### Extragerea conținutului — `_analiza/seed/`
- `extract_seed.py` — extrage textele **programatic din view-uri**, nu manual: fiecare șir
  vine din fișierul sursă, cu spațiile colapsate exact cum le colapsează browserul.
- `seed_data.json` — **43 de rânduri** extrase și verificate:

| Entitate | Rânduri | Observație |
|---|---|---|
| pricing_plans | 3 | Basic/Growth/Enterprise, $29/$39/$49, 4 facilități fiecare |
| testimonials | 4 | Ion, Dan, Julia, Maddie — text identic, rating 5.0 |
| features | 3 | features-image-1-/2/3.png |
| steps | 4 | toate „Increase Traffic", același icon |
| approach_cards | 3 | Our Story / Our Vision / Our Values |
| partners | 5 | logo-1..4 + company-logo-3 |
| benefits | 2 | cele două `li` din why-choose-us |
| marquee_items | 4 | banda din watch-story (designul o repetă ×2) |
| service_showcase | 5 | chei `research/copywrite/marketing/analytics/audit`, „marketing" implicit |
| project_showcase | 8 | project-1..4.jpg × 2 |
| project_challenges | 2 | Scalability / Cross-Device |

Cele 7 câmpuri rămase goale sunt goale **și în design** (features 2-3 n-au descriere,
partenerii n-au URL) — nu sunt erori de extragere.

### Imagini — copiate în folderele de upload
17 fișiere copiate din `web/core/assests/**` în `web/core/uploads/images/<entitate>/`,
conform cerinței Etapei 5. În DB se scrie **doar numele fișierului**; calea vine din
`<Model>::FILE_PATH`, deci înlocuirea imaginii din admin funcționează normal.
Foldere create: pricing-plans, testimonials, features, steps, approach-cards, partners,
service-showcase, project-showcase.

### Ce NU e încă făcut din Etapa 5
- migrația de seed propriu-zisă (inserarea celor 43 de rânduri × 3 limbi);
- `settings` (~20 chei), `section_headers` (~17), `menu_items` (24);
- `pages_translations` (11 pagini × 3 limbi: banner + SEO);
- copiile RO/RU pentru entitățile deja populate (services, projects, blog, faq).

## 2026-07-29 — Etapa 5 (finalizată): migrația de seed

### Files created
- `migrations/m260729_110001_seed_content_blocks.php` (46 KB) — generat din
  `_analiza/seed/seed_data.json`, nu scris de mână.
- `_analiza/seed/{extract_seed.py, seed_data.json}` — artefactele extragerii, păstrate în
  proiect ca seed-ul să fie reproductibil.

### Ce inserează
| Bloc | Rânduri | × limbi |
|---|---|---|
| 11 entități de conținut | 43 | 129 traduceri |
| `settings` | 29 | 23 cu text tradus |
| `section_headers` | 17 | 51 |
| `menu_items` | 25 (cu ierarhie parent/child) | 75 |
| `pages_translations` | 11 pagini | 33 (banner + SEO) |
| Copii RO/RU pentru services/projects/blog/faq | — | 78 |

### Multilingv — decizia clientului
RO și RU primesc **textul EN**, pentru că site-ul n-a avut niciodată altă limbă.
Fiecare rând non-EN poartă în `notice` marcajul
`AUTO: English copy used as placeholder - needs real translation`.
**284 de rânduri** sunt în această situație — lista completă se obține cu:
`SELECT * FROM <tabel>_translations WHERE notice LIKE 'AUTO:%'`.

Efectul dorit: `ActiveTranslationValidator` nu mai blochează publicarea. Verificat pe
un rând seed-at — trecerea pe ACTIVE din admin funcționează.

### Imagini
17 fișiere copiate din `web/core/assests/**` în `web/core/uploads/images/<entitate>/`.
În DB se scrie doar numele fișierului; calea vine din `<Model>::FILE_PATH`, deci
înlocuirea din admin funcționează normal.

### Bug-uri prinse la rulare (ambele reparate în extractor/generator, nu manual în date)
1. Ultimul card de testimonial se întindea până la sfârșitul blocului și aduna stelele din
   tot restul paginii → `rating` 25.0, în afara `decimal(2,1)`. Numărătoarea se face acum
   doar în `what-we-do__box-rating` al cardului.
2. `blog_translations` nu are coloana `notice` (tabel mai vechi) → `fillMissingLanguages()`
   verifică acum schema înainte de a o seta.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| `php -l` pe migrație | curat |
| `php yii migrate` | aplicată |
| `migrate/down 1` + `migrate` | **idempotent** — cifrele identice, zero duplicate |
| Traduceri per limbă, entități noi | EN 17 / RO 17 / RU 17 (section_headers, exemplu) |
| Traduceri per limbă, entități vechi | services 6/6/6, blog 7/7/7, projects 6/6/6, faq 20/20/20 |
| Publicare din admin a unui rând seed-at | ACTIVE — validatorul nu mai blochează |
| Structura `<main>` vs design, 10 pagini | **identică** (Etapa 6 n-a început) |

### Notă despre conținutul duplicat
La cererea explicită a clientului, conținutul placeholder din design a fost seed-at
**verbatim**: cele 5 carduri de service showcase au text identic (diferă doar prin
`card_key` și `tab_label`), iar cele 8 carduri de proiecte au aceeași categorie și
descriere. Asta respectă cerința „site-ul arată identic".

## 2026-07-29 — Etapa 6 (pregătire): backup + blocaj rezolvat

### Backup (cerut de spec, înainte de orice modificare de view)
`_analiza/backup-views/` — copie a `views/` și `components/`, 40 de fișiere PHP.

### Blocaj găsit ÎNAINTE de a atinge view-urile
Designul scrie o parte din fiecare titlu în `<span class="special-word">`, iar câteva
folosesc `<br>`. Extractorul de la Etapa 5 elimina tagurile, deci în DB titlul era text
simplu. Dacă Etapa 6 l-ar fi randat escapat (`Html::encode`), span-urile ar fi **dispărut
tăcut** și structura n-ar mai fi fost identică — exact ce trebuie să evite Etapa 7.

35 de titluri în 7 fișiere de view sunt afectate.

### Files created
- `_analiza/seed/title_html.json` — mapare `text simplu => HTML interior`, 39 de intrări,
  extrasă din surse.
- `migrations/m260729_120001_restore_html_in_titles.php` — înlocuiește valoarea plată cu
  HTML-ul original acolo unde se potrivește exact, în 12 tabele de traduceri.
  Idempotentă (după prima rulare valorile nu mai sunt egale cu textul plat).

**87 de rânduri** au acum markup-ul inline restaurat.

### Consecință pentru Etapa 6 — de reținut
Câmpurile de titlu **trebuie randate NEescapate**: `<?= $value ?>`, nu
`<?= Html::encode($value) ?>`. Sunt conținut scris de administrator, deci sursă de
încredere; restul câmpurilor (nume de autor, categorii, etichete) rămân escapate.

### Ce NU e făcut din Etapa 6
Rescrierea propriu-zisă a view-urilor și a controllerelor publice. Nimic din `views/`
nu a fost modificat încă — doar copiat în backup.

## 2026-07-30 — Switcher de limbi în header

### Task
„Fă undeva sus un switcher de limbi" — control de schimbare a limbii în partea de sus a
site-ului public.

### Files created
- `components/widgets/LanguageSwitcherWidget.php` — widget (nu partial, ca `HeaderWidget`/
  `FooterWidget`, pentru că interoghează baza). Citește limbile `ACTIVE` din tabelul
  `languages` — aceeași sursă din care closure-ul de bootstrap din `config/web.php` umple
  `urlManager->languages` — deci activarea unei limbi din admin e suficientă, fără cod nou.
  Se randează doar dacă există ≥ 2 limbi active.
- `components/widgets/views/language-switcher.php` — markup: buton `🌐 + cod + chevron`,
  dropdown cu `COD + nume` (numele vine din `languages.name`), `hreflang`/`lang`/`aria-current`.

### Files modified
- `components/widgets/views/header.php` — switcher + CTA învelite în `.navbar__actions`,
  ca `.container-fluid` (Bootstrap `space-between`) să rămână cu 4 copii:
  logo / meniu / acțiuni / burger. Fără wrapper, spațierea headerului s-ar fi redistribuit.
- `web/core/styles/style.css` — bloc `/***** Language switcher *****/`, în limbajul vizual
  existent (`--bg-color-3`, accent lime, `--border-radius-4`, chevron rotit la deschidere).
  `font-family: inherit` pe buton, pentru că `reset.css` nu normalizează `<button>`.
- `web/core/styles/responsive.css` — sub 992px CTA e deja `d-none`, deci în `.navbar__actions`
  rămâne doar switcher-ul, lipit de burger (`margin-left: auto`).
- `web/core/js/index.js` — `langSwitcher()`, apelat din `init()`. Toggle pe **click**
  (nu hover) ca să funcționeze și pe touch; se închide la click în afară și la `Escape`.

### Decizii notabile
1. **URL-urile** se construiesc cu `Url::current(['language' => $code])` — modul documentat
   din `codemix/localeurls` de a ținti *pagina curentă* în altă limbă. Slug-urile nu sunt
   traduse (sunt pe modelul de bază), deci paginile de detaliu se mapează 1:1.
2. **Limba implicită (en)** primește prefix explicit `/en/`: cu
   `enableLanguagePersistence = true`, localeurls tratează asta ca „reset URL" — pune
   cookie-ul `language=en` și redirectează 302 la URL-ul fără prefix.
3. **Fără iconițe de steag.** `LanguageHelper::languageList()` mapează spre coduri
   `flag-icon`, dar CSS-ul respectiv nu e încărcat nicăieri; am folosit `bi bi-globe2` +
   codul limbii ca să nu adaug o dependență CDN doar pentru steaguri.
4. Codurile din DB sunt majuscule (`RO`/`EN`/`RU`), `Yii::$app->language` e minuscul —
   widget-ul normalizează și acceptă și forma de locale (`ro-RO`) la potrivirea limbii curente.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| `php -l` pe widget + view-uri | curat |
| `/cinova/web/` | switcher afișat, curent `EN`, `<html lang="en">` |
| `/cinova/web/ro` | 200, curent `RO`, `<html lang="ro">` |
| `/cinova/web/en` | 302 → `/cinova/web/` + `Set-Cookie: language=…en` |
| `/cinova/web/ro/services/7/seo-strategy-consulting` | linkurile switcher-ului păstrează pagina (`ro`/`en`/`ru` + același slug) |
| Deschidere dropdown + click pe RO, în Chrome | trecere corectă pe `/ro`, stare activă marcată |

Verificarea vizuală la lățime mobilă n-a putut fi făcută cu screenshot (redimensionarea
ferestrei nu s-a aplicat în sesiunea de browser). Sub 992px se aplică doar două reguli noi
din `responsive.css` (`.navbar__actions` și padding-ul butonului) — de reconfirmat pe telefon.

---

## 2026-07-30 — Traducere reală RO / EN / RU a conținutului din baza de date

### Task
Conținutul exista într-o singură limbă: `m260729_110001_seed_content_blocks` a populat
rândurile RO și RU cu **textul englez**, ca placeholder (fiecare rând poartă
`notice = 'AUTO: English copy used as placeholder - needs real translation'`).
Obiectiv: fiecare limbă activă să aibă textul ei real, fără câmpuri goale și fără
text copiat dintr-o limbă în alta.

### Diagnostic (înainte)
- Limbi active: `RO`(1), `EN`(2), `RU`(3) — citite din tabelul `languages` de closure-ul
  de bootstrap din `config/web.php:10-24`. Implicită: `en` (`language` + `sourceLanguage`).
- Mecanism: tabel separat `*_translations` (`entity_id` + `language_id`), consumat prin
  `traits/TranslationTrait.php`. **Nu** există slug per limbă — `slug` stă pe tabela-părinte
  (`blog.slug`, `projects.slug`, `services.slug`, `pages.slug`), deci slug-urile nu se ating.
- 18 tabele de traduceri, ~170 de șiruri-sursă unice, RU == RO == EN în proporție de 100%.

### Files created
- `commands/TranslateContentController.php` — comandă de consolă (pattern-ul
  `Seed*Controller` din ecaterina, nu migrație, ca să poată fi re-rulată).
  Glosar unic, indexat după șirul englez original: același termen se traduce identic
  în toate cele 18 tabele („Content Marketing" apare în 5 tabele).
  Acțiuni: `translate-content` (aplică), `--dryRun`, `/audit`, `/verify`.
- `_backup/20260730_translations_before_i18n.sql` — dump al celor 18 tabele înainte de update.

### Migrations
Niciuna. Structura tabelelor nu a fost atinsă.

### Decizii notabile
1. **Idempotență prin comparație de valoare, nu prin flag.** Un câmp se scrie doar dacă
   valoarea curentă e goală, egală cu sursa engleză originală sau egală cu sursa engleză
   corectată. Orice altceva e considerat traducere făcută de om și e lăsat neatins.
   A doua rulare nu mai schimbă nimic.
2. **Engleza se corectează la sursă** (aprobat): `Our Approuch`→`Our Approach`,
   `How it work`→`How It Works`, `Avarage`→`Average`, `SEO Strategy & Consulting s`→
   fără `s`, `Subscribe Newsletter's`→`Subscribe to Newsletter`, `Building,`→`Link Building,`.
   Ambele forme (originală și corectată) sunt chei în index, ca re-rularea să rezolve.
3. **Brand unificat la `Cignova`** (aprobat): cele 20 de răspunsuri FAQ ziceau „Rankio",
   numele template-ului original.
4. **Câmpuri SEO goale — derivate, nu inventate**: `pages.seo_og_title` ← `seo_title` fără
   sufixul „ — Cignova"; `seo_description` ← `banner_title` fără taguri; `seo_og_description`
   ← `seo_description`; `seo_keywords` ← `seo_og_title` minusculat; `blog.description` ←
   `content_text` fără taguri. Se scrie doar în câmp gol.
5. **Textele „Lorem ipsum" și `<p>1</p>` NU se traduc** (aprobat) — sunt filler de template.
   Rândurile care le conțin își păstrează `notice`-ul de placeholder; restul primesc
   `AUTO: translated RO/RU from the English source`.
6. **HTML păstrat caracter cu caracter** — `<span class="special-word">` se traduce doar
   în interior; `/verify` compară lista ordonată de taguri între sursă și traduceri.
7. **Verificarea de encoding folosește `LIKE BINARY`** — colația `utf8mb4_general_ci`
   consideră `ş` (sedilă) egal cu `ș` (virgulă), deci un `LIKE` obișnuit ar fi raportat
   fals-pozitiv fiecare rând corect.

### Rollback
```
"C:/OpenServer/modules/database/MySQL-8.0-Win10/bin/mysql.exe" -uroot -h127.0.0.1 cinova < _backup/20260730_translations_before_i18n.sql
```

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| `php -l commands/TranslateContentController.php` | curat |
| `yii translate-content --dryRun` | 832 câmpuri de scris, 129 derivate, **0 șiruri fără intrare în glosar** |
| `yii translate-content` (rulare 1) | 832 traduse + 129 derivate |
| `yii translate-content` (rulare 2 — idempotență) | **0 scrieri** pe toate cele 18 tabele |
| `yii translate-content/audit` | 0 câmpuri traductibile rămase cu RU == RO, în afara excepțiilor legitime |
| `yii translate-content/verify` — encoding | 0 apariții de `ş`/`ţ` (sedilă) și 0 `U+FFFD`; toate coloanele text = `utf8mb4` |
| `yii translate-content/verify` — HTML | 6 „mismatch" raportate, toate pe `services_translations.content_text_1/2/3` #7, unde sursa e `<p>1</p>` (junk, netradus intenționat) |
| Frontend: 7 pagini publice × 3 limbi = 21 cereri | toate 200; `<html lang>` și `<title>` corecte pe fiecare limbă |
| Admin: login + `translation-view` RU / `translation-update` RO | conținut afișat în limba corectă (389 caractere chirilice / 42 diacritice RO) |
| Admin: POST pe `testimonials/translation-update?entity_id=19&lang_id=3` | salvat **doar** pe rândul `language_id=3`; RO/EN neatinse; chirilice round-trip corect. Valoarea de test a fost restaurată. |

### Probleme rămase (NU au fost atinse — sunt de view, nu de date)
1. **174 de copii hardcodate ale conținutului din DB, în 21 de fișiere de view.**
   Ele se randează în engleză pe `/ro` și `/ru` indiferent ce scrie în baza de date.
   Cele mai afectate: `views/home/index.php` (62), `views/services/index.php` (21),
   `views/projects/view.php` (19), `views/about/index.php` (18), `views/services/view.php` (11),
   `views/common/header/_nav.php` (8), `views/projects/index.php` (8).
   Meniul din header/footer, de exemplu, e scris direct în markup în loc să folosească
   `menu_items_translations`, care e complet tradus.
2. `views/common/_contact_modal.php:95` și `views/contact/index.php:123` scriu „Send Message"
   în markup, deși setarea `modal_button` există și e tradusă în DB.
3. `config/db.php` are `'charset' => 'utf8'` (utf8mb3), deși tabelele sunt `utf8mb4`.
   Pentru RO/RU nu e o problemă (≤ 3 octeți/caracter), dar orice caracter pe 4 octeți
   (emoji) ar fi trunchiat la scriere.

---

## 2026-07-30 — Navbar-ul citește din DB (era hardcodat, nu se traducea)

### Task
„Pregătește și navbar-ul, trebuie fixat pentru production, că nu se traduce."
Continuare directă a punctului 1 din problemele rămase de la intrarea precedentă:
etichetele meniului erau scrise direct în markup, deci `/ro` și `/ru` afișau meniu
englezesc chiar dacă `menu_items_translations` era complet tradus.

### Files modified
- `views/common/header/_nav.php` — rescris. Meniul se construiește din
  `MenuItems::findActive()` filtrat pe `location = HEADER`. Etichetele vin din
  `$item->t('label', $item->alias)` (limba curentă → limba-sursă → alias).
  Ierarhia părinte/copil se face în PHP peste rezultatul unei singure interogări,
  ca un submeniu să nu coste interogări în plus.
  **Markup-ul rămâne identic** — aceleași clase, aceeași structură, aceeași indentare.
- `views/common/header/_cta.php` — butonul „Get Started Today!" citește acum
  `Settings::text('cta_button')`; era hardcodat, deși valoarea exista tradusă în DB.
- `models/settings/Settings.php` — adăugat `Settings::text(string $key, $default)`:
  citește `settings_translations.value_text` pentru limba curentă, cu un cache
  static per cerere (întreg tabelul, 29 de rânduri, într-o singură interogare).

### Migrations
Niciuna. Nicio modificare de structură, de date sau de CSS/design.

### Decizii notabile
1. **URL-urile se rezolvă în trei trepte**: `menu_items.url` (absolut/extern) →
   `Url::to([menu_items.route])` → linkuri „demo single". Ultimele trei rânduri
   (`pages-service-single`, `pages-blog-single`, `pages-project-single`) nu au
   `route` — nu poate exista unul fără id — deci se rezolvă din prima înregistrare
   activă a entității, exact ca în varianta veche. Rezolvarea e leneșă: dacă
   rândurile respective nu sunt în meniu, nu se face nicio interogare.
2. **`Settings::text()` citește `value_text`, nu `value`.** Coloana `settings.value`
   ține date neutre lingvistic (email, telefon, procente); traducerile stau în
   tabelul de traduceri. Confuzia dintre ele ar fi afișat „info@domain.com" în loc de text.
3. **`Html::encode()` pe etichete** — etichetele de meniu sunt text simplu, spre
   deosebire de titlurile de secțiune care conțin `<span class="special-word">`.
4. Nu am adăugat stare „activ" pe linkul paginii curente — ar fi însemnat markup nou,
   iar cerința era doar traducerea.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| `php -l` pe cele 3 fișiere | curat |
| Navbar pe `/`, `/ro`, `/ru` | `Home/About Us/Services…` · `Acasă/Despre noi/Servicii…` · `Главная/О нас/Услуги…` |
| Buton CTA | `Get Started Today!` · `Începe chiar azi!` · `Начните сегодня!` |
| 7 pagini publice × 3 limbi | toate 200, câte 17 elemente de meniu, eticheta corectă pe limbă |
| `/{lang}/home/error` | HTTP 404 (corect pentru pagina de eroare), navbar tradus prezent |
| Structura HTML randată | identică cu varianta hardcodată (clase, imbricare, indentare) |
| Prefixe de limbă în href | corecte (`/cinova/web/ro/about-us`), inclusiv pe linkurile de detaliu |
| Număr de interogări (panoul debug) | `menu_items` ×1 + relații traduceri ×2; `settings` ×1 + ×2. **Fără N+1** |
| End-to-end admin → frontend | schimbat `menu_items` #79 label RO în „Articole" din admin → apare imediat pe `/ro`, RU neatins. Valoarea a fost restaurată. |

### Rămâne de făcut (aceeași clasă de problemă, alte fișiere)
Din cele 174 de literale hardcodate găsite anterior, navbar-ul + CTA rezolvă 10.
Mai rămân **164 în 20 de fișiere**, cele mai grele fiind `views/home/index.php` (62),
`views/services/index.php` (21), `views/projects/view.php` (19), `views/about/index.php` (18).
Footerul (`_nav-quick.php`, `_nav-services.php`, `_about.php`, `_copyright.php`,
`_cta.php`, `_newsletter.php` — 12 literale) e cel mai ieftin pas următor: datele există
deja în `menu_items` (`FOOTER_QUICK`, `FOOTER_SERVICES`) și în `settings`,
iar `Settings::text()` e deja disponibil.

---

## 2026-07-30 — Tot conținutul din view-uri citește din DB / i18n („rezolvă tot")

### Task
Continuarea directă a celor două intrări precedente. Rămăseseră **164 de literale
hardcodate în 20 de fișiere de view** — text care exista tradus în baza de date, dar
se randa în engleză pe `/ro` și `/ru`. Plus textele de interfață (butoane, placeholdere)
care n-aveau deloc un mecanism de traducere pe frontend.

### Files created
- `helpers/TextHelper.php` — `highlight()` (înfășoară ultimul cuvânt în
  `<span class="special-word">`, ca titlurile paginilor de detaliu să păstreze accentul
  din design în orice limbă), `plain()`, `excerpt()`.
- `views/common/_page_banner.php` — bannerul comun tuturor paginilor. Titlul și
  breadcrumb-ul vin din `pages_translations`; era copiat în 9 view-uri, în engleză.
- `views/common/_section_header.php` — blocul eyebrow / titlu / subtitlu, din
  `section_headers`. Era repetat în ~25 de locuri, tot în engleză.
- `views/faq/components/_faq_accordion.php` — acordeonul extras din `_faq_group`,
  ca paginile care încorporează un bloc FAQ fără titlu (about, detaliu serviciu,
  detaliu proiect) să nu mai repete întrebările în markup.
- `messages/{en,ro,ru}/frontend.php` — categoria `frontend` pentru textele de interfață
  care **nu** au rând în baza de date (butoane, placeholdere de formular, etichete).
  Directorul `messages/` nu exista, deși `config/web.php` îl configura deja.
- `migrations/m260730_120001_seed_missing_section_headers.php` — 21 de rânduri noi în
  `section_headers` + 63 de traduceri, pentru paginile care nu aveau antete în DB
  (about ×3, contact ×2, services_index ×4, services_view ×4, projects_view ×5 ș.a.).
  Textul englez e preluat verbatim din view-urile pe care le înlocuiește; RO/RU
  folosesc glosarul aprobat. Idempotentă (guard pe page_key + section_key).

### Files modified — view-uri (20)
`home/index.php` (62 de literale), `services/index.php` (21), `projects/view.php` (19),
`about/index.php` (18), `services/view.php` (11), `projects/index.php` (8),
`blog/view.php` (2), `blog/index.php`, `contact/index.php` (4), `faq/index.php`,
`home/error.php`, `common/_contact_modal.php` (2), `common/footer/*` (6 fișiere, 12),
`common/header/_nav.php` + `_cta.php`, `faq/components/_faq_group.php`,
`{services,projects,blog}/components/_*_card.php`.

### Files modified — modele & helpere
- `models/settings/Settings.php` — `text()` (traducere), `raw()` (valoare neutră
  lingvistic: email, telefon, procente), `phoneHref()`, plus **`getAlias()`**.
- `models/sectionHeaders/SectionHeaders.php` — `get($pageKey, $sectionKey)` cu cache
  per cerere (17 rânduri într-o singură interogare).
- `models/pages/Pages.php` — `byName()`, `getBannerTitle()`, `getBreadcrumbs()`,
  `crumbsFor()` (o pagină de detaliu reutilizează traseul paginii-listă + numele entității).
- `models/menuItems/MenuItems.php` — `tree()`, `getLabel()`, `getLinkUrl()`.
- `models/faq/Faq.php` — `findActive()`, `group()`.
- `helpers/SeoHelper.php` — `applyModel()`, care citește prin lanțul de fallback al
  traducerii (limba curentă → limba-sursă), spre deosebire de `apply()`, care primea
  un singur rând de traducere și pierdea tăcut meta-tagurile lipsă.
- `helpers/LanguageHelper.php` — `idByCode()`.
- `traits/TranslationTrait.php` — cache-ul de limbă mutat în `LanguageHelper`.
- `config/db.php` — `charset` → `utf8mb4`, `enableSchemaCache` → `!YII_DEBUG`.

### Decizii notabile
1. **Conținutul demo hardcodat a fost eliminat, nu tradus.** Paginile de detaliu
   (serviciu / proiect / articol) afișau un articol demo fix — același text englez pe
   fiecare înregistrare. Acum randează `content_text*` din propriul rând; un câmp gol
   nu randează nimic, în loc să afișeze textul altcuiva. **Consecință: serviciile 7-12
   și proiectele au `content_text*` goale, deci acele secțiuni apar goale până le
   completezi din admin.** Vezi „De completat" mai jos.
2. **Cardurile repetate au devenit bucle.** `services/view` repeta același card de 7 ori
   deși controllerul trimitea deja `$related`; `home` avea 6 carduri de preț pentru
   3 planuri din DB, 8 carduri de proiect statice, 4 testimoniale hardcodate.
3. **Filtrele de proiecte se derivă din date** — `projects.category` e cheia tehnică pe
   care filtrează JS-ul, `category_name` e eticheta tradusă. Nu mai trebuie ținute
   sincronizate manual.
4. **Textul „Lorem ipsum" a rămas neatins**, conform deciziei din prima etapă: e filler
   de template, identic în orice limbă. Se vede în `home`, `services/index` și în
   subtitlurile a 3 antete de secțiune.
5. **`Settings::raw()` vs `text()`** — coloana `settings.value` ține date neutre
   lingvistic (email, telefon, `87%`, `4.5`); traducerile stau în tabelul de traduceri.
   Confuzia dintre ele ar fi afișat „info@domain.com" în loc de text tradus.
6. **`getAlias()` pe Settings** repară un bug preexistent: `admin/settings/index`
   arunca `UnknownPropertyException: Settings::alias` și returna 500 (verificat pe HEAD).
7. **Cache-ul de limbă mutat din trait.** O proprietate statică declarată într-un trait
   se creează separat pentru fiecare clasă care îl folosește, deci fiecare model
   reinteroga `languages`: homepage-ul, care atinge 13 modele, făcea 25 de căutări
   identice. Acum e una per cod de limbă.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| `php -l` pe toate fișierele modificate | curat |
| Scanner de literale hardcodate (re-rulat) | **174 → 0 reale.** Cele 29 rămase sunt fals-pozitive: linii `use app\models\...;`, chei `Yii::t()` (deci traductibile) și filler-ul Lorem ipsum |
| 42 de încărcări de pagină (14 rute × 3 limbi) | 0 erori PHP, toate 200 (sau 404 pe pagina de eroare, corect) |
| Scanare text englez rămas pe RO+RU (29 de șiruri-martor × 22 de pagini) | **0** (singura potrivire e un comentariu HTML `<!-- Gallery image modal -->`) |
| `yii translate-content --dryRun` | 0 scrieri, **0 șiruri-sursă fără intrare în glosar** |
| `yii translate-content/verify` | doar cele 6 neconcordanțe cunoscute pe `services.content_text*` #7 (sursa e `<p>1</p>`, junk netradus) |
| Admin: 10 listări | toate 200 după repararea `Settings::getAlias()` |
| Interogări DB / homepage RO | 154 → 127 în dev; ~57 dintre ele sunt `SHOW FULL COLUMNS`, dezactivate în producție prin `enableSchemaCache` |

### De completat din admin (câmpuri goale, nu bug-uri)
- `services.content_text_1/2/3` pentru toate cele 6 servicii → secțiunile „solution",
  „strategies" și cardul „growth" de pe pagina de detaliu rămân fără corp.
- `projects.desc_min` și `projects.content_text` pentru toate cele 6 proiecte.
- `blog.content_text` conține o singură propoziție pentru toate cele 7 articole.
- Subtitlurile „Lorem ipsum" din `section_headers` (3), `project_showcase.desc_min` (8),
  `service_showcase.desc_min` (5), `features.desc_min` (1).

### Rămâne cunoscut
- `admin/projects/index` → 404: nu există `ProjectsController` în `modules/admin/controllers`
  (preexistent, neatins).

---

## 2026-07-30 — Rute 404 în admin reparate + admin blog reparat + conținut completat

### Task
„Verifică și rutele care dau 404; eu am verificat blogurile și ele nu lucrează;
poți să te uiți și să completezi în admin."

### Diagnostic
1. **Crawl frontend (85 de linkuri interne unice, toate paginile × 3 limbi):**
   singurele 404 sunt linkurile „Error 404" din dropdown-ul demo „Pages" — corect,
   duc intenționat la pagina de eroare.
2. **Blogul pe frontend funcționează** (toate cele 6 articole active, 3 limbi, 200).
   Ce era stricat: **admin-ul blogului** — `translation-view` / `translation-update`
   crăpau cu 500 (conexiune închisă). Cauza: view-urile din
   `modules/admin/views/blog/translations/` erau copiate de la services și refereau
   `name` / `desc_min` — atribute pe care `BlogTranslationsForm` nu le are →
   `UnknownPropertyException` fatal.
3. **Rute admin 404:** `admin/projects`, `admin/faq` (entități cu pagini publice,
   dar fără niciun controller de administrare; linkul FAQ din sidebar era schițat
   și comentat), `admin/contact` (nu există controller; formularul public de
   contact nici nu trimite nicăieri — e static în template, ramura `contactForm`
   pare să fie lucrarea viitoare pentru el — lăsat neatins).

### Files created
- `models/projects/forms/ProjectsForm.php`, `ProjectsTranslationsForm.php`,
  `ProjectsGalleryForm.php`, `models/projects/search/ProjectsSearch.php`
- `models/faq/forms/FaqForm.php`, `FaqTranslationsForm.php`,
  `models/faq/search/FaqSearch.php`
- `modules/admin/controllers/ProjectsController.php` — MainCrudTrait +
  HasGalleryTrait (`parentForeignKey = project_id`)
- `modules/admin/controllers/FaqController.php` — MainCrudTrait, fără imagini
- `modules/admin/views/projects/*` (12 fișiere: CRUD + translations + gallery)
- `modules/admin/views/faq/*` (10 fișiere: CRUD + translations)

### Files modified
- `modules/admin/views/blog/translations/_form.php` — rescris cu câmpurile reale
  ale blogului (title, category, description, content_text, seo_*)
- `modules/admin/views/blog/translations/view.php` — idem
- `models/blog/forms/BlogTranslationsForm.php` — adăugat câmpul `category`
  (coloana exista în tabel, formularul n-o expunea deloc)
- `modules/admin/components/views/aside.php` — linkuri noi Projects + FAQ
- `modules/admin/messages/{en,ro,ru}/menu.php` + `labels.php` — cheile noi
- `commands/TranslateContentController.php` — acțiune nouă `fill`

### Conținut completat (`php yii translate-content/fill`)
Cerut explicit de client („completezi în admin") — 132 de câmpuri scrise:
- `blog_translations.content_text` — articol complet (EN preluat din textul demo
  al template-ului, care se potrivea titlului; RO/RU traduse), 21 de rânduri;
  `description` re-derivat din noul conținut.
- `services_translations.content_text_1/2/3` — copy per serviciu (intro /
  secțiunea solution / cardul growth), 6 servicii × 3 limbi.
- `projects_translations.desc_min` + `content_text` — studiu de caz per categorie
  (research / copywrite / marketing / analytics / audit), 6 proiecte × 3 limbi.
Idempotent: scrie doar peste gol sau peste junk-ul cunoscut (`<p>1</p>`,
propoziția demo); a doua rulare = 0 scrieri. Backup:
`_backup/20260730_content_before_fill.sql`.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| Crawl frontend: 85 de linkuri interne | 0 rupte (except. „Error 404", intenționat) |
| Sweep admin: 92 de rute (19 entități × view/update/translations/translation-view/translation-update) | **toate 200** |
| Analiză statică: toate `translations/_form.php` vs. proprietățile formularelor | 0 nepotriviri rămase |
| `admin/blog/translation-view` + `translation-update` (era 500) | 200 |
| Round-trip save pe formularul FAQ nou (RU) | salvat doar pe `language_id=3`, RO/EN neatinse; restaurat |
| `yii translate-content/fill` ×2 | 132 scrise → 0 (idempotent) |
| `yii translate-content/verify` | **0 probleme** (junk-ul `<p>1</p>` a dispărut) |
| Frontend: 30 de încărcări (10 rute × 3 limbi) | 0 erori; conținutul nou vizibil în limba corectă |

### Rămâne cunoscut
- `admin/contact` — fără controller; formularul public de contact e static (nu
  postează nicăieri). De legat când se lucrează ramura `contactForm`.

---

## 2026-07-30 — Formularul de contact funcțional cap-coadă (pattern-ul ecaterina)

### Task
„Fă și pentru contactForm — analizează cum e făcut în ecaterina și fă fix așa."
Formularul public era markup static (fără action, fără nume de câmpuri Yii) — nu
trimitea nimic nicăieri, deși tabelul `contact` și modelul existau din iunie.

### Pattern-ul preluat din ecaterina (doar citit, neatins)
`controllers/ContactController.php` (actionSubmit JSON + honeypot + sendAdminEmail
în try/catch), `models/contact/forms/ContactRequestForm.php` (formular simplu cu
`_hp`), `modules/admin/controllers/ContactRequestsController.php` (index cu filtru
citit/necitit + view + toggle-checked), view-urile admin aferente, layout-ul de
e-mail `mail/layouts/contactRequest.php` și JS-ul `beforeSubmit` + AJAX din
`views/contact/components/_contact_section.php`.

### Files created
- `migrations/m260730_140001_add_contact_tracking_columns.php` — `checked`
  tinyint(1) def. 0 + index, `ip` varchar(45), `user_agent` varchar(500) pe
  `contact` (oglinda lui `contact_requests` din ecaterina).
- `models/contact/forms/ContactSubmitForm.php` — formularul public, cu honeypot;
  mesaje de validare prin `Yii::t('frontend', …)` în EN/RO/RU. `ContactForm`-ul
  existent (legat de record în constructor) rămâne pentru admin, neatins.
- `controllers/ContactController.php` — rescris: `index` (dă formularul view-ului)
  + `submit` (JSON: validare → honeypot fake-success → save cu `ip`/`user_agent`/
  `checked=0` → e-mail către `params['adminEmail']` în try/catch, ca submisia să
  reușească și cu SMTP căzut).
- `mail/layouts/contactRequest.php` — șablonul de e-mail, brandat Cignova.
- `views/common/_contact_form.php` — partial UNIC folosit și de pagina de contact,
  și de modalul „Get Started" (`formId` parametrizat, ca ambele să poată coexista
  pe aceeași pagină). ActiveForm cu template-ul `{input}{error}` peste clasele
  existente ale designului + alerte succes/eroare + JS-ul `beforeSubmit` din
  ecaterina (buton dezactivat + „Se trimite…" pe durata cererii).
- `modules/admin/controllers/ContactController.php` — inbox: listă cu filtru
  citit/necitit și contor de necitite, view, `toggle-checked` (POST). La marcare
  se setează și `verified_by` = userul curent; la demarcae se golește.
- `modules/admin/views/contact/{index,_search_form,table_data,view}.php`.

### Files modified
- `views/common/_contact_modal.php`, `views/contact/index.php` — formularele
  statice înlocuite cu partialul comun (`contact-modal-form` / `contact-page-form`).
- `models/contact/Contact.php` — `CHECKED_*`, reguli pt. coloanele noi,
  `isChecked()`, `getInitials()`, `getMessageShort()`, `optsChecked()`.
- `modules/admin/components/views/aside.php` — link „Contact Requests" (icon mail).
- `web/core/styles/style.css` — bloc `.contact-alert` (culorile din ecaterina) +
  ascunderea containerului de eroare gol.
- `messages/{en,ro,ru}/frontend.php`, `modules/admin/messages/*` — cheile noi.

### Decizii notabile
1. Ruta admin e `/admin/contact` (cea care dădea 404), nu `/admin/contact-requests`
   ca în ecaterina — tabelul aici se numește `contact`.
2. Butonul paginii de contact avea clasa ruptă `special-b tn` în template; partialul
   folosește `special-btn` (typo de template, nu schimbare de design).
3. E-mailul se trimite sincron ca în ecaterina; cu SMTP-ul de test a eșuat, a fost
   logat (`runtime/logs/app.log`) și submisia a reușit — exact comportamentul dorit.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| `/contact-us` în EN/RO/RU | ambele formulare prezente, placeholders traduse, CSRF + honeypot în markup |
| POST gol / parțial invalid | `success:false` cu erori per câmp, traduse |
| POST cu honeypot umplut | `success:true` fals, **niciun rând salvat** |
| POST valid pe `/ro/contact/submit` | rând salvat cu ip + user_agent + checked=0; mesaj de succes în română |
| `admin/contact` | 200, cererea listată, contor necitite afișat |
| `admin/contact/view?id=1` | 200, mesaj + IP vizibile |
| `toggle-checked` (POST) | `checked=1`, `verified_by=2`; dispare din filtrul „necitite" |
| Rândul de test | șters după verificare — tabelul pleacă curat |

---

## 2026-08-09 — Modalul de contact se închide la click în afară

### Task
Pop-up-ul „Request a Consultation" nu se închidea când dai click pe lângă el
(pe fundal); doar butonul X îl închidea.

### Files modified
- `views/common/_contact_modal.php` — eliminat `data-bs-backdrop="static"` și
  `data-bs-keyboard="false"` de pe `#contact-us-modal`.

### Decizii notabile
1. `backdrop="static"` este exact opțiunea Bootstrap 5 care blochează închiderea
   la click pe backdrop; scoasă, revine la default (`true`) → click în afară închide.
2. Scos și `keyboard="false"` ca ESC să închidă modalul — comportamentul standard
   așteptat împreună cu click-outside.
3. Modalul galeriei (`#gallery-image-modal`) nu a fost atins — nu avea `static`.

---

## 2026-08-09 — Navbar plat, slidere navigabile, conținut real + paginare

### Task
Mai multe cereri într-o singură sesiune:
1. modalul de contact să se închidă la click în afară (vezi intrarea precedentă);
2. scos meniul „Pages" din navbar, paginile reale promovate în bara principală;
3. slider-ele de pe home (proiecte, prețuri) să aibă săgeți și dots, ca pe telefon
   să se poată naviga — săgețile pe marginile ecranului, stilizate cu designul;
4. imaginea de sub „Growth Strategy" din pagina de serviciu ocupa tot ecranul;
5. blogul să tragă articole reale din BD și paginarea să funcționeze;
6. proiectele: tabel de categorii cu traduceri, atribuire pe proiect,
   filtrare funcțională + paginare, proiecte reale.

### Migrations
- `m260809_120001_flatten_header_menu` — promovează `pages-projects` → `projects`
  și `pages-faq` → `faq` la nivel de rădăcină (păstrând traducerile RO/RU deja
  introduse), apoi șterge `pages`; FK-ul `parent_id` e CASCADE, deci restul
  copiilor demo pleacă odată cu părintele. Reordonează HEADER 1–7.
- `m260809_120002_create_project_categories_table` — `alias` (cheia de filtrare,
  unic), `icon_class`, `sort_order`, `status`, coloane de audit.
- `m260809_120003_create_project_categories_translations_table` — `name`, `notice`.
- `m260809_120004_add_category_id_to_projects` — FK `ON DELETE SET NULL`
  (ștergerea unei categorii nu șterge munca din ea).
- `m260809_120005_seed_project_categories` — cele 5 categorii din design în
  EN/RO/RU + backfill `category_id` din vechiul `category` text liber
  (`test`, `copywrite`, `marketing`, `analytics`, `audit`).
- `m260809_120006_seed_project_content` — 12 proiecte (6 rescrise, 6 noi) cu
  nume/slug/rezumat/corp distincte în 3 limbi, împărțite pe cele 5 categorii.
- `m260809_120007_seed_blog_content` — 9 articole cu titluri/categorii/conținut
  distincte în 3 limbi, `published_at` descrescător.

### Files created
- `models/projectCategories/{ProjectCategories,ProjectCategoriesTranslations}.php`
- `models/projectCategories/forms/{ProjectCategoriesForm,ProjectCategoriesTranslationsForm}.php`
- `models/projectCategories/search/ProjectCategoriesSearch.php`
- `modules/admin/controllers/ProjectCategoriesController.php` +
  `modules/admin/views/project-categories/**` (CRUD complet cu traduceri)
- `views/common/_slider_controls.php` — săgeți laterale + bullets, partial comun.
- `views/common/_pagination.php` — paginarea designului legată la DataProviderHelper.

### Files modified
- `views/common/_contact_modal.php` — scos `data-bs-backdrop="static"`.
- `views/common/header/_nav.php` — eliminat codul mort pentru paginile demo.
- `views/home/index.php` — controale pe slider-ul de proiecte și pe cel de prețuri.
- `web/core/js/index.js` — `navigation` pe ambele slidere, `speed` 3000 → 700
  (3 secunde de tranziție făceau săgeata să pară moartă), `disableOnInteraction`,
  `openProjectFilter` nu mai e apelat.
- `web/core/styles/style.css` — `.slider-nav` (absolut, pe margini, blur + lime la
  hover), `.pagination li.--disabled span`, `.project-card` vizibil implicit,
  taburile de filtrare stilate și ca `<a>`.
- `web/core/styles/responsive.css` — săgeți mai mici sub 768px;
  `.post-content__growth-img img` 300px sub 992px.
- `views/services/view.php` — `col-2xl-6` (breakpoint inexistent în Bootstrap 5,
  deci ambele coloane cădeau pe `col-12`) → `col-lg-6`.
- `controllers/BlogController.php`, `views/blog/index.php` — DataProviderHelper, 6/pagină.
- `controllers/ProjectsController.php`, `views/projects/index.php` — filtrare
  server-side prin `?category=`, 6/pagină.
- `views/projects/components/_project_card.php` — categoria din tabelul nou.
- `models/projects/Projects.php` — relația `categoryRel`, `getCategoryKey()`,
  `getCategoryName()`.
- `models/projects/forms/ProjectsForm.php` + `modules/admin/views/projects/_form.php`
  — `category` text liber → dropdown `category_id`; `category` se derivă la salvare.
- `constants/EntityConstants.php`, `modules/admin/components/views/aside.php`,
  `messages/{en,ro,ru}/frontend.php`, `modules/admin/messages/*` — cheile noi.
- `config/web.php` — `assetManager.appendTimestamp = true`.

### Decizii notabile
1. **Filtrarea proiectelor a trecut din browser în query.** Designul randa toate
   proiectele și ascundea/afișa cu o clasă CSS. Asta nu poate coexista cu
   paginarea — pagina 2 ar fi filtrat 6 carduri, nu tot catalogul.
2. **Categoriile promovate, nu recreate.** `pages-projects` / `pages-faq` aveau
   deja traduceri RO/RU; le-am re-aliasat în loc să le șterg și să le reinserez.
3. **`projects.category` (text liber) rămâne**, dar e derivat la salvare din
   aliasul categoriei alese — nimic care încă îl citește nu se rupe.
4. **Imaginile proiectelor** sunt cele 8 poze din design, ciclate. Nu am descărcat
   imagini externe; se înlocuiesc din admin cu capturi reale de site.
5. `appendTimestamp` — fără el, o modificare în `style.css` nu se vedea până la un
   hard refresh, ceea ce a mascat prima versiune a stilurilor de slider.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| Navbar EN/RO/RU | 7 intrări plate, fără „Pages"; RO: Acasă / Despre noi / Servicii / Proiecte / Blog / Întrebări frecvente / Contactează-ne |
| `/blog` | 6 articole, titluri distincte, 2 pagini |
| `/blog?page=2` | ultimele 3 articole |
| `/blog?page=99` și `?page=0` | redirect înapoi în interval |
| `/projects` | 12 proiecte, 2 pagini |
| `/projects?category=technical-seo-audits` | 3 proiecte, fără paginare (sub prag) |
| `/projects?category=nonsense` | 200, revine la „Toate" (nu pagină goală) |
| `/ro/projects?category=content-marketing` | taburi traduse, tabul activ marcat, 3 carduri |
| `admin/project-categories` | 200, cele 5 categorii listate |
| `admin/projects/update?id=3` | dropdown cu cele 5 categorii traduse |
| `php -l` pe toate fișierele noi/modificate | fără erori |
| CSS servit | `.slider-nav` și `.pagination li.--disabled span` prezente |

### Rămas de verificat vizual
Extensia Chrome nu era conectată, deci verificarea s-a făcut pe HTML/CSS servit,
nu pe randare. De confirmat în browser: poziția săgeților pe slider-ul de prețuri
(container, nu full-bleed) și pe mobil.

---

## 2026-08-09 (2) — Breadcrumb navigabil, navbar centrat, galerii cu loop infinit

### Task
1. Paginile cu hero section: breadcrumb-ul să funcționeze și să fie tradus.
2. Navbarul centrat.
3. Galeriile să fie infinite loop.

### Files modified
- `views/common/_page_banner.php` — `<p>` → `<nav aria-label>`; pagina curentă
  se randează ca `<span aria-current="page">`, nu ca `<a href="">`; doar
  crumb-urile cu URL rămân linkuri.
- `web/core/styles/style.css` —
  - `.navbar > .container-fluid > .navbar__logo` și `> .navbar__actions` primesc
    `flex: 1 1 0`, iar acțiunile `justify-content: flex-end`; logo-ul trece pe
    `width: auto` ca să rămână lipit de stânga în cutia lățită;
  - hover pe crumb-uri (lime + underline), `.bread-crumbs__sep` la opacitate 0.6,
    iar regula de `font-size` mai mic s-a restrâns la separator (altfel prindea
    și noul span al paginii curente).
- `web/core/js/index.js` — `maxSlidesPerView()`, `padForLoop()`,
  `customSlider({ padSlidesForLoop })`, `originalSlideCount()`;
  `linkGalleryModal` mapează pozițiile modulo numărul real de imagini și
  folosește `data-swiper-slide-index`.
- `messages/{en,ro,ru}/frontend.php` — cheia `Breadcrumb` (aria-label).

### Decizii notabile
1. **Breadcrumb-ul era deja tradus corect** — traducerile există în
   `pages_translations.banner_breadcrumb` pentru toate limbile, iar
   `Pages::crumbsFor()` leagă corect pagina-listă pe paginile de detaliu.
   Singurul lucru rupt era randarea: *fiecare* crumb, inclusiv pagina curentă,
   ieșea ca `<a href="">` — un link care reîncărca pagina.
2. **Padding-ul pentru loop e opt-in**, nu automat. Clonarea slide-urilor schimbă
   numărul de bullets, deci se aplică doar unde chiar lipsesc slide-uri
   (galeriile, care nu au bullets); slider-ele de proiecte/prețuri rămân neatinse.
3. **`loopAdditionalSlides`** trebuie setat odată cu clonarea — doar clonarea nu
   ajunge, Swiper își dimensionează bufferul de loop din el.
4. **Prag minim de 5 slide-uri**: cu exact 3 imagini și una vizibilă, loop-ul
   Swiper sărea din două în două (0 → 2 → 0). Cu 6 merge 0→1→2→3→4→5→0.
5. Testarea în tab de fundal e înșelătoare: Chrome nu emite `transitionend`
   acolo, deci `swiper.animating` rămâne `true` și orice `slideNext()` ulterior
   e no-op. Se testează cu `slideNext(0)` (fără tranziție).

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| Breadcrumb pe about / services / projects / blog / faqs / contact, EN+RO+RU | „Acasă" / „Home" / „Главная" link corect către home-ul limbii; pagina curentă `<span aria-current="page">` |
| Breadcrumb pe blog/view, projects/view, services/view, EN+RO+RU | 3 nivele, crumb-ul de mijloc linkat către lista corespunzătoare, tot tradus |
| Breadcrumb pe 404, EN+RO+RU | „Home / Error 404", „Acasă / Eroare 404", „Главная / Ошибка 404" |
| Navbar centrat | centrul meniului = centrul containerului, offset **0px**; logo la stânga (x=63), acțiuni la dreapta |
| `.post-gallery` | 3 → 9 slide-uri, 0→1→…→8→0→1→2, înapoi 1→0→8 |
| `.gallery` (modal) | 3 → 6 slide-uri, 0→1→2→3→4→5→0 (înainte sărea 0→2→0) |
| `.see-more-swipper` | 11 slide-uri, …→10→0→1 — loop funcțional, neatins |

---

## 2026-08-09 (3) — Features ca slider pe telefon + normalizarea imaginilor

### Task
1. Secțiunea „Our Features" să devină slider pe telefon.
2. Verificare pe Nexus 5X: conținutul e prea mare, imaginile ocupă aproape tot
   ecranul; stilizare care să facă orice imagine încărcată să se așeze normal.

### Files modified
- `views/home/index.php` — blocul de features trecut de pe grila Bootstrap
  (`row`/`col`) pe markup Swiper (`.swiper` / `.swiper-wrapper` / `.swiper-slide`)
  + controalele comune de slider.
- `web/core/js/index.js` — `sliderOnMobile()` (instanțiază Swiper sub un prag și
  îl distruge cu `cleanStyles` peste el) + inițializarea `.our-feature-slider`.
- `web/core/styles/style.css` —
  - baseline global `img { max-width: 100%; height: auto }` și
    `figure { margin: 0; overflow: hidden }`;
  - `.our-feature-slider` devine `display: grid` peste 768px, cu
    `grid-column: 1 / -1` pe cardul principal și săgeți/dots ascunse.
- `web/core/styles/responsive.css` — plafoane pentru telefon (vezi mai jos).

### Decizii notabile
1. **Grid CSS, nu `row`/`col`.** `.row` și `.swiper-wrapper` sunt amândouă reguli
   flex cu o singură clasă — ar fi câștigat oricare stylesheet se încarcă ultimul,
   iar layout-ul ar fi oscilat. Grila proprie elimină conflictul.
2. **`breakpoints.enabled: false` din Swiper nu ajunge** — oprește interacțiunea
   dar lasă slide-urile ca un singur rând flex nowrap, deci grila de desktop nu
   s-ar mai fi întors. De aceea `destroy(true, true)` pe matchMedia.
3. **`img { height: auto }` e sigur** — selector de element, deci orice regulă pe
   clasă (`height: 100%` + `object-fit`) îl învinge; contează doar acolo unde
   nimic nu definea cutia, adică exact cazul problematic.
4. Fereastra Chrome nu a putut fi redimensionată sub lățimea ecranului, așa că
   viewport-ul de telefon a fost emulat cu un iframe same-origin de 412×732 —
   media queries se evaluează pe lățimea frame-ului, deci măsurătorile sunt reale.

### Verificări rulate (viewport 408×728, Nexus 5X)
| Verificare | Înainte | După |
|---|---|---|
| `.what-we-do__img-box` (cea mai mare imagine) | 671px = **92%** din ecran | 280px = 38% |
| `.blog__card-img` | 400px = 55% | 320px = 44% |
| `.our-projects__card` | 450px = 62% | 300px = 41% |
| `.about-us__img-first` | 437px = 60% | 300px = 41% |
| `.our-feature__card-text h3` | 30px (cât titlul de secțiune) | 22px |
| înălțime totală home | 19,9 ecrane | 18,5 ecrane |
| overflow orizontal | nu | nu |

**Test de robustețe:** am înlocuit imaginile din 6 containere cu un SVG portret
400×3200 și unul panoramic 4000×500. Înălțimea paginii s-a schimbat cu **0px** și
nu a apărut overflow orizontal — fiecare container și-a păstrat dimensiunea.

**Slider features:** pe telefon 3 → 6 slide-uri (padding pentru loop), 1 per view,
Swiper instanțiat; pe desktop (2560px) niciun Swiper, wrapper `display: grid` cu
două coloane de 636px, cardul principal pe toată lățimea (1296px), săgeți și dots
ascunse.

---

## 2026-08-09 (4) — Blog slider pe telefon, badge-uri ascunse, filtru scrollabil

### Files modified
- `views/home/index.php` — teaserele de blog trecute pe markup Swiper
  (grilă peste 768px, slider sub).
- `views/projects/index.php` — utilitarele `flex-wrap`/`justify-content-center`
  scoase din markup (au `!important`, deci nu puteau fi suprascrise dintr-un
  media query); centrarea și wrapping-ul stau acum în CSS.
- `web/core/js/index.js` — `sliderOnMobile('.blog-slider', …)`, `revealActiveFilter()`.
- `web/core/styles/style.css` — `.blog-slider` grilă 2/3 coloane peste 768px;
  `.projects-filter__list` wrap + centrat pe desktop.
- `web/core/styles/responsive.css` — bara de filtre devine un rând scrollabil de
  chip-uri sub 768px; `.about-us__team-box` și `.about-us__skill-box` ascunse.

### Decizii notabile
1. Cele două badge-uri plutitoare de peste poza „About" sunt poziționate absolut
   ca să stea în marginile unui layout lat. Pe telefon nu există margini: cădeau
   peste poză și peste titlul de dedesubt. Sunt decorative, deci se ascund în loc
   să fie restivuite.
2. Filtrul de proiecte: șase categorii wrapped și centrate deveneau șase rânduri
   zdrențuite — o treime de ecran înainte de orice lucrare. Un rând scrollabil e
   un pattern recunoscut de bară de filtre și ține pagina scurtă.

### Verificări (viewport 408×728)
| Verificare | Rezultat |
|---|---|
| Slider blog pe telefon | 6 slide-uri (3 + padding pentru loop), 1 per view |
| Blog pe desktop 1920px | fără Swiper, grilă de 3 coloane, săgeți ascunse |
| Badge-uri About pe telefon / desktop | `display: none` / `block` |
| Bară de filtre pe telefon | 1 rând, 50px înălțime (era 7 rânduri), chip activ derulat în vizor |
| Bară de filtre pe desktop | `flex-wrap: wrap`, centrată — neschimbată |
| Înălțime home pe telefon | 17,6 ecrane (de la 19,9 inițial) |

---

## 2026-08-09 (5) — Conținut nou: Cignova ca studio web, în EN/RO/RU

### Task
Rescrierea conținutului de pe toate paginile pentru o companie care face site-uri,
în cele trei limbi active, cu imaginile redistribuite.

### Migration
- `m260809_130001_rebrand_content_web_studio` — rescrie, potrivind pe
  `alias` / `setting_key` / `page_key`+`section_key` (deci fără inserări duble și
  fără mutări de id-uri, ca galeriile și FK-urile să rămână valide):
  - **settings** — date de contact, footer, modal, statistici;
  - **section_headers** — toate cele 35 de rânduri (home, about, contact,
    services_index, services_view, projects_view);
  - **services** — cele 6 servicii SEO devin Web Design, Dezvoltare, E-commerce,
    Aplicații web, Suport și mentenanță, Performanță și SEO (alias, slug, iconiță
    și trei blocuri de text fiecare);
  - **project_categories** — cele 5 categorii devin Site-uri de prezentare,
    Magazine online, Aplicații web, Pagini de campanie, Redesign și migrare;
  - **projects** — 12 studii de caz noi;
  - **blog** — 9 articole noi;
  - **faq** — 5 întrebări reale × 4 grupuri;
  - **pricing_plans** — Landing €1.900 / Business €4.500 / Magazin și aplicații
    la cerere, cu liste de livrabile;
  - **testimonials, features, steps, approach_cards, benefits, marquee_items,
    project_showcase, service_showcase, project_challenges, pages**.

### Files modified
- `views/common/_what_we_do.php` (nou) — mozaicul „cum lucrăm" era copiat
  identic în `views/home/index.php` și `views/services/index.php`, de ambele dăți
  cu Lorem ipsum-ul temei; acum e un singur partial parametrizat pe ruta CTA.
- `views/{faq/index,projects/view,services/view}.php` + `messages/{en,ro,ru}/frontend.php`
  — cheile de traducere rămase din tema SEO redenumite și retraduse
  (`Talk to an SEO Expert!`, `Targeted Organic Traffic`, `Growth Strategy`,
  `Technical SEO Insights`, `Results-Driven SEO Expert` etc.).

### Decizii notabile
1. **Toate cele trei limbi sunt scrise, nu doar EN.** O traducere lipsă cade pe
   limba sursă — exact așa ajunseseră paginile RO pe jumătate în engleză.
2. **Nimic nu se inserează, totul se actualizează pe alias.** Migrarea e sigură
   la re-rulare și nu mută id-uri, deci `projects_gallery` și FK-urile rămân valide.
3. **`safeDown()` nu restaurează textul vechi** — era copy demo din temă. Asta e
   documentat în migrare, nu ascuns.
4. **Datele de contact sunt placeholdere evidente** (`hello@cignova.com`,
   `(+373) 60 000 000`, „Chisinau, Republic of Moldova"). Se completează din admin.
5. **Imaginile sunt cele din design, redistribuite.** Nu am descărcat imagini
   externe. De înlocuit din admin: `projects/project-1..4.jpg` (12 proiecte),
   `people/post-1..4.jpg` (9 articole), `people/min-person-1..3.jpg` (4 testimoniale),
   `people/what-we-do-image-3.jpg`, `chart/features-image-1..3.png`.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| Titluri + H1 pe 7 pagini × 3 limbi | toate rebranduite (ex. RO: „Site-uri făcute ca să lucreze") |
| Scanare `Lorem ipsum` pe 30 URL-uri (inclusiv pagini de detaliu) | 0 apariții |
| Scanare copy SEO vechi pe aceleași URL-uri | 0 apariții |
| Scanare directă în BD după 6 șabloane de text demo | 0 rânduri |
| `php -l` pe migrare, partial și fișierele de mesaje | fără erori |

---

## 2026-08-09 (6) — Fotografii reale, gratuite, în locul celor patru din temă

### Task
Găsirea și instalarea unor imagini de calitate, gratuite, pentru tot site-ul.

### Sursa și licența
**Pexels** — licența permite folosirea comercială gratuită, fără atribuire
obligatorie (https://www.pexels.com/license/). 33 de fotografii descărcate.

### Migration
- `m260809_140001_real_photos` — leagă fiecare înregistrare de fotografia ei:
  12 proiecte, 9 articole, 8 carduri de showcase, 4 avataruri, 3 features,
  6 servicii. Fișierele stau în `web/core/assests/photos/` și se salvează cu cale
  root-absolută, ramura pe care fiecare accesor `get*ImageUrl()` o tratează deja.

### Files created / modified
- `web/core/assests/photos/` — 29 de fotografii noi.
- `web/core/assests/people/{what-we-do-image-3,sidebar-cta-image,service-solution-img,
  service-growth-img (1),big-person,min-person-1..3}.jpg` — înlocuite pe loc
  (numele sunt scrise direct în view-uri); originalele temei salvate în
  `web/core/assests/_theme-originals/`.
- `views/{home,about,services}/index.php` — `people/why-choose-image.png` →
  `photos/why-choose.jpg`.

### Decizii notabile
1. **Am respins patru fotografii pentru branduri terțe vizibile** — o dubă
   „GLOBAL24", o recepție „PULPATTA MEDICAL CENTRE", o fabrică cu logo „unifrutti"
   și un raft de supermarket plin de mărci. Pe o pagină de portofoliu, o firmă
   recognoscibilă se citește ca o afirmație că e clientul tău. Înlocuite cu
   echivalente neutre (atelier mecanic, depozit cu rafturi, cabinet medical
   modern, tejghea de delicatese).
2. **Fiecare fotografie a fost deschisă și privită înainte de a fi pusă**, nu doar
   descărcată după titlu. Așa au ieșit la iveală și problemele de brand, și un
   screenshot de cod prea aglomerat care făcea titlul cardului ilizibil (înlocuit
   cu o sală de servere întunecată).
3. **Avatarurile redimensionate**: erau portrete de 450KB randate în cercuri de
   50px. Reduse la 200px/400px — de la ~730KB la ~16KB pentru cele trei din stiva
   de avataruri.
4. **`why-choose-image.png` conținea de fapt un JPEG.** Browserele îl afișau
   oricum, dar extensia mințea; am scris un JPEG real sub `photos/` și am
   repointat cele trei view-uri.
5. Fișierele suprascrise pe loc rămân în cache-ul browserului până la un refresh
   forțat — `appendTimestamp` acoperă doar CSS/JS din asset bundle, nu `<img src>`.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| 46 de imagini distincte pe 12 pagini (RO/EN/RU + pagini de detaliu) | toate 200, zero rupte |
| Imagini rupte în DOM pe home / projects / about | 0 |
| Greutate totală a imaginilor pe toate paginile | 6,9 MB |
| `/ro/projects` vizual | 6 fotografii distincte, relevante, cu categoriile traduse |
| `/ro/blog` vizual | 6 fotografii distincte, titluri în română |
| Verificare vizuală individuală | toate cele 33 de fotografii descărcate, deschise și evaluate |

### De înlocuit când există material propriu
Fotografiile sunt stock, potrivite tematic, dar nu sunt lucrările studioului.
Capturile reale de site se pun din admin peste `photos/project-*.jpg`.

## 2026-08-19 20:30

### Task
Trecere prin toată adminka (toate cele 25 de rute), restilizare vizuală și reparare a
problemelor găsite pe drum. Auditul funcțional (ce lipsește dintr-un panou de admin) este
livrat separat ca propunere — nu s-a implementat nimic din el în această intrare.

### Files Created
- `web/core/images/no-file.png` — placeholder-ul pe care `FileHelper::getFile()` îl întorcea
  deja de la început, dar care nu exista pe disc.

### Files Modified
- `config/web.php` — `forceTranslation => true` pe sursa `admin/*`
- `modules/admin/web/css/style.css` — token-uri înlocuite cu variabile + strat „DESIGN REFRESH v3"
- `modules/admin/components/views/aside.php` — logout scos din `<nav>`, linkuri moarte ascunse
- `modules/admin/controllers/PagesController.php` — cheia `Pagess` → `Pages` (6 locuri)
- `modules/admin/messages/{en,ro,ru}/menu.php` — chei lipsă: `Blog_single`, `Testimonials`
- `modules/admin/messages/{en,ro,ru}/headings.php` — `'List : {model}'` → `'List: {model}'`
- `modules/admin/views/{blog,faq,projects,services}/view.php` — `</div>` în plus
- `modules/admin/views/{users,languages,pages}/index.php` — ordinea blocurilor pe pagină

### Database
- Nicio migrare adăugată sau modificată.

### Models
- (niciunul)

### Controllers
- `PagesController` — doar corectarea cheii de traducere, fără schimbare de comportament.

### Views
- `aside.php` — secțiunea de logout mutată în afara `<nav>`-ului, ca `.admin-aside-nav` să poată
  primi `overflow-y: auto` fără să ia cu ea și butonul de ieșire.
- `users/index.php`, `languages/index.php`, `pages/index.php` — cardul de căutare mutat de
  deasupra titlului sub el, ca să respecte ordinea folosită de toate celelalte 20 de liste:
  breadcrumb → titlu → acțiuni → căutare → tabel.

### Decizii notabile

1. **Cauza principală a aspectului „neterminat" nu era CSS-ul, ci i18n.**
   `sourceLanguage` este `en`, iar sursa `admin/*` nu avea `forceTranslation`, așa că Yii sărea
   complet peste `messages/en/*` și afișa cheile brute. Titlul fiecărei pagini era literalmente
   `index` / `create` / `view`, tabelele goale scriau `no_results`, câmpurile de fișier
   `upload_file`, iar statusurile `USER_ACTIVE`. Blocul `'*'` de deasupra avea deja flag-ul și
   comentariul care explică exact de ce — lipsea doar din blocul de admin. O linie.

2. **Tentele hardcodate au fost convertite în variabile înainte de a schimba paleta.**
   Erau 24 de `rgba(115, 128, 236, …)` și 8 de `rgba(132, 139, 200, …)` împrăștiate prin fișier —
   dacă schimbam doar `--color-primary`, toate hover-urile și umbrele rămâneau în vechiul
   periwinkle. Le-am înlocuit cu `rgba(var(--primary-rgb), …)` / `rgba(var(--shadow-rgb), …)`,
   deci acum retematizarea panoului înseamnă două linii.

3. **Paletă unificată.** Erau trei accente care se băteau pe același ecran: indigo `#7380ec`
   pentru linkuri și iconițe, mint `#41f1b6` pentru succes și verde web-safe `#339933` pe
   butoanele Create/Search/Save. Am ales indigo (`#5b5bd6`) ca acțiune principală și am lăsat
   verdele (`#0f9d76`) strict pentru stare pozitivă. Roșul `#cc0000` și portocaliul `#ffbb55`
   au fost și ele înlocuite cu echivalente moderne.

4. **Rază și umbră.** `--card-border-radius` era `2rem` (32px) lângă pastile de `999px` — cardurile
   arătau ca baloane. Scara e acum 18px card / 12px control / 8px element mic. Umbra
   `0 2rem 3rem` (48px blur) a devenit una stratificată de 1px + 24px, care se citește ca
   elevație, nu ca ceață.

5. **Greutățile de font erau 850–900 peste tot** — titluri, butoane, celule de tabel, linkuri.
   Când totul e bold, nimic nu e. Am coborât la 600–700 și am lăsat dimensiunea să facă ierarhia.

6. **Bara laterală nu încăpea în ecran.** 28 de intrări depășeau `100vh`, iar `overflow-y: auto`
   pe `<aside>` însemna că Logout era sub o margine invizibilă. Acum `<aside>` e coloană flex,
   `<nav>`-ul scrollează singur, iar Logout stă fixat jos. Etichetele lungi („Project Challenges")
   primesc ellipsis în loc să se rupă pe două rânduri.

7. **Lățimea conținutului: 1240px → 1520px.** Tabelele cu 8 coloane se înghesuiau într-o bandă
   îngustă în timp ce două treimi din ecran stăteau goale. `.form-wrap` și `.search-form-wrap`
   erau blocate separat la 1200px, deci se opreau înainte de marginea tabelului de dedesubt.

8. **Placeholder-ul de imagine lipsea de pe disc.** `FileHelper::getFile()` întoarce
   `/core/images/no-file.png` când un câmp e gol, dar directorul `web/core/images/` nu exista —
   fiecare rând fără imagine afișa iconița de imagine ruptă. Generat 320×240 (fără GD în CLI:
   scanline-uri RGB brute → `gzcompress` → chunk-uri PNG).

9. **Patru fișiere `view.php` aveau un `</div>` în plus** (blog, faq, projects, services).
   Browserele îl ignoră, dar strica structura pentru orice ar veni după.

10. **Trei intrări de meniu duceau în 404-ul site-ului public**, nu într-o eroare de admin:
    `/admin/dashboard/index`, `/admin/activity-history/index`, `/admin/examples/tables` și
    `/editmode/edit`. Niciunul dintre controllere/module nu există. Dashboard-ul a fost repointat
    pe `/admin/default/index` (care există); celelalte au fost comentate cu explicația de ce și
    ce trebuie construit ca să revină. `ActivityHistoryWidget` este deja scris și așteaptă doar
    un controller.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| `php -l` pe toate fișierele PHP atinse | 0 erori |
| Toate cele 25 de rute de listă din meniu | 25× HTTP 200, zero 404 |
| `create` / `view` / `update` / `translations` pe blog | 200, titluri traduse corect |
| Chei `Yii::t('admin/menu', …)` din controllere vs. fișierele de mesaje | 40 chei, 0 lipsă în en/ro/ru |
| Balans `<div>` în toate `view.php` / `index.php` | echilibrat |
| Temă întunecată pe listă / formular / detaliu | funcțională, carduri delimitate |

### Nu s-a atins
- `modules/admin/views/default/index.php` este în continuare pagina generată de schelet
  („This is the view content for action index"). Un dashboard real e primul punct din propunerea
  separată — nu l-am construit fără acord.
- Selectorul de limbă din bara de sus nu schimbă limba adminului: `ignoreLanguageUrlPatterns`
  scoate `/admin/` de sub `codemix/localeurls`, deci `?language=ro` e ignorat. E o problemă
  funcțională, nu de stil — inclusă în propunere.

## 2026-08-19 23:55

### Task
Implementarea punctelor 1–13 din propunerea de funcționalități pentru adminka
(dashboard, comutator de limbă, pagină de eroare proprie, căutare globală, acțiuni în masă,
reordonare drag & drop, jurnal de activitate, coș de gunoi, panou de traduceri, validare SEO,
profil/parolă, bibliotecă media, „vezi pe site"). Punctul 0 (reactivarea `AccessControl`) a fost
exclus explicit de utilizator și **nu** a fost atins.

### Files Created
- `modules/admin/components/AdminEntityRegistry.php` — registrul central al entităților
- `modules/admin/controllers/{Trash,ActivityHistory,Translations,Media,Profile,Search,Error}Controller.php`
- `modules/admin/views/{trash,activity-history,translations,media,profile,error}/index.php`
- `modules/admin/views/_seo-preview.php`
- `modules/admin/web/js/admin-tools.js`
- `models/activityHistory/ActivityHistory.php`
- `models/query/SoftDeleteQuery.php`
- `models/users/forms/ChangePasswordForm.php`
- `behaviors/ActivityLogBehavior.php`
- `traits/SoftDeleteTrait.php`
- `messages/{en,ro,ru}/auth.php`
- `migrations/m260819_210001_create_activity_history_table.php`
- `migrations/m260819_210002_add_soft_delete_columns.php`

### Files Modified
- `modules/admin/Module.php` — limba interfeței + errorAction propriu
- `modules/admin/components/HeaderWidget.php` + `views/header.php` — comutator funcțional, Ctrl+K
- `modules/admin/components/views/{aside,list-view}.php` — meniu nou, checkbox-uri, drag handle
- `modules/admin/components/views/list-view.php` — bulk + reorder, auto-detectate din registru
- `modules/admin/controllers/traits/MainCrudTrait.php` — `deleteItem` → coș, `purgeItem`, `actionBulk`, `actionReorder`
- `modules/admin/controllers/base/BaseAdminCrudController.php` — verbs pentru `bulk` / `reorder`
- `modules/admin/controllers/DefaultController.php` + `views/default/index.php` — dashboard real
- 19 modele de entitate — `SoftDeleteTrait` + `ActivityLogBehavior`
- `modules/admin/views/{blog,projects,services}/view.php` — buton „Vezi pe site"
- `modules/admin/views/{blog,pages,projects,services}/translations/_form.php` — contoare SEO
- `assets/AdminAsset.php` — `js/admin-tools.js`
- `modules/admin/web/css/style.css` — stratul „ADMIN FEATURES"
- `modules/admin/messages/{en,ro,ru}/*.php` — ~90 de chei noi, în toate cele trei limbi

### Database
- `m260819_210001` — tabela `activity_history` (entity, entity_id, entity_label, action, changes
  JSON, user_id, ip, created_at). FK spre `users` cu `SET NULL`.
- `m260819_210002` — `deleted_at` + `deleted_by` pe 19 tabele de entități, fiecare cu index pe
  `deleted_at` și FK `SET NULL` spre `users`.
- Ambele aplicate cu succes (`php yii migrate/up`).

### Models
- `ActivityHistory` — model nou pentru jurnal.
- `SoftDeleteQuery` — clasa de query cu scope-urile `notDeleted` / `onlyDeleted` / `withDeleted`.
- `ChangePasswordForm` — schimbarea parolei din profil.
- 19 modele de entități au primit `SoftDeleteTrait` și `ActivityLogBehavior`.

### Controllers
- 7 controllere noi (Trash, ActivityHistory, Translations, Media, Profile, Search, Error).
- `DefaultController` rescris: dashboard-ul înlocuiește pagina de schelet.
- `MainCrudTrait`: `deleteItem()` mută acum în coș; vechea ștergere în cascadă a devenit
  `purgeItem()` și e apelată doar din coș.

### Views
- 6 ecrane noi + parțialul `_seo-preview.php`.
- `list-view.php` — checkbox-uri de selecție, mâner de drag, bară de acțiuni în masă, toolbar de
  reordonare, toate condiționate de registru.

### Decizii notabile

1. **Un registru în loc de 21 de liste.** `helpers/EntityHelper.php` trebuia să fie asta, dar
   încă arată spre `app\models\Courses\*` — clase care n-au existat niciodată în proiect — deci
   aruncă excepție pentru orice entitate reală. N-am reparat o hartă pe care n-o apelează nimic;
   am scris `AdminEntityRegistry` cu ce livrează adminul azi. Dashboard-ul, căutarea, coșul și
   panoul de traduceri citesc toate din el, deci o entitate nouă apare peste tot dintr-o singură
   înregistrare.

2. **Ștergerea în coș nu putea fi opt-in.** O înregistrare din coș își păstrează
   `status = ACTIVE`. Dacă filtrul „nu e șters" ar fi fost opțional, orice controller public care
   uita să-l adauge ar fi continuat să servească conținut șters. De aceea `SoftDeleteQuery`
   exclude implicit rândurile din coș, iar coșul cere explicit `onlyDeleted()`. **Verificat pe
   viu:** ștergerea unui articol din admin îl scoate imediat din `/blog` și face pagina lui
   publică să întoarcă 404; restaurarea îl aduce înapoi, 200.

3. **Filtrul se aplică în `prepare()`, nu în `init()`**, ca `onlyDeleted()` să poată încă să
   schimbe decizia după `find()`. Există un flag care împiedică dublarea condiției când
   `DataProviderHelper` apelează `count()` și apoi `all()` pe același obiect de query.

4. **Un singur drum de scriere pentru jurnal.** `softDelete()` e un `save()` obișnuit, deci ar fi
   apărut în log ca „updated" cu `deleted_at`. În loc să scriu log și din trait, și din behavior
   (două surse care pot ajunge să nu fie de acord), behavior-ul citește tranziția `deleted_at`
   din evenimentul normal de update și emite `deleted` / `restored`.

5. **Jurnalul supraviețuiește înregistrării.** `entity_label` salvează alias-ul în momentul
   acțiunii. După o ștergere definitivă rândul nu mai există, iar logul ar fi putut arăta doar
   „partners #21". Testat: după purge, cele 3 intrări din log își păstrează numele.

6. **Ștergerea definitivă refolosește cascada existentă.** `TrashController` nu-și rescrie
   propria logică de ștergere de fișiere/traduceri/galerii — dă înregistrarea controllerului ei
   de CRUD și apelează `purgeItem()`, adică exact codul care a făcut dintotdeauna curățenia.

7. **Comutatorul de limbă din header nu era stricat, era imposibil.** Trimitea spre
   `Url::current(['language' => ...])`, dar `config/web.php` are `/admin/` în
   `ignoreLanguageUrlPatterns`, deci `codemix/localeurls` ștergea parametrul. Adminul își ține
   acum preferința în sesiune, sub `admin-language` — ceea ce e și comportamentul corect aici:
   limba în care un editor citește panoul n-are legătură cu limba conținutului pe care îl editează.

8. **Bulk și reordonare fără să ating 21 de fișiere de listă.** `list-view.php` rezolvă entitatea
   din registru (mergând în sus pe lanțul de moștenire, fiindcă `BlogSearch extends Blog`) și
   activează singur checkbox-urile și mânerul de drag. Cele două `<!-- <th><input type="checkbox">
   -->` comentate din widget erau exact locul unde trebuiau să ajungă.

9. **Acțiunile în masă salvează rând cu rând, nu prin `updateAll()`.** Altfel behaviour-urile nu
   s-ar declanșa, iar o schimbare de status în masă ar sări tăcut și peste jurnal, și peste
   `updated_by` / `updated_at`.

10. **Reordonarea pornește de la cea mai mică valoare de pe pagină**, nu de la 1 — altfel
    reordonarea paginii 2 ar fi mutat-o în fața paginii 1.

11. **`messages/*/auth.php` nu exista deloc.** `config/web.php` înregistrează sursa `auth` cu
    `forceTranslation`, dar niciun fișier n-o susținea, deci ecranele de login, activare și
    resetare afișau cheile brute („password must contain", „invalid_credentials"). Am creat cele
    trei fișiere cu toate cele 15 chei folosite în cod.

12. **`messages/en/history.php` avea valori în rusă** — o eroare de copy-paste care ar fi ieșit la
    iveală imediat ce jurnalul devenea vizibil. Traduse.

13. **Pagina de eroare a adminului** e comutată în `Module::init()`, deci doar pentru cereri
    `/admin/`; site-ul public rămâne pe `home/error`.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| `php -l` pe toate fișierele PHP noi și modificate | 0 erori |
| 35 de rute de admin (24 entități + 6 unelte + 5 taburi de coș) | toate 200, zero excepții |
| Ciclu complet coș: șterge → apare în coș → restaurează → revine în listă | funcțional |
| Efect pe site-ul public: articol șters dispare din `/blog`, detaliul dă 404 | confirmat |
| Restaurare: revine în `/blog`, detaliul dă 200 | confirmat |
| Ștergere definitivă: dispare din coș și din listă, logul păstrează numele | confirmat |
| Reordonare prin endpoint: ordinea se inversează și se salvează | confirmat, ordinea inițială restabilită |
| Selecție în masă: bara apare, numărătoarea corectă, id-uri în toate cele 3 formulare | confirmat |
| Căutare globală `?q=post` | 5 rezultate, randate în paletă |
| Contoare SEO | 61/60 roșu pe titlu, 101/160 verde pe descriere, previzualizare live |
| Comutator de limbă `?admin-language=ro` | tot panoul în română, persistă între pagini |
| 14 pagini publice (RO/EN/RU + pagini de detaliu) | toate 200 |
| Temă întunecată pe dashboard / coș / jurnal / traduceri | funcțională |
| Date de test rămase în DB | 0 (înregistrarea de test ștearsă definitiv, log golit) |

### Nu s-a atins
- **Punctul 0 (securitate).** `Module::behaviors()` cu `AccessControl` rămâne comentat, la cererea
  explicită a utilizatorului. Adminul este în continuare accesibil fără autentificare.
  `ProfileController` își pune totuși propria gardă, fiindcă e singurul ecran care ar da fatal
  pe o identitate `null`.
- **Profil și Bibliotecă media n-au putut fi verificate vizual**: ambele cer o sesiune
  autentificată (elFinder are `'access' => ['@']` în `config/web.php`), iar eu nu introduc parole
  în formulare. Codul e verificat sintactic și logic; rămâne de confirmat printr-un click.

## 2026-08-20 00:25

### Task
Adminul trebuie să rămână accesibil fără autentificare. `AccessControl` din `modules/admin/Module.php`
rămâne comentat, exact cum era. Scoase cele două verificări care chiar blocau un vizitator
neautentificat.

### Files Created
- (niciunul)

### Files Modified
- `modules/admin/controllers/ProfileController.php`
- `config/web.php`
- `modules/admin/views/media/index.php`
- `modules/admin/web/css/style.css`

### Database
- Nicio migrare adăugată sau modificată.

### Models
- (niciunul)

### Controllers
- `ProfileController` — garda care arunca `ForbiddenHttpException` pentru vizitatori a fost
  înlocuită cu o revenire la primul cont din tabelă.

### Views
- `media/index.php` — folosește instanța `media-finder` și marchează containerul, ca elFinder să
  primească o înălțime reală.

### Decizii notabile

1. **`Module::behaviors()` rămâne comentat, neatins.** Nu era el problema — nici nu fusese
   modificat în sesiunea anterioară.

2. **Profilul revine la primul cont când nu există sesiune.** Era singurul ecran care refuza un
   vizitator, deși tot restul panoului e deschis. Schimbarea parolei cere în continuare parola
   curentă (`ChangePasswordForm::validateCurrentPassword`), deci revenirea arată al cui e contul
   fără să-l predea. Când `Module::behaviors()` va fi decomentat, `Yii::$app->user->identity` e
   mereu setat acolo și ramura de rezervă devine inaccesibilă.

3. **elFinder avea `'access' => ['@']`.** De asta Biblioteca media randa formularul de login
   într-un iframe. Trecut pe `['@', '?']`, cu o notă să fie pus la loc odată cu `Module::behaviors()`.

4. **A doua instanță de elFinder pentru Bibliotecă, nu repointarea celei existente.**
   `elfinder` este `PathController`, care servește exact o singură rădăcină și e legat în fiecare
   buton de imagine din CKEditor — mutarea rădăcinii lui ar fi mutat și locul unde ajung fișierele
   încărcate din editor. Am adăugat `media-finder` (clasa `Controller`, multi-root) cu două
   rădăcini: `uploads/global` (folderul editorului) și `core/uploads/images` (imaginile scrise de
   formularele CRUD). **Verificat că nu s-a scurs:** formularul de traducere folosește în
   continuare `elfinder/manager`, nu `media-finder/manager`.

5. **Cadrul elFinder era gol pentru că widget-ul îi pune `height: 100%` pe iframe, dar îl
   învelește într-un `<div>` fără înălțime proprie** — procentul se raporta la un părinte cu
   înălțime automată, iar cadrul cădea la cei 150px impliciti ai unui iframe. Rezolvat prin CSS pe
   container, nu prin atribute pe iframe.

### Ce NU s-a atins
- `UsersController::actionSendActivation` și `actionSendPasswordReset` păstrează garda
  `isAdmin()` — existau dinainte și blochează trimiterea de emailuri de către un vizitator
  neautentificat, nu accesul în panou.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| `/admin/profile` fără sesiune | 200, afișează contul Administrator |
| `/admin/media` fără sesiune | 200, elFinder se încarcă pe toată înălțimea |
| Ambele rădăcini montate | `Global` + `Content images` |
| Fișiere reale accesibile în a doua rădăcină | 11 subfoldere, ex. `blog/` cu 2 fișiere |
| CKEditor folosește în continuare `elfinder`, nu `media-finder` | confirmat |
| `/elfinder/manager` (vechiul selector) | 200 |
| 15 rute de admin | toate 200, zero excepții |
| 9 pagini publice (RO/EN/RU) | toate 200 |
| `php -l` pe fișierele modificate | 0 erori |

## 2026-08-20 01:40

### Task
Portarea modulului **EditMode** (editor vizual in-place) din bcars în cinova, conform
`EDITMODE_PORTING_GUIDE.md`. Fișierele mari copiate verbatim (main.js 3220 linii,
style.css 3529, EditController 1125, RenderHelper 1071); adaptările limitate strict la
punctele din §10 ale ghidului. Conținutul site-ului NU a fost încă marcat ca editabil —
utilizatorul va decide ce se transferă din admin în editmode (regula: ce e în editmode
nu trebuie dublat în admin).

### Files Created
- `modules/editmode/**` — modulul întreg (Module, 3 controllere, 2 widget-uri + view-uri,
  layout `editmode`, shell `edit/index.php`, `web/css/style.css`, `web/js/{ajax,global,main}.js`)
- `helpers/RenderHelper.php`, `helpers/EditModeLanguageHelper.php`, `helpers/AdminLanguageHelper.php`
- `assets/EditModeAsset.php`
- `models/elements/Elements.php`, `models/ElementContent/{ElementContent,ElementContentHistory}.php`,
  `models/ElementContentDraft/ElementContentDraft.php`
- `messages/{en,ro,ru}/editmode.php`, `messages/editmode_js_keys.php`
- `migrations/m260820_100001..100005` (redenumite din m260721_000001..000005)

### Files Modified
- `config/web.php` — modulul `editmode` înregistrat (layout propriu); pattern-uri
  `ignoreLanguageUrlPatterns` pentru `editmode` (obligatoriu §12 — altfel APP_BASE din
  main.js se calculează greșit) și, cu ocazia asta, pentru `media-finder`.
- `config/params.php` — `editModeNoAuth => true`, cu avertisment de producție.
- `modules/admin/components/views/aside.php` — secțiunea „Site Edit Mode" repusă
  (fusese ascunsă cât timp modulul nu exista; linkul țintește `/editmode/edit/index`).
- `views/common/footer/_copyright.php` — element de test adăugat și apoi ELIMINAT
  (fișierul e identic cu originalul la final).

### Database
- 5 migrații noi aplicate: `elements` (key UNIC global + source_hash/label),
  `element_content` (conținut live per limbă), `element_content_history`
  (before/after + rollback_key 32 hex), `element_content_draft` (1:1 cu content).
- Migrația 000006 din ghid (ierarhia RBAC dev>admin>contentManager) **sărită
  intenționat**: cinova o are deja din `m260728_210030_create_rbac_data` — verificat
  în `auth_item_child` înainte de decizie.

### Models
- 4 modele noi copiate; namespace-urile bcars (`app\models\users\Users`,
  `app\models\Languages\Languages`, `app\models\pages\Pages`) coincid cu cinova,
  deci zero redenumiri (§10.7 a fost un no-op fericit).

### Controllers
- `EditController` — 3 adaptări, fiecare comentată în cod cu referință la ghid:
  - **§10.1** `buildSampleDetailItems()`: bcars indica Portfolio+Services; cinova
    Portfolio nu are `getUrl()`, deci lista e acum Services+Projects+Blog — exact
    cele trei entități cu rute `<entity>/<id>/<slug>` în urlManager.
  - **§10.2** `buildStaticPageItems()`: convențiile `pages` diferă — rândul home are
    `slug=''`, șabloanele de detaliu (`services/view` etc.) au rute care cer id și
    nu pot fi linkuite fără parametri, iar `error` e șablonul 404. Toate tratate.
  - **§10.8** `resolveUsername()`: users din cinova identifică prin `initials`/`email`
    (nu există username/name) — `initials` adăugat primul în lanțul de fallback.

### Views
- Layout-ul `editmode` și shell-ul copiate neatinse; înregistrează `window.EM_I18N`
  la POS_HEAD din `messages/editmode_js_keys.php` (contract cu main.js).

### Decizii notabile

1. **EditModeAsset curățat (§10.6)**: `summernote-bs5.css/js` nu au existat niciodată
   în `modules/editmode/web` din bcars (link-uri moarte) — eliminate împreună cu
   CDN-ul Summernote (editorul folosește textarea simplă). Eliminat și MDB UI kit:
   nimic din CSS-ul editorului nu-l referă, iar adminul cinova l-a scos deja pentru
   conflicte cu Bootstrap 5.
2. **elFinder**: config-ul cinova avea deja instanța `elfinder` cu handler-ul
   `dblclick` → `window.opener.SetUrl(fileUrl)` — exact contractul cerut de main.js
   (§6.5). Zero modificări necesare.
3. **i18n**: categoria `editmode` e servită de intrarea catch-all `'*'` din config
   (basePath `@app/messages`, `forceTranslation => true`) — intrarea explicită din
   ghid ar fi fost redundantă.
4. **`editModeNoAuth => true`** — aliniat cu decizia curentă a utilizatorului de a
   folosi adminul fără autentificare; behaviors-urile AccessControl rămân comentate
   în `Module.php` și `EditController.php` exact ca în sursă. La producție:
   decomentat ambele + `editModeNoAuth => false` (§8).

### Verificări rulate (smoke-test §11)
| Verificare | Rezultat |
|---|---|
| `php -l` pe toate fișierele portate + config | 0 erori |
| `node --check` pe ajax/global/main.js | OK |
| Shell `/editmode/edit` | 200, iframe + sidebar + header prezente, `EM_I18N` object |
| `site-map` | 7 pagini statice + 3 pagini detaliu (web-design / project-1 / post-1) |
| Auto-provisioning la prima randare | `pages(technical)` → `elements` → `element_content` per limbă |
| `element-content` API | live corect, `can_publish: true` |
| Draft multi-limbă (`langs=en,ro`) | salvat în ambele |
| `draft-status` | `draft` |
| Publish | publicat en+ro; textul nou vizibil pe site-ul public |
| Istoric | 1 intrare, `changed_fields=[content]`, old/new corecte, rollback_key 32 hex |
| Restore | live revenit la textul original; istoric: `active` + `restored` |
| `language-url` | `/cinova/web/blog` + ru → `/cinova/web/ru/blog` |
| Toggle „Editează" + click pe element în iframe | `data-em-edit-mode` pe html, sidebar `_active`, tag `TEXT` |
| Consolă browser | zero erori |
| Regresie: admin (dashboard, listă) + public (/, /ro/) | toate 200 |
| Curățenie post-test | element de test scos din view; `elements`/`element_content*` = 0 rânduri |

### Nu s-a făcut (așteaptă decizia utilizatorului)
- Niciun view public nu a fost marcat cu `RenderHelper::editable*()` — utilizatorul va
  spune ce conținut se mută din admin în editmode. Regula stabilită: ce ajunge în
  editmode se scoate din admin (fără dublă gestiune).
- Capturile de ecran ale editorului n-au putut fi făcute (captura sesiunii de browser
  a cedat cu eroare CDP la nivel de extensie, mid-sesiune) — funcționalitatea a fost
  verificată integral prin DOM și prin API.

## 2026-08-20 02:50

### Task
Mutarea secțiunilor indicate de utilizator (9 capturi de ecran) din gestiunea adminului în
EditMode, cu regula „ce e în EditMode nu mai există în admin". Secțiuni: home — About,
Features („What You Get"), How We Work, Why Us, Inside The Studio (video + marquee),
Newsletter; about — Intro, Our Approach, How It Works (Steps). Entități mutate integral:
**Features, Benefits, Marquee Items, Approach Cards, Steps** (toate utilizările lor, deci și
paginile about/services, nu doar cele fotografiate).

### Files Created
- `commands/EditmodeMigrateController.php` — seed + purge, mapare completă sursă→element
- `views/common/_section_header_em.php` — geamănul EditMode al `_section_header.php`

### Files Modified
- `views/home/index.php` — 6 secțiuni convertite la RenderHelper; query-urile
  Features/Benefits/MarqueeItems eliminate
- `views/about/index.php` — 4 secțiuni convertite; ApproachCards/Steps/Benefits eliminate
- `views/services/index.php` — features + why-choose convertite (elemente partajate)
- `views/common/_what_we_do.php` — tot conținutul din elemente `common-wwd-*`
- `modules/admin/components/views/aside.php` — 5 intrări de meniu scoase, cu notă
- `modules/admin/components/AdminEntityRegistry.php` — 5 intrări scoase, cu notă

### Files Deleted
- `modules/admin/controllers/{Features,Benefits,MarqueeItems,ApproachCards,Steps}Controller.php`
- `modules/admin/views/{features,benefits,marquee-items,approach-cards,steps}/` (integral)

### Database
- +113 elemente EditMode (`elements` + `element_content` × 3 limbi), seed-uite din datele reale
- `pages`: rândul home a primit slug `home` (era gol)
- Șterse după migrare: 9 rânduri `section_headers` (+traduceri) și 7 rânduri `settings`
  (+traduceri): crawl_errors_*, indexing_*, team_caption, years_experience, client_rating
- Tabelele entităților mutate (features, benefits, marquee_items, approach_cards, steps)
  **păstrate ca backup** — nimic nu le mai citește

### Decizii notabile

1. **Seed înainte de convertire, nu auto-seed.** RenderHelper ar fi umplut toate limbile cu
   default-ul englez la prima randare — s-ar fi pierdut traducerile RO/RU existente. Comanda
   copiază valorile reale per limbă din: section_headers_translations, tabelele de traduceri
   ale entităților, settings_translations și fișierele messages/{ro,ru}/frontend.php.

2. **Titlurile cu accent, împărțite în două elemente.** Rândurile vechi stocau
   `text <span class="special-word">accent</span>`, dar editorul EditMode e text simplu
   (strip_tags la salvare) — un singur element ar fi pierdut accentul verde. Fiecare
   titlu/eyebrow e acum `-title` + `-title-accent`, iar span-ul stă în markup, unde editorul
   nu-l poate strica.

3. **Elemente partajate pe pagina `technical`** pentru tot ce apare identic pe mai multe
   pagini (cardul About, skill-urile, beneficiile, feature-urile, blocul what-we-do, badge-ul
   „12+ years", CTA-urile) — home/about/services nu mai pot diverja.

4. **CTA-urile țin doar eticheta editabilă**, href-ul rămâne ruta internă din view —
   `editableLink` nu suportă sufix, iar săgeata designului vine după text. Excepție: butonul
   video (icon-only), unde chiar href-ul e ceea ce se editează.

5. **Bug prins la verificare:** seeder-ul lega toate elementele de entitate de pagina
   `technical`, dar view-urile cer `home`/`about-us` — căutarea e (page_id, key), deci prima
   randare încerca să re-creeze cheia și pica pe indexul unic global (500 pe home). Corectat
   și în date (18 elemente mutate pe pagina corectă) și în seeder.

6. **Numărătoarele animate (180+, 94)** au rămas `span.counter` — elementul editabil poartă
   chiar clasa `counter`, deci scriptul de numărare funcționează neschimbat.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| 19 rute (site RO/EN/RU, admin, editmode) | toate 200 |
| Conținut migrat pe home/about/services în 3 limbi | prezent, traduceri intacte («Înțelegem», «Разбираемся») |
| Editare pe viu: instant-publish pe titlul RO → apare pe site → restore → revine | funcțional, EN neatins |
| Admin: /features /benefits /steps | 404 (scoase); restul rutelor 200 |
| Dashboard, trash, translations după scoaterea din registru | 200, fără erori |
| `php -l` pe toate fișierele atinse | 0 erori |
| Referințe reziduale la modelele mutate în views/controllers/config | zero |

### Cum se editează de acum
Admin → „Mod Editare" → Editor (sau `/editmode/edit`) → butonul „Editează" → click pe
oricare din textele/imaginile secțiunilor mutate → Draft / Publish, per limbă sau în toate
limbile deodată. Istoricul cu rollback e pe tab-ul „Istoric".

## 2026-08-20 17:40

### Task
Upload de imagini direct în EditMode: folder dedicat sub `core/`, drag & drop în sidebar,
iar la înlocuirea unei imagini fișierul vechi se șterge automat. (Până acum sidebarul avea
doar câmpul de URL + „Alege fișier…" care deschidea elFinder în popup.)

### Files Created
- `web/core/uploads/images/editmode/` — folderul de upload (creat automat la primul upload)

### Files Modified
- `modules/editmode/controllers/EditController.php` — endpoint `upload-image` + curățarea
  fișierelor înlocuite, legată în toate cele 4 căi de scriere
- `modules/editmode/web/js/main.js` — helper `makeImageDropzone()`, montat în formularul de
  imagine simplă și în fiecare rând al blocurilor multi-imagine
- `modules/editmode/web/css/style.css` — stilurile `.em-dropzone` (hover / over / busy)
- `messages/{en,ro,ru}/editmode.php` — 5 chei noi de UI (ajung în `EM_I18N` automat,
  prin `editmode_js_keys.php`)

### Database
- Nicio migrare. Reparate 3 rânduri `element_content` + 15 de istorie stricate de un test
  propriu (vezi decizia 5).

### Decizii notabile

1. **`POST /editmode/edit/upload-image`**: acceptă doar imagini (jpg/png/gif/webp/avif,
   max 8 MB), verifică bytes-ii cu finfo, nu doar extensia; **SVG exclus intenționat** —
   poate conține scripturi, iar endpoint-ul servește fișierele brute ale editorilor.
   Numele fișierului = hash-ul conținutului, deci re-încărcarea aceleiași poze refolosește
   fișierul în loc să acumuleze copii.

2. **Ștergerea la înlocuire e condiționată de referințe.** Un fișier e șters doar dacă:
   (a) e în `core/uploads/images/editmode/` — asset-urile temei și upload-urile adminului
   nu sunt niciodată atinse; (b) după salvare, niciun rând din `element_content` sau
   `element_content_draft` nu-l mai referă (mai multe limbi sau elemente pot partaja un
   fișier). Istoria e ignorată intenționat: restaurarea unei versiuni a cărei imagine a
   fost înlocuită ulterior va arăta poza lipsă — acesta e prețul ștergerii la înlocuire,
   cum a cerut utilizatorul.

3. **Curățarea rulează în toate cele 4 căi care schimbă live-ul sau aruncă draftul**:
   instant-publish, publish (draft→live), discard (fișierele referite doar de draftul
   aruncat), restore (fișierele orfane după rollback). Fail-soft: o eroare de curățare nu
   strică niciodată publish-ul.

4. **Dropzone-ul alimentează handler-ul existent al câmpului src** — după upload setează
   valoarea și emite `input`, deci preview-ul, imaginea din iframe și starea „dirty" se
   actualizează exact ca la tastarea unui URL; zero logică duplicată. Butonul elFinder
   rămâne alături, pentru cine vrea să aleagă un fișier deja urcat.

5. **Două incidente prinse și lămurite la verificare:**
   - un fișier a „supraviețuit" primei înlocuiri doar pentru că OPcache servea încă
     controllerul fără hook-uri, compilat cu câteva secunde înainte de editare — la a doua
     rulare curățarea a funcționat; nu era un bug de cod;
   - testele mele prin curl din Git Bash au stricat 3 src-uri (MSYS a convertit
     `/cinova/...` în `C:/Program Files/Git/cinova/...`) — corectate în DB, împreună cu
     rândurile de istorie aferente.

### Verificări rulate (ciclu complet din UI, nu doar API)
| Verificare | Rezultat |
|---|---|
| Drop pe zonă cu un File real (DataTransfer) | upload → src + preview + imaginea din iframe actualizate, toast RO |
| Publish din sidebar (modal de confirmare) | imaginea urcată apare pe site-ul public |
| Înlocuire înapoi cu originalul + publish | fișierul urcat **șters automat**, folderul gol |
| Fișier partajat de mai multe limbi | păstrat cât timp mai există o referință |
| Asset-ul temei (`big-person.jpg`) | neatins de curățare |
| Draft-uri rămase / rânduri orfane | 0 |
| 7 rute (site ×3 limbi, editor, admin) | toate 200 |
| `php -l` + `node --check` | 0 erori |

## 2026-08-20 19:20

### Task
Patru cereri într-o singură rundă: (1) reparat bug-ul de text suprapus în lista de beneficii,
(2) avatarurile „oamenii care construiesc" făcute editabile, (3) toate anteturile de secțiune
rămase pe site făcute editabile, (4) secțiunea de testimoniale — antet + poză editabile,
carduri de aceeași înălțime și responsive (conținutul rămâne din DB/admin).

### Files Modified
- `views/home/index.php` — hero (titlu/subtitlu/badge/CTA/trusted-by), anteturi services /
  projects / pricing / testimonials / blog, 13 avataruri, poza din testimoniale
- `views/about/index.php` — anteturi why_choose + faq, 6 avataruri
- `views/services/index.php` — 4 anteturi, 4 avataruri
- `views/contact/index.php` — 2 anteturi
- `views/projects/view.php` — 5 anteturi (șablon partajat de toate proiectele)
- `views/services/view.php` — 5 anteturi (idem, servicii)
- `web/core/styles/style.css` — reset pentru textul editabil din listă + carduri testimoniale
- `commands/EditmodeMigrateController.php` — 20 de anteturi noi + 3 chei de settings

### Database
- +204 rânduri `element_content` (anteturi + hero + badge/trusted-by/CTA)
- Avatarurile (6 elemente × 3 limbi) create automat la prima randare web
- Șterse 7 elemente-avatar orfane rămase dintr-o primă numerotare greșită (vezi decizia 3)
- Total: 250 elemente EditMode

### Decizii notabile

1. **Bug-ul de suprapunere — cauza reală.** Tema are
   `.card-section__list-box ul li span { max-width:25px; max-height:25px; background:gradient }`,
   adică **orice** span dintr-un `<li>` devine bulina verde de bifă. Textul beneficiului a
   devenit un `<span>` editabil la migrarea anterioară, deci a fost strivit în bulină și
   rândurile s-au călcat. Rezolvat cu o clasă proprie (`.em-list-text`) și un reset punctual,
   nu prin modificarea regulii temei — bulina rămâne intactă peste tot unde e folosită.

2. **Anteturile de pe paginile de detaliu sunt UN singur element**, nu unul per proiect/serviciu:
   `projects/view` și `services/view` sunt șabloane partajate. Elementele stau pe pagina
   `technical`, deci se rezolvă din orice URL de detaliu.

3. **Avatarurile: un singur set de 6, partajat.** Prima conversie le-a numerotat secvențial pe
   pagină (1–13 pe home), deci aceeași stivă vizuală arăta fețe diferite pe home față de about.
   Renumerotate *per stivă*, ca toate să tragă din același bazin: schimbi o față o dată, se
   schimbă peste tot. Cele 7 elemente rămase din prima numerotare au fost șterse.

4. **Avatarurile NU sunt seed-uite din consolă.** Imaginile n-au traduceri de păstrat, iar
   `@web` e `/` într-o aplicație de consolă în timp ce site-ul rulează sub `/cinova/web` —
   seed-ul din CLI ar fi scris căi greșite. Le creează corect default-urile `editableImage()`
   la prima randare web.

5. **Testimonialele rămân în admin**, cum s-a cerut: doar antetul și fotografia mare au trecut
   în EditMode. Cardurile continuă să vină din entitatea `Testimonials`.

6. **Înălțimea egală** s-a rezolvat făcând slide-ul `stretch` + cardul coloană flex, cu citatul
   `flex: 1` — blocul cu autorul se lipește de marginea de jos indiferent de lungimea citatului.
   Plus două praguri responsive (≤991px, ≤575px) care reduc padding-ul și dimensiunea textului.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| Beneficii: cutiile celor două `<li>` | top 7230 (h25) și 7275 (h25) — fără suprapunere |
| Avataruri editabile în editor | 13 pe home, chei comune 1–6 pe toate paginile |
| Anteturi editabile (testimoniale/blog/what-we-do/hero) | 6 / 5 / 4 / 3 elemente |
| Poza mare din testimoniale | editabilă |
| Carduri testimoniale | toate 609px, autorul la 608px de sus → aliniat jos |
| `SectionHeaders::get` rămase neconvertite în views | 0 |
| 13 rute (site ×3 limbi, detalii, editor, admin) | toate 200 |
| `php -l` pe toate fișierele atinse | 0 erori |

## 2026-08-20 20:05

### Task
Breadcrumb-urile din banner devin editabile din EditMode — dar **numai** firimiturile de
navigare. Ultima, care este numele paginii (sau, pe o pagină de detaliu, numele
înregistrării), rămâne dinamică.

### Files Modified
- `models/pages/Pages.php` — descriptorii de breadcrumb primesc `em_key`
- `views/common/_page_banner.php` — randează eticheta prin RenderHelper când există `em_key`
- `commands/EditmodeMigrateController.php` — seed pentru cele 4 elemente de breadcrumb

### Database
- +4 elemente (`common-crumb-home` / `-services` / `-projects` / `-blog`) × 3 limbi
- Șterse 2 elemente seed-uite greșit (vezi decizia 3)

### Decizii notabile

1. **Regula de editabilitate e poziția, nu textul.** Ultima firimitură e întotdeauna pagina
   curentă: pe `/faqs` este numele paginii, pe `/services/7/...` este numele serviciului, luat
   din înregistrare. Ea nu primește `em_key`, deci `_page_banner.php` o randează ca simplu text
   encodat, exact ca înainte. Restul („Acasă", „Servicii") sunt navigare pură și devin editabile.

2. **Firimiturile de navigare sunt elemente PARTAJATE.** „Acasă" apare pe fiecare pagină internă
   și „Servicii" pe fiecare pagină de serviciu, deci fiecare este un singur element pe pagina
   `technical`. Editezi o dată → se schimbă pe tot site-ul. Cheia se derivă din numele paginii
   de listare (`services/index` → `common-crumb-services`), deci rămâne stabilă chiar dacă
   eticheta e schimbată ulterior din editor.

3. **Bug prins și reparat în aceeași rundă.** Prima versiune lăsa seed-ul pe seama
   default-urilor din view. Fiindcă elementele sunt partajate, `ensureContentRows()` a populat
   toate cele trei limbi cu valoarea paginii care s-a randat prima — o pagină RO — și paginile
   EN/RU au ajuns să afișeze „Acasă". Baza avea valorile corecte tot timpul
   (`pages_translations.banner_breadcrumb` = „Home / Services", „Главная / Услуги"): am șters
   elementele greșite și le-am seed-uit din consolă, per limbă, ca la restul migrării.
   Aceeași capcană evitată la migrarea anterioară — de data asta a fost prinsă abia la
   verificare, nu la proiectare.

4. **Click-ul pe firimitură nu navighează în editor**: `main.js` apelează `preventDefault()`
   pentru orice `.editable`, iar span-ul editabil e în interiorul `<a>`-ului, deci ancora
   rămâne funcțională pentru vizitatori și inertă în modul editare.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| FAQ (RO) | „Acasă" editabil, „Întrebări frecvente" nu |
| Detaliu serviciu (RO) | „Acasă" + „Servicii" editabile, „Web Design și UI/UX" nu |
| Detaliu blog (EN) | „Home" + „Journal" editabile, titlul articolului nu |
| Etichete per limbă după re-seed | EN Home/Services/Work/Journal, RO Acasă/Servicii/Lucrări/Jurnal, RU Главная/Услуги/Работы/Журнал |
| Editare „Acasă" în RO → publish | s-a schimbat pe faqs + detaliu serviciu + blog, EN neatins; restore a revenit |
| 15 rute (site ×3 limbi, detalii, editor, admin) | toate 200 |
| `php -l` | 0 erori |

## 2026-08-20 20:40

### Task
Titlul din banner (`<h1>`) devine editabil pe paginile de listă și pe cele statice. Pe
paginile de detaliu rămâne numele înregistrării, neatins — completează regula de la
breadcrumb-uri din intrarea precedentă.

### Files Modified
- `views/common/_page_banner.php` — randează antetul editabil când primește `emPage`/`emPrefix`
- `views/{about,services,projects,blog,faq,contact}/index.php` — trec cele două opțiuni
- `commands/EditmodeMigrateController.php` — seed pentru cele 6 anteturi de banner

### Database
- +12 elemente (6 pagini × titlu + accent) × 3 limbi = 36 rânduri `element_content`
- Total: 259 elemente EditMode

### Decizii notabile

1. **Criteriul e proveniența titlului, nu tipul paginii.** Paginile de listă/statice își iau
   `<h1>` din `pages_translations.banner_title` — text redacțional, deci editabil. Paginile de
   detaliu îl compun din numele înregistrării (`TextHelper::highlight($name)`) — acela trebuie
   să urmeze serviciul/proiectul/articolul, deci nu primește `emPrefix` și partialul îl
   randează exact ca înainte.

2. **Accentul, din nou pe două elemente.** `banner_title` conține
   `Ce <span class="special-word">facem</span>`; editorul salvează text simplu, deci titlul e
   `-title` + `-title-accent`, cu span-ul rămas în markup.

3. **Seed din consolă, nu din default-urile view-ului** — aceeași lecție ca la breadcrumb-uri:
   valorile per limbă există în `pages_translations` și trebuie copiate înainte de conversie,
   altfel prima randare le-ar aplatiza la o singură limbă.

### Verificări rulate
| Verificare | Rezultat |
|---|---|
| Servicii / FAQ / Blog / Despre / Contact (RO) | antet editabil (`*-banner-title` + accent) |
| Detaliu serviciu + detaliu blog (RO) | antet NEeditabil, ia numele înregistrării |
| Editare „Ce facem" → „Ce stim" în RO + publish | RO schimbat; EN „What we do" și RU „Что мы делаем" neatinse |
| Restore | revenit la „Ce facem" |
| 16 rute (site ×3 limbi, detalii, editor, admin) | toate 200 |
| `php -l`; draft-uri rămase | 0 erori; 0 draft-uri |

## 2026-08-20 21:00

### Task
Ghid portabil de configurare a conținutului EditMode, pentru replicarea setup-ului în alte
proiecte care au deja modulul instalat (perechea lui EDITMODE_PORTING_GUIDE.md).

### Files Created
- `EDITMODE_CONTENT_GUIDE.md` (+ copie în `C:\OpenServer\domains\localhost\`, lângă ghidul
  de portare)

### Notes
- Acoperă: tabelul de decizie editabil/admin/fix, convențiile de chei și partajarea pe
  `technical`, tiparul accent-split, regula „seed înainte de conversie" cu ordinea exactă a
  pașilor, 7 tipare de conversie a view-urilor, regula breadcrumb/H1, procedura de ștergere
  din admin, 9 capcane văzute pe viu în cinova și checklist-ul de verificare per lot.
- Inventarul complet cinova e inclus ca șablon de scope pentru proiecte similare.


---

## 2026-08-21 — Rebranding admin: „Ecaterina.md" → „Cignova"

### Task
Cerinta: inlocuirea „ecaterina" cu „cignova" in proiect. Singura aparitie reala de brand
(vizibila in UI) era logo-ul din sidebar-ul adminului. Restul celor ~95 de aparitii sunt
referinte documentare la proiectul-sursa `ecaterina` (comentarii de cod, `_analiza/*`,
istoricul din acest fisier) si au fost lasate neatinse — descriu factual de unde a fost
copiat pattern-ul, nu brandul aplicatiei.

### Files Modified
- `modules/admin/components/views/aside.php` — logo mark „E" → „C", logo text
  „Ecaterina.md" → „Cignova".

### Notes
- Verificat si continutul bazei de date (dump complet): zero aparitii „ecaterina".


---

## 2026-08-21 — Provocările de proiect mutate în EditMode; admin-ul lor șters

### Task
Secțiunea „Provocări și soluții" de pe pagina de detaliu proiect trebuie editată din
EditMode, nu din admin (cerință: „partea asta trebuie la fel să fie din edit mode,
scoate din admin"). Aplicat fluxul din EDITMODE_CONTENT_GUIDE.md: seed → conversie
view → ștergerea gestiunii din admin.

### Files Modified
- `commands/EditmodeMigrateController.php` — `seedChallenges()`: cele 2 provocări
  partajate (project_id NULL, ACTIVE, ordinea sort_order) devin elemente pe pagina
  `technical`: `pview-challenge{n}-title` / `-text` / `-point1..4` (points_text spart
  pe linii — 4 puncte per card în toate limbile). Seed per limbă din
  `project_challenges_translations`, idempotent.
- `views/projects/view.php` — bucla peste `ProjectChallenges::findActive()` înlocuită
  cu markup fix: 2 carduri (imagine|text, text|imagine — ordinele 1..4 păstrate),
  texte prin `editableText` (punctele cu clasa `em-list-text`), imaginile prin
  `editableImage` (`pview-challenge{n}-image`, default-urile vechi din view).
  Import + query șterse; secțiunea nu mai e condiționată de existența rândurilor.
- `web/core/styles/style.css` — reset punctual `.post-content__challenge-list li
  span.em-list-text` (regula temei pune inline-flex + flex-shrink:0 pe orice span
  din li — textul editabil ar fi refuzat să se strângă pe ecrane mici).
- `modules/admin/components/views/aside.php` — intrarea „Project Challenges" ștearsă;
  comentariul secțiunii Content Blocks menționează mutarea.
- `modules/admin/components/AdminEntityRegistry.php` — intrarea `project-challenges`
  ștearsă (dispare din dashboard, căutare globală, coș, traduceri); comentariul de
  sus actualizat.

### Files Deleted
- `modules/admin/controllers/ProjectChallengesController.php`
- `modules/admin/views/project-challenges/` (tot directorul)

### Notes
- Tabelele `project_challenges(_translations)` se PĂSTREAZĂ ca backup (regula §7.3
  din ghid); modelele rămân pe disc, nimic din views/controllers nu le mai importă.
- `EntityConstants::PROJECT_CHALLENGES` rămâne declarat, ca la features/benefits.
- Comanda `translate-content` păstrează maparea tabelului — tabelul există în DB.
- Rulat `php yii editmode-migrate` (idempotent, cheile vechi sărite) înainte de
  conversia view-ului; verificat paginile de proiect în EN/RO/RU după.


---

## 2026-08-21 — Price Builder: pagina din design + pași/variante configurabile din admin

### Task
Transferul paginii `price-builder.html` din design-ul sursă (`test1/ion-project`) în
aplicație, cu pașii wizard-ului gestionați din admin: adaugi un pas nou, îl sortezi
(a câtelea pas e), iar înăuntru adaugi variantele de răspuns; totul cu traduceri
(EN/RO/RU). Estimarea de preț devine data-driven: interval de bază + modificator
min/max per variantă (la pașii multi-select costul se adună per variantă bifată).

### Migrations
- `create_price_steps_table` + `create_price_steps_translations_table` — pașii
  wizard-ului: alias, select_type ENUM(SINGLE/MULTI), sort_order, status, audit,
  soft-delete; traduceri: name (eticheta din sumar), question (întrebarea).
- `create_price_options_table` + `create_price_options_translations_table` —
  variantele unui pas: step_id FK CASCADE, alias, icon (bootstrap-icons), style
  ENUM(NORMAL/GHOST), price_min/price_max (pot fi negative), sort_order, status,
  audit, soft-delete; traduceri: name, badge_text.
- `seed_price_builder` — cei 8 pași din design cu toate variantele, prețurile din
  vechiul JS hardcodat și traducerile EN/RO/RU; settings `price_builder_base_min/max`
  (1500/2200, grup price-builder); rând `pages` (name `price-builder/index`, slug
  `price-builder`) + `pages_translations` cu SEO per limbă.

### Models
- `models/priceSteps/*`: PriceSteps, PriceStepsTranslations, forms, search —
  după șablonul Faq.
- `models/priceOptions/*`: PriceOptions, PriceOptionsTranslations, forms — copil
  al pasului, după șablonul ServicesFaqs.

### Admin
- `PriceStepsController` (BaseAdminCrudController + MainCrudTrait — deci și
  reorder) + `HasPriceOptionsTrait` (CRUD-ul variantelor în interiorul pasului,
  după modelul HasFaqsTrait) + view-urile `price-steps/*` și `price-steps/options/*`.
- Înregistrat în AdminEntityRegistry (`price-steps`, Content Blocks, sortable,
  trashable), aside, EntityConstants, mesajele admin (menu/labels/headings) en/ro/ru.

### Frontend
- Regulă URL `price-builder` → `price-builder/index`; `PriceBuilderController`
  (index + submit AJAX: lead-ul cu e-mail + sumarul răspunsurilor se salvează în
  `contact` și dă e-mail adminului, ca formularul de contact).
- `views/price-builder/index.php` — wizard-ul din design, generat din DB
  (progres = pași + contact + estimare), texte fixe prin Yii::t('frontend') cu
  traduceri adăugate în messages/{ro,ru}/frontend.php.
- CSS `.price-builder*` portat din design în web/core/styles/style.css +
  responsive.css; JS `priceBuilderWizard` adaptat data-driven (data-min/max pe
  opțiuni, bază pe rădăcină, sumar generat) în web/core/js/index.js.


---

## 2026-08-21 — Link „Pricing" în navbar, către price builder

### Task
Cerință: „adaugă în navbar Pricing și să fie pus acolo" — adică o intrare de meniu
care duce la pagina /price-builder construită mai devreme.

### Migrations
- `m260821_130001_add_pricing_menu_item` — rând nou în `menu_items`
  (alias `pricing`, location HEADER, route `/price-builder/index`, sort_order 5,
  ACTIVE) + traduceri EN „Pricing" / RO „Prețuri" / RU „Цены". Blog / FAQ /
  Contact sunt împinse la 6/7/8 ca ordinea să rămână logică:
  Acasă · Despre noi · Servicii · Proiecte · Prețuri · Blog · Întrebări · Contact.
  `safeDown()` șterge rândul și readuce sort_order-ele vechi.

### Notes
- Nu s-a atins niciun view: navbar-ul se generează din `menu_items` prin
  `views/common/header/_nav.php`, deci link-ul e de acum editabil din
  admin → Menu Items (etichetă, poziție, status) fără cod.
- `route` (nu `url`) ca la restul intrărilor, ca link-ul să treacă prin regula
  `price-builder` din config/web.php și să păstreze prefixul de limbă.


---

## 2026-08-21 — Price builder: modificatori de preț reali pe fiecare variantă

### Task
Cerință: fiecare „serviciu" din pricing să aibă min price / max price în baza de
date, ca la final să rezulte prețul estimat; plus test cap-coadă că estimarea
lucrează și că e-mailul introdus ajunge în requesturi.

### Constatare
Coloanele existau deja (`price_options.price_min` / `price_max`, editabile din
admin), dar valorile seed-uite din design aveau trei goluri:
1. pasul `market` avea 0/0 pe toate cele 7 variante — piața nu influența deloc
   estimarea, deși e un pas întreg pe care îl completează clientul;
2. toate cele 7 `extras` aveau identic 200/300 — e-commerce costa cât copywriting;
3. `design-maturity/full-system` avea -300/-400: maximul scădea mai mult decât
   minimul, adică intervalul se inversa în loc să se îngusteze.

### Migrations
- `m260821_140001_refine_price_option_modifiers` — actualizează prin (step alias,
  option alias) pentru a nu depinde de id-uri:
  * market: asia 0/0 (bază), other 200/300, europe/australia/not-sure 400/600,
    us 700/1000, global 900/1300;
  * extras: cms 300/500, animations 400/700, ecommerce 900/1500,
    multi-language 350/600, integrations 400/700, branding 700/1200,
    copywriting 250/450;
  * design-maturity/full-system: -400/-300 (reducerea maximă rămâne pe capătul
    de jos, intervalul nu se mai inversează).
  `safeDown()` readuce exact valorile din seed-ul inițial.

### Notes
- Valorile rămân editabile din admin → Price Builder → pasul → Options, deci
  migrația e doar un punct de plecare corect, nu o regulă hardcodată.
- Restul de 0/0 se păstrează intenționat: sunt baze de calcul (webflow,
  „ce funcționează cel mai bine", proiect mic, termen 8-12 săptămâni).


---

## 2026-08-21 — Fix: tooltip-urile din coloana de acțiuni erau tăiate

### Task
Raportat de client: la hover pe iconițele din listele admin, tooltip-ul nu se
vedea normal — era retezat pe jumătate (v. Contact Requests, iconița „View").

### Cauza
`.ttp::after` centrează bula pe iconiță (`left: 50%; transform: translateX(-50%)`).
Iconițele stau în ULTIMA coloană, deci jumătatea dreaptă a tooltip-ului ieșea
peste marginea tabelului, iar acolo taie două reguli: `.grid-table table
{ overflow: hidden }` (necesar pentru colțurile rotunjite) și `.grid-table
{ overflow-x: auto }` (scroll pe tabele late).

### Files Modified
- `modules/admin/web/css/style.css` — bloc nou „TOOLTIPS IN THE ACTIONS COLUMN":
  în `.table-actions` tooltip-ul se ancorează la marginea dreaptă a iconiței
  (`left: auto; right: 0; transform-origin: bottom right`), deci crește spre
  stânga și rămâne mereu în interiorul tabelului. Plus `z-index: 5`.

### Notes
- Nu s-a atins niciun `overflow`: tabelele late își păstrează scroll-ul
  orizontal, iar colțurile rotunjite rămân intacte.
- Verificat pe Contact Requests (2 butoane) și pe Price Builder (5 butoane,
  inclusiv „Delete", iconița cea mai din dreapta).


---

## 2026-08-21 — Price builder: prețul estimat vizibil și pe pasul de e-mail

### Task
Cerință: pe ultimul pas (cel cu e-mailul) trebuie să se vadă prețul estimat —
până acum apărea abia pe ecranul următor.

### Files Modified
- `views/price-builder/index.php` — pe pasul `contact`, între subtitlu și
  formular, un bloc `.price-builder__preview` cu eticheta „Preț estimat" și
  valoarea `data-summary="preview-price"`.
- `web/core/js/index.js` — `renderPreview()` scrie `calculateEstimate()` în acel
  element, apelat din `goToStep()` când se intră pe pasul `contact` (aceeași
  funcție de calcul ca pe ecranul final, deci cele două cifre nu pot diverge).
- `web/core/styles/style.css` — stiluri `.price-builder__preview*`: etichetă mică
  și estompată, sumă mai mică decât cea de pe ecranul final, chenar punctat.
- `messages/{ro,ru}/frontend.php` — cheia „Estimated price".

### Notes
- Ecranul final rămâne neschimbat (suma mare + defalcarea pe pași + disclaimer);
  pasul de e-mail arată doar suma, ca vizitatorul să știe ce primește înainte
  să-și lase adresa.


---

## 2026-08-21 — Price builder: pasul de e-mail contopit cu ecranul de estimare

### Task
Cerință: penultima și ultima pagină să fie un tot întreg — estimarea rămâne pe
pagină, iar acolo se pune și e-mailul, cu trimitere.

### Files Modified
- `views/price-builder/index.php` — pasul separat `contact` eliminat (împreună cu
  blocul de previzualizare adăugat mai devreme, rămas fără rost). Ecranul
  `estimate` conține acum, în ordine: titlu, suma, defalcarea pe pași,
  disclaimer, apoi formularul de e-mail (invitație + câmp + buton „Trimite-mi
  estimarea" + notă), link-ul „Programează un apel gratuit" și „Ia de la capăt".
  Bara de progres are `count($steps) + 1` segmente.
- `web/core/js/index.js` — `renderPreview()` scos; `submitLead()` devine async și
  întoarce succes/eșec pe baza răspunsului JSON; handler-ul de submit e legat de
  `[data-step='estimate'] form`, validează e-mailul, dezactivează butonul pe
  durata cererii și înlocuiește nota cu mesajul de confirmare (sau reactivează
  butonul la eroare). Nu mai există `goNext()` după trimitere — ecranul rămâne.
- `web/core/styles/style.css` — stiluri `.price-builder__lead*` (separator,
  spațiere), `--ghost` pentru butonul secundar, stări `--sent` / `--failed` pe
  notă; blocul `.price-builder__preview*` (nefolosit acum) eliminat.
- `messages/{ro,ru}/frontend.php` — chei noi pentru invitație, buton și cele
  două mesaje de rezultat; cheia `Estimated price` scoasă.

### Notes
- Fluxul are acum un pas mai puțin: ultima întrebare duce direct la estimare.
- Trimiterea rămâne pe același endpoint (`/price-builder/submit`) și salvează în
  `contact` cu sumarul + estimarea, ca înainte.


---

## 2026-08-21 — Price builder: revenire la două ecrane (decizie client)

### Task
Contopirea de mai sus a fost anulată la cererea clientului: penultimul ecran
rămâne cel cu e-mailul (dar cu prețul estimat afișat), iar ultimul rămâne
sumarul, exact ca în design — suma mare, defalcarea pe pași, disclaimer,
„Programează un apel gratuit" și „Ia de la capăt".

### Files Modified
- `views/price-builder/index.php` — pasul `contact` pus la loc, cu blocul
  „Preț estimat"; ecranul `estimate` curățat de formularul de e-mail și de
  butonul secundar; bara de progres înapoi la `count($steps) + 2`; atributele
  `data-sent-text` / `data-failed-text` scoase.
- `web/core/js/index.js` — `renderPreview()` reintrodus și apelat la intrarea pe
  `contact`; `submitLead()` redevine fire-and-forget; handler-ul de submit e din
  nou pe `[data-step='contact'] form` și face `goNext()` spre sumar.
- `web/core/styles/style.css` — stilurile `.price-builder__lead*`, `--ghost` pe
  buton și stările `--sent` / `--failed` eliminate; blocul
  `.price-builder__preview*` pus la loc.
- `messages/{ro,ru}/frontend.php` — cele patru chei ale variantei contopite
  eliminate, cheia `Estimated price` pusă la loc.

### Notes
- Atenție pentru viitor: apostroful într-un string PHP cu ghilimele simple se
  scrie `\'`, nu `''` — greșeala a spart temporar ambele fișiere de traduceri.
- Verificat după revenire: previzualizarea și suma finală sunt identice
  ($4 200 - $6 500 în test), iar lead-ul ajunge în `contact` (id 7).


---

## 2026-08-21 — Price builder: date de contact editabile pe ecranul de sumar

### Task
Cerință: pe ecranul final să existe încă două câmpuri — e-mail (precompletat cu
cel introdus la pasul anterior, dar modificabil) și telefon, care nu e obligatoriu.

### Files Modified
- `controllers/PriceBuilderController.php` — logica de salvare extrasă în
  `storeLead()`; `actionSubmit()` ține id-ul rândului creat în sesiune
  (`priceBuilderLeadId`); `actionUpdate()` nou actualizează ACEL rând cu
  e-mailul corectat + telefonul. Id-ul nu vine niciodată din client, deci
  nimeni nu poate suprascrie cererea altcuiva; dacă sesiunea nu are id
  (cookie curățat, altă sesiune), se creează un rând nou în loc să eșueze.
- `views/price-builder/index.php` — pe ecranul `estimate`, între disclaimer și
  butonul de apel, formularul „Datele tale de contact": e-mail + telefon
  (placeholder „opțional") + buton de salvare + notă de confirmare ascunsă.
  Atribute noi pe rădăcină: `data-update-url`, `data-saved-text`,
  `data-failed-text`.
- `web/core/js/index.js` — la intrarea pe `estimate`, e-mailul se precompletează
  din răspunsul pasului anterior (doar cât timp vizitatorul nu l-a editat el
  însuși — `data-touched`); `updateLead()` trimite e-mail + telefon + sumar și
  întoarce succes/eșec; nota afișează rezultatul, butonul se blochează după o
  salvare reușită și rămâne reîncercabil la eroare; `resetWizard()` curăță și
  acest formular.
- `web/core/styles/style.css` — `.price-builder__details*`, spațiere între cele
  două câmpuri, stări `--sent` / `--failed` pe notă.
- `messages/{ro,ru}/frontend.php` — chei noi (fără apostrofuri, ca să nu se
  repete problema de escaping): titlu bloc, placeholder telefon, buton, cele
  două mesaje de rezultat.

### Notes
- Telefonul e opțional: gol înseamnă „—" în `contact.phone`, coloana fiind
  NOT NULL.
- Lead-ul rămâne unul singur per parcurgere: pasul cu e-mailul îl creează,
  ecranul final îl corectează.


---

## 2026-08-21 — Price builder: spațiere pe blocul de jos al ecranului de sumar

### Task
Cerință: elementele de jos (câmpurile de contact și butoanele) stăteau prea
înghesuite unele în altele.

### Files Modified
- `web/core/styles/style.css` — ritm vertical nou pe ecranul `estimate`:
  separatorul blocului de contact 48px sus / 40px padding (era 36/32), titlul
  24px sub el, câmpurile la 20px distanță, ultimul câmp 28px până la butonul de
  salvare, 40px între formular și „Programează un apel gratuit"
  (`.price-builder__details + .price-builder__submit--ghost`) și 28px până la
  „Ia de la capăt".

### Notes
- Distanțele măsurate în browser după modificare: 20 / 28 / 40 / 28 px.
- Nu s-a atins nimic din markup sau din logica formularului.


---

## 2026-08-21 — Admin dashboard: carduri distanțate; spațierea din frontend anulată

### Task
Clarificare de la client: cererea de spațiere se referea la dashboard-ul din
admin, nu la wizard-ul de pe site. Modificarea făcută anterior pe ecranul de
sumar al price builder-ului a fost anulată.

### Files Modified
- `web/core/styles/style.css` — revenire la valorile dinainte pe
  `.price-builder__details*` (36/32px, titlu 20px, câmpuri 16px) și
  `.price-builder__submit--ghost` (16px); regulile adăugate pentru ultimul câmp,
  pentru `details + ghost` și pentru „Ia de la capăt" au fost șterse.
- `modules/admin/web/css/style.css` — aer în dashboard: `.dashboard-overview-grid`
  gap 10 → 18px, `.dashboard-overview-item` padding 12/14 → 16/18px și gap intern
  2 → 4px, `.dashboard-stats` gap 16 → 20px și margine 20 → 24px,
  `.admin-widget-card` padding 20/22 → 24/26px și margine 20 → 24px,
  `.admin-widget-header` 14 → 18px sub titlu.

### Notes
- Distanțe verificate în browser: 18px între cardurile din „Content overview"
  (orizontal și vertical), 20px între cele patru statistici de sus.
- Doar CSS; markup-ul dashboard-ului rămâne neatins.


---

## 2026-08-21 — Admin dashboard: spațiu între rândul „Needs attention" și „Content overview"

### Task
Cerință: gap/padding între tabelul „Needs attention" și cardul „Content overview",
care stăteau lipite.

### Cauza
Cardurile din `.dashboard-columns` au `height: 100%` (regula veche de la linia
5446), iar întinderea lor în celula de grid anulează efectul propriului
`margin-bottom` — distanța măsurată între cele două blocuri era exact 0px.

### Files Modified
- `modules/admin/web/css/style.css` — `.dashboard-columns` primește
  `margin-bottom: 24px` (marginea stă pe rândul-container, deci nu mai depinde
  de marginile cardurilor întinse) și `gap` 16 → 20px, ca distanța dintre cele
  două coloane să fie în același ritm cu restul paginii.

### Notes
- Verificat în browser: 24px între „Needs attention" și „Content overview",
  20px între „Needs attention" și „Recent activity".


---

## 2026-08-21 — ADMIN_PATTERN_GUIDE.md: specificația reutilizabilă a adminului

### Task
Cerință: un document care descrie cum trebuie să arate adminul și ce funcțional
trebuie să aibă, ca să poată fi folosit ca pattern într-un alt proiect — clientul
creează baza de date și spune cum trebuie să lucreze, iar de acolo se generează
paginile de admin și apoi controllerele de frontend care trag datele în pagini.

### Files Created
- `ADMIN_PATTERN_GUIDE.md` (+ copie în `C:\OpenServer\domains\localhost\`, lângă
  EDITMODE_PORTING_GUIDE.md și EDITMODE_CONTENT_GUIDE.md)

### Conținut
14 secțiuni: convenții DB (entitate + traduceri, ENUM prin EnumHelper, soft
delete, audit), infrastructura care se copiază o singură dată, nucleul CRUD
(BaseAdminCrudController + MainCrudTrait, cu pipeline-ul de upload și regulile
de bulk/reorder), anatomia unei entități cu șabloane complete (model, forms,
search, controller, view-uri), contractele exacte ale widget-urilor și ale JS-ului
(toate cheile din `options`, atributele `data-*`, endpoint-urile), ecranele de
infrastructură (dashboard, coș, jurnal, căutare globală, traduceri, media, auth),
i18n-ul adminului, RBAC, rețeta pas cu pas, wiring-ul de frontend, lista de
recepție a funcționalului și capcanele.

### Notes
- Secțiunea 13 listează explicit și **defectele din cinova care NU trebuie
  copiate**: `AccessControl` comentat pe modulul admin (panoul e public azi),
  `restore-all` în afara VerbFilter, cele două mecanisme paralele de limbă pentru
  admin, cele 3 interogări per listă din DataProviderHelper, `format` ignorat în
  DetailViewWidget, cod mort (`tableCheckbox`, `admin/dashboard`, `EntityHelper`).
- Faptele au fost verificate în cod (nu deduse): contractele widget-urilor,
  selectorii JS, cheile de mesaje și comportamentul controllerelor de infrastructură.

---

## 2026-08-30 — Demo sandbox, reparație FileHelper, și yii2-admin-kit

### Task
Trei cereri: (1) pagina demo prin care un vizitator folosește adminul și
EditMode-ul reale fără să atingă site-ul; (2) un script care instalează adminul
+ EditMode într-un proiect nou, cu un fișier de configurare declarativ pentru
entități; (3) documentația planului de lucru admin ↔ EditMode ↔ controllere,
plus o propunere de optimizare mai bună dacă există.

### Files Created — demo sandbox
- `components/DemoSandbox.php` — detecția host-ului demo (rulează ÎNAINTE ca
  aplicația să existe, deci lista de host-uri vine din params), token în cookie
  semnat, `resolveDbName()`, auto-login, maparea folderelor de upload.
- `components/DemoGuard.php` — `ActionFilter` pe aplicație: rute blocate
  (users/*, profile/change-password, languages/update), rate-limit 120 req/min
  și 30 scrieri/min per vizitator, plafon POST, headere `X-Robots-Tag: noindex`
  și `Content-Security-Policy: frame-ancestors`.
- `config/demo.php` — suprascrierile aplicate DOAR pe host-ul demo: DSN spre
  copia bazei, `mailer.useFileTransport`, sesiune separată, root-uri elFinder
  remapate, `params.demoMode`, `editModeNoAuth => false`.
- `commands/DemoController.php` — `demo/snapshot` (copie curățată: contact → 3
  fictive, users → 1 demo, istoric gol, verificare care EȘUEAZĂ dacă filtrarea
  n-a ținut), `demo/reset`, `demo/provision`, `demo/cleanup`, `demo/stats`.
- `controllers/DemoController.php`, `views/demo/index.php`,
  `web/core/styles/demo.css`, `views/common/_demo_banner.php`.

### Files Modified
- `config/web.php` — regula `demo => demo/index`; la final, merge cu
  `config/demo.php` când host-ul e demo. **`Yii::setAlias('@app', …)` adăugat
  înainte**: `@app` e altfel înregistrat abia de constructorul aplicației, deci
  clasele `app\…` nu se pot autoîncărca în timpul construirii configurației —
  fără el, site-ul dădea eroare fatală (prins la testare, reparat).
- `config/params.php` — `demoHosts`, `demoSiteUrl`, `mainSiteUrl`,
  `demoUserEmail`, `demoPerVisitor`, `demoTtlHours`, `demoFrameAncestors`.
- `helpers/FileHelper.php` — vezi mai jos; plus maparea uploadurilor în demo,
  `deleteFile()` no-op în demo, `getFile()` copy-on-write (întâi folderul
  demo, apoi cel real).
- `modules/editmode/controllers/EditController.php` — `UPLOAD_DIR` mapat prin
  DemoSandbox; `cleanupReplacedUploads()` no-op în demo.
- Cele trei layout-uri — bannerul demo (nu randează nimic în afara sandbox-ului).

### Bug reparat: FileHelper îi lipseau 5 metode
`BaseAdminCrudController` și `HasGalleryTrait` apelau `FileHelper::tempExists`,
`saveTempUpload`, `moveTempToFinal`, `deleteTempFile` (12 apeluri), iar
view-urile `File::tempFileUrl` — **niciuna definită**. Efect: eroare fatală la
orice creare/editare din admin cu imagine care pică la validare. Implementate,
cu folder temporar sub `core/uploads/tmp`, nume `md5+ext`, validare
anti-path-traversal și curățare a fișierelor abandonate mai vechi de 24 h.

### Files Created — yii2-admin-kit (în afara proiectului)
`C:\OpenServer\domains\localhost\yii2-admin-kit\`
- `install.php` — porter, nu arhivă: copiază din cinova în proiectul țintă
  (223 fișiere + 17 migrații redenumite), repară două defecte cunoscute,
  resetează cele trei fișiere specifice proiectului, generează snippet-ul de
  config, instalează generatorul. Are `--dry-run`, `--force`, `--check`.
- `config/entities.example.php` — manifestul declarativ, cu toate cheile documentate.
- `templates/KitController.php` — `kit/make-entity`, `kit/list`, `kit/doctor`.
- `templates/entity/*.tpl` — 8 șabloane + `_helpers.php`.
- `README.md`, `docs/ADMIN_CONTRACT.md`, `docs/EDITMODE_CONTRACT.md`,
  `docs/OPTIMIZATION.md`.

### Verificare
- `php -l` pe toate fișierele atinse; 238 fișiere instalate în proiectul de
  test, 0 erori de sintaxă; 26 fișiere generate din manifest, 0 erori.
- cinova: `/`, `/admin`, `/admin/testimonials`, `/admin/trash`,
  `/editmode/edit`, `/demo`, `/about-us`, `/services`, `/contact-us`,
  `/price-builder` — toate fără erori.

### Notes
- Trei bug-uri proprii prinse prin testare, nu prin citire: aliasul `@app`
  lipsă în config; regexul de resetare a registrului care nu se potrivea cu
  forma reală (`self::$cache = [`, nu `return self::$cache = [`) și lăsa toate
  cele 16 entități cinova în proiectul nou; șabloanele generatorului necopiate
  în țintă.
- Sandbox-ul e la nivelul v1 (o bază demo comună, reset pe cron). Codul pentru
  v2 (o bază per vizitator) există și se activează cu `demoPerVisitor`, dar
  cere `CREATE DATABASE` și tabelul `demo_sandboxes`.
- `/demo` afișează o notă de configurare până când `params.demoSiteUrl` chiar
  arată spre un host demo funcțional.
- `editModeNoAuth` a rămas `true` pe site-ul real — nu l-am schimbat, fiindcă
  ar bloca lucrul local. Rămâne precondiția din DEMO_SANDBOX_ANALYSIS.md §6.

---

## 2026-08-30 (b) — yii2-admin-kit: comanda unică `admin-kit`

### Task
Cerință de urmare: în loc de `php install.php --from=… --to=… --db=…`, o comandă
simplă — intri în terminal în folderul proiectului, scrii o comandă, și pune tot
ce trebuie.

### Files Created (în afara proiectului, în yii2-admin-kit/)
- `install.bat` — se rulează O SINGURĂ DATĂ; scrie un shim în primul folder de
  pe PATH care chiar există (`C:\OSPanel\bin` aici), deci `admin-kit` merge din
  orice terminal.
- `bin/admin-kit.bat` — shim-ul propriu-zis; găsește PHP-ul.
- `kit.config.php` — proiectul-sursă implicit, ca `admin-kit install` să meargă
  fără niciun argument.

### Files Modified
- `install.php` — subcomenzi (`install`/`check`/`entity`/`help`); ținta implicită
  e folderul curent; patch automat pe `config/web.php` + `config/console.php`
  (cu backup și verificare `php -l`, cu revenire dacă nu parsează); creare bază
  de date; rulare migrații RBAC + kit.
- `templates/KitController.php` — la prima entitate dintr-un proiect nou nu
  exista de unde clona view-urile; acum cade pe proiectul-sursă
  (`commands/kit-templates/source.php`, scris de instalator).
- `README.md` — refăcut în jurul comenzii unice.

### Bug-uri reparate (toate prinse rulând, nu citind)
1. **Extensii PHP absente.** `php.ini` din OpenServer conține placeholdere
   (`%sprogdir%`) pe care le înlocuiește doar consola lui, deci `php.exe` pornit
   altfel n-are pdo_mysql → „could not find driver" la orice migrare.
   Instalatorul se relansează singur cu `-d extension_dir/-d extension` și
   pasează flag-urile prin variabilă de mediu către `yii` (fără asta, procesul
   copil avea extensiile, calcula „nu sunt necesare" și nepotul rămânea fără).
2. **Migrațiile erau invizibile.** Redenumirea producea `m26083001_…` (8 cifre);
   Yii cere `m` + 6 cifre + `_` + 6 cifre. `migrate` raporta „No new migrations
   found" iar instalarea părea reușită cu baza goală. Acum `m260830_000001_…`.
3. **`config/db.php` trunchiat.** Regexul `dbname=[^';]*` nu excludea ghilimelele
   duble; pe un db.php scris cu `"` înghițea restul fișierului. Corectat la
   `[^'";\r\n]*`, plus backup și verificare de sintaxă.
4. **Migrația de soft-delete pica.** Listează cele 19 tabele de conținut din
   cinova, inexistente într-un proiect nou → pica la primul ALTER și oprea toate
   migrațiile de după. Instalatorul o îngustează la tabelele pe care le creează
   chiar el; entitățile generate își aduc singure `deleted_at/deleted_by`.
5. **`.bat` cu terminații Unix.** `cmd.exe` cere CRLF; fișierele erau ilizibile.
6. **`C:\OpenServer\bin` nu există** deși e în PATH; `install.bat` încearcă acum
   mai mulți candidați.

### Verificare (end-to-end, pe un proiect Yii2 basic curat)
- `admin-kit install` → 223 fișiere + 17 migrații, config patch-uit, baza creată,
  RBAC + migrațiile kitului aplicate: **18 tabele, 33 limbi (3 active), 3 roluri,
  contul bootstrap, 11 pagini**.
- `admin-kit entity testimonials` → 8 fișiere PHP + 12 view-uri clonate +
  înregistrare în registru/constante/mesaje; `yii migrate` → încă 2 tabele.
- `admin-kit check` → toate verificările trec.
- 261 fișiere PHP în proiectul rezultat, **0 erori de sintaxă**.
- Baza de test ștearsă, proiectul de test șters, `vendor` din cinova neatins.

### Notes
- Shim-ul e la `C:\OSPanel\bin\admin-kit.bat` — se șterge dacă nu mai e dorit.
- Patch-ul de config e automat doar când poate fi făcut sigur; altfel scrie
  `config/admin-kit.snippet.php` și spune explicit.

---

## 2026-08-31 — /demo: refactor, starea vine din fapte nu din config

### Task
Cerință: „intră pe site și vei vedea că demo-ul nu lucrează cum ar trebui —
uită-te singur și fă refactor."

### Ce era stricat (constatat în browser, nu presupus)
Pagina se randa ca „gata" doar fiindcă `params.demoSiteUrl` era un string nevid.
În realitate:
- `demo.cinova.local` nu exista — nici în hosts, nici ca domeniu OpenServer
  (`ping` eșua, `curl` întorcea 000);
- bazele `cinova_demo` / `cinova_demo_snapshot` nu existau.

Deci butonul „Pornește demo-ul" încărca un iframe către un host mort, iar cele
**3 linkuri „Deschide panoul" duceau în gol**. O fațadă peste nimic.

Cauză de fond: alegerea host-ului era greșită și pentru producție —
`demo.cinova.local` nu împarte niciun domeniu înregistrabil cu site-ul pe care e
încorporat, deci cookie-ul de sesiune al editorului ar fi fost third-party în
iframe și blocat. Analiza cerea explicit un **subdomeniu** (DEMO_SANDBOX_ANALYSIS.md §2);
parametrii o contraziceau.

### Files Modified
- `components/DemoSandbox.php` — **`status()`**: readiness derivată din fapte
  (snapshot există, sandbox există, contul demo există, host-ul rezolvă, host-ul
  e în `demoHosts`), cu cache 60s. Înlocuiește „string nevid = gata".
- `controllers/DemoController.php` — pasează `ready` + `checks` în view;
  `checks` doar pentru editori (un vizitator nu vede instrucțiunile de setup);
  pe host-ul demo redirectează la `/demo` de pe site-ul real în loc de 404.
- `views/demo/index.php` — două forme: cu sandbox funcțional, cele două uși;
  fără, **secțiunea editorului dispare complet** și CTA devine „Programează o
  demonstrație". Zero linkuri moarte, în orice stare. Plus watchdog de 12s pe
  iframe: dacă nu se încarcă, arată fallback cu „deschide în filă nouă", fiindcă
  un iframe cross-origin nu dă eveniment de eroare.
- `web/core/styles/demo.css` — stiluri pentru panoul de diagnostic și pentru
  starea „editorul nu răspunde".
- `commands/DemoController.php` — **`php yii demo/setup`**: snapshot + reset +
  verificare + tipărește exact ce a rămas manual; `--withDomain` scrie linia în
  `Default_domains.txt` (cu backup).
- `config/params.php` — host demo mutat pe `demo.cinova.com` (subdomeniu al lui
  `cinova.com`, care era deja domeniu OpenServer funcțional → same-site, deci
  cookie-ul merge în iframe); `mainSiteUrl` pe `http://cinova.com`;
  `demoFrameAncestors` include acum `http://cinova.com` — altfel browserul ar fi
  blocat iframe-ul când /demo e deschis de pe acel domeniu.

### Rulat
- `php yii demo/setup --withDomain` → `cinova_demo_snapshot` și `cinova_demo`
  create din 67 de tabele, verificarea trecută; linia
  `demo.cinova.com;\localhost\cinova\web` adăugată în profilul OpenServer
  (backup `Default_domains.txt.bak-260831-140415`).
- Sandbox verificat: **3 cereri fictive** (nu cele 10 reale), 1 user
  `demo@cinova.md` cu rol `admin`, istoric și jurnal goale. `cinova.contact`
  neatins — tot 10 rânduri.

### Stare
5 din 6 verificări trec. Rămâne una singură, care nu se poate face din cod:
**repornirea OpenServer**, ca să genereze vhost-ul și intrarea în hosts pentru
`demo.cinova.com`. Până atunci pagina ascunde onest demo-ul.

### Notes
- O măsurătoare de layout părea să arate panoul strivit la 56px; era artefact —
  fereastra Chrome era ascunsă (`visibility: hidden`, `innerWidth: 0`). Nu am
  „reparat" un bug inexistent.
- Toate rutele verificate după refactor, și pe `localhost/cinova/web` și pe
  `cinova.com`: fără erori.

---

## 2026-08-31 (b) — /demo rescris: pagină-exemplu editabilă pe loc

### Task
Clarificare: „demo chiar trebuie să fie pe site ca o pagină care o poți edita,
ca un exemplu de home page, ca să înțelegi."

Cerința reală era alta decât ce construisem: nu un site-copie pe alt host,
încărcat într-un iframe, ci **o singură pagină pe site care arată ca un homepage
și pe care vizitatorul o editează direct**.

### Ce am construit
`/demo` e acum un homepage-exemplu complet (hero, 3 carduri de servicii, bandă
de statistici, testimonial, CTA) cu **33 de câmpuri editabile**: 28 text, 3
iconițe, 2 imagini.

- Bară lipicioasă sus: „Editează pagina" (toggle), contor de modificări,
  „Resetează".
- Editare pe loc: click pe text → `contenteditable` inline; Enter salvează,
  Escape anulează. Click pe imagine sau iconiță → popover cu variante.
- Fiecare modificare merge în `sessionStorage` — privată vizitatorului prin
  construcție, dispare la închiderea filei, nu ajunge niciodată la server.
- Sub exemplu, o secțiune care spune onest ce face în plus panoul real:
  traduceri, istoric cu revenire, ciornă înainte de publicare, liste care cresc.

### De ce așa
Nu are nevoie de al doilea host, de o copie a bazei, de login sau de restart —
și merge și pe telefon, spre deosebire de editorul real. Fiindcă nu are stare pe
server, nu are nimic de protejat, nimic de resetat și nimic de abuzat.
Împrumută vocabularul editorului real (contur la hover, click pentru editare,
mesaj de salvare) ca ce învață vizitatorul aici să se transfere la panoul real,
dar nu simulează istoricul și traducerile — le descrie.

### Files Created
- `web/core/js/demo-page.js` — editorul in-page (vanilla, fără dependințe):
  aplicare la încărcare, toggle, text/imagine/iconiță, toast, reset.

### Files Modified
- `views/demo/index.php` — rescris complet ca pagină-exemplu.
- `web/core/styles/demo.css` — rescris: pagina-exemplu, bara, popover-ul,
  toast-ul, afordanțele de editare. Păstrat `.demo-banner` (folosit de sandbox).
- `controllers/DemoController.php` — fără stare; doar redirect când e rulat pe
  host-ul sandbox.
- `commands/DemoController.php` — `demo/stats` afișează acum și raportul
  `DemoSandbox::status()`, ca să nu rămână cod mort, și spune explicit că
  mediul sandbox e OPȚIONAL — `/demo` nu depinde de el.

### Verificat în browser, prin interacțiune reală
toggle on/off · click pe titlu → contenteditable → Enter → salvat în
sessionStorage · contor „1 modificare" · toast · popover imagine (4 variante)
și schimbare · popover iconiță (8 variante) și schimbare · **reload → toate cele
3 modificări restaurate**, modul editare oprit · **Resetează → totul la
implicit, storage golit**. Cele 10 rute ale site-ului: fără erori.

### Limită de verificare
Randarea vizuală NU a putut fi confirmată: fereastra Chrome de pe mașină era
ascunsă (`visibility: hidden`, `innerWidth: 0`), deci capturile și măsurătorile
de layout erau invalide. Structura și încărcarea imaginilor au fost verificate
programatic.

### Notes
- Infrastructura de sandbox (host separat + copie de bază) rămâne intactă și
  dormantă; nu mai e folosită de `/demo`. Bazele `cinova_demo*` și linia
  `demo.cinova.com` din OpenServer rămân — se șterg dacă nu mai sunt dorite.

---

## 2026-08-31 (c) — /demo: text invizibil (negru pe negru) și accent transparent

### Task
„Schimbă puțin design-ul, e negru și nu se vede."

### Cauzele (măsurate în browser, nu ghicite)
Două bug-uri reale, ambele de moștenire CSS:

1. **Text negru pe negru.** Tema pune `background` pe `body` dar **nu pune
   `color`** — fiecare pagină își colorează textul per componentă. Pagina demo
   fiind construită pe clase proprii, a moștenit negrul implicit din Bootstrap:
   `color: rgb(33,37,41)` pe fundal `rgb(13,13,13)`. Practic invizibil.
2. **Accentul complet transparent.** `.special-word` din temă își pictează
   gradientul cu `-webkit-text-fill-color: transparent`, iar proprietatea
   **se moștenește** — deci `<span class="demo-edit">` dinăuntru randa text
   transparent. Titlurile își pierdeau jumătatea accentuată.

### Files Modified
- `views/demo/index.php` — cele 3 `.special-word` înlocuite cu `.demo-x__accent`,
  o clasă proprie cu culoare solidă, care rezistă la a avea copii.
- `web/core/styles/demo.css`:
  - culoare de text declarată o dată, pe `.demo-content`, plus reguli explicite
    pentru titluri / paragrafe / citate;
  - tokenuri noi `--demo-text` (#f2f4f0) și `--demo-muted` (#a8aeaa); textul
    secundar nu mai e „opacitate peste o culoare moștenită", ci culoare
    explicită — opacitatea peste un negru moștenit era exact capcana;
  - suprafețe mai deschise decât fundalul (`#16171a` / `#1d1f23` peste `#0d0d0d`)
    ca panourile să se desprindă, borduri mai vizibile (`#ffffff26`);
  - ritm vizual: o spălare radială discretă în spatele hero-ului și fundal
    propriu pentru secțiunea de explicații — pagina nu mai e un singur ton plat
    de sus până jos.

### Verificat
Contrast recalculat în pagină pentru 15 elemente: **7.9 – 17.6** (AA cere 4.5).
Zero elemente sub prag. Accentul: `rgb(207,248,94)` cu `-webkit-text-fill-color`
readus la `currentColor`. Editarea funcționează în continuare după rescrierea
CSS-ului (toggle → click → Enter → salvat). CSS-ul are acuzele balansate (86/86)
și se servește complet (13 KB). Rutele site-ului: fără erori.

### Limită de verificare
Tot nu am putut vedea pagina: fereastra Chrome e ascunsă
(`visibility: hidden`, `devicePixelRatio: 0.75`), iar în starea asta Chrome
întoarce valori nesigure pentru proprietățile de paint — `outline-color` și
`background-color` citite pe un element cu regula aplicată arătau valorile
vechi, deși `cursor: text` și `matches()` dovedeau că regula se aplică.
Culorile de text, în schimb, s-au citit consistent și pe ele se bazează
măsurătorile de contrast de mai sus.

---

## 2026-08-31 (d) — /demo: sidebar de editare ca în editorul real + secțiune din DB

### Task
Două cereri: (1) editarea să funcționeze ca în admin/EditMode — click pe element
→ apare în stânga un panou cu formularul de editare; (2) un exemplu de
componentă care se actualizează prin baza de date, din panoul de administrare.

### 1. Sidebar-ul de editare
Editorul in-page nu mai folosește contenteditable inline + popover; acum imită
forma editorului real (al cărui sidebar e tot pe stânga):

- click pe un element cu contur → panou fix pe stânga (320px) cu: etichetă de
  tip (TEXT / IMAGINE / ICONIȚĂ), cheia elementului, formularul potrivit
  (textarea pentru text; grilă de variante pentru imagini/iconițe), butoane
  **Publică** / **Renunță**;
- pagina alunecă la dreapta (margin animat) în loc să fie acoperită — vizitatorul
  trebuie să vadă elementul pe care îl editează, altfel preview-ul live nu
  înseamnă nimic;
- **preview live**: pagina se schimbă pe măsură ce tastezi; doar „Publică"
  reține (sessionStorage). „Renunță", Escape sau schimbarea elementului fac
  rollback — aceeași împărțire draft/live ca în editorul real, la scară mică;
- elementul în editare primește contur solid (.demo-selected);
- sub 900px sidebar-ul devine bottom-sheet, pagina nu se mai mută.

### 2. Secțiunea administrată din baza de date
Între testimonial și CTA: **„Prețuri din baza de date"** — cele 3 rânduri reale
din `pricing_plans` (`PricingPlans::findActive()`), cu badge „Secțiune
administrată din panou", explicație, și link către `/admin/pricing-plans`
(„schimbă un preț, apoi reîncarcă pagina"). Cardurile NU sunt marcate editabile
— exact contrastul pe care pagina îl predă: contur verde = editezi pe pagină;
badge DB = editezi din panou. Secțiunea dispare dacă tabelul e gol.

### Files Modified
- `web/core/js/demo-page.js` — rescris pe modelul sidebar (open/rollback/publish).
- `views/demo/index.php` — markup-ul sidebar-ului în locul popover-ului;
  secțiunea DB; guard `$plans ?? []`.
- `controllers/DemoController.php` — `plans => PricingPlans::findActive()->limit(3)`.
- `web/core/styles/demo.css` — stiluri sidebar (+ bottom-sheet), .demo-selected,
  secțiunea DB; stilurile popover-ului eliminate.

### Incident: preț corupt de propriul test, reparat
Primul test de round-trip a folosit `€` prin clientul mysql din Git Bash, care
afișează `€` ca `?`; am capturat valoarea afișată și am scris-o înapoi, deci
`pricing_plans.id=16` a ajuns literal `?1,900`. Detectat prin `HEX(price)`
(3F312C393030), reparat cu `CONCAT(UNHEX('E282AC'), '1,900')` — verificat
`E282AC312C393030` = `€1,900`, identic cu seed-ul din migrația
m260809_130001. Lecția era deja în EDITMODE_CONTENT_GUIDE §8 („teste prin curl
din Git Bash… verifică DB după teste") — de data asta pe mysql, nu pe curl.

### Verificat
- Sidebar în browser, prin interacțiune reală: deschidere pe click (TEXT, cheia,
  valoarea precompletată), preview live la tastare, Publică → sessionStorage,
  închidere; IMAGINE → grilă cu 4 variante, alegere → preview, Renunță →
  imaginea revine și storage-ul rămâne neatins.
- Round-trip DB cu marker ASCII: UPDATE preț → pagina îl arată → restaurare →
  pagina curată; octeții finali identici cu originalul.
- PHP/JS lint curat, CSS balansat (128/128), rutele site-ului fără erori.

---

## 2026-08-31 (e) — /demo: navbar peste sidebar; sandbox per-vizitator cu ștergere la 1h; adminul real încuiat

### Task
Două cereri: (1) navbarul site-ului acoperea sidebar-ul de editare de pe /demo;
(2) demo-ul să folosească o BAZĂ SEPARATĂ, una per utilizator, ștearsă după 1h
și readusă la default — iar vizitatorii să nu aibă acces la baza site-ului.

### 1. Navbar peste sidebar (măsurat în browser)
Header-ul temei e sticky cu z-index 1000; sidebar-ul avea 80 → primii 115px ai
formularului erau acoperiți. În plus bara demo (sticky top:0) ar fi intrat sub
navbar la scroll.
- `demo.css`: sidebar z-index 1100 (peste header, ca editorul real), toast 1200,
  bara demo `top: var(--demo-topbar)`;
- `demo-page.js`: `trackHeaderHeight()` măsoară header-ul la load/resize și
  setează variabila — înălțimea navbarului e responsive, un offset fix ar fi
  derivat. Verificat: bara se lipește sub header (nu sub el), sidebar-ul acoperă
  fâșia header-ului pe stânga.

### 2. Sandbox per-vizitator, activ (v2)
- `migrations/m260831_150001_create_demo_sandboxes_table.php` — registrul în
  baza PRINCIPALĂ (token unic, db_name, ip_hash — doar hash, nu IP brut,
  created_at, last_seen_at). Aplicată.
- `DemoSandbox` — provisionare web completă: prima vizită pe host-ul demo →
  token 16 hex, `CREATE DATABASE cinova_demo_<token>` clonat din snapshot
  (schema prin SHOW CREATE TABLE, deci cu FK-uri), cookie, redirect; cookie
  valid dar bază expirată → reconstruită sub același token, fără redirect
  (DSN-ul deja arată spre numele ăla). Cookie NESEMNAT intenționat (setcookie):
  DSN-ul se alege la construirea configului, înainte să existe componenta
  request care ar valida semnătura; secretul e chiar token-ul (64 biți,
  validat regex). Gărzi: `assertDisposable()` pe orice CREATE/DROP (doar
  `*_demo_<16hex>`), fără provisionare pe non-GET/asset-uri (crawlerele nu
  primesc baze), max 3 provisionări/IP/oră, plafon global `demoMaxSandboxes`
  (30) cu evacuarea celui mai vechi, degradare pe sandbox-ul comun la orice
  eroare.
- **Ștergerea după 1h**: `demoTtlHours => 1`; curățenie leneșă la request
  (throttle 60s, fără cron) + `php yii demo/cleanup`; DROP DATABASE + rândul
  din registru + folderul de upload.
- `config/demo.php` — componenta `mainDb` (doar pentru registru; codul
  aplicației rămâne pe copia vizitatorului), `config/web.php` — closure-ul de
  bootstrap cu try/catch (altfel cererea care urma să creeze baza pica pe
  „limba din tabela languages").

### 3. Vizitatorii nu mai au acces la baza site-ului
- `AccessControl` DECOMENTAT în `modules/admin/Module.php`,
  `modules/editmode/Module.php`, `EditController::behaviors()` (+ VerbFilter);
  elFinder/media-finder `access => ['@']`; `editModeNoAuth => false`.
- Verificat: `/admin`, `/admin/pricing-plans`, `/editmode/edit` → 302 la
  `/auth/login`; POST guest pe save-draft respins; HTML-ul public nu mai
  conține `editable`/`data-key` (0 apariții). `/auth/login` → 200; contul de
  operator e cel din migrația m260728_210031 (admin@cinova.local).
- `/demo` de pe site-ul principal nu mai trimite spre adminul real: nota
  secțiunii DB oferă „Deschide copia ta de probă" către host-ul demo doar când
  sandbox-ul e funcțional; pe host-ul demo, /demo se servește local (nu mai
  redirectează) și acolo invitația „schimbă prețul în panou, reîncarcă pagina"
  e literal adevărată, pe copia proprie.

### Bug prins de test și reparat: două ceasuri la expirare
Primul test de expirare a picat: `last_seen_at` era scris cu `date()` din PHP
iar cutoff-ul comparat tot în PHP, dar web-PHP, consola-PHP și MySQL nu împart
neapărat timezone-ul (php.ini-ul OpenServer nici nu se încarcă în CLI).
Simptom: sandbox-uri care ar expira cu ore întârziere sau în avans. Fix: un
singur ceas — al bazei — peste tot (`NOW()` la scriere în touch/provision,
`last_seen_at < NOW() - INTERVAL :h HOUR` la citire, în DemoSandbox și în
comanda demo). Re-testat: provision → backdate 2h → cleanup șterge baza și
rândul; un sandbox proaspăt NU e atins.

### Rămâne un singur pas manual
Restart OpenServer, ca să genereze vhost-ul pentru `demo.cinova.com` (linia e
deja în profil). Până atunci /demo ascunde linkul spre sandbox (starea vine din
`DemoSandbox::status()`), iar restul paginii funcționează complet.

---

## 2026-08-31 (f) — link „Demo" în meniul site-ului

### Task
Întrebare-cerință: „de unde pot să o accesez de pe site, ca user nou?" —
pagina /demo exista, dar niciun link din navigație nu ducea la ea, deci un
vizitator nou n-avea cum s-o descopere.

### Database (conținut, nu migrație — exact ce ar fi făcut adminul din panou)
- `menu_items`: rând nou `demo → /demo/index`, HEADER, sort_order 6 (după
  „Prețuri"); blog/faq/contact împinse la 7/8/9.
- `menu_items_translations`: RO „Demo", EN „Demo", RU „Демо".

### Verificat
Meniul HEADER randează acum: Home, About, Services, Projects, Pricing, Demo,
Blog, FAQ, Contact — linkul `/demo` apare în HTML-ul homepage-ului.

---

## 2026-08-31 (g) — /demo: design pentru necunoscători — checklist ghidat + taburi cu componente din panou

### Task
„Fă designul confortabil pentru un user necunoscător, adaugă taburi ca să poată
manevra pagina, niște componente noi."

### 1. Ghidaj pentru primul contact
- **Checklist cu 3 pași în bara de sus** („1 Pornește editarea · 2 Dă click pe
  un text · 3 Publică") care se bifează singur pe măsură ce vizitatorul chiar
  face pașii — nimeni nu citește instrucțiuni, dar toată lumea urmează o listă
  care reacționează. Progresul supraviețuiește reload-ului (sessionStorage);
  pe telefon rămân doar bulinele numerotate.
- **Puls luminos pe butonul „Editează pagina"** până la prima apăsare — spune
  „începe de aici" fără niciun cuvânt; dispare apoi; la `prefers-reduced-motion`
  devine un inel static.
- **Primul Publish primește mesaj propriu**: „Prima modificare publicată!
  Exact așa lucrezi pe site-ul tău."

### 2. Secțiunea „Componente din baza de date" → TABURI, 3 componente noi
Secțiunea de prețuri a devenit un tab-strip cu 4 taburi, fiecare = un ecran din
panoul de administrare, cu date reale:
- **Prețuri** (pricing_plans — exista),
- **Testimoniale** (nou: card cu stele după rating, citat, avatar, autor/rol),
- **Întrebări** (nou: acordeon nativ `<details>` — zero JS de stricat,
  accesibil din tastatură),
- **Parteneri** (nou: grilă de logo-uri, cu filtru de luminozitate ca SVG-urile
  închise să rămână vizibile pe fundal închis).
Taburile fără date dispar (un necunoscător nu trebuie să vadă un panou gol).
Fiecare panou are dedesubt „Se administrează din: Panou → X" — text simplu pe
site-ul public, **link viu doar în sandbox**, unde invitația „schimbă ceva
acolo, apoi reîncarcă" e literal adevărată pe copia proprie.

### Files Modified
- `controllers/DemoController.php` — + testimonials(3), faqs(4), partners(6).
- `views/demo/index.php` — stepper în bară; secțiunea cu taburi (definiții
  declarative, gating pe seturi goale, sursă per tab).
- `web/core/js/demo-page.js` — initTabs(), markStep/renderSteps (persistente),
  pulsul scos la prima activare, toast-ul primului publish.
- `web/core/styles/demo.css` — stepper, puls (cu reduced-motion), tab-strip,
  carduri de testimonial, acordeon FAQ, grilă parteneri.

### Verificat în browser, prin interacțiune reală
4 taburi comută corect (panou unic activ, aria-selected), 4 FAQ-uri în acordeon,
logo-urile partenerilor toate încărcate; pulsul dispare la prima apăsare;
checklist-ul trece 1→2→3 exact pe acțiunile reale (toggle → click pe titlu →
Publish) și persistă în sessionStorage; mesajul primului publish apare. Lint
PHP/JS curat, CSS 179/179 balansat.

### Notes
Nu s-au creat ecrane noi în admin: cele patru taburi arată exact ecranele care
EXISTĂ deja (pricing-plans, testimonials, faq, partners) — demo-ul predă
panoul real, nu unul inventat.

---

## 2026-08-31 (h) — /admin-demo: panou de administrare separat pentru demo

### Task
„Adminul pentru demo trebuie să fie aparte față de adminul site-ului; fă o
pagină adminDemo cu același funcțional; scoate din admin ce ai adăugat și pune
în admin demo; adaugă pe pagina demo un link la admin."

### Cum, și de ce nu un al doilea modul
Panoul își construiește singur linkurile cu rute absolute — `'/admin/' . $entity`
— în **106 locuri din 27 de fișiere** (controllere, trait-uri, widget-uri,
view-uri). Un al doilea modul care reutilizează aceleași controllere ar fi
randat un panou ale cărui linkuri sar toate înapoi în adminul REAL; iar
rescrierea celor 106 e o modificare mare, cu eșec tăcut, într-un cod care merge.

Yii are deja mecanismul potrivit: **o regulă de URL deține ambele direcții.**
`components/AdminDemoUrlRule.php`:
- intrarea `/admin-demo/x/y` se parsează în ruta `admin/x/y` → toate
  controllerele, view-urile și widget-urile rulează neatinse;
- ieșirea: rutele `admin/...` se rescriu în `/admin-demo/...` **doar** cât ține
  o cerere demo, deci panoul real își păstrează URL-urile.

### Izolarea (DemoSandbox::activateAdminDemo)
Rulată din regulă, în timpul parsării URL-ului — cel mai devreme punct în care
se știe, și înainte să se deschidă sesiunea sau să ruleze vreun query. Ordinea
contează:
1. **sesiune proprie** (`CINOVAADMINDEMOSESSID`, cookie de auto-login separat) —
   altfel logarea operatorului demo ar înlocui cine e logat în panoul REAL, cele
   două fiind pe același host;
2. **`db` mutat pe copie** (`useDemoDatabase()`) — de aici încolo nimic
   accesibil nu mai poate atinge `cinova`;
3. **operatorul demo**, care are rolul `admin` ÎN copie → AccessControl-ul
   panoului trece normal, fără bypass și fără caz special în modulul admin.

### Ce s-a scos din admin / ce s-a adăugat în admin demo
- Bannerul „ești în demo" a fost **scos** din `modules/admin/views/layouts/admin.php`.
- `modules/admin/views/layouts/admin-demo.php` — layout nou care **împachetează**
  layoutul real cu `beginContent()` (nu îl copiază, deci nu poate diverge) și
  adaugă banda. Se aplică doar când regula e activă.

### Linkuri pentru utilizator pe /demo
Buton permanent în bara de sus („Deschide panoul demo"), buton principal în
secțiunea finală, plus linkul din fiecare tab („Panou → Prețuri/Testimoniale/
FAQ/Parteneri"). **Toate duc în /admin-demo, niciunul în adminul real** —
verificat: 6 linkuri spre admin-demo, 0 spre /admin.

### Bug-uri prinse de teste, reparate
1. **Formularul de căutare scăpa în adminul real.** `searchAction` era un șir
   simplu `'/admin/…/search'`; șirurile ocolesc regulile de URL, deci căutarea
   din panoul demo ar fi trimis vizitatorul la login. Trecut prin
   `Url::to([...])` în MainCrudTrait + Languages/Pages/UsersController.
   Verificat: 0 linkuri și 0 `action=` spre `/admin/` în panoul demo.
2. **Panoul demo provisiona baze fără plafon.** `useDemoDatabase()` nu apela
   `allowProvisioning()` — testele mele cu curl (fără cookie) au creat 5 baze.
   Orice client care ignoră cookie-urile ar fi primit o bază la fiecare cerere.
   Acum e sub aceleași plafoane (3/IP/oră, max 30 global) și peste plafon cade
   pe copia comună — niciodată pe baza site-ului. Re-testat: 5 cereri
   fără cookie → 3 baze, restul răspund tot 200.

### Verificat
- `/admin-demo`, `/admin-demo/{pricing-plans,contact,testimonials,faq,partners,
  trash,translations,media,default/index}` → toate 200.
- `/admin` (real) → 302 la `/auth/login`, neschimbat.
- **Izolare dovedită**: panoul demo listează cele 3 cereri fictive
  (`@example.com`), nu cele 10 reale; scriere în copia vizitatorului → apare în
  panou; `cinova.contact` rămâne 10 rânduri, 0 markeri.
- Bazele de test șterse; rămân doar `cinova`, `cinova_demo`,
  `cinova_demo_snapshot`. Lint PHP curat, CSS 185/185, rutele publice fără erori.

---

## 2026-08-31 (i) — /admin-demo redus la componentele paginii demo

### Task
„Lasă în acest admin doar funcționalul pentru pagina demo — admin demo nu
trebuie să aibă nicio legătură de conținut cu adminul simplu."

Panoul demo moștenea tot meniul adminului real (Services, Blog, Projects,
Portfolio, Contact Requests, Pages, Settings, Languages…), adică exact
conținutul site-ului.

### Whitelist, nu ascundere
`AdminDemoUrlRule::ENTITIES = ['pricing-plans','testimonials','faq','partners']`
(+ `default`, `error`). Impus în `admin/Module::beforeAction()` — **404** pentru
orice alt controller. Ascunderea din meniu ar fi fost decor: URL-urile sunt
ghicibile. Whitelist, nu blacklist: o entitate adăugată mâine în admin rămâne
în afara demo-ului până când o pune cineva explicit aici.

### Un filtru, multe ecrane
`AdminEntityRegistry::all()` filtrează la whitelist când regula e activă.
Dashboard-ul, căutarea globală, coșul și ecranul de traduceri se construiesc
TOATE din registru, deci un singur filtru le împiedică pe toate să listeze
conținutul site-ului. Registrul complet a fost mutat în `entities()` privat.

### Files Created / Modified
- `components/AdminDemoUrlRule.php` — `ENTITIES`, `INFRA`, `allows()`.
- `modules/admin/Module.php` — `beforeAction()` cu 404 pentru ecranele din afara demo.
- `modules/admin/components/AdminEntityRegistry.php` — `all()` filtrează; `entities()` nou.
- `modules/admin/components/AsideWidget.php` + `views/aside-demo.php` (nou) —
  sidebar propriu, generat din registru (deci nu poate diverge de ce servește
  panoul); ieșirea e „Înapoi la pagina demo", nu logout — nu există cont din
  care să ieși.
- `modules/admin/views/default/index.php` — în demo dispar cardurile „Cereri
  noi" și „Traduceri lipsă", blocurile „Needs attention" și „Recent activity"
  (toate duc la ecrane inexistente); acțiunile rapide se generează din registru.
- `modules/admin/components/views/header.php` — în demo dispar meniul de unelte
  (Media, Coș, linkuri externe), profilul și logout-ul; rămâne un indicator
  „Demo · copie temporară".

### Verificat
- Permise: `/admin-demo`, `/pricing-plans`, `/testimonials`, `/faq`,
  `/partners` → **200**.
- Blocate: blog, projects, services, portfolio, contact, settings, pages, users,
  languages, trash, translations, media, activity-history, project-categories,
  price-steps → **404** (15 din 15).
- Sidebar demo: Dashboard, FAQ, Pricing Plans, Testimonials, Partners, Înapoi.
- Dashboard demo: 4 carduri în „Content overview", 4 linkuri, **zero** linkuri
  spre ecrane blocate.
- Adminul REAL neatins: registrul întoarce în continuare 16 entități, `/admin`
  cere login. Rutele publice fără erori.

### Notes
Repararea a două stricăciuni proprii în timpul lucrului: un `perl` a
uppercase-uit trei linii `searchAction` (reparate manual) și un `\a` din Python
a devenit octetul BEL în `header.php` (reparat la nivel de octeți). Ambele
prinse de `php -l` înainte de a ajunge în pagină.

---

## 2026-08-31 (j) — /admin-demo: stiluri reparate, banda verde scoasă

### Task
„Uite la admin demo, ceva s-a întâmplat cu stilurile la CSS, și scoate banda
asta verde."

### Cauza reală a stilurilor stricate
`aside-demo.php` inventase propriile clase — `<aside class="aside">`,
`admin-aside-logo` — în loc să folosească contractul pe care îl țintește
`modules/admin/web/css/style.css`: `admin-aside`, `admin-aside-brand`,
`admin-aside-nav`, `admin-aside-section`, `admin-aside-section-title`,
`a.aside`, `admin-aside-section-last`. Rezultat: sidebar-ul demo randa
practic nestilizat.

A doua cauză, mai mică: banda verde încărca `demo.css` (foaia paginii publice
/demo) peste CSS-ul panoului și insera un nod în plus în zona de conținut, deci
panoul demo nu mai arăta ca cel real. `demo.css` NU avea selectori care se
scurg — toți erau limitați la `.demo*` — dar nu avea ce căuta acolo.

### Modificări
- `modules/admin/components/views/aside-demo.php` — rescris pe markup-ul exact
  al sidebar-ului real; conținutul rămâne generat din AdminEntityRegistry
  (filtrat la whitelist), deci meniul nu poate diverge de ce servește panoul.
- `modules/admin/Module.php` — scos override-ul de layout; panoul demo
  folosește acum layout-ul real, neschimbat.
- `modules/admin/views/layouts/admin-demo.php` — **șters** (nu mai are rost).

### Verificat
- CSS încărcat în /admin-demo == CSS-ul adminului real (7 fișiere, fără demo.css).
- `demo-banner`: 0 apariții. `<aside class="aside">`: 0.
- Clasele de layout se potrivesc 1:1 cu adminul real (admin-aside,
  admin-aside-brand, admin-aside-nav, admin-aside-logo-mark,
  admin-aside-section-last — toate 1/1).
- Identitatea demo rămâne clară fără bandă: sidebar „Panou demo", header
  „Demo · copie temporară", ambele cu „Înapoi la pagina demo".
- Cele 5 ecrane demo → 200; cele blocate (blog, services, users, settings,
  trash) → 404; `/admin` real → 302 la login.

---

## 2026-09-03 (a) — butonul „Start A Project" duce la pagina Pricing

### Task
„daca apesi pe start project pune sa se duca ca pe pagina pricing"

### Ce făcea înainte
Butonul CTA din header (eticheta vine din `settings`, cheia `cta_button`, care
în engleză e „Start A Project") nu era un link. Era un `<div>` cu
`data-bs-toggle="modal"` care deschidea modala de contact `#contact-us-modal`.
Ancora dinăuntru nu avea `href`, deci nu naviga nicăieri și nu era focusabilă
cu tastatura.

### Modificări
- `views/common/header/_cta.php` — atributele de modal scoase; ancora primește
  `href="<?= Url::to(['/price-builder/index']) ?>"`, adică exact ruta pe care o
  folosește și elementul „Pricing" din meniu (`menu_items.route`). Url::to
  păstrează prefixul de limbă, deci butonul duce la `/ro/...` și `/ru/...` când
  vizitatorul e pe acele limbi.

### Decizii
Ruta e scrisă direct, nu citită din `menu_items`, ca să nu adauge o
interogare pe fiecare pagină. Aceeași convenție e folosită deja în
`views/demo/index.php:451`.

Modala `#contact-us-modal` rămâne randată din `views/layouts/main.php`. Nu am
șters-o: nimic altceva nu o mai deschide acum, dar ștergerea markup-ului ei
depășește ce s-a cerut și e ușor de reactivat.

### Verificat
- `/`, `/ro`, `/ru` → butonul are `href` către `/price-builder/index`,
  cu prefixul de limbă corect pe fiecare.
- `data-bs-toggle` și `data-bs-target` nu mai apar în header.
- Ruta țintă răspunde 200 pe toate cele trei limbi.

---

## 2026-09-03 (b) — imaginile din EditMode nu se mai vedeau pe cinova.com

### Task
„poti verifica ce s-a intamplat cu imaginile de pe cinova, ca nu merg si stilul
a aplecat putin din cauza asta"

### Cauza
`RenderHelper::editableImage()` salvează în `element_content.src` exact URL-ul
primit din view, iar view-urile îl construiesc cu `Url::to('@web/core/...')`.
Cât timp site-ul a fost servit din localhost/cinova/web, acel apel returna
`/cinova/web/core/...`, deci prefixul locului de montare a intrat în conținut.
Pe cinova.com rădăcina web ESTE folderul `web/`, deci aceleași rânduri indicau
o cale inexistentă și pozele dădeau 404. Fiind `<img>` fără dimensiuni impuse,
locul gol a împins blocurile din jur — de acolo și senzația că „stilul a
aplecat".

Nu era o regresie recentă: fișierele erau toate pe disc, doar prefixul stocat
era greșit. Afecta 9 poze pe prima pagină, 8 pe /about-us, 8 pe /services.

### Soluția
Ce se stochează nu mai depinde de locul de montare. Tot ce servește aplicația
stă sub `web/core/`, deci partea portabilă e calea de la „core/" încolo.

- `helpers/RenderHelper.php` — două metode noi:
  - `normalizeSrc()` taie orice prefix dinaintea primului segment `core/` și
    lasă neatinse URL-urile externe (orice schemă, protocol-relative, `data:`).
  - `resolveSrc()` pune la loc baza curentă, cu `Url::to('@web/...')`.
  Sunt folosite la însămânțare (`editableImage`, `initElement`), la randare
  (`editableImage`, `applyContentToNode`, blocurile de imagini) și în
  `storedSrc()`.
- `modules/editmode/controllers/EditController.php` — `assignSafeFields()`
  normalizează `src` la salvare (nu și `href`, care poate fi legitim o cale
  absolută în afara arborelui de assets); la fel pentru fiecare imagine dintr-un
  bloc. `uploadPathFromUrl()` recunoaște acum folderul de upload și fără slash
  la început, altfel curățenia fișierelor înlocuite ar fi încetat să mai
  găsească ceva.
- `migrations/m260903_090001_normalize_element_content_src.php` — trece prin
  `element_content`, `element_content_draft` și ambele coloane din
  `element_content_history` și aplică aceeași regulă pe ce era deja salvat.
  Regula stă într-un singur loc (RenderHelper), deci migrarea nu poate diverge
  de cod. `down()` nu pune prefixul la loc, pentru că nu mai contează.

### Verificat
- Migrarea: 99 valori normalizate (69 în `element_content`, 30 în istoric).
- `/`, `/about-us`, `/services`, `/ro`, `/ru/services` → zero căi cu prefixul
  vechi.
- Recrawl public complet: 184 pagini pe RO/EN/RU, 0 erori; 65 fișiere statice
  verificate, **0 rupte** (erau 15 înainte).
- Aceeași bază de date randează acum prefixul corect pe AMBELE montări:
  `/core/assests/people/big-person.jpg` pe cinova.com și
  `/cinova/web/core/assests/people/big-person.jpg` pe localhost/cinova/web,
  ambele 200.
- Regula testată izolat pe 12 intrări: prefixe vechi, prefixe noi, cale fără
  slash, upload-uri, URL absolut care conține „core/" (rămâne neatins),
  protocol-relative, `data:`, gol, null, nume de fișier cu spații și paranteze.

---

## 2026-09-03 (c) — mailerul: expeditorul respins de mail.ru, formularul de contact nu trimitea nimic

### Task
„uite aici cum e facut senderul de emailuri [bcars] si configureeazal si la mine,
eu nu stiu daca lai configratt mai devreme, verifica daca lucreaza la forma de la
contact_us"

### Despre bcars
Nu are ce fi copiat de acolo. `bcars/config/web.php` are mailerul exact ca în
șablonul Yii: `useFileTransport => true`, fără SMTP, iar
`bcars/controllers/ContactController.php` nu trimite niciun e-mail. Singurele
apeluri `mailer->compose()` din bcars sunt cele de cont din
`models/users/Users.php`, și ele scriu tot în fișier.

### Ce era stricat în cinova
Transportul SMTP era corect și funcțional — verificat la nivel de protocol:
conexiune `ssl://smtp.mail.ru:465`, `AUTH LOGIN` → `235 Authentication succeeded`.

Problema era expeditorul. `params.senderEmail` rămăsese placeholderul
`noreply@example.com`, iar mail.ru refuză orice `MAIL FROM` care nu e cutia
autentificată:

    MAIL FROM:<noreply@example.com>   → 550 not local sender over smtp
    MAIL FROM:<ciobanu-23@inbox.ru>   → 250 OK

Deci fiecare notificare de contact eșua. `ContactController::sendAdminEmail()`
prinde excepția intenționat, ca trimiterea formularului să reușească chiar dacă
SMTP-ul e picat, deci vizitatorul primea „Your message has been sent
successfully!" iar eroarea rămânea doar în log. Dovada în `runtime/logs/app.log`,
pentru cererea #12 trimisă azi la 19:32, înainte de reparație:

    [yii\symfonymailer\Mailer::sendMessage] Expected response code "250" but got
    code "550", with message "550 not local sender over smtp".
    [ContactController::sendAdminEmail] Contact email failed for request #12

Al doilea placeholder: `params.adminEmail` era `admin@example.com`, adică un
domeniu rezervat IANA — chiar și cu expeditor bun, notificarea n-avea unde ajunge.

### Modificări
- `config/params.php` — `senderEmail` și `adminEmail` puse pe
  `ciobanu-23@inbox.ru` (cutia cu care se autentifică transportul);
  `senderName` din „Example.com mailer" în „Cinova". Comentarii care spun de ce
  `senderEmail` nu e liber ales.
- `config/web.php` — scoase din `transport` cheile `class => Swift_SmtpTransport`
  și `encryption => ssl`. Sunt rămășițe de SwiftMailer pe care
  `yii\symfonymailer` le ignoră complet, deci sugerau o setare care nu era de
  fapt activă. Rămân `scheme => smtps`, `host`, `port`, `username`, `password`.

### Verificat
- SMTP la nivel de protocol: `235 Authentication succeeded`. Testele de
  expeditor se opresc înainte de `DATA`, deci n-au trimis nimic.
- Trimitere reală prin formular: POST pe `/contact/submit` → `{"success":true}`,
  rândul #13 salvat în `contact`, **zero** erori în log după el. Comparat cu
  #12, care are cele două erori de mai sus.

### Notes
Rândul #13 din `contact` e o intrare de TEST („TEST / Verificare mailer"), o
poți șterge din panou.

Parola SMTP stă în clar în `config/web.php`, care e urmărit de git — la fel ca
datele din `config/db.php`. Nu am schimbat asta, dar merită mutată într-un
fișier neurmărit înainte de publicare.

---

## 2026-09-03 (d) — completare la (b): și răspunsurile JSON ale editorului

### De ce
Reparația din (b) mută în baza de date calea relativă la rădăcina web
(`core/assests/...`). Randarea HTML o rezolvă, dar editorul primește imagini și
prin JSON, iar `modules/editmode/web/js/main.js` le pune direct în `img.src` —
la diff-ul din istoric (liniile 990-991) și la restaurare (1187). O cale
relativă s-ar fi rezolvat față de adresa paginii curente, deci miniaturile din
istoric ar fi dat 404 pe orice pagină în afară de rădăcină.

### Modificări
- `modules/editmode/controllers/EditController.php` — `RenderHelper::resolveSrc()`
  pe fiecare `src` care pleacă spre browser: `element-content`, `publish`,
  `instant-publish`, `discard`, `restore` și ambele coloane din `history`.
- Metodă nouă `resolveContentSrcs()`: blocurile de imagini își țin pozele în
  JSON-ul din `content`, deci și acelea se rezolvă la ieșire. Azi nu există
  niciun rând de acest tip în baza de date, dar funcția e activă, deci gaura e
  închisă înainte să apară.
- Închiderea `$fields` din `actionElementContent()` nu mai e `static` — apelează
  acum o metodă de instanță.

### Verificat
- `element-content` întoarce `/core/assests/people/min-person-1.jpg`, nu forma
  relativă.
- `history` pentru `common-about-photo` pe RO, EN și RU: 6 intrări fiecare,
  toate `src_old`/`src_new` ca URL-uri absolute de la rădăcină.
- `site-map`, `draft-status`, `language-url`, shell-ul editorului: toate 200.

### Notes
Două intrări din istoric arată către
`core/uploads/images/editmode/f829b914...png`, care nu mai există pe disc.
Nu e o regresie: `cleanupReplacedUploads()` șterge fișierul înlocuit, iar
comentariul din cod spune explicit că istoricul e ignorat la curățenie, deci o
versiune veche poate rămâne fără poză. Comportament neschimbat de (b) sau (d).

---

## 2026-09-04 — formularul de contact: erorile nu mai împing layout-ul

### De ce
La validare, mesajele de eroare apăreau/dispăreau sub fiecare câmp și schimbau
înălțimea formularului — câmpurile și butonul săreau în sus/jos la fiecare
blur sau submit. Se vedea cel mai clar la submit gol, când apar cinci mesaje
deodată.

### Ce s-a schimbat
- `views/common/_contact_form.php` — formularul primește clasa `contact-form`,
  iar fiecare câmp `contact-form__field` (mesajul și `--message`). Am scos
  utilitarele Bootstrap de spațiere (`mb-2`, `mb-sm-5 mb-4`), spațierea o dă
  acum CSS-ul; containerul butonului are `contact-form__actions`.
- `web/core/styles/style.css` — bloc nou la final: fiecare câmp are
  `padding-bottom: 26px` rezervat, iar `.error` e poziționat absolut în acea
  bandă. Afișarea sau ascunderea mesajului nu mai atinge înălțimea nimănui.
- `models/contact/forms/ContactSubmitForm.php` — `tooShort`/`tooLong` scurte
  („Minimum 2 caractere.") în loc de mesajele lungi implicite din Yii, ca să
  încapă pe rândul rezervat.
- `messages/{ru,ro,en}/frontend.php` — cheile `At least {min} characters.` și
  `At most {max} characters.`.

### Design
- Placeholder-ele erau alb pur, la fel ca textul introdus — acum
  `rgba(255,255,255,.45)`, deci se vede ce e completat și ce nu.
- Focus: chenar lime + halou discret (`box-shadow` 3px) în loc de
  `border-color: unset`, care oricum era CSS invalid.
- Stare invalidă: chenar roșu translucid + fundal roșu foarte slab pe input;
  mesajul e `#ff8b8b`, 13px, cu fade-in de 200ms. Stare validă (Yii pune
  `has-success` la blur): chenar lime slab.
- `textarea` are `min-height: 140px` și `resize: vertical`.
- Alertele de succes/eroare de deasupra formularului erau cutii deschise la
  culoare, de Bootstrap, pe panou negru — acum sunt pe paleta temei (lime
  translucid / roșu translucid).

### Verificat
Pe `/ru/contact`, cu submit gol: înălțimea formularului 413.55px înainte și
după apariția celor cinci erori, poziția butonului identică (1047.46px).
Blur pe `first_name` cu o singură literă → „Минимум 2 символа.", încape pe un
rând (`scrollWidth == clientWidth`), câmpul rămâne la 85.78px.

---

## 2026-09-04 (b) — textele formularului de contact în EditMode (tiparul bcars)

### De ce
Mesajele de validare, confirmarea de trimitere și eticheta „se trimite" erau
`Yii::t('frontend', …)` — orice reformulare cerea un deploy. bcars le ține de
mult în EditMode (`common-form-error-*`, vezi `views/common/_contact_quote.php`
și `models/contact/forms/QuoteForm.php`); același tipar, portat aici.

### Chei noi (toate pe pagina `technical`)
- Etichete: `common-contact-form-firstname|-lastname|-email|-phone|-message`.
- Mesaje partajate: `common-form-error-required|-email|-phone|-min|-max|-generic`,
  `common-form-success`, `common-form-sending`.

### Modificări
- `models/contact/forms/ContactSubmitForm.php` — `t()` (wrapper peste
  `RenderHelper::textValue('technical', …)`), `rules()` și `attributeLabels()`
  citesc elementele, cu vechiul `Yii::t` păstrat ca default/fallback. Nou:
  `editableStrings()`, lista `cheie => default` pe care o randează panoul.
- `controllers/ContactController.php` — confirmarea și eroarea generică vin din
  `common-form-success` / `common-form-error-generic` (metodă `successMessage()`).
- `views/common/_contact_form.php` — eticheta „se trimite" din element; sub
  formular, doar pentru editor (`RenderHelper::isEditor()`), un panou punctat
  care listează toate textele ca spanuri editabile, plus hint despre
  `{attribute}` / `{min}` / `{max}`.
- `web/core/styles/style.css` — `.contact-form__editor-panel|__editor-title|__editor-hint`.
- `commands/EditmodeMigrateController.php` — cele 13 chei adăugate în `MESSAGES`.
- `messages/{ru,ro,en}/frontend.php` — două chei pentru textul panoului.
- `EDITMODE_CONTENT_GUIDE.md` — §5.8 nouă (tiparul complet: bazin partajat de
  chei, modelul care citește elemente, etichetele = aceleași elemente ca
  placeholder-ele, placeholderele `{attribute}`, sincronizarea client/server,
  panoul de editor, seed înainte de conversie, ce NU se mută). Plus un rând în
  tabelul de decizie §1, două capcane noi în §8 și intrarea din inventarul §10.

### Invariantul important
`attributeLabels()` citește ACELEAȘI elemente pe care view-ul le pune ca
`placeholder`, deci „«Prenume» este obligatoriu" nu poate contrazice eticheta de
pe ecran. `{attribute}`, `{min}` și `{max}` rămân funcționale în textul editat —
Yii le înlocuiește la formatare; șterse, mesajul rămâne fără numele câmpului.

### Verificat
- `php yii editmode-migrate` rulat ÎNAINTE de prima randare (§4): 39 rânduri
  `element_content` = 13 chei × 3 limbi, fiecare cu traducerea reală din
  `messages/{ro,ru}/frontend.php` (verificat rând cu rând în DB).
- `/{ru,ro,en}/contact` și cele trei homepage-uri → 200.
- Pe RO placeholder-ele vin din elemente (`Prenume`, `Număr de telefon`), iar
  mesajele de validare client-side randate în pagină sunt cele din elemente
  („Acest câmp este obligatoriu.", „Minimum 2 caractere.").
- Ca vizitator neautentificat, panoul de editor nu apare în HTML (0 apariții).
- `php -l` curat pe toate fișierele PHP atinse.

### Rămas de verificat manual
Panoul de editor în sesiune de editor (login la `/auth/login`) — click pe fiecare
string, ciclu draft → publish → istoric. Nu l-am putut testa singur: nu introduc
parole în formulare.

---

## 2026-09-04 (c) — builder-ul devine configurator: fără preț estimat

### De ce
Wizard-ul se prezenta ca un calculator de preț și se termina cu o sumă mare pe
ecran („Iată estimarea ta — $4 150 – $6 350"). Un număr dat înainte de o
discuție reală e o promisiune pe care nimeni n-o poate ține. Ideea rămâne
aceeași — intri, alegi ce vrei — dar rezultatul e configurația, nu prețul.

### Ce nu mai vede vizitatorul
- `views/price-builder/index.php` — șters blocul `price-builder__preview`
  (eticheta „Preț estimat" + suma de pe ecranul de e-mail) și linia mare
  `price-builder__price` de pe ecranul final. Rămâne doar lista alegerilor.
- `web/core/js/index.js` — `renderPreview()` și scrierea în
  `[data-summary="price"]` au dispărut.
- `web/core/styles/style.css` — regulile `.price-builder__price*` și
  `.price-builder__preview*` șterse; `.price-builder__summary` primește
  `margin-top: 32px`, spațiul pe care îl dădea înainte prețul.

### Ce s-a păstrat intenționat
`calculateEstimate()` rulează în continuare, dar **doar** pentru linia
`Estimate: $…` din lead-ul care ajunge la echipă (`summaryText()` → contact +
e-mail către admin). Numărul e util intern; nu mai apare nicăieri în interfață.
Verificat pe lead-ul #15: rândul e acolo. Ruta `/price-builder`, alias-urile
(`pricing`, `data-step="estimate"`) și setările `price_builder_base_*` își
păstrează numele — redenumirea lor ar schimba URL-uri și chei de admin pentru o
modificare de text.

### Reformulare „calculează un preț" → „configurează-ți site-ul"
- `messages/{ro,ru}/frontend.php` — cheile sursă schimbate:
  `One last step before your setup`, `…after seeing your setup.`,
  `See my setup`, `Here's your setup`, plus un disclaimer fără sumă
  („Asta e configurația pe care ai făcut-o…"). Cheia `Estimated price` ștearsă,
  nu mai are utilizator. EN e limba sursă, deci nu are nevoie de rânduri.
- `views/demo/index.php` — butonul „Calculează un preț" → „Configurează-ți
  site-ul".
- `migrations/m260904_120001_rebrand_price_builder_as_configurator.php` —
  conținut din DB: eticheta din meniu (`Prețuri` / `Pricing` / `Цены` →
  `Configurator` / `Configurator` / `Конфигуратор`) și `seo_title`-ul paginii
  (`Estimare de preț pentru proiect` → `Configurează-ți site-ul`, etc).
  Update-urile se potrivesc pe valoarea VECHE, deci o etichetă rescrisă între
  timp din admin nu e călcată; `safeDown()` face drumul invers.

### Ce am lăsat neatins, deliberat
Secțiunea de pachete de pe home (`section_headers` `home/pricing`: „Prețuri —
Pachete clare, fără surprize la oră") și planurile din `pricing_plans`. Alea
sunt prețuri reale, publicate, nu un calculator — spune-mi dacă vrei și acolo
altă abordare.

### Verificat
- `/{ro,ru,en}/price-builder` și `/ro/demo` → 200; `node --check` pe index.js.
- Parcurs complet în browser pe RO: cele 8 întrebări → ecranul de e-mail
  („Un ultim pas înainte de configurația ta", fără sumă) → ecranul final
  („Iată configurația ta"), cu cele 8 rânduri de sumar corecte și **niciun `$`**
  în tot ecranul (test `/\$\s?\d/` pe `textContent`).
- Lead-ul #15 salvat în `contact` are alegerile + `Estimate: $4 200 - $6 500`.
- Nav: RO/EN `Configurator`, RU `Конфигуратор`; titlul paginii
  „Configurează-ți site-ul".
- Rest de curățenie: lead-ul de test #15 (`config-test@cignova.com`) a rămas în
  `contact`, ca celelalte rânduri de test de dinainte.

### Panoul de editor din (b) — verificat acum
Cu sesiune de editor, `/ro/contact` randează panoul cu toate cele 8 texte ca
spanuri editabile, cu valorile RO corecte (`common-form-error-required` =
„Acest câmp este obligatoriu.", …). Rămâne de făcut manual un ciclu
draft → publish → istoric.

---

## 2026-09-04 (d) — navbar-ul și pagina demo intră în EditMode, în 3 limbi

### 1. Navbar (`m260904_140001_seed_navbar_editmode_elements`)

Nimic din navbar nu se putea edita din EditMode: etichetele veneau din
`menu_items_translations` (ecran de admin), logo-ul era hardcodat, iar butonul
CTA citea `settings.cta_button`.

- Etichetele → elemente `common-menu-<alias>` pe `technical`, seed-uite per
  limbă din traducerile existente. `MenuItems::getLabel()` citește elementul,
  cu rândul din DB ca fallback — deci header, footer și orice alt loc care
  randează itemul folosesc același text și nu pot diverge.
- „Un singur stăpân" (ghid §0) fără să pierdem ecranul de admin: câmpul `label`
  din formularul de traduceri devine `readonly` + hint care trimite în EditMode.
  `readonly`, nu `disabled` — un `disabled` nu se trimite la POST și ar goli
  rândul-seed la prima salvare.
- Logo → `editableImage('technical', 'common-header-logo', …)`; nu se seed-uiește
  din consolă (§4).
- CTA → `common-header-cta`, seed-uit din `settings_translations`, apoi rândul
  din `settings` a fost șters: nu-l mai citește nimeni (§7). `safeDown()` îl
  reconstruiește din element.
- **Comutatorul de limbi rămâne nemarcat**, intenționat: e navigație
  funcțională, iar o etichetă schimbată acolo strică selectorul.
- Bonus prins pe drum: eticheta RU a itemului „Demo" era literalmente `????`
  (mojibake în DB, se vedea în navbar) → `Демо`.

### 2. Pagina demo (`m260904_150001`, `m260904_150002`)

Era singura pagină scrisă integral cu literale românești: `/en/demo` și
`/ru/demo` serveau română, și nimic nu se putea reformula fără deploy.

- **86 de chei noi pe pagina `demo`**, seed-uite explicit în RO/EN/RU
  (258 rânduri `element_content`): bara de editare, pagina-exemplu (`demo-x-*`),
  secțiunea cu componente din baza de date, secțiunea finală, sidebar-ul
  simulat și textele scrise de JS.
- **Textele din `demo-page.js`** (eticheta butonului, contorul, hint-urile,
  toast-urile) rămâneau românești oricum — un `.js` nu poate citi RenderHelper.
  View-ul le predă acum prin `window.DEMO_I18N`, la fel ca `DEMO_CHOICES`, iar
  scriptul le citește cu `t('cheie', 'literal vechi')`, păstrând literalul ca
  fallback. Plural: două chei + `{n}`.
- **Cele două editoare coexistă pe aceleași noduri**: `data-demo-key`
  (simularea din sessionStorage, a vizitatorului) și `data-key` (EditMode).
  Nu se ceartă — `demo-page.js` interceptează click-urile doar după ce
  vizitatorul apasă „Editează pagina".
- **SEO**: pagina n-avea niciun rând în `pages_translations`, în nicio limbă.
  Cauza reală: rândul din `pages` fusese auto-creat de RenderHelper cu
  `name = 'demo'` (cheia de pagină), în timp ce `DemoController` îl caută după
  `name = 'demo/index'` (ruta) — deci primea `null` și `SeoHelper` nu aplica
  nimic. Redenumit + trei rânduri de SEO. RenderHelper rezolvă după `slug`,
  care nu s-a schimbat.

### Capcană prinsă în timpul lucrului
Prima versiune a ambelor migrări făcea seed pe TOATE limbile din tabel (33),
nu doar pe cele active: 2739 de rânduri în loc de 258. Rulate `migrate/down`,
corectate cu `WHERE status = 'ACTIVE'`, rulate din nou. E acum și în ghid, §8.

### Documentație
`EDITMODE_CONTENT_GUIDE.md` §5.9 nouă — „Navigație, logo și textele care
trăiesc în JS": etichetele de meniu și rezolvarea „un singur stăpân" fără
ștergerea adminului, logo-ul, de ce comutatorul de limbi nu se marchează,
tiparul `window.*_I18N` pentru texte din `.js`, și coexistența celor două
editoare pe demo. Plus trei capcane noi în §8 și două intrări în inventarul §10.

### Verificat
- `/{ro,en,ru}/demo` → 200; niciun cuvânt cu diacritice românești rămas în
  pagina RU; `window.DEMO_I18N` livrat în rusă.
- Parcurs complet al simulării pe `/en/demo`, în browser: „Edit the page" →
  click pe titlu → sidebar cu valoarea curentă → tastat → „Publish" → textul
  s-a schimbat în pagină, toast „First change published!", contor „1 change".
  Tot fluxul în engleză, inclusiv ce scrie JS-ul.
- Navbar RU randează din elemente: `Конфигуратор`, `Демо` (nu `????`),
  `Начать проект` din `common-header-cta` după ștergerea setării.
- `<title>` per limbă pe `/demo`: RO/EN/RU, fiecare diferit.
- `php -l` pe toate fișierele PHP atinse, `node --check` pe `demo-page.js`.

### Rămas de verificat manual
Click efectiv pe elementele noi într-o sesiune de editor (navbar + demo):
sidebarul EditMode să se deschidă cu formularul corect, plus un ciclu
draft → publish → istoric. Sesiunea de editor din browser expirase când am
ajuns la verificare, iar eu nu introduc parole.

---

## 2026-09-04 (e) — /admin-demo: deschis pentru toți, o bază de date per vizitator

### Ce nu mergea
Două bug-uri, ambele raportate ca „nu pot intra" / „nu se salvează nimic".

**1. 403 pentru un admin logat.** Un utilizator autentificat în panoul REAL care
deschidea `/admin-demo` era purtat înăuntru cu propria identitate (`user #2` în
log). Id-ul lui nu înseamnă nimic în copia demo, deci RBAC nu-i găsea niciun rol
și `AccessControl` răspundea `ForbiddenHttpException`. Sesiunea demo avea alt
NUME, dar aceleași CHEI de sesiune (`__id`, `__authKey`) — și era de ajuns ca
identitatea reală să fie citită.

**2. Orice POST → 400 „Unable to verify your data submission".** Era notat în
cod ca defect cunoscut, cu observația că „tokenul e valid când e verificat în
timpul parsării URL-ului". Cauza reală, găsită prin bisecție cu probe în
`parseRequest` / `activateAdminDemo` / `DemoGuard`:

- `ensureLoggedIn()` rula ÎN `AdminDemoUrlRule::parseRequest()`, adică în
  mijlocul lui `$request->resolve()`. `User::login()` regenerează acolo și
  id-ul de sesiune, și tokenul CSRF; sesiunea scrisă la finalul cererii nu mai
  corespundea id-ului trimis în `Set-Cookie`, deci cererea următoare venea ca
  guest, se autentifica din nou, regenera din nou tokenul — iar formularul
  randat cu o secundă înainte nu mai valida niciodată.
- Peste asta, **modulul debug** (activ pe YII_ENV_DEV) cere tokenul CSRF în
  bootstrap, înainte de rutare, și cache-ul lui `Request::$_csrfToken` rămânea
  cu un token pe care nimeni nu-l primise. Probat direct: cu debug scos din
  `/admin-demo`, același POST trece de la 400 la 200 și sesiunea nu mai
  „curge" (0 `Set-Cookie` de sesiune în loc de 2 per cerere).

### Modificări
- `config/web.php` — bloc nou: pentru `/admin-demo` se configurează din start
  `session.name`, `user.idParam = __id_admin_demo`,
  `user.authKeyParam = __authKey_admin_demo`, `identityCookie` propriu și
  `enableAutoLogin = false`. Trebuie să fie în CONFIGURAȚIE, nu într-o regulă
  de URL: `Session::getHasSessionId()` își memorează răspunsul la prima
  întrebare, iar în dev întreabă modulul debug, în bootstrap.
  Tot acolo: debug și Gii nu se mai încarcă pentru `/admin-demo` — și pentru
  bug-ul de mai sus, și pentru că panoul e public, iar toolbar-ul ar da unui
  străin query-urile, configurația și log-ul site-ului.
- `components/AdminDemoUrlRule.php` — `isAdminDemoUri()`, verificare pe
  `$_SERVER['REQUEST_URI']`, dinainte să existe aplicația (pe segment de cale,
  ca un slug care conține cuvântul să nu declanșeze).
- `components/DemoSandbox.php` — `activateAdminDemo()` face acum doar ce ține de
  rutare (baza de date + guard); autentificarea s-a mutat în
  `signInDemoOperator()`, apelată din `DemoGuard::beforeAction`, adică pe
  beforeAction-ul aplicației: după ce Yii e pornit, dar tot înaintea
  `AccessControl`-ului modulului admin. Metoda respinge orice identitate care
  nu e operatorul demo (`logout(false)` — doar identitatea, sesiunea rămâne).
  Verificarea folosește `$user->getIdentity()`, nu un `Users::findOne()` nou:
  `Users::find()` are scope de soft-delete, așa că re-interogarea chiar a
  operatorului logat putea întoarce null → logout + login la fiecare cerere →
  token CSRF regenerat la fiecare cerere.
- `components/DemoSandbox.php` + `config/params.php` — plafonul de sandbox-uri
  per IP a crescut de la 3/oră (constantă în cod) la `demoMaxPerIpPerHour = 10`
  (parametru). Sub vechea valoare, al 4-lea vizitator dintr-un birou cu NAT
  cădea pe copia PARTAJATĂ și vedea modificările celorlalți — exact ce nu
  trebuie. Peste plafon comportamentul rămâne același, documentat acum explicit.

### Verificat (curl, două „persoane" diferite)
- `/admin-demo`, `/pricing-plans`, `/testimonials`, `/faq`, `/partners` → 200
  pentru un vizitator neautentificat, fără cont.
- **Scriere reală**: POST pe `pricing-plans/create` → 302 (redirect de succes),
  nu 400.
- **Izolare**: vizitatorul A (`demo_t=8d5d6e1a…`) creează un plan și îl vede;
  vizitatorul B (`demo_t=318e1d74…`) primește altă bază de date și NU vede
  rândul lui A (grep = 0). În MySQL: `cinova_demo_8d5d…` are 4 planuri,
  `cinova_demo_318e…` are 3.
- **Baza reală neatinsă**: `cinova` are tot 3 planuri după toate testele.
- Ecran din afara listei albe (`/admin-demo/blog`) → 404; `/admin` real →
  302 la login; site-ul public → 200.

### Ce rămâne deschis
- Durata de viață e cea existentă: `demoTtlHours = 1`, maximum
  `demoMaxSandboxes = 30`, curățenie la fiecare activare + `demo/cleanup`.
  Dacă vrei alt interval, e un singur parametru.
- N-am putut testa cazul „admin real logat deschide /admin-demo" end-to-end
  (nu introduc parole); izolarea e închisă prin `idParam`/`authKeyParam`
  separate, dar merită un click de confirmare din partea ta.
- `cinova_demo` (copia partajată) conține rândul de test `guest-a-plan`,
  creat înainte de ridicarea plafonului.

---

## 2026-09-04 (f) — completare la (e): sandbox-ul nu mai murea sub vizitator

### Ce am găsit verificând întrebarea „chiar are fiecare guest un demo al lui?"
Da, dar nu „pe cât timp lucrează" — pe cât timp trecea de la CREARE.

`touch()`, care împinge `last_seen_at` înainte, era apelat doar din
`onBeforeRequest`, iar acela e legat exclusiv în `config/demo.php`, adică pe
HOST-ul de demo. Pe `/admin-demo` de pe host-ul principal nu rula niciodată.

Mai rău: chiar dacă rula, ar fi eșuat. `registryDb()` folosește componenta
`mainDb` dacă există, altfel cade pe `db` — iar `db` fusese deja comutat pe
copia vizitatorului. Registrul `demo_sandboxes` stă în baza PRINCIPALĂ, deci
update-ul dădea `Table 'cinova_demo_<token>.demo_sandboxes' doesn't exist`,
prins într-un `catch` care doar loga un warning. Nimic nu se rupea vizibil:
`last_seen_at` pur și simplu nu se mișca, iar curățenia arunca baza de date a
cuiva la o oră după creare, indiferent cât de intens lucra în ea.

### Modificări
- `components/DemoSandbox.php` — `useDemoDatabase()` înregistrează componenta
  `mainDb` (conexiunea reală din `config/db.php`) ÎNAINTE de a comuta `db`, deci
  registrul rămâne accesibil după comutare, la fel ca pe host-ul de demo.
- `components/DemoSandbox.php` — `signInDemoOperator()` apelează `touch()` la
  fiecare cerere din `/admin-demo`. Ora devine ce a pretins mereu că e: o oră
  de INACTIVITATE, nu o oră de la creare.

### Verificat
- `last_seen_at` pentru `d30d8ce3…`: inactiv de 681s → după o vizită, 1s.
  Sandbox-ul vecin (`138e8b67…`) rămâne neatins la 687s, deci se împinge doar
  al vizitatorului activ.
- Fluxul complet, cu cache-ul de rate-limit golit (vizitator nou):
  token `e8ef885149b7ca76` → creare plan → 302 → își vede rândul; alt vizitator,
  fără cookie, nu-l vede (0).
- `grep "demo touch failed"` nu mai produce intrări noi după fix.

---

## 2026-09-05 — /admin-demo: izolare pe rânduri în loc de bază per vizitator

### De ce
Fiecare vizitator al panoului demo primea o bază de date întreagă: 67 de tabele,
6,5 MB și ~3,4 s de SQL, ca să fie separate 113 rânduri de conținut. Măsurat
înainte de schimbare:

- 6656 KB per copie, din care aproape nimic date — costul e per TABEL
  (`innodb_file_per_table = ON`, ~100 KB podea/tabel). 15 tabele complet goale
  ocupau singure 1168 KB.
- prima pagină a unui vizitator nou: 4,4–6,5 s.
- datele reale ale celor 4 entități: 113 rânduri, 66 KB serializat.

Panoul demo ajunge la patru entități (lista albă din `AdminDemoUrlRule`), deci
separarea încape într-o coloană: tokenul din cookie-ul `demo_t`, care alegea o
CONEXIUNE, alege acum RÂNDURI.

### Cum
- `migrations/m260905_100001_add_sandbox_token_to_demo_entities.php` — coloana
  `sandbox_token` + index pe `pricing_plans`, `testimonials`, `faq`, `partners`.
  Se adaugă și în baza REALĂ, unde rămâne NULL: bazele demo sunt clone ale ei,
  deci o coloană doar în copie ar fi ștearsă de următorul `demo/snapshot`.
  NULL marchează setul-etalon din care se seamănă fiecare vizitator.
- `components/DemoTenancy.php` — tot mecanismul într-un loc: scope, semănat,
  ștergere, numărare. Traducerile NU poartă coloana: se ajunge la ele prin
  `entity_id`, care e deja unic per vizitator (părinții sunt inserați proaspăt),
  iar `(entity_id, language_id)` e UNIQUE — exact de asta id-urile se remapează
  la copiere în loc să fie refolosite.
- `models/query/SoftDeleteQuery.php` — scope-ul de citire se aplică în
  `prepare()`, singurul loc prin care trec deja toate cele patru modele. Per
  model ar fi însemnat patru ocazii de a-l uita, iar un scope uitat aici e un
  vizitator care citește rândurile altuia.
- `components/DemoTenantBehavior.php` + cele 4 modele — la INSERT rândul e
  marcat cu tokenul. Doar insert: un UPDATE poate ajunge numai la un rând deja
  încărcat printr-o interogare scoped.
- `components/DemoSandbox.php` — `/admin-demo` folosește acum baza partajată
  `cinova_demo` și seamănă rândurile; `dropSandbox()` recunoaște un sandbox pe
  rânduri și îi șterge rândurile în loc să arunce baza partajată.
- `commands/DemoController.php` — `demo/stats` raportează ambele forme.

### Bug prins la prima rulare
Semănatul a picat cu `fk_pricing_plans_updated_by`: rândurile-etalon au fost
scrise în baza reală și arată către operatori reali (`created_by = 2`), dar copia
demo păstrează doar utilizatorul demo. `created_by` / `updated_by` / `deleted_by`
se golesc acum la copiere — e și răspunsul onest: în baza asta nu le-a creat
nimeni.

### Verificat
- Prima pagină a unui vizitator nou: **0,65 s** (era 4,4–6,5 s). A doua: 0,47 s.
- Doi vizitatori: A creează un plan și îl vede; B primește setul lui curat și NU
  vede rândul lui A (grep = 0), dar are propria listă completă.
- În DB: fiecare token are 32 de înregistrări-părinte + traducerile lor
  (A are 33, cu planul creat); setul-etalon rămâne neatins la 32.
- **Test de acces încrucișat**: A deschide rândul lui → 200; rândul lui B → 404;
  un rând din setul-etalon → 404. Scope-ul ține prin `findModel()`-ul CRUD-ului.
- Baza reală: 0 rânduri marcate, conținut neschimbat.
- Site-ul public randează în continuare planurile și FAQ-ul, inclusiv cu un
  cookie `demo_t` prezent — scope-ul cere `AdminDemoUrlRule::isActive()`.
- `cinova_demo` a crescut cu ~270 KB pentru TREI vizitatori (~90 KB fiecare),
  față de 6,5 MB per vizitator înainte.

### Ce am dat la schimb, explicit
Izolarea nu mai e garantată de MySQL, ci de cod. O interogare care scapă de
scope e o scurgere. De asta scope-ul e într-un singur loc, iar cele patru tabele
sunt enumerate explicit în `DemoTenancy::TABLES`.

Host-ul de demo (`demo.cinova.md`) păstrează bază per vizitator: acolo rulează
tot site-ul, se citesc toate cele 67 de tabele. Cele două forme coexistă
intenționat — `useDemoDatabase()` alege între ele după `AdminDemoUrlRule`.

### Rămas
Cele 5 baze `cinova_demo_<token>` de dinainte sunt reziduuri; expiră singure sau
`php yii demo/cleanup` le ia acum.

---

## 2026-09-05 (b) — bug de izolare prins la testul în browser

### Ce s-a văzut
Prima verificare reală în browser a panoului demo a arătat **20 de rânduri în
lista de planuri, în loc de 3** — adică rândurile tuturor vizitatorilor, prima
pagină a unei interogări nefiltrate.

### Cauza
`DemoTenancy::token()` citea `$_COOKIE`. La PRIMA cerere a unui vizitator nou
tokenul tocmai e creat, iar `setcookie()` doar pune un antet în răspuns —
`$_COOKIE` rămâne gol până la cererea următoare. Deci pe acea primă pagină
`isActive()` întorcea false, scope-ul nu se aplica, și vizitatorul vedea tot.

Testele mele cu curl n-au prins-o pentru că verificau conținutul pe a doua
cerere, cea care avea deja cookie-ul. Exact cazul pe care doar un browser real,
intrând prima dată, îl parcurge.

### Reparat
- `DemoSandbox::setTokenCookie()` scrie tokenul și în `$_COOKIE`, ca restul
  cererii care îl creează să-l vadă.
- `DemoTenancy::isActive()` nu mai memorează răspunsul: tokenul apare la
  mijlocul cererii, iar un „false" calculat cu o clipă prea devreme ar fi rămas
  valabil până la final — adică fix scurgerea de mai sus.

### Verificat în browser, ca vizitator real
- Prima pagină, vizitator nou: „Result 1 - 3 of 3" — doar setul lui.
- Creare prin interfața reală (formularul Create, butonul Save): „Record has
  been successfully created", id 22. În DB rândul poartă tokenul lui.
- Lista lui devine „Result 1 - 4 of 4"; celelalte trei entități arată exact
  setul propriu: FAQ 20, parteneri 5, testimoniale 4.
- Ecranele `update` și `translations` pe rândurile proprii → 200; pe un rând
  care nu e al lui → 404.
- Al doilea vizitator (fără cookie): nu vede planul primului (0 apariții), iar
  deschiderea directă a `view?id=22` → 404.
- Ștergere: al treilea vizitator își șterge un rând → în DB doar el are un rând
  în coș (1), ceilalți neatinși.
- Site-ul public randează 3 planuri și cu cookie-ul demo prezent, și fără el;
  baza reală are 0 rânduri marcate.

### Notă despre cum se testează
Cookie-ul `demo_t` e `httpOnly`, deci nu poate fi șters din JS — al doilea
vizitator nu se poate simula în aceeași filă. Testul „doi vizitatori" se face
din afara browserului sau într-o fereastră privată.

---

## 2026-09-05 (c) — curățenie după testele din ziua asta

La cererea ta, doar reziduurile de test — schema și codul rămân.

- **Bazele `cinova_demo_<token>`** din schema veche: dispăruseră deja singure
  între timp (erau inactive de peste o oră, iar `cleanupExpired()` le-a luat la
  o cerere ulterioară). Au rămas trei baze, cele corecte: `cinova`,
  `cinova_demo`, `cinova_demo_snapshot`.
- **1 018 rânduri marcate** șterse din `cinova_demo` (905 la prima trecere +
  113 ale unui vizitator apărut în timpul curățeniei, din fila de browser rămasă
  deschisă — am închis-o). Setul-etalon rămâne intact: 32 de înregistrări.
- **7 rânduri din registrul `demo_sandboxes`**, acum gol.
- **Contact #15** (`config-test@cignova.com`), lead-ul creat de testul meu la
  price builder.

Neatinse, intenționat: contactele #2–#11 și #14 (existau înainte de sesiune),
#16 și #17 (par din testele tale — spune-mi dacă le vrei șterse), coloana
`sandbox_token` din `cinova` (0 rânduri marcate, o cere codul) și tot ce ține de
EditMode, navbar, pagina demo și configurator.

### Margine ascuțită prinsă la verificarea de după curățenie
Un vizitator căruia limitatorul de rată îi refuza un token rămânea **fără
scope** — iar „fără scope" înseamnă acum că vede rândurile tuturor, nu doar ale
lui. Cu bazele separate de dinainte, refuzul îl trimitea pe copia partajată;
cu izolarea pe rânduri, același refuz devenise o scurgere.

- `DemoTenancy::applyTo()` filtrează acum `sandbox_token IS NULL` când nu există
  token: vizitatorul vede setul-etalon, adică demo-ul neatins, și niciodată
  rândurile altcuiva.
- Plafonul per IP care păzea o copie de 6,5 MB păzește acum ~113 inserări, deci
  pentru `/admin-demo` are propria valoare, generoasă:
  `demoMaxPerIpPerHourPanel = 200`. A fi refuzat acolo nu mai e inofensiv —
  înseamnă că nu poți păstra nicio modificare.

Verificat: un vizitator nou primește token și vede 3 rânduri (ale lui); baza a
rămas curată după testele finale (0 rânduri marcate, registru gol).

---

## 2026-09-05 (d) — al doilea bug prins în browser: cookie-ul murea sub vizitator

### Simptom
În browser, un `update` pe un testimonial propriu returna **404 la salvare**,
deși formularul se deschisese cu 200 cu o clipă înainte.

### Cauza
Cookie-ul `demo_t` era setat o singură dată, cu expirare la o oră de la CREARE.
Datele, în schimb, trăiesc o oră de INACTIVITATE (`touch()` împinge
`last_seen_at` la fiecare cerere). Cele două nu erau de acord, și vizitatorul
pierdea: la o oră de la început browserul nu mai trimitea cookie-ul, cererea
următoare crea un token nou, iar rândurile lui deveneau brusc ale altcuiva —
404 fix în mijlocul unei salvări. Rândurile rămâneau în baza de date, orfane,
până la expirare.

Cu bazele separate de dinainte simptomul ar fi fost la fel, doar mai rar
observabil; izolarea pe rânduri l-a scos la suprafață.

### Reparat
`DemoSandbox::touch()` re-setează cookie-ul la fiecare cerere — expirare
glisantă, la fel ca `last_seen_at`. Verificat: a doua vizită întoarce
`Set-Cookie: demo_t=<același token>` cu expirarea împinsă înainte.

### Verificat în browser, după reparare
- `update` pe un testimonial propriu → „Record has been successfully updated",
  alias și rating schimbate. În DB doar rândul lui (id 83) e modificat; setul
  etalon și celelalte cinci seturi de vizitatori, neatinse.
- Ecranul de traduceri al aceluiași rând arată corect toate trei limbile
  („Result 1 - 3 of 3", toate `Translated`) — deci `entity_id`-ul remapat la
  semănare se leagă de părintele potrivit.
- Salvare pe traducerea RO: `updated_at` s-a schimbat **doar** pe
  `entity_id=83`; celelalte cinci rânduri RO ale aceluiași testimonial și cel
  etalon au rămas la `2026-08-09`.
- După navigare, vizitatorul își vede în continuare modificările
  („ion-editat-in-browser, rating 4.7") — cookie-ul nu mai cade.
- Baza reală `cinova`: testimonialele neschimbate, 5.0, aliasuri originale.

### Notă de metodă
Câmpul de conținut al traducerii e un editor WYSIWYG. Setarea directă a
`textarea.value` din JS nu ajunge la modelul editorului, deci la submit se
rescrie conținutul original — motiv pentru care textul meu nu a apărut, deși
rândul a fost scris. Nu e un bug al aplicației; pentru un test real pe acel
câmp trebuie tastat în editor.

### Curățenie
Datele de test șterse din nou: 565 de rânduri marcate, 4 rânduri de registru.
`cinova_demo` a rămas cu setul-etalon (3/4/20/5), 0 rânduri marcate.

---

## 2026-09-05 (e) — „am schimbat în baza de date și nu se vede pe pagina demo"

### Diagnostic
Nu era un bug. Sunt trei baze de date, iar modificarea era într-una și
verificarea în alta:

| Bază | Cine o citește |
|---|---|
| `cinova` | site-ul public, inclusiv pagina `/demo` |
| `cinova_demo_snapshot` | tiparul din care se clonează |
| `cinova_demo` | panoul `/admin-demo` |

Demonstrat cu două probe pe date reale (puse la loc după test):
- `pricing_plans.price` → €7,777 direct în `cinova`: **apare imediat** pe
  `/ro/demo` și pe `/ro`. Deci nu există niciun cache între DB și pagină —
  verificat și în cod: nu se folosește nicăieri cache de conținut, iar
  `enableSchemaCache` e oprit în dev.
- `menu_items_translations.label` → PROBA-MENIU: **nu apare**, fiindcă eticheta
  de meniu e de ieri un element EditMode (`common-menu-about-us`). Regula §0 din
  ghid: un conținut are un singur stăpân.

Cazul concret: numele planului fusese schimbat în `cinova` DUPĂ ce fusese luat
tiparul (19:50), deci panoul demo arăta încă valoarea veche („Landing" în loc de
„Site de prezentare").

### Rezolvat
`php yii demo/snapshot` + `php yii demo/reset` — cele trei baze arată acum
aceeași valoare. Plus curățenie: 4 rânduri învechite din registru (sandbox-uri
ale căror rânduri tocmai fuseseră șterse de reset) și vizitatorul de test.

### De reținut
Panoul demo rulează pe o copie de aruncat, nu pe site-ul viu — asta e tot rostul
lui. Orice modificare din site-ul real apare acolo doar după reîmprospătarea
copiei, cu cele două comenzi de mai sus. Merită rulate ori de câte ori conținutul
real se schimbă semnificativ.

---

## 2026-09-05 (f) — pagina /demo arată acum sandbox-ul vizitatorului

### Problema
Sub fiecare tab, pagina demo scria: „Se administrează din: Panou → Planuri de
preț — schimbă ceva acolo, apoi reîncarcă pagina asta." Pe host-ul principal
promisiunea era falsă: panoul scria în `cinova_demo` (rândurile vizitatorului),
iar pagina citea site-ul real `cinova`. Cineva făcea exact ce i se spunea și nu
se întâmpla nimic — exact confuzia raportată.

Nu era o regresie de azi: înainte panoul scria într-o bază per vizitator, deci
promisiunea era la fel de falsă, doar mai greu de observat.

### Rezolvat
- `DemoTenancy::visitorContent()` — pentru `/demo` pe host-ul principal, cele
  patru liste se citesc din `cinova_demo`, filtrate pe tokenul vizitatorului.
  Conexiunea se schimbă doar cât durează interogările; `findActive()` face deja
  eager-load pe ambele relații de traducere, deci randarea nu mai atinge baza
  după ce `db` revine la cea reală.
- Regula cine-ce-vede:
  - **fără cookie `demo_t`** (n-a deschis niciodată panoul) → conținutul real al
    site-ului, ca până acum. Pagina e și pagina de marketing.
  - **cu sandbox propriu** → rândurile lui.
  - **pe host-ul de demo** → nimic nu se schimbă, acolo `db` e deja copia lui.
- Textul devine onest în ambele cazuri: cheia nouă `demo-db-source-own`
  („— asta e copia ta: ce schimbi acolo apare aici la reîncărcare."), seed-uită
  RO/EN/RU prin `m260905_120001`. Vechea invitație rămâne pentru cine n-are încă
  sandbox.

### Verificat
Fluxul complet, ca un vizitator care face exact ce scrie pe pagină:
1. deschide `/admin-demo` → primește sandbox
2. schimbă prețul din formularul panoului → 302
3. reîncarcă `/ro/demo` → **vede „999 USD"**
4. alt vizitator fără cookie → vede €1,900, conținutul real
5. `cinova` → neatins

Ambele texte, în RO și RU, se afișează corect pentru cazul potrivit.

Notă: un 500 apărut în timpul testului a fost din encodarea caracterului € în
curl-ul meu (`Incorrect string value: '\x80999'`), nu din aplicație — cu un preț
ASCII salvarea trece.

---

## 2026-09-05 (g) — panoul de texte al formularului apărea pe site, nu doar în editor

### Simptom
Pe `/ru/contact`, sub formular, se vedea panoul „СООБЩЕНИЯ ФОРМЫ (ВИДНЫ ТОЛЬКО В
EDIT MODE)" — exact ce promitea că nu face.

### Cauza
Condiția era `RenderHelper::isEditor()`, care răspunde „poate omul ăsta să
editeze". E adevărat și pentru un administrator care doar navighează pe site,
deci panoul apărea pe pagina publică pentru orice utilizator logat cu rol de
editor. Pentru marcajele invizibile (`class="editable"` nu face nimic fără CSS-ul
editorului) diferența nu contează; pentru un bloc cu corp, contează.

### Reparat
`RenderHelper::isEditorSurface()` — nou. Cere, în plus față de rol, ca pagina să
fie randată CHIAR ÎN editor: acesta încarcă site-ul într-un iframe, iar browserul
marchează acea cerere cu `Sec-Fetch-Dest: iframe`. Se acceptă și `?em_preview=`,
pe care editorul îl adaugă la previzualizarea ciornelor. În development, cu
`editModeNoAuth`, rămâne vizibil pentru toată lumea, ca și contururile punctate.

`views/common/_contact_form.php` folosește acum noua condiție. E singurul loc din
proiect cu `isEditor()` — verificat prin grep.

### Verificat
Ipoteza pe care se sprijină fixul, măsurată în browser cu o probă temporară
(ștearsă după): aceeași adresă cerută în trei feluri întoarce
`sec-fetch-dest=iframe` din iframe, `document` la navigare normală și `empty`
prin `fetch()`. Deci semnalul distinge exact cazurile care trebuie.

Verificarea completă cu sesiune de editor rămâne de făcut manual: fără login nu
pot distinge cele două cazuri prin curl (ambele dau 0, corect).

### Documentat
`EDITMODE_CONTENT_GUIDE.md` §5.8 f — regula `isEditorSurface()` vs `isEditor()`,
cu motivul și cu semnalul folosit.
