Перейти до вмісту

«Симуляція вступу»

ПолеЗначення
Статус🟢 prod — лендинг, конструктор і paywall змержені в main і віддаються живим сайтом. ⚠️ Файл-специфікація docs/domains/simulation/overview.md (2026-06-18) ще каже «коду фічі ще немає» — застаріло: фічу зібрано пізніше (PR #434 → аналітика #436 → drag-reorder #441).
Поверхніweb (публічний лендинг /simulation + кабінетний конструктор /profile/simulation) + безкоштовний тізер на сторінці оффера (ApplicantsCard → вкладки «Симуляція»). Бот має власний аналог («Куди я вступлю?») — це інша реалізація.
Доступністьбудувати/зберігати вступліст і рахувати КБ — безкоштовно (потрібен акаунт); присуд (де проходиш · статус · запас · «згорілі» пріоритети) — premium one-off (разова покупка simulation, без підписки, доступ до кінця сезону SIMULATION_SEASON_END). Прайс — серверний (≈ 399 ₴, див. Дані та API).
Власникфронт — Шура; алгоритм/КБ — Льоша (за docs/domains/simulation/overview.md)
Останнє підтвердження2026-06-27 · curl (live: /uk/simulation 200, /uk/simulation/success 200, /profile/simulation → 307 /uk/auth/login) + код:main (frontend 982662a6, api-v2 6a1d530)

«Симуляція вступу» відповідає на головне тривожне питання абітурієнта-2026: «Чи я проходжу на бюджет?» Замість збирати вступну стратегію вручну (Excel із торішніми балами, зошит, десяток вкладок ЄДЕБО, скрін у телефоні, поради в чатах) — жоден із цих способів не каже, чи ти реально вступаєш — абітурієнт вводить бали НМТ і свій вступліст (список КП — зв’язок ЗВО + спеціальність + фінансування), а інструмент проганяє його через правила розподілу й показує по кожній заяві, куди він проходить і з яким запасом балів.

Це продуктизована, монетизована вершина воронки шансів: калькулятор конкурсного бала«Мій бал»wizard «Куди я вступлю?»симуляція пріоритетного списку.

Цінність для абітурієнта (free vs paid)

Section titled “Цінність для абітурієнта (free vs paid)”

Із лендингового блоку «Одна оплата — без підписки» (PricingSection):

Безкоштовно (0 ₴, «назавжди»)Повний доступ (разова оплата)
пошук конкурсних пропозиційпоказуємо, де ти проходиш на бюджет
розрахунок конкурсного бала (КБ)статус по кожній заяві: проходиш / на межі / ні
збереження вступлістазапас балів від прохідного по кожній заяві
прохідні бали минулих роківякі пріоритети «згорають» і чому
базові тести НМТперевірка списку (ліміти й мінімальний бал)
безстроковий доступ до результату на сезон

Коротко: зібрати й зберегти вступліст і порахувати КБ — безкоштовно; сам присуд (де проходиш, статус, запас, які пріоритети згорають) за офіційним алгоритмом розподілу — платний анлок. Безкоштовна CTA веде в наявний застосунок (/offers/result), платна — у checkout.

⚠️ Що працює зараз (Стан 1) vs обіцяне (Стан 2)

Section titled “⚠️ Що працює зараз (Стан 1) vs обіцяне (Стан 2)”

Команда розвела дві механіки (docs/domains/simulation/overview.md):

  • Стан 1 — конструктор + статус (зараз у проді): додавання КП, намір бюджет/контракт, шанс (КБ vs торішній прохідний-2025) і запас, drag-пріоритети. Алгоритм широкого конкурсу не потрібен.
  • Стан 2 — симуляція широкого конкурсу (відкладено, після старту подачі 19.07, на живих даних кампанії-2026): поточний прохідний-2026 (денний бекенд-прогон) + вердикт (deferred-acceptance, суперобсяг, субконкурси).

Наслідок: кнопка «Перерахувати» в проді запускає клієнтський placeholder-движок вердикту (utils/simulate.ts), а не справжній алгоритм розподілу. Реальний прохідний-2026 і вердикт — це Стан 2, ще не побудований. Шанс/запас на даних 2025 — справжні.

Публічний лендинг: https://abitly.org/uk/simulation (локаль-префікс завжди є; сторінка індексується). Конструктор: https://abitly.org/profile/simulation (кабінет, noindex, гейт за логіном і entitlement). Сторінка-повернення: /uk/simulation/success.

Конструктор (/profile/simulation)

Section titled “Конструктор (/profile/simulation)”

Гейт: paywall за SIMULATION_PAYWALL_ENABLED. Залогінений користувач без entitlement simulation бачить недимісабельну SimulationPaywallModal («Купити» → checkout, «Дізнатись більше» → лендинг) замість конструктора; з entitlement — рендериться SimulationDashboard.

  1. Вхід у конструкторSimulationDashboard (season-banner Стану-1, ScoresCard, список, завжди змонтована AddKpModal); на устаканенні списку — подія simulation_constructor_viewed.
  2. Бали НМТScoresCard: обов’язкові (укр/мат/історія) + 4-й предмет + бонус-конкурс; валідація 100–200; «Зберегти» персистить grades на бекенд → simulation_scores_saved.
  3. Порожній станSimulationEmpty (CTA → відкрити «Додати КП»; simulation_add_kp_opened source=empty_state).
  4. Додати КП — модалка (на мобілці bottom-sheet): вкладки «Пошук усіх КП» (typeahead + фасет-чіпи регіон/ЗВО/спеціальність/форма) і «Мої вступлісти»; мультивибір (≤30 на сторінку), намір авто-проставляється (контракт-only → контракт; інакше бюджет, поки є бюджетні слоти). Apply шле один applyChangessimulation_kp_added / simulation_kp_removed source=modal.
  5. Перегляд списку — режими Таблиця / Картки / Редагування; кожен рядок несе тріаду Намір · Шанс · Запас; тулбар показує X/10 загалом і Y/5 на бюджет.
  6. Редагування пріоритетів і наміру — drag/стрілки (debounce 400 мс → reorder, подія simulation_priorities_reordered), сегмент Бюджет/Контракт (updateIntent; при повному бюджеті → SwapIntentModalswapIntent), дублювання на протилежний намір, видалення; автозбереження.
  7. Запуск симуляціїSimulateBand «Перерахувати» (активна за ≥1 заяви та ≥1 балів); клієнтський прогін → вердикт на рядок; зміна списку після прогону → isStale ховає колонку вердикту.
  8. РезультатFeedbackCard («що варто запам’ятати») + чіп вердикту на картці/рядку.

Тріада (навмисно різнокольорова): Намір — куди хочеш (бюджет→контракт / тільки контракт); Шанс — де реально опинишся за балом (на бюджет / на контракт / не проходиш); Запас — розрив КБ до прохідного рівня наміру. Ліміти (серверні): ≤10 заяв, ≤5 на бюджет.

Checkout переюзає Monobank-флоу. Специфіка симуляції — анлок видає feature-entitlement (не файл) і сторінка-success полить статус:

sequenceDiagram
    autonumber
    participant U as Абітурієнт
    participant W as frontend (web)
    participant A as api-v2 (premium)
    participant M as Monobank
    participant DB as Postgres

    U->>W: Купити (лендинг / paywall-модалка)
    W->>A: GET /premium/simulation/product
    A-->>W: { productId, priceUah }
    W->>A: POST /premium/create-payment {productId, productType:simulation}
    A->>DB: premium_orders ← pending (amount = серверний прайс)
    A->>M: create invoice (sum, redirectUrl=/simulation/success?orderId)
    A-->>W: { orderId, paymentUrl }
    W-->>U: window.location → Monobank (pay.mbnk.biz)
    U->>M: оплата
    M->>A: webhook /premium/webhook/monobank (x-sign)
    A->>DB: premium_orders → success · grant entitlement (user_feature_overrides simulation=true)
    A->>U: лист «Симуляцію розблоковано»
    U->>W: повернення на /simulation/success?orderId
    W->>A: GET /premium/order-status (poll кожні 2с)
    A-->>W: success → інвалідація entitlements
    W-->>U: redirect на /profile/simulation (конструктор розблоковано)

Воронки та аналітика (що зараз реалізовано)

Section titled “Воронки та аналітика (що зараз реалізовано)”

Pipeline: track()window.dataLayer → GTM GTM-W64R6XXK → GA4 G-XKDRSM3T0Z (dataLayer фаєриться лише на хості abitly.org/www.abitly.org — QA через GA4 DebugView на проді). Деталі та план — docs/domains/simulation/attribution.md у репо фронтенду.

Meta Pixel (для платної реклами): на success-сторінці, всередині isPaid-guard’а, окрім simulation_purchase тепер фаєриться і fbq('track','Purchase', {value, currency}, {eventID: orderId}) у піксель 4273494836207329 (FE PR #497/#499, prod) — щоб кампанії оптимізувались на реальну покупку, а не на проксі. Контекст — Платна реклама Meta.

ПоверхняЩо цеПодія / source
Тізер на сторінці оффера — вкладка «Симуляція»SimulationApplicantsTable (симульований ранжований список заявок цього оффера на даних 2025); анонімам розмито під AuthPopupOverlay → реєстраціяclick_on_admission_simulation {offer_id, university_id}
Тізер — вкладка «Симуляція + сортування»той самий віджет, інший режимclick_on_admission_simulation_sorting
Лендинг /simulation (публічний, індексований)головна UTM-ціль зовнішніх кампанійsimulation_landing_view {is_authenticated}
Лендинг — CTA «Спробувати» (hero / «як це працює»)у безкоштовний конструктор (не checkout)simulation_try_clicked {placement}
Лендинг — кнопки «Купити» (pricing / final CTA)у Monobank-checkoutsimulation_begin_checkout source=simulation_landing_pricing | simulation_landing_cta
Paywall-модалка в кабінеті «Купити»головний in-product вхід у платну воронкуsimulation_begin_checkout source=paywall_modal
Навігація кабінету (sidebar + header)лінк на /profile/simulationлише навігація, без події
Сторінка-successпідтвердження покупкиsimulation_purchase {transaction_id}
flowchart LR
    subgraph FREE[Безкоштовний верх воронки]
      O[Сторінка оффера → тізер «Симуляція»] -->|анонім| R[AuthPopupOverlay → реєстрація]
    end
    subgraph PAID[Платний верх воронки]
      L[Лендинг /simulation] -->|Спробувати| C0[Конструктор free]
      L -->|Купити| CO
    end
    R --> C0
    CO[Monobank checkout] --> S[success-сторінка: poll статусу]
    S --> E[entitlement simulation]
    E --> C[Конструктор розблоковано]
    C --> F[11-подійна продуктова воронка]

Реалізовані події (16 типізованих)

Section titled “Реалізовані події (16 типізованих)”

Paywall / ecommerce (3):

ПодіяКолиПримітка
simulation_begin_checkoutonMutate create-paymentнесе source, value, items
simulation_payment_init_failedonError create-paymentнесе source + product_id
simulation_purchasesuccess-page, isPaidtransaction_id=orderId; без source (редірект Monobank стирає стан FE)

Продуктова воронка + лендинг (11, PR #436): simulation_constructor_viewed, add_kp_opened, add_kp_searched (PII-safe: довжина/кількість, не текст), kp_added (core), kp_removed, view_mode_changed, intent_changed (core), priorities_reordered (core), scores_saved (core), landing_view, try_clicked. Чокпоінти (single-source): useAddKp.flush, useEditReorder.commitOrder, useScoresForm, useSimulationPurchase.

Тізер оффера (2): click_on_admission_simulation, click_on_admission_simulation_sorting.

PII-безпека: лише ID (offer_id), ніколи назв ЗВО/спеціальностей; пошук шле has_query/query_length/results_count, не сирий текст; scores_saved — лише булеві прапори, не бали.

  • Зроблено (інструментарієм): gtm:sync --apply застейджив усі події у GTM (workspace 86); ga4:dimensions:sync зареєстрував параметри як event-scoped custom dimensions (⚠️ property на 49/50 — близько ліміту; dimensions не ретроактивні).
  • Не зроблено (людські кроки): ручний PUBLISH GTM-workspace; ремап simulation_purchase → подія purchase + Ecommerce (інакше доходу не видно в GA4 Monetization); pre-launch «List unwanted referrals» = pay.mbnk.biz (нерероактивно — інакше повернення з Monobank псує атрибуцію сесій покупки); UTM-конвенція для маркетингу; smoke на /offers/result + DebugView.
  • Свідома прогалина: покупку-по-поверхні не несемо через редірект — її читають когортно в GA4 через лійку begin_checkout(source) → purchase. fbclid невидимий для GA4 → лінки Meta/TikTok мусять мати UTM.
ДжерелоЕндпоінт / entityПризначення
api-v2GET /profile/simulation · POST /…/applications · PATCH/DELETE /…/applications/:id · POST /…/applications/swap-intent · PUT /…/order · POST /…/hydrate · GET /…/offers · GET /…/search-filtersCRUD конструктора (SimulationController). ⚠️ гейт лише JwtAuthGuard — серверного entitlement-guard на цих ендпоінтах немає
api-v2GET /premium/simulation/product (public, Redis-cached) · POST /premium/create-payment · GET /premium/order-status · POST /premium/webhook/monobankпрайс + checkout + статус + вебхук (спільний premium-модуль)
api-v2GET /subscriptions/featuresчитання entitlement simulation (FE entitlementsApi.getMine → hasFeature)
Postgressimulation_applicationдані конструктора: userId + offerId + intent + priority; UNIQUE(userId, offerId, intent); ліміти ≤10 / ≤5
Postgrespremium_ordersпокупка (product_type='simulation', amount у копійках, status) — деталі
Postgresuser_feature_overridesде живе entitlement: feature_key='simulation', value='true', expires_at=кінець сезону, reason='premium_order:<id>'

Entitlement. Вебхук Monobank на success + productType=simulationgrantSimulationEntitlement (ідемпотентний upsert у user_feature_overrides). Читання — FeatureResolverService (override → план активної підписки → дефолт free-плану). Гейт paywall — лише фронтовий (гейт сторінки + paywall-модалка) + сам факт оплаченого ордера; серверного feature-guard на CRUD немає.

Прайс — серверний, FE суми не шле. src/api/premium/simulation-product.config.ts: priceCoins = SIMULATION_PRICE_KOPEKS (копійки; дефолт-плейсхолдер 1000 = 10 ₴, у проді перевизначено через SSM ≈ 39900 = 399 ₴), expiresAt = SIMULATION_SEASON_END. Назви env — в environments. Читання прайсу Redis-кешоване (12 год) → зміна прайсу потребує скиду кешу. FE відображає priceUah; копія «399 ₴» — лише fallback до завантаження.

Зв’язки з іншими фічами

Section titled “Зв’язки з іншими фічами”
РепоШляхПримітка
abitly-frontend-v2src/app/[locale]/(main)/simulation/ · (profile)/profile/simulation/лендинг + success + конструктор (роути)
abitly-frontend-v2src/components/pages/simulation/**лендинг, success, спільний buy-флоу (useSimulationPurchase)
abitly-frontend-v2src/components/pages/profile/pages/Simulation/**SimulationDashboard + Scores/AddKp/Table/Cards/Edit/Bands + хуки
abitly-frontend-v2src/components/pages/offers/pages/search/OfferPage/components/SimulationApplicantsTable/**безкоштовний тізер на сторінці оффера
abitly-frontend-v2src/api/simulation/** · src/lib/analytics/events/simulation.tsAPI-клієнти + типізовані події
abitly-frontend-v2docs/domains/simulation/**першоджерело дизайну (overview / mechanics / attribution / components) — детальніше за цю картку
abitly-api-v2src/api/simulation/** · src/api/premium/** (+ simulation-product.config.ts)CRUD + checkout + вебхук + прайс/сезон
abitly-api-v2src/database/entities/{simulationApplication,premiumOrder,userFeatureOverride}.tsentities + міграції
  • Серверний paywall відсутній на CRUD. SimulationController гейтить лише JwtAuthGuard; entitlement simulation перевіряється тільки на фронті + наявністю оплаченого ордера. Будь-який залогінений може дернути CRUD-ендпоінти симуляції напряму. TODO: додати feature-guard на бекенді.
  • Вердикт у проді — клієнтський placeholder (utils/simulate.ts), не справжній алгоритм розподілу. Реальний широкий конкурс + прохідний-2026 (Стан 2) — після 19.07, ще не побудовано.
  • Атрибуція покупки не запущена до кінця: GTM-workspace застейджено, але не опубліковано; purchase→Monetization-ремап і Monobank-referral-exclusion (pay.mbnk.biz, нерероактивно) — невиконані людські кроки. До цього дохід не видно в GA4 і рання атрибуція псується.
  • Прайс кешований у Redis 12 год — зміна SIMULATION_PRICE_KOPEKS без скиду ключа *getSimulationProduct* дає розбіжність відображення/списання до 12 год.
  • order_product_type enum-drift / buyer_email vs email у міграції premium_orders — позначено агентом для звірки. TODO: верифікувати.
  • reversed (повернення) не відкликає entitlement — доступ гасне лише по expires_at (кінець сезону). TODO: уточнити політику рефандів.
ДатаПодіяДжерело
2026-06-18дизайн-специфікація Стану 1/2 (тріада, ліміти, mechanics)docs/domains/simulation/overview.md
2026-06-25дизайн атрибуції/UTMdocs/domains/simulation/attribution.md
2026-06-26+11 типізованих продуктових подійPR #436 (frontend)
2026-06-27drag-reorder пріоритетів у таблиціPR #441 (982662a6)
2026-06-27single-origin фікс email/redirect URL у premiumPR #526 (api-v2 6a1d530)
2026-06-27створено картку 🟢; підтверджено live (curl) + код:mainця картка