Навигатор медиамикса FMCG · eGrocery Edition
????

Навигатор медиамикса FMCG · eGrocery Edition

Оптимизация бюджета для продуктовых брендов на российском рынке онлайн-доставки

????

Начните расчет

Заполните параметры слева и нажмите "Рассчитать оптимальный микс" для получения рекомендаций

:root { /* Primitive Color Tokens */ --color-white: rgba(255, 255, 255, 1); --color-black: rgba(0, 0, 0, 1); --color-cream-50: rgba(252, 252, 249, 1); --color-cream-100: rgba(255, 255, 253, 1); --color-gray-200: rgba(245, 245, 245, 1); --color-gray-300: rgba(167, 169, 169, 1); --color-gray-400: rgba(119, 124, 124, 1); --color-slate-500: rgba(98, 108, 113, 1); --color-brown-600: rgba(94, 82, 64, 1); --color-charcoal-700: rgba(31, 33, 33, 1); --color-charcoal-800: rgba(38, 40, 40, 1); --color-slate-900: rgba(19, 52, 59, 1); --color-teal-300: rgba(50, 184, 198, 1); --color-teal-400: rgba(45, 166, 178, 1); --color-teal-500: rgba(33, 128, 141, 1); --color-teal-600: rgba(29, 116, 128, 1); --color-teal-700: rgba(26, 104, 115, 1); --color-teal-800: rgba(41, 150, 161, 1); --color-red-400: rgba(255, 84, 89, 1); --color-red-500: rgba(192, 21, 47, 1); --color-orange-400: rgba(230, 129, 97, 1); --color-orange-500: rgba(168, 75, 47, 1); /* RGB versions for opacity control */ --color-brown-600-rgb: 94, 82, 64; --color-teal-500-rgb: 33, 128, 141; --color-slate-900-rgb: 19, 52, 59; --color-slate-500-rgb: 98, 108, 113; --color-red-500-rgb: 192, 21, 47; --color-red-400-rgb: 255, 84, 89; --color-orange-500-rgb: 168, 75, 47; --color-orange-400-rgb: 230, 129, 97; /* Background color tokens (Light Mode) */ --color-bg-1: rgba(59, 130, 246, 0.08); /* Light blue */ --color-bg-2: rgba(245, 158, 11, 0.08); /* Light yellow */ --color-bg-3: rgba(34, 197, 94, 0.08); /* Light green */ --color-bg-4: rgba(239, 68, 68, 0.08); /* Light red */ --color-bg-5: rgba(147, 51, 234, 0.08); /* Light purple */ --color-bg-6: rgba(249, 115, 22, 0.08); /* Light orange */ --color-bg-7: rgba(236, 72, 153, 0.08); /* Light pink */ --color-bg-8: rgba(6, 182, 212, 0.08); /* Light cyan */ /* Semantic Color Tokens (Light Mode) */ --color-background: var(--color-cream-50); --color-surface: var(--color-cream-100); --color-text: var(--color-slate-900); --color-text-secondary: var(--color-slate-500); --color-primary: var(--color-teal-500); --color-primary-hover: var(--color-teal-600); --color-primary-active: var(--color-teal-700); --color-secondary: rgba(var(--color-brown-600-rgb), 0.12); --color-secondary-hover: rgba(var(--color-brown-600-rgb), 0.2); --color-secondary-active: rgba(var(--color-brown-600-rgb), 0.25); --color-border: rgba(var(--color-brown-600-rgb), 0.2); --color-btn-primary-text: var(--color-cream-50); --color-card-border: rgba(var(--color-brown-600-rgb), 0.12); --color-card-border-inner: rgba(var(--color-brown-600-rgb), 0.12); --color-error: var(--color-red-500); --color-success: var(--color-teal-500); --color-warning: var(--color-orange-500); --color-info: var(--color-slate-500); --color-focus-ring: rgba(var(--color-teal-500-rgb), 0.4); --color-select-caret: rgba(var(--color-slate-900-rgb), 0.8); /* Common style patterns */ --focus-ring: 0 0 0 3px var(--color-focus-ring); --focus-outline: 2px solid var(--color-primary); --status-bg-opacity: 0.15; --status-border-opacity: 0.25; --select-caret-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23134252' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); --select-caret-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23f5f5f5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); /* RGB versions for opacity control */ --color-success-rgb: 33, 128, 141; --color-error-rgb: 192, 21, 47; --color-warning-rgb: 168, 75, 47; --color-info-rgb: 98, 108, 113; /* Typography */ --font-family-base: "FKGroteskNeue", "Geist", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-family-mono: "Berkeley Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --font-size-xs: 11px; --font-size-sm: 12px; --font-size-base: 14px; --font-size-md: 14px; --font-size-lg: 16px; --font-size-xl: 18px; --font-size-2xl: 20px; --font-size-3xl: 24px; --font-size-4xl: 30px; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 550; --font-weight-bold: 600; --line-height-tight: 1.2; --line-height-normal: 1.5; --letter-spacing-tight: -0.01em; /* Spacing */ --space-0: 0; --space-1: 1px; --space-2: 2px; --space-4: 4px; --space-6: 6px; --space-8: 8px; --space-10: 10px; --space-12: 12px; --space-16: 16px; --space-20: 20px; --space-24: 24px; --space-32: 32px; /* Border Radius */ --radius-sm: 6px; --radius-base: 8px; --radius-md: 10px; --radius-lg: 12px; --radius-full: 9999px; /* Shadows */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.02); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.04), 0 2px 4px -1px rgba(0, 0, 0, 0.02); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.04), 0 4px 6px -2px rgba(0, 0, 0, 0.02); --shadow-inset-sm: inset 0 1px 0 rgba(255, 255, 255, 0.15), inset 0 -1px 0 rgba(0, 0, 0, 0.03); /* Animation */ --duration-fast: 150ms; --duration-normal: 250ms; --ease-standard: cubic-bezier(0.16, 1, 0.3, 1); /* Layout */ --container-sm: 640px; --container-md: 768px; --container-lg: 1024px; --container-xl: 1280px; } /* Dark mode colors */ @media (prefers-color-scheme: dark) { :root { /* RGB versions for opacity control (Dark Mode) */ --color-gray-400-rgb: 119, 124, 124; --color-teal-300-rgb: 50, 184, 198; --color-gray-300-rgb: 167, 169, 169; --color-gray-200-rgb: 245, 245, 245; /* Background color tokens (Dark Mode) */ --color-bg-1: rgba(29, 78, 216, 0.15); /* Dark blue */ --color-bg-2: rgba(180, 83, 9, 0.15); /* Dark yellow */ --color-bg-3: rgba(21, 128, 61, 0.15); /* Dark green */ --color-bg-4: rgba(185, 28, 28, 0.15); /* Dark red */ --color-bg-5: rgba(107, 33, 168, 0.15); /* Dark purple */ --color-bg-6: rgba(194, 65, 12, 0.15); /* Dark orange */ --color-bg-7: rgba(190, 24, 93, 0.15); /* Dark pink */ --color-bg-8: rgba(8, 145, 178, 0.15); /* Dark cyan */ /* Semantic Color Tokens (Dark Mode) */ --color-background: var(--color-charcoal-700); --color-surface: var(--color-charcoal-800); --color-text: var(--color-gray-200); --color-text-secondary: rgba(var(--color-gray-300-rgb), 0.7); --color-primary: var(--color-teal-300); --color-primary-hover: var(--color-teal-400); --color-primary-active: var(--color-teal-800); --color-secondary: rgba(var(--color-gray-400-rgb), 0.15); --color-secondary-hover: rgba(var(--color-gray-400-rgb), 0.25); --color-secondary-active: rgba(var(--color-gray-400-rgb), 0.3); --color-border: rgba(var(--color-gray-400-rgb), 0.3); --color-error: var(--color-red-400); --color-success: var(--color-teal-300); --color-warning: var(--color-orange-400); --color-info: var(--color-gray-300); --color-focus-ring: rgba(var(--color-teal-300-rgb), 0.4); --color-btn-primary-text: var(--color-slate-900); --color-card-border: rgba(var(--color-gray-400-rgb), 0.2); --color-card-border-inner: rgba(var(--color-gray-400-rgb), 0.15); --shadow-inset-sm: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 0 rgba(0, 0, 0, 0.15); --button-border-secondary: rgba(var(--color-gray-400-rgb), 0.2); --color-border-secondary: rgba(var(--color-gray-400-rgb), 0.2); --color-select-caret: rgba(var(--color-gray-200-rgb), 0.8); /* Common style patterns - updated for dark mode */ --focus-ring: 0 0 0 3px var(--color-focus-ring); --focus-outline: 2px solid var(--color-primary); --status-bg-opacity: 0.15; --status-border-opacity: 0.25; --select-caret-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23134252' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); --select-caret-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23f5f5f5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); /* RGB versions for dark mode */ --color-success-rgb: var(--color-teal-300-rgb); --color-error-rgb: var(--color-red-400-rgb); --color-warning-rgb: var(--color-orange-400-rgb); --color-info-rgb: var(--color-gray-300-rgb); } } /* Data attribute for manual theme switching */ [data-color-scheme="dark"] { /* RGB versions for opacity control (dark mode) */ --color-gray-400-rgb: 119, 124, 124; --color-teal-300-rgb: 50, 184, 198; --color-gray-300-rgb: 167, 169, 169; --color-gray-200-rgb: 245, 245, 245; /* Colorful background palette - Dark Mode */ --color-bg-1: rgba(29, 78, 216, 0.15); /* Dark blue */ --color-bg-2: rgba(180, 83, 9, 0.15); /* Dark yellow */ --color-bg-3: rgba(21, 128, 61, 0.15); /* Dark green */ --color-bg-4: rgba(185, 28, 28, 0.15); /* Dark red */ --color-bg-5: rgba(107, 33, 168, 0.15); /* Dark purple */ --color-bg-6: rgba(194, 65, 12, 0.15); /* Dark orange */ --color-bg-7: rgba(190, 24, 93, 0.15); /* Dark pink */ --color-bg-8: rgba(8, 145, 178, 0.15); /* Dark cyan */ /* Semantic Color Tokens (Dark Mode) */ --color-background: var(--color-charcoal-700); --color-surface: var(--color-charcoal-800); --color-text: var(--color-gray-200); --color-text-secondary: rgba(var(--color-gray-300-rgb), 0.7); --color-primary: var(--color-teal-300); --color-primary-hover: var(--color-teal-400); --color-primary-active: var(--color-teal-800); --color-secondary: rgba(var(--color-gray-400-rgb), 0.15); --color-secondary-hover: rgba(var(--color-gray-400-rgb), 0.25); --color-secondary-active: rgba(var(--color-gray-400-rgb), 0.3); --color-border: rgba(var(--color-gray-400-rgb), 0.3); --color-error: var(--color-red-400); --color-success: var(--color-teal-300); --color-warning: var(--color-orange-400); --color-info: var(--color-gray-300); --color-focus-ring: rgba(var(--color-teal-300-rgb), 0.4); --color-btn-primary-text: var(--color-slate-900); --color-card-border: rgba(var(--color-gray-400-rgb), 0.15); --color-card-border-inner: rgba(var(--color-gray-400-rgb), 0.15); --shadow-inset-sm: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 0 rgba(0, 0, 0, 0.15); --color-border-secondary: rgba(var(--color-gray-400-rgb), 0.2); --color-select-caret: rgba(var(--color-gray-200-rgb), 0.8); /* Common style patterns - updated for dark mode */ --focus-ring: 0 0 0 3px var(--color-focus-ring); --focus-outline: 2px solid var(--color-primary); --status-bg-opacity: 0.15; --status-border-opacity: 0.25; --select-caret-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23134252' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); --select-caret-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23f5f5f5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); /* RGB versions for dark mode */ --color-success-rgb: var(--color-teal-300-rgb); --color-error-rgb: var(--color-red-400-rgb); --color-warning-rgb: var(--color-orange-400-rgb); --color-info-rgb: var(--color-gray-300-rgb); } [data-color-scheme="light"] { /* RGB versions for opacity control (light mode) */ --color-brown-600-rgb: 94, 82, 64; --color-teal-500-rgb: 33, 128, 141; --color-slate-900-rgb: 19, 52, 59; /* Semantic Color Tokens (Light Mode) */ --color-background: var(--color-cream-50); --color-surface: var(--color-cream-100); --color-text: var(--color-slate-900); --color-text-secondary: var(--color-slate-500); --color-primary: var(--color-teal-500); --color-primary-hover: var(--color-teal-600); --color-primary-active: var(--color-teal-700); --color-secondary: rgba(var(--color-brown-600-rgb), 0.12); --color-secondary-hover: rgba(var(--color-brown-600-rgb), 0.2); --color-secondary-active: rgba(var(--color-brown-600-rgb), 0.25); --color-border: rgba(var(--color-brown-600-rgb), 0.2); --color-btn-primary-text: var(--color-cream-50); --color-card-border: rgba(var(--color-brown-600-rgb), 0.12); --color-card-border-inner: rgba(var(--color-brown-600-rgb), 0.12); --color-error: var(--color-red-500); --color-success: var(--color-teal-500); --color-warning: var(--color-orange-500); --color-info: var(--color-slate-500); --color-focus-ring: rgba(var(--color-teal-500-rgb), 0.4); /* RGB versions for light mode */ --color-success-rgb: var(--color-teal-500-rgb); --color-error-rgb: var(--color-red-500-rgb); --color-warning-rgb: var(--color-orange-500-rgb); --color-info-rgb: var(--color-slate-500-rgb); } /* Base styles */ html { font-size: var(--font-size-base); font-family: var(--font-family-base); line-height: var(--line-height-normal); color: var(--color-text); background-color: var(--color-background); -webkit-font-smoothing: antialiased; box-sizing: border-box; } body { margin: 0; padding: 0; } *, *::before, *::after { box-sizing: inherit; } /* Typography */ h1, h2, h3, h4, h5, h6 { margin: 0; font-weight: var(--font-weight-semibold); line-height: var(--line-height-tight); color: var(--color-text); letter-spacing: var(--letter-spacing-tight); } h1 { font-size: var(--font-size-4xl); } h2 { font-size: var(--font-size-3xl); } h3 { font-size: var(--font-size-2xl); } h4 { font-size: var(--font-size-xl); } h5 { font-size: var(--font-size-lg); } h6 { font-size: var(--font-size-md); } p { margin: 0 0 var(--space-16) 0; } a { color: var(--color-primary); text-decoration: none; transition: color var(--duration-fast) var(--ease-standard); } a:hover { color: var(--color-primary-hover); } code, pre { font-family: var(--font-family-mono); font-size: calc(var(--font-size-base) * 0.95); background-color: var(--color-secondary); border-radius: var(--radius-sm); } code { padding: var(--space-1) var(--space-4); } pre { padding: var(--space-16); margin: var(--space-16) 0; overflow: auto; border: 1px solid var(--color-border); } pre code { background: none; padding: 0; } /* Buttons */ .btn { display: inline-flex; align-items: center; justify-content: center; padding: var(--space-8) var(--space-16); border-radius: var(--radius-base); font-size: var(--font-size-base); font-weight: 500; line-height: 1.5; cursor: pointer; transition: all var(--duration-normal) var(--ease-standard); border: none; text-decoration: none; position: relative; } .btn:focus-visible { outline: none; box-shadow: var(--focus-ring); } .btn--primary { background: var(--color-primary); color: var(--color-btn-primary-text); } .btn--primary:hover { background: var(--color-primary-hover); } .btn--primary:active { background: var(--color-primary-active); } .btn--secondary { background: var(--color-secondary); color: var(--color-text); } .btn--secondary:hover { background: var(--color-secondary-hover); } .btn--secondary:active { background: var(--color-secondary-active); } .btn--outline { background: transparent; border: 1px solid var(--color-border); color: var(--color-text); } .btn--outline:hover { background: var(--color-secondary); } .btn--sm { padding: var(--space-4) var(--space-12); font-size: var(--font-size-sm); border-radius: var(--radius-sm); } .btn--lg { padding: var(--space-10) var(--space-20); font-size: var(--font-size-lg); border-radius: var(--radius-md); } .btn--full-width { width: 100%; } .btn:disabled { opacity: 0.5; cursor: not-allowed; } /* Form elements */ .form-control { display: block; width: 100%; padding: var(--space-8) var(--space-12); font-size: var(--font-size-md); line-height: 1.5; color: var(--color-text); background-color: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-base); transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); } textarea.form-control { font-family: var(--font-family-base); font-size: var(--font-size-base); } select.form-control { padding: var(--space-8) var(--space-12); -webkit-appearance: none; -moz-appearance: none; appearance: none; background-image: var(--select-caret-light); background-repeat: no-repeat; background-position: right var(--space-12) center; background-size: 16px; padding-right: var(--space-32); } /* Add a dark mode specific caret */ @media (prefers-color-scheme: dark) { select.form-control { background-image: var(--select-caret-dark); } } /* Also handle data-color-scheme */ [data-color-scheme="dark"] select.form-control { background-image: var(--select-caret-dark); } [data-color-scheme="light"] select.form-control { background-image: var(--select-caret-light); } .form-control:focus { border-color: var(--color-primary); outline: var(--focus-outline); } .form-label { display: block; margin-bottom: var(--space-8); font-weight: var(--font-weight-medium); font-size: var(--font-size-sm); } .form-group { margin-bottom: var(--space-16); } /* Card component */ .card { background-color: var(--color-surface); border-radius: var(--radius-lg); border: 1px solid var(--color-card-border); box-shadow: var(--shadow-sm); overflow: hidden; transition: box-shadow var(--duration-normal) var(--ease-standard); } .card:hover { box-shadow: var(--shadow-md); } .card__body { padding: var(--space-16); } .card__header, .card__footer { padding: var(--space-16); border-bottom: 1px solid var(--color-card-border-inner); } /* Status indicators - simplified with CSS variables */ .status { display: inline-flex; align-items: center; padding: var(--space-6) var(--space-12); border-radius: var(--radius-full); font-weight: var(--font-weight-medium); font-size: var(--font-size-sm); } .status--success { background-color: rgba( var(--color-success-rgb, 33, 128, 141), var(--status-bg-opacity) ); color: var(--color-success); border: 1px solid rgba(var(--color-success-rgb, 33, 128, 141), var(--status-border-opacity)); } .status--error { background-color: rgba( var(--color-error-rgb, 192, 21, 47), var(--status-bg-opacity) ); color: var(--color-error); border: 1px solid rgba(var(--color-error-rgb, 192, 21, 47), var(--status-border-opacity)); } .status--warning { background-color: rgba( var(--color-warning-rgb, 168, 75, 47), var(--status-bg-opacity) ); color: var(--color-warning); border: 1px solid rgba(var(--color-warning-rgb, 168, 75, 47), var(--status-border-opacity)); } .status--info { background-color: rgba( var(--color-info-rgb, 98, 108, 113), var(--status-bg-opacity) ); color: var(--color-info); border: 1px solid rgba(var(--color-info-rgb, 98, 108, 113), var(--status-border-opacity)); } /* Container layout */ .container { width: 100%; margin-right: auto; margin-left: auto; padding-right: var(--space-16); padding-left: var(--space-16); } @media (min-width: 640px) { .container { max-width: var(--container-sm); } } @media (min-width: 768px) { .container { max-width: var(--container-md); } } @media (min-width: 1024px) { .container { max-width: var(--container-lg); } } @media (min-width: 1280px) { .container { max-width: var(--container-xl); } } /* Utility classes */ .flex { display: flex; } .flex-col { flex-direction: column; } .items-center { align-items: center; } .justify-center { justify-content: center; } .justify-between { justify-content: space-between; } .gap-4 { gap: var(--space-4); } .gap-8 { gap: var(--space-8); } .gap-16 { gap: var(--space-16); } .m-0 { margin: 0; } .mt-8 { margin-top: var(--space-8); } .mb-8 { margin-bottom: var(--space-8); } .mx-8 { margin-left: var(--space-8); margin-right: var(--space-8); } .my-8 { margin-top: var(--space-8); margin-bottom: var(--space-8); } .p-0 { padding: 0; } .py-8 { padding-top: var(--space-8); padding-bottom: var(--space-8); } .px-8 { padding-left: var(--space-8); padding-right: var(--space-8); } .py-16 { padding-top: var(--space-16); padding-bottom: var(--space-16); } .px-16 { padding-left: var(--space-16); padding-right: var(--space-16); } .block { display: block; } .hidden { display: none; } /* Accessibility */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; } :focus-visible { outline: var(--focus-outline); outline-offset: 2px; } /* Dark mode specifics */ [data-color-scheme="dark"] .btn--outline { border: 1px solid var(--color-border-secondary); } @font-face { font-family: 'FKGroteskNeue'; src: url('https://r2cdn.perplexity.ai/fonts/FKGroteskNeue.woff2') format('woff2'); } /* END PERPLEXITY DESIGN SYSTEM */ :root { /* Notion-style colors */ --bg-primary: #FFFFFF; --bg-secondary: #F7F6F3; --bg-hover: #F1F0ED; --text-primary: #37352F; --text-secondary: #787774; --text-tertiary: #9B9A97; --accent-color: #0F7B6C; --accent-hover: #0D6A5C; --border-color: #E9E9E7; --shadow-sm: 0 1px 3px rgba(0,0,0,0.1); --shadow-md: 0 2px 8px rgba(0,0,0,0.08); --radius: 6px; --success: #00875A; --warning: #FF991F; --danger: #DE350B; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: var(--bg-secondary); color: var(--text-primary); line-height: 1.5; } /* Header */ .header { background: var(--bg-primary); border-bottom: 1px solid var(--border-color); position: sticky; top: 0; z-index: 100; box-shadow: var(--shadow-sm); } .header-content { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; max-width: 1800px; margin: 0 auto; } .header-title { display: flex; align-items: center; gap: 12px; } .header-icon { font-size: 28px; } .header h1 { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; } .header-subtitle { font-size: 13px; color: var(--text-secondary); margin: 0; } .btn-export { background: var(--bg-hover); border: 1px solid var(--border-color); padding: 8px 16px; border-radius: var(--radius); font-size: 14px; color: var(--text-primary); cursor: pointer; transition: all 0.2s; } .btn-export:hover { background: var(--bg-secondary); } /* Main Container */ .main-container { display: flex; max-width: 1800px; margin: 0 auto; min-height: calc(100vh - 70px); } /* Sidebar */ .sidebar { width: 320px; background: var(--bg-primary); border-right: 1px solid var(--border-color); overflow-y: auto; position: sticky; top: 70px; height: calc(100vh - 70px); } .sidebar-content { padding: 24px; } .section { margin-bottom: 32px; } .section-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 16px; text-transform: uppercase; letter-spacing: 0.5px; } .form-group { margin-bottom: 20px; } .label { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-primary); margin-bottom: 8px; font-weight: 500; } .tooltip { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; background: var(--bg-hover); border-radius: 50%; font-size: 11px; color: var(--text-secondary); cursor: help; position: relative; } .tooltip:hover::after { content: attr(data-tooltip); position: absolute; left: 24px; top: 50%; transform: translateY(-50%); background: var(--text-primary); color: white; padding: 8px 12px; border-radius: var(--radius); font-size: 12px; white-space: nowrap; z-index: 10; box-shadow: var(--shadow-md); } /* Form Elements */ .select-input { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); border-radius: var(--radius); font-size: 14px; color: var(--text-primary); background: var(--bg-primary); cursor: pointer; transition: all 0.2s; } .select-input:hover { border-color: var(--text-tertiary); } .select-input:focus { outline: none; border-color: var(--accent-color); box-shadow: 0 0 0 3px rgba(15, 123, 108, 0.1); } .slider { width: 100%; height: 6px; border-radius: 3px; background: var(--bg-hover); outline: none; -webkit-appearance: none; } .slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--accent-color); cursor: pointer; box-shadow: var(--shadow-sm); } .slider::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: var(--accent-color); cursor: pointer; border: none; box-shadow: var(--shadow-sm); } .slider-labels { display: flex; justify-content: space-between; margin-top: 6px; font-size: 11px; color: var(--text-tertiary); } .text-input { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); border-radius: var(--radius); font-size: 14px; color: var(--text-primary); background: var(--bg-primary); transition: all 0.2s; } .text-input:focus { outline: none; border-color: var(--accent-color); box-shadow: 0 0 0 3px rgba(15, 123, 108, 0.1); } .input-hint { font-size: 12px; color: var(--text-tertiary); margin-top: 4px; } .toggle-group { display: flex; gap: 8px; } .toggle-btn { flex: 1; padding: 10px; border: 1px solid var(--border-color); border-radius: var(--radius); background: var(--bg-primary); color: var(--text-primary); font-size: 14px; cursor: pointer; transition: all 0.2s; } .toggle-btn:hover { background: var(--bg-hover); } .toggle-btn.active { background: var(--accent-color); color: white; border-color: var(--accent-color); } .risk-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .risk-btn { padding: 10px; border: 1px solid var(--border-color); border-radius: var(--radius); background: var(--bg-primary); color: var(--text-primary); font-size: 13px; cursor: pointer; transition: all 0.2s; } .risk-btn:hover { background: var(--bg-hover); } .risk-btn.active { background: var(--accent-color); color: white; border-color: var(--accent-color); } .radio-group { display: flex; flex-direction: column; gap: 10px; } .radio-label { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px; border-radius: var(--radius); transition: background 0.2s; } .radio-label:hover { background: var(--bg-hover); } .radio-label input[type="radio"] { cursor: pointer; } .radio-label span { font-size: 14px; color: var(--text-primary); } .btn-calculate { width: 100%; padding: 14px; background: var(--accent-color); color: white; border: none; border-radius: var(--radius); font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; box-shadow: var(--shadow-sm); } .btn-calculate:hover { background: var(--accent-hover); transform: translateY(-1px); box-shadow: var(--shadow-md); } .btn-calculate:active { transform: translateY(0); } /* Content Area */ .content { flex: 1; padding: 32px; overflow-y: auto; } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 500px; text-align: center; } .empty-icon { font-size: 64px; margin-bottom: 16px; opacity: 0.5; } .empty-state h2 { font-size: 24px; margin-bottom: 8px; color: var(--text-primary); } .empty-state p { font-size: 15px; color: var(--text-secondary); max-width: 400px; } /* Results */ .results { animation: fadeIn 0.5s ease-in-out; } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } /* Metrics Grid */ .metrics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 32px; } .metric-card { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius); padding: 24px; box-shadow: var(--shadow-sm); transition: all 0.3s; } .metric-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); } .metric-label { font-size: 13px; color: var(--text-secondary); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; } .metric-value { font-size: 32px; font-weight: 700; color: var(--accent-color); margin-bottom: 4px; } .metric-change { font-size: 13px; color: var(--success); } /* Chart Section */ .chart-section { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius); padding: 24px; margin-bottom: 24px; box-shadow: var(--shadow-sm); } .chart-container { position: relative; margin-top: 20px; } .chart-description { color: var(--text-secondary); font-size: 14px; margin-bottom: 16px; line-height: 1.5; } /* Table */ .table-section { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius); padding: 24px; margin-bottom: 24px; box-shadow: var(--shadow-sm); } .table-wrapper { overflow-x: auto; margin-top: 16px; } .analysis-table { width: 100%; border-collapse: collapse; font-size: 14px; } .analysis-table thead { background: var(--bg-secondary); } .analysis-table th { text-align: left; padding: 12px 16px; font-weight: 600; color: var(--text-primary); border-bottom: 2px solid var(--border-color); } .analysis-table td { padding: 12px 16px; border-bottom: 1px solid var(--border-color); color: var(--text-primary); } .analysis-table tbody tr:hover { background: var(--bg-hover); } .efficiency-indicator { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; } .efficiency-high { background: rgba(0, 135, 90, 0.15); color: var(--success); } .efficiency-medium { background: rgba(255, 153, 31, 0.15); color: var(--warning); } .efficiency-low { background: rgba(222, 53, 11, 0.15); color: var(--danger); } /* Scenario Section */ .scenario-section { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius); padding: 24px; margin-bottom: 24px; box-shadow: var(--shadow-sm); } .scenario-buttons { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-top: 16px; } .scenario-btn { padding: 12px 16px; background: var(--bg-hover); border: 1px solid var(--border-color); border-radius: var(--radius); font-size: 14px; color: var(--text-primary); cursor: pointer; transition: all 0.2s; } .scenario-btn:hover { background: var(--accent-color); color: white; border-color: var(--accent-color); transform: translateY(-1px); } .scenario-results { margin-top: 20px; padding: 20px; background: var(--bg-secondary); border-radius: var(--radius); } .scenario-comparison { display: flex; align-items: center; justify-content: center; gap: 32px; } .comparison-item { text-align: center; } .comparison-label { font-size: 13px; color: var(--text-secondary); margin-bottom: 8px; } .comparison-value { font-size: 28px; font-weight: 700; color: var(--accent-color); } .comparison-arrow { font-size: 28px; color: var(--text-tertiary); } /* Recommendations */ .recommendations-section { background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius); padding: 24px; box-shadow: var(--shadow-sm); } .recommendations-content { margin-top: 16px; } .recommendations-content ul { list-style: none; padding: 0; } .recommendations-content li { padding: 12px 0; padding-left: 28px; position: relative; font-size: 14px; color: var(--text-primary); line-height: 1.6; } .recommendations-content li::before { content: "•"; position: absolute; left: 12px; color: var(--accent-color); font-size: 18px; } /* Price Tag */ .price-tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: var(--bg-hover); color: var(--text-secondary); margin-left: 4px; } /* Rating Stars */ .rating-stars { font-size: 16px; margin-left: 4px; } /* Accordion */ .accordion { margin-top: 12px; } .accordion-item { border: 1px solid var(--border-color); border-radius: var(--radius); margin-bottom: 8px; overflow: hidden; } .accordion-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 14px; background: var(--bg-secondary); cursor: pointer; font-size: 13px; font-weight: 600; transition: background 0.2s; } .accordion-header:hover { background: var(--bg-hover); } .accordion-icon { font-size: 12px; transition: transform 0.3s; } .accordion-header.active .accordion-icon { transform: rotate(180deg); } .accordion-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; background: var(--bg-primary); } .accordion-content.active { max-height: 1000px; padding: 12px 14px; } /* Platform Cards */ .platform-list { margin-top: 12px; } .platform-card { border: 1px solid var(--border-color); border-radius: var(--radius); margin-bottom: 12px; background: var(--bg-primary); overflow: hidden; } .platform-header { display: flex; align-items: flex-start; gap: 10px; padding: 12px; cursor: pointer; transition: background 0.2s; } .platform-header:hover { background: var(--bg-hover); } .platform-info { flex: 1; } .platform-name { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; } .platform-stats { font-size: 11px; color: var(--text-secondary); } .platform-details { padding: 0 12px 12px 12px; display: none; border-top: 1px solid var(--border-color); background: var(--bg-secondary); font-size: 12px; } .platform-details.active { display: block; padding-top: 12px; } .platform-details p { margin: 6px 0; line-height: 1.4; } .badge-express { display: inline-block; background: rgba(15, 123, 108, 0.15); color: var(--accent-color); padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; margin-left: 4px; } .label-small { display: block; font-size: 12px; color: var(--text-secondary); margin: 8px 0 4px 0; font-weight: 500; } .external-channels { margin-top: 12px; } /* Goals */ .goals-checkboxes { display: flex; flex-direction: column; gap: 8px; } .goal-priority { margin-bottom: 12px; } .priority-sum { margin-top: 12px; padding: 8px; background: var(--bg-hover); border-radius: var(--radius); text-align: center; font-size: 13px; font-weight: 600; } .priority-sum span { color: var(--accent-color); } /* Channel Groups */ .channel-group { margin-bottom: 16px; } .channel-label { display: flex; align-items: center; gap: 8px; padding: 8px 0; font-size: 14px; cursor: pointer; } .channel-checkbox { cursor: pointer; width: 16px; height: 16px; } .market-share { font-size: 11px; color: var(--text-tertiary); background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; margin-left: 4px; } .subchannel-group { margin-left: 28px; padding-left: 12px; border-left: 2px solid var(--border-color); display: none; } .subchannel-group.active { display: block; } .subchannel-label { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; color: var(--text-secondary); cursor: pointer; } .subchannel-label input { cursor: pointer; } .info-box { background: var(--bg-secondary); padding: 12px; border-radius: var(--radius); margin-top: 8px; font-size: 13px; color: var(--text-primary); line-height: 1.6; } .info-box p { margin: 0; } /* Distribution Grid */ .distribution-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-top: 16px; } .gauge-card { background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--radius); padding: 20px; text-align: center; } .gauge-label { font-size: 12px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; } .gauge-value { font-size: 36px; font-weight: 700; color: var(--accent-color); margin-bottom: 8px; } .gauge-status { font-size: 13px; font-weight: 600; padding: 4px 12px; border-radius: 12px; display: inline-block; } .gauge-status.excellent { background: rgba(0, 135, 90, 0.15); color: var(--success); } .gauge-status.good { background: rgba(15, 123, 108, 0.15); color: var(--accent-color); } .gauge-status.warning { background: rgba(255, 153, 31, 0.15); color: var(--warning); } .distribution-insight { margin-top: 16px; padding: 16px; background: var(--bg-secondary); border-radius: var(--radius); font-size: 14px; line-height: 1.6; } /* Reputation Display */ .reputation-display { display: grid; grid-template-columns: 1fr 2fr; gap: 24px; margin-top: 16px; } .reputation-main { text-align: center; padding: 24px; background: var(--bg-secondary); border-radius: var(--radius); } .big-rating { font-size: 48px; margin-bottom: 8px; } .rating-number { font-size: 32px; font-weight: 700; color: var(--accent-color); margin-bottom: 8px; } .review-count-display { font-size: 14px; color: var(--text-secondary); } .reputation-impact { padding: 16px; background: var(--bg-secondary); border-radius: var(--radius); font-size: 14px; line-height: 1.6; } /* Diminishing Returns Section */ #diminishingReturnsSection { display: none; } #diminishingReturnsSection.active { display: block; } /* Responsive */ @media (max-width: 1024px) { .sidebar { width: 300px; } .metrics-grid { grid-template-columns: repeat(2, 1fr); } .reputation-display { grid-template-columns: 1fr; } } @media (max-width: 768px) { .main-container { flex-direction: column; } .sidebar { width: 100%; height: auto; position: relative; top: 0; } .content { padding: 20px; } .header-content { flex-direction: column; gap: 12px; align-items: flex-start; } .metrics-grid { grid-template-columns: 1fr; } .distribution-grid { grid-template-columns: 1fr; } .reputation-display { grid-template-columns: 1fr; } .scenario-buttons { grid-template-columns: 1fr; } }
// Russian eGrocery Market Data 2025 egrocery-data.js const egroceryPlatforms = { ozon: { name: 'Ozon', annualSales: 2523000, growth: 0.62, avgCheck: 1790, categoryShare: 7.83, targetAudience: 2413.96, awarenessFormats: 9, performanceFormats: 5, commission: [0.05, 0.25], baseROAS: [3.2, 4.5], position: 'leader' }, wb: { name: 'Wildberries', annualSales: 3320000, growth: 0.55, avgCheck: 870, categoryShare: 24.4, targetAudience: 6358, awarenessFormats: 7, performanceFormats: 3, commission: [0.05, 0.20], baseROAS: [2.8, 4.2], position: 'leader' }, yandex: { name: 'Яндекс.Маркет', annualSales: 535300, growth: 0.45, avgCheck: 4250, categoryShare: 6.62, targetAudience: 2687, awarenessFormats: 7, performanceFormats: 5, commission: [0.03, 0.15], baseROAS: [3.5, 4.8], ecosystemBonus: 0.25, position: 'top5' }, samokat: { name: 'Самокат', annualSales: 244200, growth: 0.53, avgCheck: 960, categoryShare: 56.5, targetAudience: 2338, awarenessFormats: 10, performanceFormats: 11, commission: [0.10, 0.20], baseROAS: [3.8, 5.2], deliveryType: 'express', expressBonus: 0.25, position: 'top5' }, lavka: { name: 'Яндекс Лавка', annualSales: 133700, growth: 0.73, avgCheck: 1260, categoryShare: 26.48, targetAudience: 812, awarenessFormats: 9, performanceFormats: 10, commission: [0.10, 0.20], baseROAS: [3.6, 5.0], deliveryType: 'express', expressBonus: 0.25, position: 'top5' }, kuper: { name: 'Купер (СберМаркет)', annualSales: 173000, growth: 0.36, avgCheck: 2390, categoryShare: 44.4, targetAudience: 2736, awarenessFormats: 11, performanceFormats: 10, commission: [0.08, 0.18], baseROAS: [2.8, 3.8], position: 'major' }, pyaterochka: { name: 'Пятёрочка доставка', annualSales: 84900, growth: 1.0, avgCheck: 1510, categoryShare: 44.1, targetAudience: 2252, awarenessFormats: 2, performanceFormats: 6, commission: [0.10, 0.20], baseROAS: [2.5, 3.8], position: 'growing' }, yeda: { name: 'Яндекс Еда', annualSales: 69400, growth: 1.04, avgCheck: 2070, categoryShare: 20.7, targetAudience: 812, awarenessFormats: 18, performanceFormats: 21, commission: [0.15, 0.30], baseROAS: [3.2, 4.5], deliveryType: 'food', position: 'growing' }, lenta: { name: 'Лента', annualSales: 42200, growth: 0.30, avgCheck: 3060, categoryShare: 33.79, targetAudience: 1340, awarenessFormats: 11, performanceFormats: 11, commission: [0.08, 0.18], baseROAS: [2.4, 3.5], position: 'mid' }, perekrestok: { name: 'Перекрёсток', annualSales: 43200, growth: 0.53, avgCheck: 2160, categoryShare: 15.0, targetAudience: 782, awarenessFormats: 5, performanceFormats: 7, commission: [0.08, 0.18], baseROAS: [2.6, 3.6], position: 'mid' }, magnit: { name: 'Магнит', annualSales: 33700, growth: 1.17, avgCheck: 1550, categoryShare: 7.70, targetAudience: 1584, awarenessFormats: 9, performanceFormats: 11, commission: [0.10, 0.20], baseROAS: [2.8, 4.0], position: 'fastgrowing' } }; const fmcgCategories = { dairy: { name: 'Молочная продукция', type: 'ultra_fresh', optimalPlatforms: ['samokat', 'lavka', 'pyaterochka'], roasModifier: 1.15, expressBonus: 1.25 }, meat: { name: 'Мясо и деликатесы', type: 'ultra_fresh', optimalPlatforms: ['samokat', 'ozon', 'perekrestok'], roasModifier: 1.10, expressBonus: 1.20 }, grocery: { name: 'Бакалея', type: 'basic', optimalPlatforms: ['wb', 'ozon', 'yandex'], roasModifier: 1.0, promoSensitivity: 'high' }, beverages: { name: 'Напитки', type: 'basic', optimalPlatforms: ['wb', 'ozon', 'kuper'], roasModifier: 0.95, promoSensitivity: 'high' }, confectionery: { name: 'Кондитерские изделия', type: 'basic', optimalPlatforms: ['wb', 'ozon', 'magnit'], roasModifier: 1.05, impulsePurchase: true }, snacks: { name: 'Снеки и чипсы', type: 'basic', optimalPlatforms: ['samokat', 'lavka', 'wb'], roasModifier: 1.20, impulsePurchase: true, expressBonus: 1.30 }, baby: { name: 'Детское питание', type: 'basic', optimalPlatforms: ['wb', 'ozon', 'yandex'], roasModifier: 1.25, loyaltyFactor: 'high' }, frozen: { name: 'Замороженные продукты', type: 'basic', optimalPlatforms: ['ozon', 'kuper', 'lenta'], roasModifier: 1.0 }, ready: { name: 'Готовая еда', type: 'ready_to_eat', optimalPlatforms: ['samokat', 'yeda', 'lavka'], roasModifier: 1.35, growthRate: 0.40, expressBonus: 1.40 }, fruits: { name: 'Фрукты и овощи', type: 'ultra_fresh', optimalPlatforms: ['samokat', 'lavka', 'ozon'], roasModifier: 1.10, expressBonus: 1.30 } }; const externalChannels = { external_direct: { name: 'Яндекс.Директ', baseROAS: [3.0, 4.8], type: 'external', synergy: { yandex: 0.30 } }, external_vk: { name: 'VK Реклама', baseROAS: [2.5, 3.8], type: 'external' }, external_telegram: { name: 'Telegram Ads', baseROAS: [2.8, 4.2], type: 'external' }, tv: { name: 'ТВ реклама', baseROAS: [1.5, 2.5], type: 'traditional', awarenessWeight: 0.8 }, outdoor: { name: 'Outdoor', baseROAS: [1.2, 2.0], type: 'traditional', awarenessWeight: 0.7 }, radio: { name: 'Радио', baseROAS: [1.3, 2.2], type: 'traditional', awarenessWeight: 0.6 }, content: { name: 'Контент-маркетинг', baseROAS: [2.0, 3.5], type: 'traditional', awarenessWeight: 0.5 } }; const marketData2025 = { totalMarket: 1700000, // млн руб growth: 0.33, totalOrders: 1100, // млн заказов avgCheck: 1480, onlineShare: 5.6, expressShare: 0.67, expressGrowth: 0.40 }; const positioningModifiers = { economy: { wb: 1.15, samokat: 1.12, magnit: 1.15, yandex: 0.85, ozon: 0.90 }, mid: { universal: 1.0 }, premium: { yandex: 1.20, ozon: 1.15, wb: 0.90, samokat: 0.85 } }; const synergies = { yandexEcosystem: 0.25, externalToMarketplace: 0.20, tvEgrocery: 0.15, contentPlatforms: 0.18 };
// FMCG eGrocery Media Mix Calculator egrocery-calculator.js let appState = { // FMCG params fmcgCategory: 'dairy', positioning: 'mid', productType: 'basic', brandRevenue: 500, // Campaign budget: 10000000, flightDuration: 3, goals: { awareness: true, performance: true, brand: false }, priorities: { awareness: 40, performance: 50, brand: 10 }, // Operational osa: 85, rating: 4.2, reviewCount: 500, priceIndex: 1.0, promoDepth: 15, promoFreq: 10, // Platforms platforms: { ozon: { selected: true, budget: 20 }, wb: { selected: true, budget: 25 }, yandex: { selected: true, budget: 15 }, samokat: { selected: false, budget: 0 }, lavka: { selected: false, budget: 0 }, kuper: { selected: false, budget: 0 }, pyaterochka: { selected: false, budget: 0 }, yeda: { selected: false, budget: 0 }, lenta: { selected: false, budget: 0 }, perekrestok: { selected: false, budget: 0 }, magnit: { selected: false, budget: 0 } }, // External external: { external_direct: false, external_vk: false, external_telegram: false, tv: false, outdoor: false, radio: false, content: false } }; let currentResults = null; let charts = {}; // Utility functions function formatNumber(num) { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } function formatCurrency(num) { if (num >= 1000000000) { return (num / 1000000000).toFixed(1) + ' млрд ₽'; } else if (num >= 1000000) { return (num / 1000000).toFixed(1) + ' млн ₽'; } else if (num >= 1000) { return (num / 1000).toFixed(0) + ' тыс ₽'; } return formatNumber(Math.round(num)) + ' ₽'; } function parseBudget(str) { return parseInt(str.replace(/\s/g, '')); } // Core calculation engine function calculateResults() { const category = fmcgCategories[appState.fmcgCategory]; // Get active platforms const activePlatforms = Object.keys(appState.platforms).filter( key => appState.platforms[key].selected ); if (activePlatforms.length === 0) { return null; } // Calculate modifiers const osaModifier = appState.osa / 100; const ratingModifier = 1 + (appState.rating - 4.0) * 0.12; const reviewTrust = Math.min(1.0, Math.log10(appState.reviewCount + 1) / 8); const priceElasticity = -1.5; const priceModifier = Math.pow(appState.priceIndex, priceElasticity); const promoLift = 1 + (appState.promoDepth / 100 * 0.4) * (appState.promoFreq / 30); const totalModifier = osaModifier * ratingModifier * reviewTrust * priceModifier * promoLift; // Calculate budget distribution const platformResults = {}; let totalPlatformBudget = 0; activePlatforms.forEach(key => { const budgetPercent = appState.platforms[key].budget; totalPlatformBudget += budgetPercent; }); // Normalize if over 100% const normalizeFactor = totalPlatformBudget > 100 ? 100 / totalPlatformBudget : 1; let totalROAS = 0; let totalRevenue = 0; let totalCommission = 0; activePlatforms.forEach(key => { const platform = egroceryPlatforms[key]; const budgetPercent = appState.platforms[key].budget * normalizeFactor; const budget = appState.budget * (budgetPercent / 100); // Calculate platform ROAS let baseROAS = (platform.baseROAS[0] + platform.baseROAS[1]) / 2; // Apply category modifier baseROAS *= category.roasModifier; // Apply positioning modifier const positioning = appState.positioning; if (positioningModifiers[positioning][key]) { baseROAS *= positioningModifiers[positioning][key]; } else if (positioningModifiers[positioning].universal) { baseROAS *= positioningModifiers[positioning].universal; } // Express delivery bonus if (platform.deliveryType === 'express' && category.expressBonus) { baseROAS *= category.expressBonus; } // Product type bonus if (appState.productType === 'ultra_fresh' && platform.deliveryType === 'express') { baseROAS *= 1.25; } else if (appState.productType === 'ready_to_eat' && platform.deliveryType) { baseROAS *= 1.35; } // Apply total modifiers const effectiveROAS = baseROAS * totalModifier; // Diminishing returns const diminishingFactor = 1 - (budgetPercent / 100) * 0.2; const finalROAS = effectiveROAS * diminishingFactor; const revenue = budget * finalROAS; const commissionRate = (platform.commission[0] + platform.commission[1]) / 2; const commission = revenue * commissionRate; platformResults[key] = { name: platform.name, budget: budget, budgetPercent: budgetPercent, roas: finalROAS, revenue: revenue, commission: commission, netRevenue: revenue - commission, platform: platform }; totalROAS += finalROAS * (budgetPercent / 100); totalRevenue += revenue; totalCommission += commission; }); // Add external channels const externalResults = {}; let externalBudget = 0; let externalRevenue = 0; Object.keys(appState.external).forEach(key => { if (appState.external[key]) { const channel = externalChannels[key]; const budget = appState.budget * 0.10; // Allocate 10% to each external const baseROAS = (channel.baseROAS[0] + channel.baseROAS[1]) / 2; let effectiveROAS = baseROAS * totalModifier; // Synergy with platforms if (key === 'external_direct' && appState.platforms.yandex.selected) { effectiveROAS *= (1 + synergies.yandexEcosystem); } const revenue = budget * effectiveROAS; externalResults[key] = { name: channel.name, budget: budget, roas: effectiveROAS, revenue: revenue }; externalBudget += budget; externalRevenue += revenue; } }); const totalBudgetUsed = appState.budget + externalBudget; const netProfit = totalRevenue + externalRevenue - totalBudgetUsed - totalCommission; const overallROI = ((totalRevenue + externalRevenue - totalBudgetUsed) / totalBudgetUsed) * 100; // Calculate efficiency score const efficiencyScore = Math.min(100, ( (totalROAS / 5) * 40 + (appState.osa / 100) * 15 + (appState.rating / 5) * 15 + (activePlatforms.length >= 3 ? 20 : activePlatforms.length * 6.67) + 10 )); return { platforms: platformResults, external: externalResults, totalROAS: totalROAS, totalRevenue: totalRevenue, totalCommission: totalCommission, netProfit: netProfit, overallROI: overallROI, efficiencyScore: efficiencyScore, modifiers: { osa: osaModifier, rating: ratingModifier, review: reviewTrust, price: priceModifier, promo: promoLift, total: totalModifier } }; } // Generate recommendations function generateRecommendations(results) { const recommendations = []; const category = fmcgCategories[appState.fmcgCategory]; // Top platform const sortedPlatforms = Object.entries(results.platforms) .sort((a, b) => b[1].roas - a[1].roas); if (sortedPlatforms.length > 0) { const top = sortedPlatforms[0]; recommendations.push( `Лучшая площадка: ${top[1].name} с ROAS ${top[1].roas.toFixed(2)}` ); } // OSA recommendations if (appState.osa < 90) { const lostSales = (90 - appState.osa) * 0.07; const potentialGain = results.totalRevenue * (lostSales / (100 - lostSales)); recommendations.push( `OSA ${appState.osa}% — теряете ~${(lostSales * 100).toFixed(1)}% продаж (${formatCurrency(potentialGain)}). Улучшите дистрибуцию!` ); } // Rating recommendations if (appState.rating < 4.0) { const conversionLoss = (4.0 - appState.rating) * 12; recommendations.push( `Рейтинг ${appState.rating} — конверсия на ${conversionLoss.toFixed(0)}% ниже. Работайте с качеством и отзывами` ); } // Category-specific if (category.type === 'ultra_fresh') { const hasExpress = Object.keys(results.platforms).some(key => results.platforms[key].platform.deliveryType === 'express' ); if (!hasExpress) { recommendations.push( `Для ${category.name} экспресс-доставка (Самокат, Лавка) даёт +25% ROAS. Добавьте эти площадки!` ); } else { recommendations.push( `Отличный выбор! Экспресс-площадки оптимальны для ${category.name} (+${(category.expressBonus * 100 - 100).toFixed(0)}% эффективности)` ); } } // Positioning advice if (appState.positioning === 'economy') { if (results.platforms.wb && results.platforms.wb.budgetPercent > 30) { recommendations.push( `Эконом-сегмент: Wildberries — оптимальная площадка (+15% эффективности)` ); } } else if (appState.positioning === 'premium') { if (results.platforms.yandex && results.platforms.yandex.budgetPercent > 20) { recommendations.push( `Премиум-сегмент: Яндекс.Маркет показывает +20% для премиальных брендов` ); } } // Synergy recommendations if (appState.platforms.yandex.selected && appState.external.external_direct) { recommendations.push( `Синергия Яндекс: Маркет + Директ дают +30% эффективности — отличная комбинация!` ); } // Growth platforms const fastGrowingPlatforms = ['lavka', 'magnit', 'yeda', 'pyaterochka']; const hasFastGrowing = fastGrowingPlatforms.some(key => appState.platforms[key].selected); if (!hasFastGrowing) { recommendations.push( `Рассмотрите быстрорастущие площадки: Яндекс Лавка (+73%), Магнит (+117%), Яндекс Еда (+104%) для будущего роста` ); } // eGrocery market context recommendations.push( `Контекст 2025: Рынок eGrocery 1.7 трлн ₽ (+33% г/г), доля онлайн в FMCG достигла 5.6% — исторический максимум` ); return recommendations; }
// Data app.js const categories = [ { name: "FMCG (Товары повседневного спроса)", channels: { tv: { base_roi: 2.8, optimal_share: 35 }, bvod: { base_roi: 2.5, optimal_share: 10 }, radio: { base_roi: 1.9, optimal_share: 12 }, ooh: { base_roi: 1.5, optimal_share: 8 }, digital: { base_roi: 3.2, optimal_share: 20 }, contextual: { base_roi: 3.5, optimal_share: 8 }, social: { base_roi: 2.4, optimal_share: 5 }, email: { base_roi: 4.0, optimal_share: 2 } } }, { name: "Ритейл (Розничная торговля)", channels: { tv: { base_roi: 2.2, optimal_share: 25 }, bvod: { base_roi: 2.8, optimal_share: 12 }, radio: { base_roi: 1.7, optimal_share: 10 }, ooh: { base_roi: 2.0, optimal_share: 15 }, digital: { base_roi: 3.8, optimal_share: 22 }, contextual: { base_roi: 4.2, optimal_share: 10 }, social: { base_roi: 3.0, optimal_share: 4 }, email: { base_roi: 3.5, optimal_share: 2 } } }, { name: "Финансы", channels: { tv: { base_roi: 1.8, optimal_share: 20 }, bvod: { base_roi: 2.3, optimal_share: 15 }, radio: { base_roi: 1.5, optimal_share: 8 }, ooh: { base_roi: 1.3, optimal_share: 7 }, digital: { base_roi: 4.5, optimal_share: 30 }, contextual: { base_roi: 5.0, optimal_share: 12 }, social: { base_roi: 2.8, optimal_share: 6 }, email: { base_roi: 4.5, optimal_share: 2 } } }, { name: "Технологии", channels: { tv: { base_roi: 1.5, optimal_share: 15 }, bvod: { base_roi: 2.5, optimal_share: 18 }, radio: { base_roi: 1.2, optimal_share: 5 }, ooh: { base_roi: 1.3, optimal_share: 5 }, digital: { base_roi: 4.5, optimal_share: 32 }, contextual: { base_roi: 5.5, optimal_share: 15 }, social: { base_roi: 3.8, optimal_share: 8 }, email: { base_roi: 4.2, optimal_share: 2 } } }, { name: "Путешествия", channels: { tv: { base_roi: 2.0, optimal_share: 22 }, bvod: { base_roi: 2.6, optimal_share: 14 }, radio: { base_roi: 1.6, optimal_share: 9 }, ooh: { base_roi: 1.8, optimal_share: 12 }, digital: { base_roi: 4.0, optimal_share: 25 }, contextual: { base_roi: 4.8, optimal_share: 12 }, social: { base_roi: 3.2, optimal_share: 5 }, email: { base_roi: 3.8, optimal_share: 1 } } }, { name: "Автомобили", channels: { tv: { base_roi: 2.5, optimal_share: 32 }, bvod: { base_roi: 2.4, optimal_share: 13 }, radio: { base_roi: 1.8, optimal_share: 11 }, ooh: { base_roi: 1.9, optimal_share: 14 }, digital: { base_roi: 3.5, optimal_share: 18 }, contextual: { base_roi: 4.0, optimal_share: 8 }, social: { base_roi: 2.6, optimal_share: 3 }, email: { base_roi: 3.2, optimal_share: 1 } } }, { name: "Недвижимость", channels: { tv: { base_roi: 1.6, optimal_share: 18 }, bvod: { base_roi: 2.1, optimal_share: 12 }, radio: { base_roi: 1.4, optimal_share: 7 }, ooh: { base_roi: 2.2, optimal_share: 16 }, digital: { base_roi: 4.2, optimal_share: 28 }, contextual: { base_roi: 4.8, optimal_share: 13 }, social: { base_roi: 3.0, optimal_share: 5 }, email: { base_roi: 3.5, optimal_share: 1 } } } ]; const channelNames = { tv: "ТВ (линейное)", bvod: "BVOD (онлайн-ТВ)", radio: "Радио", ooh: "Наружная реклама", digital: "Digital-реклама", contextual: "Контекстная реклама", social: "Социальные сети", email: "Email-маркетинг" }; const riskLevels = [ { name: "Низкий", variance_multiplier: 0.85, diversification: 0.95 }, { name: "Средний", variance_multiplier: 1.0, diversification: 1.0 }, { name: "Высокий", variance_multiplier: 1.15, diversification: 1.05 }, { name: "Очень высокий", variance_multiplier: 1.3, diversification: 1.1 } ]; const timeHorizons = [ { name: "Краткосрочная (1-3 месяца)", multiplier: 0.8, digital_boost: 1.2 }, { name: "Среднесрочная (до 1 года)", multiplier: 1.0, digital_boost: 1.0 }, { name: "Долгосрочная (2+ года)", multiplier: 1.4, digital_boost: 0.9 } ]; // State let currentState = { category: 0, brandSize: 10000, onlineShare: 30, audienceType: 'mass', budget: 50000000, riskLevel: 1, timeHorizon: 1, // Distribution osa: 90, isa: 95, numDist: 75, weightedDist: 80, // Pricing priceIndex: 1.0, promoDepth: 15, promoFreq: 10, promoBudget: 5000000, // Reputation rating: 4.2, reviewCount: 1500, negativeRate: 8, // Channels (active) channels: { wb: true, wb_auto: true, wb_search: true, wb_card: false, wb_banners: false, ozon: true, ozon_search: true, ozon_templates: false, ozon_loyalty: false, yandex: false, yandex_boost: false, yandex_shelves: false, external_direct: false, external_vk: false, external_telegram: false, own_site: false, tv: false, bvod: false, radio: false, ooh: false } }; let currentResults = null; let charts = {}; let channelDetailsChartInstance = null; let diminishingReturnsChartInstance = null; // Utility Functions function formatNumber(num) { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); } function formatCurrency(num) { if (num >= 1000000000) { return (num / 1000000000).toFixed(1) + ' млрд ₽'; } else if (num >= 1000000) { return (num / 1000000).toFixed(1) + ' млн ₽'; } return formatNumber(Math.round(num)) + ' ₽'; } function parseBudget(str) { return parseInt(str.replace(/\s/g, '')); } // Extended channel data with e-commerce const extendedChannelData = { wb_auto: { name: "WB: Автокампании", roi: [2.8, 3.5], commission: 0.12, type: 'marketplace' }, wb_search: { name: "WB: Поиск", roi: [3.2, 4.0], commission: 0.12, type: 'marketplace' }, wb_card: { name: "WB: Карточка товара", roi: [3.5, 4.2], commission: 0.12, type: 'marketplace' }, wb_banners: { name: "WB: Баннеры", roi: [2.2, 2.8], commission: 0.12, type: 'marketplace' }, ozon_search: { name: "Ozon: Поиск (CPS)", roi: [3.5, 4.5], commission: 0.15, type: 'marketplace' }, ozon_templates: { name: "Ozon: Трафареты", roi: [2.8, 3.4], commission: 0.15, type: 'marketplace' }, ozon_loyalty: { name: "Ozon: Баллы", roi: [2.2, 2.8], commission: 0.15, type: 'marketplace' }, yandex_boost: { name: "ЯМ: Буст продаж", roi: [3.8, 4.6], commission: 0.08, type: 'marketplace' }, yandex_shelves: { name: "ЯМ: Полки", roi: [2.5, 3.2], commission: 0.08, type: 'marketplace' }, external_direct: { name: "Яндекс.Директ → MP", roi: [3.0, 4.5], commission: 0, type: 'external' }, external_vk: { name: "VK Реклама → MP", roi: [2.5, 3.5], commission: 0, type: 'external' }, external_telegram: { name: "Telegram Ads → MP", roi: [2.8, 4.0], commission: 0, type: 'external' }, own_site: { name: "Свой сайт", roi: [2.0, 3.5], commission: 0, type: 'own' }, tv: { name: "ТВ", roi: [2.2, 2.8], commission: 0, type: 'traditional' }, bvod: { name: "BVOD", roi: [2.4, 2.9], commission: 0, type: 'traditional' }, radio: { name: "Радио", roi: [1.6, 2.1], commission: 0, type: 'traditional' }, ooh: { name: "OOH", roi: [1.4, 2.0], commission: 0, type: 'traditional' } }; // Calculation Engine function calculateOptimalMix() { const category = categories[currentState.category]; const risk = riskLevels[currentState.riskLevel]; const timeHorizon = timeHorizons[currentState.timeHorizon]; // Calculate distribution impact const osaFactor = currentState.osa / 100; const isaFactor = currentState.isa / 100; const numDistFactor = currentState.numDist / 100; const weightedDistFactor = currentState.weightedDist / 100; const distributionMultiplier = osaFactor * isaFactor * (numDistFactor * 0.6 + weightedDistFactor * 0.4) * 1.2; // Calculate price impact const categoryElasticity = [-1.8, -1.5, -0.9, -1.1, -1.4, -0.7, -0.5][currentState.category]; const audienceModifier = currentState.audienceType === 'mass' ? 1.2 : 0.8; const elasticity = categoryElasticity * audienceModifier; const priceMultiplier = Math.pow(currentState.priceIndex, elasticity); const promoLift = 1 + (currentState.promoDepth / 100 * 0.5) * (currentState.promoFreq / 30); const priceEffectMultiplier = priceMultiplier * promoLift; // Calculate reputation impact const ratingBoost = 1 + (currentState.rating - 3.0) * 0.15; const reviewTrust = Math.min(1.0, Math.log10(currentState.reviewCount + 1) / 3); const negativePenalty = Math.max(0.7, 1 - (currentState.negativeRate / 100) * 1.5); const reputationMultiplier = ratingBoost * reviewTrust * negativePenalty; // Get active channels const activeChannels = Object.keys(currentState.channels).filter(key => currentState.channels[key]); if (activeChannels.length === 0) { return null; // No channels selected } // Calculate adjusted ROIs for active channels const channelData = {}; activeChannels.forEach(channelKey => { const channelInfo = extendedChannelData[channelKey]; if (!channelInfo) return; // Base ROI (take average of range) let baseROI = (channelInfo.roi[0] + channelInfo.roi[1]) / 2; // Brand size modifier const brandSizeNormalized = (currentState.brandSize - 100) / 49900; if (channelInfo.type === 'traditional') { baseROI *= (1 + brandSizeNormalized * 0.2); } else if (channelInfo.type === 'marketplace' || channelInfo.type === 'external') { baseROI *= (1 + (1 - brandSizeNormalized) * 0.15); } // Online share modifier (e-commerce channels benefit) if (channelInfo.type === 'marketplace' || channelInfo.type === 'external' || channelInfo.type === 'own') { baseROI *= (1 + currentState.onlineShare / 100 * 0.3); } // Audience type modifier if (currentState.audienceType === 'niche') { if (channelInfo.type !== 'traditional') { baseROI *= 1.2; } else { baseROI *= 0.85; } } // Time horizon modifier if (channelInfo.type !== 'traditional') { baseROI *= timeHorizon.digital_boost; } // Risk modifier baseROI *= risk.variance_multiplier; // Apply all factor multipliers baseROI *= distributionMultiplier; baseROI *= priceEffectMultiplier; baseROI *= reputationMultiplier; channelData[channelKey] = { name: channelInfo.name, baseROI: baseROI, commission: channelInfo.commission, type: channelInfo.type, optimalShare: 100 / activeChannels.length // Start equal }; }); // Optimize shares based on ROI and risk let totalShare = 0; const avgROI = Object.values(channelData).reduce((sum, ch) => sum + ch.baseROI, 0) / activeChannels.length; Object.keys(channelData).forEach(key => { let share = channelData[key].optimalShare; // Adjust based on ROI performance const roiRatio = channelData[key].baseROI / avgROI; share *= Math.pow(roiRatio, 0.5); // Square root for smoother distribution // Risk-based adjustment if (currentState.riskLevel === 0) { // Low risk - more equal distribution share = share * 0.8 + (100 / activeChannels.length) * 0.2; } else if (currentState.riskLevel >= 2) { // High risk - concentrate on top performers if (channelData[key].baseROI > avgROI) { share *= 1.3; } else { share *= 0.6; } } channelData[key].share = share; totalShare += share; }); // Normalize shares to 100% Object.keys(channelData).forEach(key => { channelData[key].share = (channelData[key].share / totalShare) * 100; channelData[key].budget = currentState.budget * (channelData[key].share / 100); }); // Calculate diminishing returns ROI Object.keys(channelData).forEach(key => { const share = channelData[key].share; const diminishingFactor = 1 - (share / 100) * 0.3; channelData[key].effectiveROI = channelData[key].baseROI * diminishingFactor; }); // Calculate synergy bonuses let synergyBonus = 1.0; const hasMarketplace = activeChannels.some(ch => extendedChannelData[ch]?.type === 'marketplace'); const hasExternal = activeChannels.some(ch => extendedChannelData[ch]?.type === 'external'); const hasTraditional = activeChannels.some(ch => extendedChannelData[ch]?.type === 'traditional'); const hasYandexMarket = currentState.channels.yandex; const hasYandexDirect = currentState.channels.external_direct; if (hasMarketplace && hasExternal) { synergyBonus *= 1.25; // +25% for marketplace + external traffic } if (hasMarketplace && hasTraditional) { synergyBonus *= 1.20; // +20% for marketplace + traditional } if (hasYandexMarket && hasYandexDirect) { synergyBonus *= 1.30; // +30% for Yandex ecosystem } if (currentState.channels.own_site && hasMarketplace) { synergyBonus *= 1.30; // +30% for own site + marketplace } // Calculate overall metrics let totalROI = 0; let totalSales = 0; let totalCommissions = 0; let ecommerceBudget = 0; Object.keys(channelData).forEach(key => { const share = channelData[key].share; const diminishingFactor = 1 - (share / 100) * 0.25; const roi = channelData[key].baseROI * diminishingFactor * synergyBonus; const budget = currentState.budget * (share / 100); const sales = budget * roi; const commission = sales * channelData[key].commission; channelData[key].effectiveROI = roi; channelData[key].budget = budget; channelData[key].sales = sales; channelData[key].commission = commission; channelData[key].finalROI = roi; totalROI += roi * (share / 100); totalSales += sales; totalCommissions += commission; if (channelData[key].type === 'marketplace' || channelData[key].type === 'external' || channelData[key].type === 'own') { ecommerceBudget += budget; } }); totalROI *= timeHorizon.multiplier; totalSales *= timeHorizon.multiplier; const netProfit = totalSales - currentState.budget - totalCommissions - currentState.promoBudget; const efficiency = (netProfit / currentState.budget) * 100; const ecomShare = (ecommerceBudget / currentState.budget) * 100; return { channels: channelData, totalROI: totalROI, totalSales: totalSales, netProfit: netProfit, efficiency: efficiency, synergyBonus: synergyBonus, totalCommissions: totalCommissions, ecomShare: ecomShare, distributionMultiplier: distributionMultiplier, priceEffectMultiplier: priceEffectMultiplier, reputationMultiplier: reputationMultiplier }; } function generateRecommendations(results) { const recommendations = []; const channels = results.channels; // Find top performers const sortedChannels = Object.entries(channels).sort((a, b) => b[1].finalROI - a[1].finalROI); const topChannel = sortedChannels[0]; recommendations.push(`Наиболее эффективный канал: ${topChannel[1].name} с ROI ${topChannel[1].finalROI.toFixed(2)}`); // Distribution recommendations if (currentState.osa < 90) { const lostSales = (90 - currentState.osa) * 0.7; recommendations.push(`Ваш OSA ${currentState.osa}% - это теряет ~${lostSales.toFixed(1)}% продаж. Инвестиции в улучшение дистрибуции окупятся в 2-3х`); } if (currentState.isa < 95) { recommendations.push(`ISA ${currentState.isa}% - необходимо улучшить наличие на складах для снижения потерь`); } // Rating recommendations if (currentState.rating < 4.0) { const conversionLoss = (4.0 - currentState.rating) * 15; recommendations.push(`При рейтинге ${currentState.rating} конверсия на ${conversionLoss.toFixed(0)}% ниже. Работайте с негативными отзывами`); } if (currentState.negativeRate > 15) { recommendations.push(`Доля негатива ${currentState.negativeRate}% - критичная зона! Срочно улучшайте качество продукта/сервиса`); } // Price recommendations if (currentState.priceIndex > 1.2 && currentState.promoFreq > 15) { recommendations.push(`Ценовой индекс ${currentState.priceIndex} (премиум) + высокая частота промо = неконсистентное позиционирование`); } // Marketplace recommendations const wbChannels = Object.keys(channels).filter(k => k.startsWith('wb_')); if (wbChannels.length > 0) { const wbShare = wbChannels.reduce((sum, k) => sum + channels[k].share, 0); if (wbShare > 60) { recommendations.push(`Wildberries занимает ${wbShare.toFixed(0)}% e-commerce бюджета - диверсифицируйте риски через Ozon и ЯМ`); } } // Synergy recommendations if (currentState.channels.yandex && currentState.channels.external_direct) { recommendations.push('Отличная синергия: Яндекс.Маркет + Директ дают +30% эффективности'); } // Category-specific if (currentState.category === 0 && currentState.channels.tv && currentState.channels.wb) { recommendations.push('Ваша категория FMCG - ТВ + WB дают наилучший охват массовой аудитории'); } if (currentState.channels.own_site) { recommendations.push('Свой сайт - отличное решение для LTV (+63%) и повторных покупок (+54%)'); } return recommendations; } // UI Update Functions function updateResults() { currentResults = calculateOptimalMix(); if (!currentResults) { alert('Пожалуйста, выберите хотя бы один канал в блоке E-commerce микс'); return; } // Show results area document.getElementById('emptyState').style.display = 'none'; document.getElementById('results').style.display = 'block'; // Update metrics document.getElementById('roiValue').textContent = (currentResults.totalROI * 100).toFixed(1) + '%'; document.getElementById('salesValue').textContent = formatCurrency(currentResults.totalSales); document.getElementById('profitValue').textContent = formatCurrency(currentResults.netProfit); document.getElementById('ecomShareValue').textContent = currentResults.ecomShare.toFixed(1) + '%'; document.getElementById('efficiencyValue').textContent = currentResults.efficiency.toFixed(0); // Update distribution gauges updateDistributionGauges(); // Update reputation display updateReputationDisplay(); // Update charts updateCharts(); // Update table updateTable(); // Update recommendations updateRecommendations(); } function updateDistributionGauges() { document.getElementById('gaugeOSA').textContent = currentState.osa + '%'; document.getElementById('gaugeISA').textContent = currentState.isa + '%'; document.getElementById('gaugeNumDist').textContent = currentState.numDist + '%'; document.getElementById('gaugeWeightedDist').textContent = currentState.weightedDist + '%'; // Status colors const osaStatus = document.getElementById('statusOSA'); if (currentState.osa >= 95) { osaStatus.textContent = 'Отлично'; osaStatus.className = 'gauge-status excellent'; } else if (currentState.osa >= 85) { osaStatus.textContent = 'Хорошо'; osaStatus.className = 'gauge-status good'; } else { osaStatus.textContent = 'Требует улучшения'; osaStatus.className = 'gauge-status warning'; } const isaStatus = document.getElementById('statusISA'); if (currentState.isa >= 98) { isaStatus.textContent = 'Отлично'; isaStatus.className = 'gauge-status excellent'; } else if (currentState.isa >= 90) { isaStatus.textContent = 'Хорошо'; isaStatus.className = 'gauge-status good'; } else { isaStatus.textContent = 'Требует улучшения'; isaStatus.className = 'gauge-status warning'; } // Distribution insight const lostSales = (100 - currentState.osa) * 0.7; const insight = `При текущей дистрибуции вы теряете примерно ${lostSales.toFixed(1)}% продаж из-за недостаточной доступности товара.`; document.getElementById('distributionInsight').innerHTML = insight; } function updateReputationDisplay() { const rating = currentState.rating; const fullStars = Math.floor(rating); const halfStar = (rating % 1) >= 0.5; let stars = '⭐'.repeat(fullStars); if (halfStar && fullStars < 5) stars += '½'; document.getElementById('bigRating').textContent = stars; document.getElementById('bigRatingNum').textContent = rating.toFixed(1); document.getElementById('reviewCountDisplay').textContent = formatNumber(currentState.reviewCount) + ' отзывов'; const conversionImpact = ((rating - 3.0) * 15).toFixed(0); const trustScore = (Math.min(1.0, Math.log10(currentState.reviewCount + 1) / 3) * 100).toFixed(0); const negativeImpact = (currentState.negativeRate * 1.5).toFixed(0); const impact = `

Влияние на конверсию:

• Рейтинг: ${conversionImpact > 0 ? '+' : ''}${conversionImpact}%

• Доверие (отзывы): ${trustScore}%

• Потери от негатива: -${negativeImpact}%

`; document.getElementById('reputationImpact').innerHTML = impact; } function updateCharts() { const channels = currentResults.channels; const channelKeys = Object.keys(channels); // Tree/Pie Chart for budget distribution const pieData = { labels: channelKeys.map(key => channels[key].name), datasets: [{ data: channelKeys.map(key => channels[key].share.toFixed(1)), backgroundColor: [ '#1FB8CD', '#FFC185', '#B4413C', '#ECEBD5', '#5D878F', '#DB4545', '#D2BA4C', '#964325', '#944454', '#13343B', '#32B8C6', '#E68161', '#7FB3D5', '#C39BD3', '#F8C471', '#85C1E2' ] }] }; if (charts.tree) { charts.tree.data = pieData; charts.tree.update(); } else { const ctx = document.getElementById('treeChart').getContext('2d'); charts.tree = new Chart(ctx, { type: 'doughnut', data: pieData, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { font: { size: 11 }, padding: 10 } }, tooltip: { callbacks: { label: function(context) { const label = context.label || ''; const value = context.parsed || 0; const budget = channels[channelKeys[context.dataIndex]].budget; return `${label}: ${value}% (${formatCurrency(budget)})`; } } } } } }); } // Waterfall Chart for factors impact const baseSales = currentState.budget * 2.5; const mediaImpact = baseSales * (currentResults.totalROI - 2.5); const distImpact = baseSales * (currentResults.distributionMultiplier - 1); const priceImpact = baseSales * (currentResults.priceEffectMultiplier - 1); const reputationImpact = baseSales * (currentResults.reputationMultiplier - 1); const negativeLoss = baseSales * 0.05; const finalSales = currentResults.totalSales; const waterfallData = { labels: ['Базовые продажи', 'Медиа-микс', 'Дистрибуция', 'Промо', 'Рейтинг', 'Потери', 'Итого'], datasets: [{ label: 'Влияние на продажи', data: [ baseSales / 1000000, (baseSales + mediaImpact) / 1000000, (baseSales + mediaImpact + distImpact) / 1000000, (baseSales + mediaImpact + distImpact + priceImpact) / 1000000, (baseSales + mediaImpact + distImpact + priceImpact + reputationImpact) / 1000000, (baseSales + mediaImpact + distImpact + priceImpact + reputationImpact - negativeLoss) / 1000000, finalSales / 1000000 ], backgroundColor: ['#1FB8CD', '#5D878F', '#D2BA4C', '#FFC185', '#32B8C6', '#DB4545', '#0F7B6C'] }] }; if (charts.waterfall) { charts.waterfall.data = waterfallData; charts.waterfall.update(); } else { const ctx = document.getElementById('waterfallChart').getContext('2d'); charts.waterfall = new Chart(ctx, { type: 'bar', data: waterfallData, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: function(context) { return `${context.parsed.y.toFixed(1)} млн ₽`; } } } }, scales: { y: { beginAtZero: true, title: { display: true, text: 'Продажи (млн ₽)' } } } } }); } // Channel Details Chart (Horizontal Bar) renderChannelDetailsChart(currentResults, currentState.budget); // Diminishing Returns Chart (Line) renderDiminishingReturnsChart(currentResults); } function renderChannelDetailsChart(results, budget) { // Destroy previous chart if (channelDetailsChartInstance) { channelDetailsChartInstance.destroy(); } const ctx = document.getElementById('channelDetailsChart').getContext('2d'); // Collect all channels with budget > 0 const channels = []; const budgets = []; const colors = []; // Sort channels by budget descending const sortedChannels = Object.entries(results.channels) .filter(([key, channel]) => channel.budget > 0) .sort((a, b) => b[1].budget - a[1].budget); sortedChannels.forEach(([key, channel]) => { channels.push(channel.name); budgets.push(channel.budget / 1000000); // in millions ₽ // Colors by types if (key.includes('wb')) colors.push('#CB11AB'); else if (key.includes('ozon')) colors.push('#005BFF'); else if (key.includes('yandex')) colors.push('#FFCC00'); else if (key.includes('tv') || key.includes('radio') || key.includes('ooh') || key.includes('bvod')) colors.push('#6366F1'); else if (key.includes('digital') || key.includes('contextual')) colors.push('#10B981'); else if (key.includes('social') || key.includes('vk') || key.includes('telegram')) colors.push('#8B5CF6'); else if (key.includes('external')) colors.push('#F59E0B'); else if (key.includes('own')) colors.push('#0F7B6C'); else colors.push('#94A3B8'); }); channelDetailsChartInstance = new Chart(ctx, { type: 'bar', data: { labels: channels, datasets: [{ label: 'Бюджет (млн ₽)', data: budgets, backgroundColor: colors, borderWidth: 0 }] }, options: { indexAxis: 'y', // Horizontal orientation responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: function(context) { return `Бюджет: ${context.parsed.x.toFixed(2)} млн ₽`; } } } }, scales: { x: { beginAtZero: true, title: { display: true, text: 'Бюджет (млн ₽)' }, grid: { color: 'rgba(0, 0, 0, 0.05)' } }, y: { grid: { display: false } } } } }); } function renderDiminishingReturnsChart(results) { // Destroy previous chart if (diminishingReturnsChartInstance) { diminishingReturnsChartInstance.destroy(); } const ctx = document.getElementById('diminishingReturnsChart').getContext('2d'); // Create curves for top-5 channels by budget const topChannels = Object.entries(results.channels) .filter(([key, channel]) => channel.budget > 0) .sort((a, b) => b[1].budget - a[1].budget) .slice(0, 5); const datasets = []; const colors = ['#6366F1', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6']; topChannels.forEach(([key, channel], index) => { const points = []; const baseROI = channel.baseROI; // Create 20 points from 0% to 100% budget share for (let share = 0; share <= 100; share += 5) { const effectiveROI = baseROI * (1 - (share / 100) * 0.3); points.push({ x: share, y: effectiveROI }); } // Find current share of channel const currentShare = channel.share; const currentROI = baseROI * (1 - (currentShare / 100) * 0.3); // Add curve line datasets.push({ label: channel.name, data: points, borderColor: colors[index], backgroundColor: colors[index] + '20', borderWidth: 2, tension: 0.4, fill: false, pointRadius: 0, pointHoverRadius: 6 }); // Add current budget point datasets.push({ label: channel.name + ' (текущий)', data: [{x: currentShare, y: currentROI}], borderColor: colors[index], backgroundColor: colors[index], pointRadius: 8, pointStyle: 'circle', showLine: false }); }); diminishingReturnsChartInstance = new Chart(ctx, { type: 'line', data: { datasets: datasets }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: 'top', labels: { font: { size: 11 }, padding: 10, filter: function(item) { // Show only main lines, not current points return !item.text.includes('(текущий)'); } } }, tooltip: { callbacks: { label: function(context) { if (context.dataset.label.includes('(текущий)')) { return `Текущая позиция: ROI ${context.parsed.y.toFixed(2)} при ${context.parsed.x.toFixed(1)}% бюджета`; } return `${context.dataset.label}: ROI ${context.parsed.y.toFixed(2)} при ${context.parsed.x.toFixed(0)}% доли`; } } } }, scales: { x: { type: 'linear', title: { display: true, text: 'Доля канала в бюджете (%)' }, min: 0, max: 100, grid: { color: 'rgba(0, 0, 0, 0.05)' } }, y: { title: { display: true, text: 'Эффективный ROI' }, beginAtZero: true, grid: { color: 'rgba(0, 0, 0, 0.1)' } } } } }); } function updateTable() { const channels = currentResults.channels; const tbody = document.getElementById('tableBody'); tbody.innerHTML = ''; // Sort by ROI descending const sortedKeys = Object.keys(channels).sort((a, b) => channels[b].finalROI - channels[a].finalROI); sortedKeys.forEach(key => { const channel = channels[key]; const row = document.createElement('tr'); let efficiencyClass = 'efficiency-medium'; let efficiencyText = 'Средняя'; if (channel.finalROI > 3.5) { efficiencyClass = 'efficiency-high'; efficiencyText = 'Высокая'; } else if (channel.finalROI < 2.0) { efficiencyClass = 'efficiency-low'; efficiencyText = 'Низкая'; } row.innerHTML = ` ${channel.name} ${formatCurrency(channel.budget)} ${channel.share.toFixed(1)}% ${channel.finalROI.toFixed(2)} ${formatCurrency(channel.sales)} ${efficiencyText} `; tbody.appendChild(row); }); } function updateRecommendations() { const recommendations = generateRecommendations(currentResults); const container = document.getElementById('recommendations'); container.innerHTML = '
    ' + recommendations.map(rec => `
  • ${rec}
  • `).join('') + '
'; } // Event Handlers function setupEventListeners() { // Category document.getElementById('category').addEventListener('change', (e) => { currentState.category = parseInt(e.target.value); }); // Brand Size document.getElementById('brandSize').addEventListener('input', (e) => { currentState.brandSize = parseInt(e.target.value); const value = currentState.brandSize; let displayValue; if (value >= 1000) { displayValue = (value / 1000).toFixed(1) + ' млрд ₽'; } else { displayValue = value + ' млн ₽'; } document.getElementById('brandSizeValue').textContent = displayValue; }); // Online Share document.getElementById('onlineShare').addEventListener('input', (e) => { currentState.onlineShare = parseInt(e.target.value); document.getElementById('onlineShareValue').textContent = currentState.onlineShare + '%'; }); // Audience Type document.querySelectorAll('.toggle-btn').forEach(btn => { btn.addEventListener('click', (e) => { document.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); e.target.classList.add('active'); currentState.audienceType = e.target.dataset.value; }); }); // Budget const budgetInput = document.getElementById('budget'); budgetInput.addEventListener('input', (e) => { let value = e.target.value.replace(/\s/g, ''); if (!isNaN(value) && value !== '') { currentState.budget = parseInt(value); e.target.value = formatNumber(value); } }); budgetInput.addEventListener('blur', (e) => { let value = parseBudget(e.target.value); if (value < 1000000) value = 1000000; if (value > 1000000000) value = 1000000000; currentState.budget = value; e.target.value = formatNumber(value); }); // Risk Level document.querySelectorAll('.risk-btn').forEach(btn => { btn.addEventListener('click', (e) => { document.querySelectorAll('.risk-btn').forEach(b => b.classList.remove('active')); e.target.classList.add('active'); currentState.riskLevel = parseInt(e.target.dataset.risk); }); }); // Time Horizon document.querySelectorAll('input[name="timeHorizon"]').forEach(radio => { radio.addEventListener('change', (e) => { currentState.timeHorizon = parseInt(e.target.value); }); }); // Distribution parameters document.getElementById('osa').addEventListener('input', (e) => { currentState.osa = parseInt(e.target.value); document.getElementById('osaValue').textContent = currentState.osa + '%'; }); document.getElementById('isa').addEventListener('input', (e) => { currentState.isa = parseInt(e.target.value); document.getElementById('isaValue').textContent = currentState.isa + '%'; }); document.getElementById('numDist').addEventListener('input', (e) => { currentState.numDist = parseInt(e.target.value); document.getElementById('numDistValue').textContent = currentState.numDist + '%'; }); document.getElementById('weightedDist').addEventListener('input', (e) => { currentState.weightedDist = parseInt(e.target.value); document.getElementById('weightedDistValue').textContent = currentState.weightedDist + '%'; }); // Pricing parameters document.getElementById('priceIndex').addEventListener('input', (e) => { currentState.priceIndex = parseFloat(e.target.value); document.getElementById('priceIndexValue').textContent = currentState.priceIndex.toFixed(2); const priceTag = document.getElementById('pricePosition'); if (currentState.priceIndex < 0.9) { priceTag.textContent = 'Эконом'; } else if (currentState.priceIndex <= 1.1) { priceTag.textContent = 'Средний'; } else { priceTag.textContent = 'Премиум'; } }); document.getElementById('promoDepth').addEventListener('input', (e) => { currentState.promoDepth = parseInt(e.target.value); document.getElementById('promoDepthValue').textContent = currentState.promoDepth + '%'; }); document.getElementById('promoFreq').addEventListener('input', (e) => { currentState.promoFreq = parseInt(e.target.value); document.getElementById('promoFreqValue').textContent = currentState.promoFreq + ' дней/мес'; }); document.getElementById('promoBudget').addEventListener('input', (e) => { let value = e.target.value.replace(/[^0-9]/g, ''); if (value) { currentState.promoBudget = parseFloat(value) * 1000000; } }); // Reputation parameters document.getElementById('rating').addEventListener('input', (e) => { currentState.rating = parseFloat(e.target.value); document.getElementById('ratingValue').textContent = currentState.rating.toFixed(1); const fullStars = Math.floor(currentState.rating); const halfStar = (currentState.rating % 1) >= 0.5; let stars = '⭐'.repeat(fullStars); if (halfStar && fullStars < 5) stars += '½'; document.getElementById('ratingStars').textContent = stars; }); document.getElementById('reviewCount').addEventListener('input', (e) => { let value = e.target.value.replace(/[^0-9]/g, ''); if (value) { currentState.reviewCount = parseInt(value); e.target.value = formatNumber(currentState.reviewCount); } }); document.getElementById('negativeRate').addEventListener('input', (e) => { currentState.negativeRate = parseInt(e.target.value); document.getElementById('negativeRateValue').textContent = currentState.negativeRate + '%'; }); // Channel checkboxes const channelIds = [ 'wb', 'wb_auto', 'wb_search', 'wb_card', 'wb_banners', 'ozon', 'ozon_search', 'ozon_templates', 'ozon_loyalty', 'yandex', 'yandex_boost', 'yandex_shelves', 'external_direct', 'external_vk', 'external_telegram', 'own_site', 'tv', 'bvod', 'radio', 'ooh' ]; channelIds.forEach(id => { const checkbox = document.getElementById(id); if (checkbox) { checkbox.addEventListener('change', (e) => { currentState.channels[id] = e.target.checked; // Show/hide subchannels if (id === 'wb') { const subchannels = document.getElementById('wbSubchannels'); if (subchannels) { subchannels.classList.toggle('active', e.target.checked); } } else if (id === 'ozon') { const subchannels = document.getElementById('ozonSubchannels'); if (subchannels) { subchannels.classList.toggle('active', e.target.checked); } } else if (id === 'yandex') { const subchannels = document.getElementById('yandexSubchannels'); if (subchannels) { subchannels.classList.toggle('active', e.target.checked); } } }); } }); // Accordion functionality document.querySelectorAll('.accordion-header').forEach(header => { header.addEventListener('click', () => { const target = header.dataset.target; const content = document.getElementById(target); header.classList.toggle('active'); content.classList.toggle('active'); }); }); // Calculate Button document.getElementById('calculateBtn').addEventListener('click', () => { updateResults(); }); // Scenario Buttons document.querySelectorAll('.scenario-btn').forEach(btn => { btn.addEventListener('click', (e) => { const scenario = e.target.dataset.scenario; runScenario(scenario); }); }); // Export Button document.getElementById('exportBtn').addEventListener('click', () => { alert('Функция экспорта в PDF будет доступна в следующей версии'); }); } function runScenario(scenario) { if (!currentResults) return; let scenarioState = JSON.parse(JSON.stringify(currentState)); switch(scenario) { case 'increase': scenarioState.budget = currentState.budget * 1.2; break; case 'decrease': scenarioState.budget = currentState.budget * 0.8; break; case 'marketplace': // Turn on all marketplaces, turn off traditional scenarioState.channels.wb = true; scenarioState.channels.ozon = true; scenarioState.channels.yandex = true; scenarioState.channels.tv = false; scenarioState.channels.radio = false; break; case 'wildberries': scenarioState.channels.wb = true; scenarioState.channels.wb_auto = true; scenarioState.channels.wb_search = true; scenarioState.channels.wb_card = true; break; case 'multiplatform': scenarioState.channels.wb = true; scenarioState.channels.ozon = true; scenarioState.channels.yandex = true; break; case 'improveOSA': scenarioState.osa = 95; break; case 'improveRating': scenarioState.rating = 4.5; break; case 'aggressivePromo': scenarioState.promoDepth = 30; scenarioState.promoFreq = 25; break; } // Temporarily apply scenario const tempState = JSON.parse(JSON.stringify(currentState)); currentState = scenarioState; const scenarioResults = calculateOptimalMix(); currentState = tempState; if (!scenarioResults) { alert('Не удалось рассчитать сценарий'); return; } // Show comparison document.getElementById('scenarioResults').style.display = 'block'; document.getElementById('currentROI').textContent = (currentResults.totalROI * 100).toFixed(1) + '%'; document.getElementById('scenarioROI').textContent = (scenarioResults.totalROI * 100).toFixed(1) + '%'; // Change color based on result const scenarioValue = document.getElementById('scenarioROI'); if (scenarioResults.totalROI > currentResults.totalROI) { scenarioValue.style.color = '#00875A'; } else { scenarioValue.style.color = '#DE350B'; } } // Initialize setupEventListeners(); // Set initial values document.getElementById('brandSizeValue').textContent = '10 млрд ₽'; document.getElementById('onlineShareValue').textContent = '30%'; document.getElementById('osaValue').textContent = '90%'; document.getElementById('isaValue').textContent = '95%'; document.getElementById('numDistValue').textContent = '75%'; document.getElementById('weightedDistValue').textContent = '80%'; document.getElementById('priceIndexValue').textContent = '1.0'; document.getElementById('pricePosition').textContent = 'Средний'; document.getElementById('promoDepthValue').textContent = '15%'; document.getElementById('promoFreqValue').textContent = '10 дней/мес'; document.getElementById('ratingValue').textContent = '4.2'; document.getElementById('ratingStars').textContent = '⭐⭐⭐⭐'; document.getElementById('negativeRateValue').textContent = '8%'; // Show initial subchannels for active platforms document.getElementById('wbSubchannels').classList.add('active'); document.getElementById('ozonSubchannels').classList.add('active');
// UI Event Handlers and Display Updates ui-handler.js // Initialize UI function initializeUI() { setupEventListeners(); updateAllDisplayValues(); } // Setup all event listeners function setupEventListeners() { // FMCG Category document.getElementById('fmcgCategory').addEventListener('change', (e) => { appState.fmcgCategory = e.target.value; }); // Positioning document.querySelectorAll('input[name="positioning"]').forEach(radio => { radio.addEventListener('change', (e) => { appState.positioning = e.target.value; }); }); // Product Type document.querySelectorAll('input[name="productType"]').forEach(radio => { radio.addEventListener('change', (e) => { appState.productType = e.target.value; }); }); // Brand Revenue document.getElementById('brandRevenue').addEventListener('input', (e) => { appState.brandRevenue = parseInt(e.target.value); const value = appState.brandRevenue; let displayValue = value >= 1000 ? (value / 1000).toFixed(1) + ' млрд ₽' : value + ' млн ₽'; document.getElementById('brandRevenueValue').textContent = displayValue; }); // Budget const budgetInput = document.getElementById('budget'); budgetInput.addEventListener('input', (e) => { let value = e.target.value.replace(/\s/g, ''); if (!isNaN(value) && value !== '') { appState.budget = parseInt(value); e.target.value = formatNumber(value); } }); budgetInput.addEventListener('blur', (e) => { let value = parseBudget(e.target.value); if (value < 1000000) value = 1000000; if (value > 500000000) value = 500000000; appState.budget = value; e.target.value = formatNumber(value); }); // Flight Duration document.getElementById('flightDuration').addEventListener('input', (e) => { appState.flightDuration = parseInt(e.target.value); const months = appState.flightDuration; document.getElementById('flightDurationValue').textContent = months === 1 ? '1 месяц' : months < 5 ? months + ' месяца' : months + ' месяцев'; }); // Goals ['goalAwareness', 'goalPerformance', 'goalBrand'].forEach(id => { document.getElementById(id).addEventListener('change', (e) => { const goal = id.replace('goal', '').toLowerCase(); appState.goals[goal] = e.target.checked; }); }); // Priorities ['priorityAwareness', 'priorityPerformance', 'priorityBrand'].forEach(id => { document.getElementById(id).addEventListener('input', (e) => { const priority = id.replace('priority', '').toLowerCase(); appState.priorities[priority] = parseInt(e.target.value); document.getElementById(id + 'Value').textContent = e.target.value + '%'; updatePrioritySum(); }); }); // OSA document.getElementById('osa').addEventListener('input', (e) => { appState.osa = parseInt(e.target.value); document.getElementById('osaValue').textContent = appState.osa + '%'; }); // Rating document.getElementById('rating').addEventListener('input', (e) => { appState.rating = parseFloat(e.target.value); document.getElementById('ratingValue').textContent = appState.rating.toFixed(1); updateRatingStars(); }); // Review Count document.getElementById('reviewCount').addEventListener('input', (e) => { let value = e.target.value.replace(/[^0-9]/g, ''); if (value) { appState.reviewCount = parseInt(value); e.target.value = formatNumber(appState.reviewCount); } }); // Price Index document.getElementById('priceIndex').addEventListener('input', (e) => { appState.priceIndex = parseFloat(e.target.value); document.getElementById('priceIndexValue').textContent = appState.priceIndex.toFixed(2); }); // Promo Depth document.getElementById('promoDepth').addEventListener('input', (e) => { appState.promoDepth = parseInt(e.target.value); document.getElementById('promoDepthValue').textContent = appState.promoDepth + '%'; }); // Promo Frequency document.getElementById('promoFreq').addEventListener('input', (e) => { appState.promoFreq = parseInt(e.target.value); document.getElementById('promoFreqValue').textContent = appState.promoFreq + ' дней/мес'; }); // Platform checkboxes and budget sliders Object.keys(egroceryPlatforms).forEach(key => { const checkbox = document.getElementById('platform_' + key); if (checkbox) { checkbox.addEventListener('change', (e) => { appState.platforms[key].selected = e.target.checked; const details = document.getElementById('details_' + key); if (details) { details.classList.toggle('active', e.target.checked); } }); } const slider = document.getElementById('budget_' + key); if (slider) { slider.addEventListener('input', (e) => { appState.platforms[key].budget = parseInt(e.target.value); document.getElementById('budget_' + key + '_value').textContent = e.target.value + '%'; }); } }); // External channels Object.keys(externalChannels).forEach(key => { const checkbox = document.getElementById(key); if (checkbox) { checkbox.addEventListener('change', (e) => { appState.external[key] = e.target.checked; }); } }); // Accordion document.querySelectorAll('.accordion-header').forEach(header => { header.addEventListener('click', () => { const target = header.dataset.target; const content = document.getElementById(target); header.classList.toggle('active'); content.classList.toggle('active'); }); }); // Calculate Button document.getElementById('calculateBtn').addEventListener('click', () => { performCalculation(); }); // Scenario Buttons document.querySelectorAll('.scenario-btn').forEach(btn => { btn.addEventListener('click', (e) => { const scenario = e.target.dataset.scenario; runScenario(scenario); }); }); // Export Button document.getElementById('exportBtn').addEventListener('click', () => { alert('Функция экспорта в PDF будет доступна в следующей версии'); }); } function updateAllDisplayValues() { document.getElementById('brandRevenueValue').textContent = '500 млн ₽'; document.getElementById('flightDurationValue').textContent = '3 месяца'; document.getElementById('osaValue').textContent = '85%'; document.getElementById('ratingValue').textContent = '4.2'; updateRatingStars(); document.getElementById('priceIndexValue').textContent = '1.0'; document.getElementById('promoDepthValue').textContent = '15%'; document.getElementById('promoFreqValue').textContent = '10 дней/мес'; updatePrioritySum(); } function updateRatingStars() { const rating = appState.rating; const fullStars = Math.floor(rating); const halfStar = (rating % 1) >= 0.5; let stars = '⭐'.repeat(fullStars); if (halfStar && fullStars < 5) stars += '½'; document.getElementById('ratingStars').textContent = stars; } function updatePrioritySum() { const sum = appState.priorities.awareness + appState.priorities.performance + appState.priorities.brand; const sumElement = document.getElementById('prioritySum'); sumElement.innerHTML = `Сумма: ${sum}%`; if (sum !== 100) { sumElement.style.color = '#DE350B'; } else { sumElement.style.color = 'inherit'; } } // Perform calculation and display results function performCalculation() { currentResults = calculateResults(); if (!currentResults) { alert('Пожалуйста, выберите хотя бы одну eGrocery площадку'); return; } // Show results document.getElementById('emptyState').style.display = 'none'; document.getElementById('results').style.display = 'block'; // Update KPI cards document.getElementById('roiValue').textContent = currentResults.overallROI.toFixed(1) + '%'; document.getElementById('salesValue').textContent = formatCurrency(currentResults.totalRevenue); document.getElementById('profitValue').textContent = formatCurrency(currentResults.netProfit); const ecomSharePercent = (Object.values(currentResults.platforms).reduce((sum, p) => sum + p.budgetPercent, 0)); document.getElementById('ecomShareValue').textContent = ecomSharePercent.toFixed(1) + '%'; document.getElementById('efficiencyValue').textContent = Math.round(currentResults.efficiencyScore); // Update changes document.getElementById('roiChange').textContent = currentResults.overallROI > 0 ? '+' + currentResults.overallROI.toFixed(1) + '%' : ''; document.getElementById('salesChange').textContent = '+' + ((currentResults.totalRevenue / appState.budget - 1) * 100).toFixed(1) + '%'; document.getElementById('profitChange').textContent = 'После затрат'; document.getElementById('ecomShareChange').textContent = 'От бюджета'; document.getElementById('efficiencyChange').textContent = 'из 100'; // Update charts updateCharts(); // Update table updateTable(); // Update diminishing returns chart console.log('Calling renderDiminishingReturnsChart...'); renderDiminishingReturnsChart(currentResults); // Update recommendations updateRecommendations(); // Scroll to results document.getElementById('results').scrollIntoView({ behavior: 'smooth', block: 'start' }); } // Update all charts function updateCharts() { updateDistributionChart(); updateWaterfallChart(); updateChannelDetailsChart(); updateRadarChart(); } function updateDistributionChart() { const labels = []; const data = []; const colors = ['#005BFF', '#CB11AB', '#FFCC00', '#00C08C', '#FC0', '#6366F1', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']; Object.entries(currentResults.platforms).forEach(([key, platform]) => { labels.push(platform.name); data.push(platform.budgetPercent.toFixed(1)); }); const chartData = { labels: labels, datasets: [{ data: data, backgroundColor: colors.slice(0, labels.length) }] }; const ctx = document.getElementById('treeChart').getContext('2d'); if (charts.distribution) { charts.distribution.data = chartData; charts.distribution.update(); } else { charts.distribution = new Chart(ctx, { type: 'doughnut', data: chartData, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { font: { size: 11 }, padding: 10 } }, tooltip: { callbacks: { label: function(context) { const platform = Object.values(currentResults.platforms)[context.dataIndex]; return `${context.label}: ${context.parsed}% (${formatCurrency(platform.budget)})`; } } } } } }); } } function updateWaterfallChart() { const baseSales = appState.budget * 2.0; const stages = [ { label: 'Базовые продажи', value: baseSales }, { label: 'Медиамикс', value: baseSales + (currentResults.totalRevenue - baseSales) * 0.5 }, { label: 'OSA', value: baseSales + (currentResults.totalRevenue - baseSales) * 0.7 }, { label: 'Рейтинг', value: baseSales + (currentResults.totalRevenue - baseSales) * 0.85 }, { label: 'Промо', value: currentResults.totalRevenue }, { label: 'Комиссии', value: currentResults.totalRevenue - currentResults.totalCommission }, { label: 'Итого', value: currentResults.totalRevenue - currentResults.totalCommission } ]; const ctx = document.getElementById('waterfallChart').getContext('2d'); const chartData = { labels: stages.map(s => s.label), datasets: [{ label: 'Выручка (млн ₽)', data: stages.map(s => s.value / 1000000), backgroundColor: ['#1FB8CD', '#5D878F', '#D2BA4C', '#FFC185', '#32B8C6', '#DB4545', '#0F7B6C'] }] }; if (charts.waterfall) { charts.waterfall.data = chartData; charts.waterfall.update(); } else { charts.waterfall = new Chart(ctx, { type: 'bar', data: chartData, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: function(context) { return `${context.parsed.y.toFixed(1)} млн ₽`; } } } }, scales: { y: { beginAtZero: true, title: { display: true, text: 'Млн ₽' } } } } }); } } function updateChannelDetailsChart() { const platforms = Object.entries(currentResults.platforms) .sort((a, b) => b[1].budget - a[1].budget); const labels = platforms.map(([key, p]) => p.name); const budgets = platforms.map(([key, p]) => p.budget / 1000000); const colors = ['#005BFF', '#CB11AB', '#FFCC00', '#00C08C', '#FC0', '#6366F1', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6']; const ctx = document.getElementById('channelDetailsChart').getContext('2d'); const chartData = { labels: labels, datasets: [{ label: 'Бюджет (млн ₽)', data: budgets, backgroundColor: colors.slice(0, labels.length) }] }; if (charts.details) { charts.details.data = chartData; charts.details.update(); } else { charts.details = new Chart(ctx, { type: 'bar', data: chartData, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: function(context) { return `${context.parsed.x.toFixed(2)} млн ₽`; } } } }, scales: { x: { beginAtZero: true, title: { display: true, text: 'Бюджет (млн ₽)' } } } } }); } } function updateRadarChart() { // Placeholder for radar chart comparing top platforms // Would show metrics like: sales volume, growth, avg check, formats count, ROAS, audience } function renderDiminishingReturnsChart(results) { console.log('renderDiminishingReturnsChart called', results); // Show section const section = document.getElementById('diminishingReturnsSection'); if (section) { section.style.display = 'block'; } // Destroy previous chart if (charts.diminishingReturns) { charts.diminishingReturns.destroy(); charts.diminishingReturns = null; } const canvas = document.getElementById('diminishingReturnsChart'); if (!canvas) { console.error('Canvas element not found!'); return; } const ctx = canvas.getContext('2d'); // Get top 5 platforms by budget const platformsArray = Object.entries(results.platforms) .filter(([key, p]) => p.budget > 0) .sort((a, b) => b[1].budget - a[1].budget) .slice(0, 5); console.log('Top 5 platforms for curves:', platformsArray); if (platformsArray.length === 0) { console.warn('No platforms with budget > 0'); return; } // Colors for platforms const colors = [ '#005BFF', // Ozon blue '#CB11AB', // Wildberries purple '#FFCC00', // Yandex yellow '#00C08C', // Samokat green '#FF6B6B' // Others red ]; const datasets = []; const totalBudget = Object.values(results.platforms).reduce((sum, p) => sum + p.budget, 0); // Create curves for each platform platformsArray.forEach(([key, platform], index) => { const baseROAS = platform.roas / (1 - (platform.budgetPercent / 100) * 0.2); // Reverse diminishing effect to get base const currentShare = platform.budgetPercent; // Create curve points from 0% to 80% budget share const curvePoints = []; for (let share = 0; share <= 80; share += 2) { // Diminishing returns formula const diminishingFactor = 1 - (share / 100) * 0.35; const effectiveROAS = baseROAS * diminishingFactor; curvePoints.push({ x: share, y: Math.max(effectiveROAS, 0.5) }); } // Add curve line datasets.push({ label: platform.name, data: curvePoints, borderColor: colors[index], backgroundColor: 'transparent', borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, tension: 0.3, fill: false }); // Add current position point const currentROAS = platform.roas; datasets.push({ label: `${platform.name} (текущий)`, data: [{ x: currentShare, y: Math.max(currentROAS, 0.5) }], borderColor: colors[index], backgroundColor: colors[index], pointRadius: 8, pointStyle: 'circle', pointBorderWidth: 2, pointBorderColor: '#ffffff', showLine: false }); }); console.log('Creating chart with datasets:', datasets); // Create chart try { charts.diminishingReturns = new Chart(ctx, { type: 'line', data: { datasets: datasets }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'nearest', axis: 'x', intersect: false }, plugins: { legend: { display: true, position: 'bottom', labels: { padding: 15, font: { size: 12 }, usePointStyle: true, filter: function(item) { return !item.text.includes('(текущий)'); } } }, tooltip: { enabled: true, backgroundColor: 'rgba(0, 0, 0, 0.8)', padding: 12, titleFont: { size: 13, weight: 'bold' }, bodyFont: { size: 12 }, callbacks: { title: function(context) { return context[0].dataset.label; }, label: function(context) { const share = context.parsed.x.toFixed(1); const roas = context.parsed.y.toFixed(2); return `Доля бюджета: ${share}% → ROAS: ${roas}`; } } } }, scales: { x: { type: 'linear', min: 0, max: 80, title: { display: true, text: 'Доля площадки в общем бюджете (%)', font: { size: 13, weight: 'bold' }, padding: { top: 10 } }, ticks: { stepSize: 10, callback: function(value) { return value + '%'; } }, grid: { color: 'rgba(0, 0, 0, 0.05)' } }, y: { beginAtZero: true, title: { display: true, text: 'Эффективный ROAS', font: { size: 13, weight: 'bold' }, padding: { bottom: 10 } }, ticks: { callback: function(value) { return value.toFixed(1); } }, grid: { color: 'rgba(0, 0, 0, 0.1)' } } } } }); console.log('Chart created successfully!'); } catch (error) { console.error('Error creating chart:', error); } } function updateTable() { const tbody = document.getElementById('tableBody'); tbody.innerHTML = ''; const sorted = Object.entries(currentResults.platforms) .sort((a, b) => b[1].roas - a[1].roas); sorted.forEach(([key, platform]) => { const row = document.createElement('tr'); let effClass = 'efficiency-medium'; let effText = 'Средняя'; if (platform.roas > 4.0) { effClass = 'efficiency-high'; effText = 'Высокая'; } else if (platform.roas < 2.5) { effClass = 'efficiency-low'; effText = 'Низкая'; } row.innerHTML = ` ${platform.name} ${formatCurrency(platform.budget)} ${platform.budgetPercent.toFixed(1)}% ${platform.roas.toFixed(2)} ${formatCurrency(platform.revenue)} ${effText} `; tbody.appendChild(row); }); } function updateRecommendations() { const recommendations = generateRecommendations(currentResults); const container = document.getElementById('recommendations'); container.innerHTML = '
    ' + recommendations.map(rec => `
  • ${rec}
  • `).join('') + '
'; } // Scenario analysis function runScenario(scenario) { if (!currentResults) return; const originalState = JSON.parse(JSON.stringify(appState)); switch(scenario) { case 'increase': appState.budget *= 1.25; break; case 'decrease': appState.budget *= 0.75; break; case 'focus3': // Focus on top 3 platforms by ROAS const top3 = Object.entries(currentResults.platforms) .sort((a, b) => b[1].roas - a[1].roas) .slice(0, 3) .map(([key]) => key); Object.keys(appState.platforms).forEach(key => { if (top3.includes(key)) { appState.platforms[key].selected = true; appState.platforms[key].budget = 33.33; } else { appState.platforms[key].selected = false; appState.platforms[key].budget = 0; } }); break; case 'wb': appState.platforms.wb.selected = true; appState.platforms.wb.budget = 70; break; case 'multiplatform': ['ozon', 'wb', 'yandex', 'samokat', 'lavka'].forEach(key => { appState.platforms[key].selected = true; appState.platforms[key].budget = 20; }); break; case 'improveOSA': appState.osa = 95; break; case 'improveRating': appState.rating = 4.5; break; case 'aggressivePromo': appState.promoDepth = 30; appState.promoFreq = 20; break; } const scenarioResults = calculateResults(); appState = originalState; if (!scenarioResults) { alert('Не удалось рассчитать сценарий'); return; } // Show comparison document.getElementById('scenarioResults').style.display = 'block'; document.getElementById('currentROI').textContent = currentResults.overallROI.toFixed(1) + '%'; document.getElementById('scenarioROI').textContent = scenarioResults.overallROI.toFixed(1) + '%'; const scenarioValue = document.getElementById('scenarioROI'); if (scenarioResults.overallROI > currentResults.overallROI) { scenarioValue.style.color = '#00875A'; } else { scenarioValue.style.color = '#DE350B'; } } // Initialize on load document.addEventListener('DOMContentLoaded', initializeUI);