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:
parent
8b25918e30
commit
298b29cd52
5
.github/workflows/deploy-timeweb.yml
vendored
5
.github/workflows/deploy-timeweb.yml
vendored
@ -43,6 +43,8 @@ jobs:
|
||||
files=(
|
||||
index.html
|
||||
app-runtime.js
|
||||
core/hotfix-v1763.js
|
||||
core/trial-promo-developer-v181.js
|
||||
core/account-center-v1780.js
|
||||
core/company-branding.js
|
||||
core/sun-safe.js
|
||||
@ -130,6 +132,9 @@ jobs:
|
||||
- name: Check published employee session and mobile logout
|
||||
timeout-minutes: 4
|
||||
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
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@ -26,7 +26,17 @@
|
||||
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(){
|
||||
loadPromoControls();
|
||||
patchDeveloperOpen();
|
||||
}
|
||||
|
||||
|
||||
@ -1,21 +1,76 @@
|
||||
/* Developer invitations, using the existing server-enforced AAL2 RPCs. */
|
||||
(()=>{
|
||||
'use strict';
|
||||
if(window.CateriumTrialPromoDeveloperV181)return;
|
||||
const VERSION='18.1-trial-promo-developer';
|
||||
const $=id=>document.getElementById(id),qa=(s,r=document)=>[...r.querySelectorAll(s)];
|
||||
const client=()=>window.SunCloudV2?.getClient?.()||null;
|
||||
const dev=()=>window.SunDeveloperV22||null;
|
||||
const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const toast=(t,type='info')=>window.SunEnterprise?.toast?.(t,type,5000);
|
||||
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;}
|
||||
function fmt(v){if(!v)return'—';try{return new Date(v).toLocaleString('ru-RU',{dateStyle:'short',timeStyle:'short'})}catch(_){return String(v)}}
|
||||
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);}
|
||||
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 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>';
|
||||
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>`;
|
||||
$('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}});
|
||||
}catch(e){root.innerHTML=`<div class="sun-dev-card"><h3>Не удалось загрузить промокоды</h3><p>${esc(e?.message||e)}</p></div>`;}}
|
||||
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}}
|
||||
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});}}
|
||||
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});
|
||||
'use strict';
|
||||
if(window.CateriumTrialPromoDeveloperV181)return;
|
||||
const VERSION='20260921-promo-entry',TAB='sunDeveloperActiveTabV22';
|
||||
const $=id=>document.getElementById(id),cloud=()=>window.SunCloudV2;
|
||||
const plans={basic:'Базовый',professional:'Профессиональный',full:'Полный'};
|
||||
const esc=v=>window.SunSafe.escapeHTML(String(v??'')),fmt=v=>v?new Date(v).toLocaleString('ru-RU'):'—';
|
||||
let view=null,frame=0;
|
||||
const identity=()=>JSON.stringify([cloud()?.getSession?.()?.user?.id,cloud()?.getWorkspace?.()?.id]);
|
||||
const allowed=()=>Boolean(cloud()?.getSession?.()?.user&&window.SunDeveloperV22?.isPlatformAdmin?.()&&!cloud()?.isSupportMode?.()&&!document.body.classList.contains('sun-cloud-auth-required'));
|
||||
const selected=()=>allowed()&&$('sun-developer-console-v22')?.classList.contains('on')&&localStorage.getItem(TAB)==='promos';
|
||||
const current=v=>view===v&&v.identity===identity()&&selected();
|
||||
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;}
|
||||
const message=e=>['PGRST202','42883'].includes(e?.code)?'Серверный модуль промокодов недоступен.':String(e?.message||e||'Не удалось выполнить операцию.');
|
||||
function styles(){
|
||||
if($('cateriumPromoDevStyle'))return;
|
||||
const s=document.createElement('style');s.id='cateriumPromoDevStyle';s.textContent=`
|
||||
#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}
|
||||
@media(max-width:600px){.ctm-promo-tools{grid-template-columns:minmax(0,1fr)}#ctmPromoOpen{width:100%}.ctm-promo-actions button{flex:1}}
|
||||
`;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&¤t(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});
|
||||
})();
|
||||
@ -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 CORE=[
|
||||
'./core/trial-promo-developer-v181.js?v=20260921-promo-entry',
|
||||
'./core/banquet-client-menu.js?v=20260920-onepage',
|
||||
'./core/training-catalog.js?v=20260920-training',
|
||||
'./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'
|
||||
];
|
||||
const CRITICAL_FRESH=new Set([
|
||||
'/core/trial-promo-developer-v181.js','/core/hotfix-v1763.js',
|
||||
'/core/banquet-client-menu.js',
|
||||
'/core/training-catalog.js',
|
||||
'/core/catalog-pricing.js','/core/client-menu.css',
|
||||
|
||||
@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
export default defineConfig({
|
||||
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,
|
||||
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},
|
||||
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-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:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}}
|
||||
]
|
||||
|
||||
21
tests/production-promo-entry.mjs
Normal file
21
tests/production-promo-entry.mjs
Normal 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();}
|
||||
54
tests/promo-entry-fixture.mjs
Normal file
54
tests/promo-entry-fixture.mjs
Normal 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});
|
||||
}
|
||||
55
tests/promo-entry.spec.mjs
Normal file
55
tests/promo-entry.spec.mjs
Normal 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');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user