Expose developer promo creation with three subscription plans (#39)

Load the missing promo module, add a prominent creation button and place Promos beside Overview. Use existing AAL2-guarded server RPCs with selected plan, subscription duration and code validity; preserve form input, honest clipboard feedback and scope async responses. Full PR QA succeeded in run 35547013076. No live data, SQL, MFA or unfinished training branch changes. Preserve normal main QA and exact-asset/browser publication verification.
This commit is contained in:
pavlov346346-source 2026-09-21 03:30:21 +03:00 committed by GitHub
parent 8b25918e30
commit 298b29cd52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 225 additions and 23 deletions

View File

@ -43,6 +43,8 @@ jobs:
files=( files=(
index.html index.html
app-runtime.js app-runtime.js
core/hotfix-v1763.js
core/trial-promo-developer-v181.js
core/account-center-v1780.js core/account-center-v1780.js
core/company-branding.js core/company-branding.js
core/sun-safe.js core/sun-safe.js
@ -130,6 +132,9 @@ jobs:
- name: Check published employee session and mobile logout - name: Check published employee session and mobile logout
timeout-minutes: 4 timeout-minutes: 4
run: node tests/production-employee-session.mjs run: node tests/production-employee-session.mjs
- name: Check published developer promo entry and plan selection
timeout-minutes: 4
run: node tests/production-promo-entry.mjs
- name: Save production UI verification - name: Save production UI verification
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@ -26,7 +26,17 @@
dev.open?.(); dev.open?.();
} }
function loadPromoControls(){
const id='cateriumTrialPromoDeveloperV181Script';
if(window.CateriumTrialPromoDeveloperV181||document.getElementById(id))return;
const script=document.createElement('script');script.id=id;
script.src='core/trial-promo-developer-v181.js?v=20260921-promo-entry';script.async=true;
script.onerror=()=>{script.remove();console.warn('[Caterium] Не загрузился раздел промокодов.');};
document.head.append(script);
}
function maintain(){ function maintain(){
loadPromoControls();
patchDeveloperOpen(); patchDeveloperOpen();
} }

View File

@ -1,21 +1,76 @@
/* Developer invitations, using the existing server-enforced AAL2 RPCs. */
(()=>{ (()=>{
'use strict'; 'use strict';
if(window.CateriumTrialPromoDeveloperV181)return; if(window.CateriumTrialPromoDeveloperV181)return;
const VERSION='18.1-trial-promo-developer'; const VERSION='20260921-promo-entry',TAB='sunDeveloperActiveTabV22';
const $=id=>document.getElementById(id),qa=(s,r=document)=>[...r.querySelectorAll(s)]; const $=id=>document.getElementById(id),cloud=()=>window.SunCloudV2;
const client=()=>window.SunCloudV2?.getClient?.()||null; const plans={basic:'Базовый',professional:'Профессиональный',full:'Полный'};
const dev=()=>window.SunDeveloperV22||null; const esc=v=>window.SunSafe.escapeHTML(String(v??'')),fmt=v=>v?new Date(v).toLocaleString('ru-RU'):'—';
const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); let view=null,frame=0;
const toast=(t,type='info')=>window.SunEnterprise?.toast?.(t,type,5000); const identity=()=>JSON.stringify([cloud()?.getSession?.()?.user?.id,cloud()?.getWorkspace?.()?.id]);
async function rpc(name,args={}){const c=client();if(!c)throw new Error('Supabase не подключён.');const {data,error}=await c.rpc(name,args);if(error)throw error;return data;} const allowed=()=>Boolean(cloud()?.getSession?.()?.user&&window.SunDeveloperV22?.isPlatformAdmin?.()&&!cloud()?.isSupportMode?.()&&!document.body.classList.contains('sun-cloud-auth-required'));
function fmt(v){if(!v)return'—';try{return new Date(v).toLocaleString('ru-RU',{dateStyle:'short',timeStyle:'short'})}catch(_){return String(v)}} const selected=()=>allowed()&&$('sun-developer-console-v22')?.classList.contains('on')&&localStorage.getItem(TAB)==='promos';
function ensureStyle(){if($('cateriumPromoDevStyle'))return;const s=document.createElement('style');s.id='cateriumPromoDevStyle';s.textContent=`.ctm-promo-tools{display:grid;grid-template-columns:1.2fr 1.2fr .65fr .65fr .65fr auto;gap:8px;align-items:end;margin-bottom:14px}.ctm-promo-tools label{display:grid;gap:5px;font-size:11px;font-weight:800;color:#686e76}.ctm-promo-tools input,.ctm-promo-tools select{min-height:39px;border:1px solid #d8dbe0;border-radius:9px;padding:7px 9px;background:#fff}.ctm-promo-code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:900;letter-spacing:.04em}.ctm-promo-copy{cursor:pointer}.ctm-promo-note{margin:0 0 12px;color:#747b83;font-size:12px}@media(max-width:1000px){.ctm-promo-tools{grid-template-columns:1fr 1fr}.ctm-promo-tools button{grid-column:1/-1}}`;document.head.appendChild(s);} const current=v=>view===v&&v.identity===identity()&&selected();
function addTab(){const root=$('sun-developer-console-v22');if(!root)return false;const tabs=root.querySelector('[data-dev-tab]')?.parentElement;if(!tabs)return false;if(!tabs.querySelector('[data-dev-tab="promos"]')){const b=document.createElement('button');b.type='button';b.dataset.devTab='promos';b.textContent='Промокоды';tabs.appendChild(b);}return true;} async function rpc(name,args={}){const c=cloud()?.getClient?.();if(!c||!allowed())throw new Error('Войдите в кабинет разработчика и подтвердите 2FA.');const r=await c.rpc(name,args);if(r.error)throw r.error;return r.data;}
async function render(){const root=$('sunDevBody');if(!root)return;qa('#sun-developer-console-v22 [data-dev-tab]').forEach(b=>b.classList.toggle('on',b.dataset.devTab==='promos'));try{localStorage.setItem('sunDeveloperActiveTabV22','promos')}catch(_){}root.innerHTML='<div class="sun-dev-empty">Загрузка промокодов…</div>'; const message=e=>['PGRST202','42883'].includes(e?.code)?'Серверный модуль промокодов недоступен.':String(e?.message||e||'Не удалось выполнить операцию.');
try{const rows=await rpc('sun_dev_list_trial_promos',{p_limit:300})||[];root.innerHTML=`<div class="sun-dev-card"><h2 style="margin-top:0">Промокоды пробной версии</h2><p class="ctm-promo-note">Сгенерируйте код автоматически или напишите свой. Email можно оставить пустым для универсального кода. По умолчанию код одноразовый.</p><div class="ctm-promo-tools"><label>Промокод<input id="ctmPromoCode" maxlength="32" placeholder="Оставьте пустым для генерации"></label><label>Email клиента<input id="ctmPromoEmail" type="email" placeholder="client@example.com"></label><label>Trial, дней<input id="ctmPromoTrial" type="number" min="1" max="365" value="14"></label><label>Код действует, дней<input id="ctmPromoValid" type="number" min="1" max="365" value="7"></label><label>Активаций<input id="ctmPromoUses" type="number" min="1" max="10000" value="1"></label><button class="primary" id="ctmPromoCreate" type="button">Создать промокод</button></div><div id="ctmPromoCreated"></div></div><div class="sun-dev-table-wrap" style="margin-top:12px"><table class="sun-dev-table"><thead><tr><th>Код</th><th>Клиент</th><th>Trial</th><th>Действует до</th><th>Использования</th><th>Последняя активация</th><th>Статус</th><th></th></tr></thead><tbody>${rows.map(r=>`<tr><td><button class="outline ctm-promo-code ctm-promo-copy" data-copy="${esc(r.code)}" title="Скопировать">${esc(r.code)}</button></td><td>${esc(r.client_email||'Любой email')}</td><td>${Number(r.trial_days||0)} дн.</td><td>${fmt(r.valid_until)}</td><td>${Number(r.use_count||0)} / ${Number(r.max_uses||0)}</td><td>${r.last_redeemed_at?`${fmt(r.last_redeemed_at)}<div class="sun-dev-muted">${esc(r.last_workspace_name||'')} ${esc(r.last_redeemed_email||'')}</div>`:'—'}</td><td><span class="sun-dev-pill ${r.is_active?'ok':'bad'}">${r.is_active?'Активен':'Отключён'}</span></td><td><button class="outline" data-toggle="${esc(r.promo_id)}" data-active="${r.is_active?'1':'0'}">${r.is_active?'Отключить':'Включить'}</button></td></tr>`).join('')||'<tr><td colspan="8" class="sun-dev-empty">Промокодов пока нет.</td></tr>'}</tbody></table></div>`; function styles(){
$('ctmPromoCode').oninput=e=>e.target.value=e.target.value.toUpperCase().replace(/\s+/g,'');$('ctmPromoCreate').onclick=create;qa('[data-copy]',root).forEach(b=>b.onclick=async()=>{try{await navigator.clipboard.writeText(b.dataset.copy);toast('Промокод скопирован.','success')}catch(_){toast('Не удалось скопировать код.','error')}});qa('[data-toggle]',root).forEach(b=>b.onclick=async()=>{b.disabled=true;try{await rpc('sun_dev_set_trial_promo_active',{p_promo:b.dataset.toggle,p_active:b.dataset.active!=='1'});toast('Статус промокода изменён.','success');await render()}catch(e){toast(e?.message||String(e),'error');b.disabled=false}}); if($('cateriumPromoDevStyle'))return;
}catch(e){root.innerHTML=`<div class="sun-dev-card"><h3>Не удалось загрузить промокоды</h3><p>${esc(e?.message||e)}</p></div>`;}} const s=document.createElement('style');s.id='cateriumPromoDevStyle';s.textContent=`
async function create(){const btn=$('ctmPromoCreate');btn.disabled=true;try{const data=await rpc('sun_dev_create_trial_promo',{p_code:String($('ctmPromoCode')?.value||'').trim()||null,p_email:String($('ctmPromoEmail')?.value||'').trim()||null,p_trial_days:Number($('ctmPromoTrial')?.value||14),p_valid_days:Number($('ctmPromoValid')?.value||7),p_max_uses:Number($('ctmPromoUses')?.value||1),p_plan:'full',p_note:null});const code=data?.code||'';try{await navigator.clipboard.writeText(code)}catch(_){}toast(`Промокод ${code} создан${code?' и скопирован':''}.`,'success');await render()}catch(e){toast(e?.message||String(e),'error')}finally{if(btn?.isConnected)btn.disabled=false}} #ctmPromoOpen{min-height:44px}#ctmPromoPanel{min-width:0}.ctm-promo-tools{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;align-items:end}.ctm-promo-tools label{display:grid;gap:6px;min-width:0;font-size:12px;font-weight:700}.ctm-promo-tools input,.ctm-promo-tools select{box-sizing:border-box;width:100%;min-width:0;min-height:44px;padding:9px;border:1px solid #d8dbe0;border-radius:9px;font:inherit;background:#fff}.ctm-promo-wide{grid-column:1/-1}.ctm-promo-note{font-size:13px;line-height:1.5;color:#697078}.ctm-promo-result{padding:14px;border:1px solid #d5bb77;border-radius:12px;background:#fffaec;margin-top:14px}.ctm-promo-result textarea{box-sizing:border-box;width:100%;min-height:175px;margin:10px 0;padding:10px;resize:vertical;font:13px/1.5 Arial,sans-serif}.ctm-promo-actions{display:flex;flex-wrap:wrap;gap:8px}.ctm-promo-actions button{min-height:44px}.ctm-promo-code{font-family:ui-monospace,monospace;overflow-wrap:anywhere}#ctmPromoError,#ctmPromoListError{color:#a23426}#ctmPromoError:empty,#ctmPromoListError:empty{display:none}.ctm-promo-list{display:grid;gap:10px;margin-top:15px}.ctm-promo-list article{padding:12px;border:1px solid #e0e0dc;border-radius:10px;min-width:0}.ctm-promo-list p{font-size:12px;line-height:1.5;margin:6px 0}
function bind(){ensureStyle();if(!addTab())return;const root=$('sun-developer-console-v22');if(root&&!root.dataset.promoV181){root.dataset.promoV181='1';root.addEventListener('click',e=>{const b=e.target.closest('[data-dev-tab="promos"]');if(!b)return;e.preventDefault();e.stopImmediatePropagation();render();},true);}const d=dev();if(d&&!d.__promoV181&&typeof d.open==='function'){const original=d.open.bind(d);d.open=async source=>{const r=await original(source);addTab();if(localStorage.getItem('sunDeveloperActiveTabV22')==='promos')await render();return r};Object.defineProperty(d,'__promoV181',{value:true,configurable:true});}} @media(max-width:600px){.ctm-promo-tools{grid-template-columns:minmax(0,1fr)}#ctmPromoOpen{width:100%}.ctm-promo-actions button{flex:1}}
const obs=new MutationObserver(bind);obs.observe(document.documentElement,{childList:true,subtree:true});bind();setInterval(()=>{if(!document.hidden)bind()},3000);window.CateriumTrialPromoDeveloperV181=Object.freeze({VERSION,render,bind}); `;document.head.append(s);
})(); }
function invitation(data,args){return `Приглашение в Caterium\nРегистрация: https://app.caterium.ru/\nНа экране входа выберите регистрацию и введите промокод: ${data.code}\nТариф: ${plans[args.p_plan]}\nСрок подписки: ${args.p_trial_days} дн. с активации после подтверждения email\nАктивировать приглашение до: ${fmt(data.valid_until)}${args.p_email?'\nДля email: '+args.p_email:''}`;}
async function copy(text,status){try{await navigator.clipboard.writeText(text);if(status.isConnected)status.textContent='Скопировано.';}catch(_){if(status.isConnected)status.textContent='Выделите и скопируйте текст вручную: автоматическое копирование недоступно.';}}
async function loadList(v){
const serial=++v.listRequest,list=v.node.querySelector('#ctmPromoList'),error=v.node.querySelector('#ctmPromoListError');error.textContent='';list.setAttribute('aria-busy','true');
try{
const rows=await rpc('sun_dev_list_trial_promos',{p_limit:300});if(!current(v)||serial!==v.listRequest)return;
if(!Array.isArray(rows))throw new Error('Сервер вернул неполный список кодов.');
list.innerHTML=rows.map((r,i)=>`<article><b class="ctm-promo-code">${esc(r.code)}</b><p>${esc(r.client_email||'Любой email')} · Подписка: ${Number(r.trial_days)} дн.</p><p>Активировать до: ${esc(fmt(r.valid_until))} · Использовано: ${Number(r.use_count)} / ${Number(r.max_uses)} · ${r.is_active?'Включён':'Отключён'}</p><div class="ctm-promo-actions"><button type="button" class="outline" data-copy-code="${i}">Скопировать код</button><button type="button" class="outline" data-toggle-code="${i}">${r.is_active?'Отключить код':'Включить код'}</button></div></article>`).join('')||'<p>Промокодов пока нет. Создайте первый выше.</p>';
list.querySelectorAll('[data-copy-code]').forEach(b=>b.onclick=()=>copy(rows[Number(b.dataset.copyCode)].code,v.node.querySelector('#ctmPromoCopyStatus')));
list.querySelectorAll('[data-toggle-code]').forEach(b=>b.onclick=async()=>{const row=rows[Number(b.dataset.toggleCode)];if(!current(v))return;b.disabled=true;try{await rpc('sun_dev_set_trial_promo_active',{p_promo:row.promo_id,p_active:!row.is_active});if(current(v))await loadList(v);}catch(e){if(current(v))error.textContent=message(e);}finally{b.disabled=false;}});
}catch(e){if(current(v)&&serial===v.listRequest)error.textContent=message(e);}finally{if(serial===v.listRequest)list.removeAttribute('aria-busy');}
}
async function create(v){
if(!current(v)||v.creating)return;
const form=v.node.querySelector('form');if(!form.reportValidity())return;
const q=id=>v.node.querySelector('#'+id);
const args={p_code:q('ctmPromoCode').value.trim().toUpperCase()||null,p_email:q('ctmPromoEmail').value.trim()||null,p_plan:q('ctmPromoPlan').value,p_trial_days:Number(q('ctmPromoTrial').value),p_valid_days:Number(q('ctmPromoValid').value),p_max_uses:Number(q('ctmPromoUses').value),p_note:null};
if(!plans[args.p_plan])return;
const button=q('ctmPromoCreate'),error=q('ctmPromoError');v.creating=true;button.disabled=true;error.textContent='';
try{
const data=await rpc('sun_dev_create_trial_promo',args);if(!current(v))return;
if(!data?.code)throw new Error('Сервер не вернул созданный код. Проверьте список перед повторной попыткой.');
q('ctmPromoCreated').innerHTML='<section class="ctm-promo-result"><b>Промокод создан — отправьте приглашение получателю</b><textarea id="ctmInvitationText" readonly aria-label="Текст приглашения"></textarea><div class="ctm-promo-actions"><button type="button" class="primary" id="ctmInvitationCopy">Скопировать приглашение</button><button type="button" class="outline" id="ctmCodeCopy">Скопировать код</button></div></section>';
const text=invitation(data,args);q('ctmInvitationText').value=text;q('ctmInvitationCopy').onclick=()=>copy(text,q('ctmPromoCopyStatus'));q('ctmCodeCopy').onclick=()=>copy(data.code,q('ctmPromoCopyStatus'));void loadList(v);
}catch(e){if(current(v))error.textContent=message(e);}finally{v.creating=false;button.disabled=false;}
}
function render(){
if(!allowed()||!$('sun-developer-console-v22')?.classList.contains('on'))return;
localStorage.setItem(TAB,'promos');document.querySelectorAll('#sun-developer-console-v22 [data-dev-tab]').forEach(b=>b.classList.toggle('on',b.dataset.devTab==='promos'));
if(view?.identity===identity()){$('sunDevBody')?.replaceChildren(view.node);return;}
const node=document.createElement('section');node.id='ctmPromoPanel';node.className='sun-dev-card';
node.innerHTML=`<h2>Промокоды для регистрации и подписки</h2><p class="ctm-promo-note">Выберите тариф и срок доступа. Промокод можно сгенерировать автоматически или задать вручную. Срок подписки начинается при активации после подтверждения email, а срок действия кода — при его создании.</p><form class="ctm-promo-tools"><label>Тариф<select id="ctmPromoPlan"><option value="basic">Базовый</option><option value="professional">Профессиональный</option><option value="full" selected>Полный</option></select></label><label>Подписка, дней<input id="ctmPromoTrial" type="number" min="1" max="365" step="1" required value="14"></label><label>Код можно активировать в течение, дней<input id="ctmPromoValid" type="number" min="1" max="365" step="1" required value="7"></label><label>Количество активаций<input id="ctmPromoUses" type="number" min="1" max="10000" step="1" required value="1"></label><label>Email получателя — необязательно<input id="ctmPromoEmail" type="email" maxlength="254" placeholder="client@example.com"></label><label>Свой код — необязательно<input id="ctmPromoCode" maxlength="32" pattern="[A-Za-z0-9\\-]{3,32}" placeholder="Оставьте пустым для генерации"></label><button id="ctmPromoCreate" type="submit" class="primary ctm-promo-wide">Создать промокод</button></form><p id="ctmPromoError" role="alert"></p><div id="ctmPromoCreated"></div><p id="ctmPromoCopyStatus" class="ctm-promo-note" role="status"></p><h3>Созданные промокоды</h3><button id="ctmPromoReload" type="button" class="outline">Обновить список</button><p id="ctmPromoListError" role="alert"></p><div id="ctmPromoList" class="ctm-promo-list"></div>`;
const v={node,identity:identity(),creating:false,listRequest:0};view=v;node.querySelector('form').addEventListener('submit',e=>{e.preventDefault();void create(v);});node.querySelector('#ctmPromoReload').onclick=()=>loadList(v);$('sunDevBody')?.replaceChildren(node);void loadList(v);
}
function bind(){
styles();const root=$('sun-developer-console-v22');if(!root||!allowed())return;
const tabs=root.querySelector('.sun-dev-tabs');if(!tabs)return;
let tab=tabs.querySelector('[data-dev-tab="promos"]');if(!tab){tab=document.createElement('button');tab.type='button';tab.dataset.devTab='promos';tab.textContent='Промокоды';}
const overview=tabs.querySelector('[data-dev-tab="overview"]');if(overview&&overview.nextElementSibling!==tab)overview.after(tab);else if(!tab.parentElement)tabs.prepend(tab);
const actions=root.querySelector('.sun-dev-head-actions');if(actions&&!$('ctmPromoOpen')){const button=document.createElement('button');button.type='button';button.id='ctmPromoOpen';button.className='primary';button.textContent='Создать промокод';actions.prepend(button);}
// A slow response from the previous tab must not replace a live promo form.
if(view&&current(view)&&!view.node.isConnected)$('sunDevBody')?.replaceChildren(view.node);
}
function schedule(){if(frame)return;frame=requestAnimationFrame(()=>{frame=0;bind();});}
function reset(){view?.node.remove();view=null;$('ctmPromoOpen')?.remove();document.querySelector('[data-dev-tab="promos"]')?.remove();}
document.addEventListener('click',e=>{
if(e.target.closest?.('#ctmPromoOpen,#sun-developer-console-v22 [data-dev-tab="promos"]')){e.preventDefault();e.stopImmediatePropagation();render();}
else if(e.target.closest?.('#sunDevRefresh')&&selected()){e.preventDefault();e.stopImmediatePropagation();if(view)void loadList(view);}
},true);
window.addEventListener('sun:cloud-tenant-changing',reset);
window.addEventListener('sun:cloud-permissions-changed',()=>{if(!allowed()||view?.identity!==identity())reset();schedule();});
new MutationObserver(schedule).observe(document.documentElement,{childList:true,subtree:true});bind();
window.CateriumTrialPromoDeveloperV181=Object.freeze({VERSION,render,bind});
})();

View File

@ -1,6 +1,7 @@
const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery'; const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery-promo-entry';
const VERSION='20260918-ui-stability'; const VERSION='20260918-ui-stability';
const CORE=[ const CORE=[
'./core/trial-promo-developer-v181.js?v=20260921-promo-entry',
'./core/banquet-client-menu.js?v=20260920-onepage', './core/banquet-client-menu.js?v=20260920-onepage',
'./core/training-catalog.js?v=20260920-training', './core/training-catalog.js?v=20260920-training',
'./core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu', './core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu',
@ -14,6 +15,7 @@ const CORE=[
'./offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg' './offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg'
]; ];
const CRITICAL_FRESH=new Set([ const CRITICAL_FRESH=new Set([
'/core/trial-promo-developer-v181.js','/core/hotfix-v1763.js',
'/core/banquet-client-menu.js', '/core/banquet-client-menu.js',
'/core/training-catalog.js', '/core/training-catalog.js',
'/core/catalog-pricing.js','/core/client-menu.css', '/core/catalog-pricing.js','/core/client-menu.css',

View File

@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
export default defineConfig({ export default defineConfig({
testDir:'.', testDir:'.',
testMatch:['developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], testMatch:['promo-entry.spec.mjs','developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'],
timeout:30000, timeout:30000,
use:{baseURL:'http://127.0.0.1:4173'}, use:{baseURL:'http://127.0.0.1:4173'},
webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},
projects:[ projects:[
{name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}},
{name:'iphone-webkit',testMatch:['developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'iphone-webkit',testMatch:['promo-entry.spec.mjs','developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}},
{name:'desktop',use:{...devices['Desktop Chrome']}}, {name:'desktop',use:{...devices['Desktop Chrome']}},
{name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}}
] ]

View File

@ -0,0 +1,21 @@
import fs from 'node:fs/promises';
import {chromium} from '@playwright/test';
import {bootPromoFixture,exercisePromoCreation} from './promo-entry-fixture.mjs';
const base=new URL(process.env.TIMEWEB_BASE_URL||'https://app.caterium.ru');
if(base.protocol!=='https:')throw new Error('HTTPS required');
const output='production-ui-results';await fs.mkdir(output,{recursive:true});
const browser=await chromium.launch(),results=[];
try{
for(const width of [390,1440]){
const context=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'});
try{
const page=await context.newPage();await bootPromoFixture(page,base.href);
await page.screenshot({path:`${output}/promo-entry-${width}.png`,animations:'disabled'});
await exercisePromoCreation(page);
await page.locator('#ctmPromoPanel').screenshot({path:`${output}/promo-form-${width}.png`,animations:'disabled'});
results.push({width,loadedFromApp:true,headerEntry:true,threePlans:true,syntheticCreation:true});
console.log(`PASS published promo UI ${width}px: real loader, visible entry, three plans, selected duration, synthetic creation`);
}finally{await context.close();}
}
await fs.writeFile(`${output}/promo-entry.json`,JSON.stringify({backend:'synthetic and network-blocked; no real codes created',results},null,2));
}finally{await browser.close();}

View File

@ -0,0 +1,54 @@
import {expect} from '@playwright/test';
// Only GET requests for public static assets are allowed. No real account,
// promo, subscription or backend is read or written, including on production.
export async function bootPromoFixture(page,base='http://127.0.0.1:4173/'){
const origin=new URL(base).origin;
await page.route('**/*',route=>{const r=route.request(),u=new URL(r.url());return r.method()==='GET'&&u.origin===origin&&!u.pathname.startsWith('/api/')?route.continue():route.abort();});
await page.addInitScript(()=>{
const user={id:'promo-test-user',email:'promo-ui@example.invalid',email_confirmed_at:'2026-01-01T00:00:00Z'},ws='promo-test-workspace';
window.promoCalls=[];window.promoRows=[];window.promoCreateError=null;window.promoDelay=false;
localStorage.setItem('sunCloudV2Config',JSON.stringify({workspaceId:ws,localWorkspaceId:ws,tenantStorageReady:true,autoSync:false}));
localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]');
const features=Object.fromEntries('orders calendar clients catalog_view catalog_edit production shopping stock routes mailings money stats_basic stats_advanced team suppliers print settings branding client_offers offer_templates backups audit users_manage'.split(' ').map(k=>[k,true]));
const sdk={
auth:{onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),getSession:async()=>({data:{session:{user}},error:null}),getUser:async()=>({data:{user},error:null}),mfa:{getAuthenticatorAssuranceLevel:async()=>({data:{currentLevel:'aal2'}})}},
rpc:async(name,args={})=>{
if(name==='sun_my_workspaces')return {data:[{id:ws,name:'Проверка интерфейса',role:'admin',is_active:true,permissions:{}}]};
if(name==='sun_subscription_snapshot')return {data:{plan_id:'full',plan_name:'Полный',status:'active',access_mode:'full',features}};
if(name==='sun_is_platform_admin')return {data:true};
if(name==='sun_dev_dashboard')return {data:{}};
if(name==='caterium_trial_demo_status')return {data:{canInstall:false,canUpgrade:false}};
if(name==='sun_dev_list_trial_promos'){promoCalls.push({name,args});return {data:structuredClone(promoRows)};}
if(name==='sun_dev_create_trial_promo'){
promoCalls.push({name,args});if(promoDelay)await new Promise(resolve=>window.completePromo=resolve);
if(promoCreateError)return {error:{message:promoCreateError}};
const data={promo_id:'test-code-'+promoRows.length,code:args.p_code||'CTM-UI-TEST',trial_days:args.p_trial_days,valid_until:'2099-01-01T00:00:00Z'};
promoRows.push({...data,client_email:args.p_email,max_uses:args.p_max_uses,use_count:0,is_active:true});return {data};
}
if(name==='sun_dev_set_trial_promo_active'){promoCalls.push({name,args});const r=promoRows.find(r=>r.promo_id===args.p_promo);if(r)r.is_active=args.p_active;return {data:null};}
return {data:null,error:null};
},channel:()=>({on(){return this},subscribe(){return this}}),removeChannel(){}
};
window.supabase={createClient:()=>sdk};
});
await page.goto(base,{waitUntil:'domcontentloaded',timeout:60000});
await expect(page.locator('body > header')).toBeVisible({timeout:20000});
await page.waitForFunction(()=>window.SunDeveloperV22&&window.SunDeveloperUXV1768&&window.CateriumTrialPromoDeveloperV181);
await page.locator('#sunDeveloperNavV22').click();
await expect(page.locator('#ctmPromoOpen')).toBeVisible();
}
export async function exercisePromoCreation(page){
await page.locator('#ctmPromoOpen').click();
await expect(page.locator('#ctmPromoPlan')).toBeVisible();
await expect(page.locator('#ctmPromoPlan option')).toHaveCount(3);
await page.locator('#ctmPromoPlan').selectOption('professional');
await page.locator('#ctmPromoTrial').fill('30');
await page.locator('#ctmPromoValid').fill('7');
await page.locator('#ctmPromoCreate').click();
await expect(page.locator('#ctmInvitationText')).toHaveValue(/Профессиональный/);
await expect(page.locator('#ctmInvitationText')).toHaveValue(/30 дн/);
await expect(page.locator('#ctmPromoList')).toContainText('CTM-UI-TEST');
const calls=await page.evaluate(()=>promoCalls.filter(c=>c.name==='sun_dev_create_trial_promo'));
expect(calls).toHaveLength(1);expect(calls[0].args).toMatchObject({p_plan:'professional',p_trial_days:30,p_valid_days:7,p_max_uses:1,p_code:null});
}

View File

@ -0,0 +1,55 @@
import {test,expect} from '@playwright/test';
import {bootPromoFixture,exercisePromoCreation} from './promo-entry-fixture.mjs';
test.use({serviceWorkers:'block'});
test('the real app loads promo controls and shows an obvious creation entry',async({page})=>{
await bootPromoFixture(page);
expect(await page.locator('[data-dev-tab="overview"]').evaluate(el=>el.nextElementSibling?.dataset.devTab)).toBe('promos');
await exercisePromoCreation(page);
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await page.locator('#ctmPromoReload').click();
await expect(page.locator('#ctmInvitationText')).toHaveValue(/CTM-UI-TEST/);
await page.locator('[data-toggle-code="0"]').click();
await expect(page.locator('#ctmPromoList')).toContainText('Отключён');
});
test('all three plans reach the RPC and invalid input sends no creation request',async({page})=>{
await bootPromoFixture(page);await page.locator('[data-dev-tab="promos"]').click();
await page.locator('#ctmPromoTrial').fill('0');await page.locator('#ctmPromoCreate').click();
expect(await page.evaluate(()=>promoCalls.filter(c=>c.name==='sun_dev_create_trial_promo').length)).toBe(0);
await page.locator('#ctmPromoTrial').fill('21');
for(const plan of ['basic','professional','full']){
await page.locator('#ctmPromoPlan').selectOption(plan);await page.locator('#ctmPromoCreate').click();
await expect.poll(()=>page.evaluate(()=>promoCalls.filter(c=>c.name==='sun_dev_create_trial_promo').at(-1)?.args.p_plan)).toBe(plan);
await expect(page.locator('#ctmPromoCreate')).toBeEnabled();
}
});
test('failed creation is shown as an error and is not automatically retried',async({page})=>{
await bootPromoFixture(page);await page.locator('#ctmPromoOpen').click();
await page.evaluate(()=>{promoCreateError='Подтвердите 2FA';});await page.locator('#ctmPromoCreate').click();
await expect(page.locator('#ctmPromoError')).toContainText('Подтвердите 2FA');await expect(page.locator('#ctmInvitationText')).toHaveCount(0);
expect(await page.evaluate(()=>promoCalls.filter(c=>c.name==='sun_dev_create_trial_promo').length)).toBe(1);
await expect(page.locator('#ctmPromoCreate')).toBeEnabled();
});
test('a delayed creation response cannot take over another tab or account',async({page})=>{
await bootPromoFixture(page);await page.locator('#ctmPromoOpen').click();
await page.evaluate(()=>{promoDelay=true;});await page.locator('#ctmPromoCreate').click();
await expect(page.locator('#ctmPromoCreate')).toBeDisabled();
await page.locator('[data-dev-tab="overview"]').click();await page.evaluate(()=>completePromo());
await expect(page.locator('#ctmInvitationText')).toHaveCount(0);
await expect(page.locator('[data-dev-tab="overview"]')).toHaveClass(/on/);
await page.locator('#ctmPromoOpen').click();
await expect(page.locator('#ctmPromoCreate')).toBeEnabled();
await page.evaluate(()=>{window.dispatchEvent(new Event('sun:cloud-tenant-changing'));});
await expect(page.locator('#ctmPromoPanel')).toHaveCount(0);
});
test('maintenance does not rebuild a form or lose unsaved input',async({page})=>{
await bootPromoFixture(page);await page.locator('#ctmPromoOpen').click();
await page.locator('#ctmPromoEmail').fill('draft@example.invalid');
await page.evaluate(()=>{window.promoInput=document.getElementById('ctmPromoEmail');for(let i=0;i<10;i++)CateriumTrialPromoDeveloperV181.bind();});
expect(await page.evaluate(()=>promoInput===document.getElementById('ctmPromoEmail'))).toBe(true);
await expect(page.locator('#ctmPromoEmail')).toHaveValue('draft@example.invalid');
});