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.
77 lines
12 KiB
JavaScript
77 lines
12 KiB
JavaScript
/* Developer invitations, using the existing server-enforced AAL2 RPCs. */
|
||
(()=>{
|
||
'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});
|
||
})();
|