Выберите тариф и срок доступа. Промокод можно сгенерировать автоматически или задать вручную. Срок подписки начинается при активации после подтверждения email, а срок действия кода — при его создании.
Созданные промокоды
`;
+ 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});
+})();
diff --git a/public/service-worker.js b/public/service-worker.js
index 39becce..2d96559 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -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',
diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs
index c47374d..71a909f 100644
--- a/tests/playwright.config.mjs
+++ b/tests/playwright.config.mjs
@@ -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}}
]
diff --git a/tests/production-promo-entry.mjs b/tests/production-promo-entry.mjs
new file mode 100644
index 0000000..69785ee
--- /dev/null
+++ b/tests/production-promo-entry.mjs
@@ -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();}
diff --git a/tests/promo-entry-fixture.mjs b/tests/promo-entry-fixture.mjs
new file mode 100644
index 0000000..cd0b2a6
--- /dev/null
+++ b/tests/promo-entry-fixture.mjs
@@ -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});
+}
diff --git a/tests/promo-entry.spec.mjs b/tests/promo-entry.spec.mjs
new file mode 100644
index 0000000..227d783
--- /dev/null
+++ b/tests/promo-entry.spec.mjs
@@ -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');
+});