@@ -0,0 +1,4 @@ + + + + @@ -0,0 +1,4 @@ + + + + @@ -1,34 +0,0 @@ -'use client' - -import { initializeFaro, getWebInstrumentations, faro } from '@grafana/faro-web-sdk' -import { TracingInstrumentation } from '@grafana/faro-web-tracing' - -export default function FrontendObservability(): null { - if (faro.api) { - return null - } - - try { - initializeFaro({ - url: process.env.NEXT_PUBLIC_FARO_API_URL, - apiKey: process.env.NEXT_PUBLIC_FARO_API_KEY, - app: { - name: process.env.NEXT_PUBLIC_FARO_APP_NAME, - version: process.env.NEXT_PUBLIC_RELEASE, - environment: process.env.NEXT_PUBLIC_ENVIRONMENT, - }, - instrumentations: [ - ...getWebInstrumentations({ - captureConsole: true, - enablePerformanceInstrumentation: false, - enableContentSecurityPolicyInstrumentation: false, - }), - new TracingInstrumentation(), - ], - }) - - } catch (error) { - } finally { - return null - } -} @@ -7,6 +7,6 @@ export const formatFeatureValue = (quantity: number, measurementUnit: string): { const unit = unitMap[measurementUnit] || measurementUnit - return { number: `~${quantity}`, unit: unit } + return { number: `${quantity}`, unit: unit } } @@ -3,6 +3,7 @@ import { Box, LinearProgress, linearProgressClasses, Stack, styled, Typography } import { commaSeparated } from '#/shared' import styles from './subscription.module.scss' +import { declineToken } from '#/shared/lib/helpers/get-token' const BorderLinearProgress = styled(LinearProgress)(() => ({ height: 18, @@ -33,7 +34,7 @@ export const CurrentPlanAndBalance = ({ balance, planTokenLimit, planPrice }: Cu - МОЙ ТАРИФ + Мой тариф @@ -52,7 +53,7 @@ export const CurrentPlanAndBalance = ({ balance, planTokenLimit, planPrice }: Cu - МОЙ БАЛАНС + Мой баланс - {balance} токенов + {declineToken(balance.toString())} {/* {balance >= 100 ? 100 : balance} % */} {Math.round(percentage)} % @@ -36,7 +36,11 @@ const Offer: React.FC = ({ uid, tokens_per_plan, price, grouped_fea if (!group?.features || !Array.isArray(group.features)) { return [] } - return group.features.map((feature) => { + + const limit = group.name === 'Изображения' ? 2 : 1 + const featuresToShow = group.features.slice(0, limit) + + return featuresToShow.map((feature) => { const group_name = unitMap[group.name] || group.name return `${feature.quantity} ${group_name} в ${feature.name} ` @@ -81,7 +85,7 @@ const Offer: React.FC = ({ uid, tokens_per_plan, price, grouped_fea } }} > - {isCurrentSubscription ? 'Текущая подписка' : 'Сменить подписку'} + {isCurrentSubscription ? 'Текущая подписка' : 'Оформить подписку'} ) @@ -20,6 +20,11 @@ align-items: center; gap: 20px; margin-bottom: 20px; + flex-shrink: 0; + + @media (min-width: 769px) and (max-height: 767px) { + margin-bottom: 15px; + } @media (min-width: 400px) and (max-width: 550px) { gap: 20px; @@ -51,10 +56,18 @@ height: 500px; overflow-y: auto; margin-bottom: 20px; - flex-shrink: 0; + flex: 1; + min-height: 0; + + @media (min-width: 769px) and (max-height: 767px) { + height: auto; + flex: 1; + margin-bottom: 15px; + } @media (max-width: 768px) { height: calc(100vh - 150px); + flex: 0 0 auto; margin-bottom: 15px; @media (max-width: 450px) { @@ -75,6 +88,12 @@ padding: 0 30px; flex-shrink: 0; + @media (min-width: 769px) and (max-height: 767px) { + height: 70px; + margin: auto 0 0 -25px; + padding: 0 25px; + } + @media (max-width: 768px) { height: 50px; padding: 0 20px; @@ -127,6 +146,61 @@ } } +.fontSizeDisplay { + font-family: Raleway, sans-serif; + width: 50px; + height: 42px; + background-color: #d9d9d91a; + border-radius: 15px; + display: flex; + align-items: center; + justify-content: center; + padding: 4px 2px; +} + +.copyButton { + width: 42px; + height: 42px; + background-color: #d9d9d91a; + border-radius: 15px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 4px 2px; + transition: background-color 0.2s; + + &:hover { + background-color: #d9d9d92a; + } + + @media (min-width: 400px) and (max-width: 550px) { + width: 40px; + height: 40px; + } + + @media (max-width: 768px) { + width: 40px; + height: 40px; + } +} + +.fontSizeText { + font-weight: 500; + font-style: normal; + font-size: 14px; + color: #ffffff; + user-select: none; + + @media (min-width: 400px) and (max-width: 550px) { + font-size: 13px; + } + + @media (max-width: 768px) { + font-size: 13px; + } +} + .fontLabel { font-family: Raleway, sans-serif; font-weight: 500; @@ -1,11 +1,12 @@ -import React, { useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { Box, MenuItem, Select, SelectChangeEvent, Typography } from '@mui/material' import Dialog from '@mui/material/Dialog' import Image from 'next/image' +import { Markdown } from '#/widgets/markdown/markdown' + import { ZoomOutIcon as ZoomInIcon } from './icons/zoom-in-icon' import { ZoomOutIcon } from './icons/zoom-out-icon' -import { Markdown } from '#/widgets/markdown/markdown' import styles from './fullscreen-message-modal.module.scss' @@ -24,6 +25,8 @@ export const FullscreenMessageModal: React.FC = ({ }) => { const [fontFamily, setFontFamily] = useState('Raleway,sans-serif') const [fontSize, setFontSize] = useState(16) + const contentRef = useRef(null) + const markdownRef = useRef(null) const handleFontChange = (event: SelectChangeEvent) => { setFontFamily(event.target.value) @@ -37,6 +40,151 @@ export const FullscreenMessageModal: React.FC = ({ setFontSize((prev) => Math.max(prev - 2, 10)) } + const copyIconRef = useRef(null) + + // Функция для создания отформатированного HTML для Word + const createFormattedHtml = (htmlContent: string) => { + // Создаем временный контейнер для обработки HTML + const tempDiv = document.createElement('div') + tempDiv.innerHTML = htmlContent + + // Добавляем inline стили ко всем элементам через setAttribute + const allElements = tempDiv.querySelectorAll('*') + allElements.forEach((el) => { + const htmlEl = el as HTMLElement + // Получаем текущий style или создаем новый + const currentStyle = htmlEl.getAttribute('style') || '' + const newStyle = `${currentStyle ? currentStyle + '; ' : ''}font-family: ${fontFamily}; font-size: ${fontSize}pt;` + htmlEl.setAttribute('style', newStyle) + }) + + // Также применяем к самому контейнеру + const containerStyle = `font-family: ${fontFamily}; font-size: ${fontSize}pt;` + tempDiv.setAttribute('style', containerStyle) + + return ` + + + + + + + + + ${tempDiv.innerHTML} + + ` + } + + + // Функция для смены иконки на галочку + const showCopySuccess = () => { + const svg = copyIconRef.current + if (!svg) return + + const originalHTML = svg.innerHTML + svg.innerHTML = ` + + ` + svg.style.animation = 'pulse 0.3s ease' + + // Добавляем анимацию (только один раз) + if (!document.querySelector('#copy-icon-animation')) { + const style = document.createElement('style') + style.id = 'copy-icon-animation' + style.textContent = ` + @keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.2); } + } + ` + document.head.appendChild(style) + } + + // Возвращаем исходную иконку через 1.5 секунды + setTimeout(() => { + if (svg) { + svg.innerHTML = originalHTML + svg.style.animation = '' + } + }, 1500) + } + + // Обработка обычного копирования (Ctrl+C / Cmd+C) + useEffect(() => { + const handleCopy = (e: ClipboardEvent) => { + const selection = window.getSelection() + if (!selection || !selection.toString()) return + + const range = selection.getRangeAt(0) + const tempDiv = document.createElement('div') + tempDiv.appendChild(range.cloneContents()) + + e.clipboardData?.setData('text/html', createFormattedHtml(tempDiv.innerHTML)) + e.preventDefault() + } + + document.addEventListener('copy', handleCopy) + return () => document.removeEventListener('copy', handleCopy) + }, [fontFamily, fontSize]) + + // Обработка копирования по клику на иконку + const handleCopy = async (e?: React.MouseEvent) => { + if (e) { + e.preventDefault() + e.stopPropagation() + } + + if (!markdownRef.current) return + + const htmlContent = markdownRef.current.innerHTML + const textContent = markdownRef.current.innerText || '' + const formattedHtml = createFormattedHtml(htmlContent) + + try { + const clipboardItem = new ClipboardItem({ + 'text/html': new Blob([formattedHtml], { type: 'text/html' }), + 'text/plain': new Blob([textContent], { type: 'text/plain' }), + }) + await navigator.clipboard.write([clipboardItem]) + showCopySuccess() + } catch { + // Fallback для старых браузеров + try { + const tempDiv = document.createElement('div') + tempDiv.innerHTML = htmlContent + tempDiv.style.fontFamily = fontFamily + tempDiv.style.fontSize = `${fontSize}pt` + tempDiv.style.position = 'absolute' + tempDiv.style.left = '-9999px' + document.body.appendChild(tempDiv) + + const range = document.createRange() + range.selectNodeContents(tempDiv) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + document.execCommand('copy') + selection?.removeAllRanges() + + document.body.removeChild(tempDiv) + showCopySuccess() + } catch { + // Игнорируем ошибки + } + } + } + const fonts = ['Raleway,sans-serif', 'Arial', 'Times New Roman', 'Courier New', 'Georgia', 'Verdana'] return ( @@ -58,6 +206,10 @@ export const FullscreenMessageModal: React.FC = ({ display: 'flex', flexDirection: 'column', overflow: 'hidden', + '@media (min-width: 769px) and (max-height: 767px)': { + height: '500px', + padding: '25px 25px 0 25px', + }, '@media (min-width: 400px) and (max-width: 550px)': { maxWidth: '100%', width: '100vw', @@ -102,9 +254,10 @@ export const FullscreenMessageModal: React.FC = ({ )} - + {messageContent && ( = ({ + + + {fontSize}px + + + + { + e.currentTarget.style.opacity = '0.7' + }} + onMouseLeave={(e) => { + e.currentTarget.style.opacity = '1' + }} + > + + + + + + @@ -28,6 +28,12 @@ export const menuListTop = [ icon: '/svg/side-menu/star', activeList: ['subscribe'], }, + { + title: 'Настройки', + link: '/account?scope=setting', + icon: '/svg/side-menu/settings', + activeList: ['account'], + }, ] export const menuListMiddle = [ @@ -98,24 +98,6 @@ export const Statistics = (props: any) => { })} - {show_balance && ( - - - Мой баланс - - - {balance} токенов - {balance >= 100 ? 100 : balance} % - - = 100 ? 100 : balance} /> - - )} > $ENV + - export RELEASE="$(date -Iseconds)" + - echo -e "\nRELEASE=$RELEASE\nNEXT_PUBLIC_RELEASE=$RELEASE\nCI=true" >> $ENV - cp $ENV .env.production - docker compose --env-file $ENV build - docker compose push @@ -41,6 +41,8 @@ deploy_staging: only: - staging before_script: + - export RELEASE="$(date -Iseconds)" + - echo -e "\nRELEASE=$RELEASE\nNEXT_PUBLIC_RELEASE=$RELEASE" >> $ENV - cp $ENV .env.production - mkdir -p $DOCKER_CERT_PATH - echo "$STAGING_CLUSTER_CA" > $DOCKER_CERT_PATH/ca.pem @@ -54,8 +56,8 @@ deploy_staging: build_production: stage: Build script: - - export RELEASE=$(echo -n $(date '+%D %X') | md5sum | awk '{print $1}') - - echo -e "\nNEXT_PUBLIC_RELEASE=$RELEASE" >> $ENV + - export RELEASE="$(date -Iseconds)" + - echo -e "\nRELEASE=$RELEASE\nNEXT_PUBLIC_RELEASE=$RELEASE\nCI=true" >> $ENV - cp $ENV .env.production - docker compose -f stack.yml --env-file $ENV build - docker compose -f stack.yml push @@ -84,6 +86,8 @@ deploy_production: only: - main before_script: + - export RELEASE="$(date -Iseconds)" + - echo -e "\nRELEASE=$RELEASE\nNEXT_PUBLIC_RELEASE=$RELEASE" >> $ENV - cp $ENV .env.production - mkdir -p $DOCKER_CERT_PATH - echo "$PRODUCTION_CLUSTER_CA" > $DOCKER_CERT_PATH/ca.pem @@ -1,32 +0,0 @@ -// @ts-nocheck - -import { Context } from "@opentelemetry/api"; -import { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-node"; -import { registerOTel } from "@vercel/otel"; - -class SpanNameProcessor implements SpanProcessor { - forceFlush(): Promise { - return Promise.resolve(); - } - onStart(span: Span, parentContext: Context): void { - if (span.name.startsWith("GET /_next/static")) { - span.updateName("GET /_next/static"); - } else if (span.name.startsWith("GET /_next/data")) { - span.updateName("GET /_next/data"); - } else if (span.name.startsWith("GET /_next/image")) { - span.updateName("GET /_next/image"); - } - } - onEnd(span: ReadableSpan): void { - } - shutdown(): Promise { - return Promise.resolve(); - } -} - -export function register() { - registerOTel({ - serviceName: `${process.env.NEXT_PUBLIC_FARO_APP_NAMESPACE}:${process.env.NEXT_PUBLIC_FARO_APP_NAME}` || "unregistered", - spanProcessors: ["auto", new SpanNameProcessor()], - }); -} \ No newline at end of file @@ -8,7 +8,6 @@ const nextConfig = { asyncWebAssembly: true, layers: true, } - config.devtool = 'source-map' const fileLoaderRule = config.module.rules.find((rule) => rule.test?.test?.('.svg')) config.module.rules.push( @@ -28,7 +27,6 @@ const nextConfig = { return config }, - productionBrowserSourceMaps: true, reactStrictMode: false, images: { domains: [ @@ -38,9 +36,6 @@ const nextConfig = { 'localhost', ], }, - experimental: { - instrumentationHook: true, - }, } const withBundleAnalyzer = require('@next/bundle-analyzer')({ @@ -48,3 +43,26 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({ }) module.exports = withBundleAnalyzer(nextConfig) + +const { withSentryConfig } = require("@sentry/nextjs"); + +module.exports = withSentryConfig(module.exports, { + org: process.env.NEXT_PUBLIC_SENTRY_ORGANIZATION, + project: process.env.NEXT_PUBLIC_SENTRY_PROJECT_NAME, + sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL, + release: { + name: process.env.NEXT_PUBLIC_RELEASE ?? 'not-stated', + deploy: { + env: process.env.NODE_ENV + } + }, + telemetry: false, + silent: !process.env.CI, + widenClientFileUpload: true, + tunnelRoute: "/monitoring", + webpack: { + treeshake: { + removeDebugLogging: true, + }, + }, +}); @@ -29,18 +29,13 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@fontsource/roboto": "^4.5.8", - "@grafana/faro-web-sdk": "^1.19.0", - "@grafana/faro-web-tracing": "^1.19.0", "@mui/icons-material": "^5.11.11", "@mui/material": "^5.11.12", "@mui/styled-engine-sc": "^5.11.11", "@mui/x-date-pickers": "^6.6.0", "@next/bundle-analyzer": "^13.4.3", - "@opentelemetry/api-logs": "^0.57.2", - "@opentelemetry/instrumentation": "^0.57.2", - "@opentelemetry/sdk-logs": "^0.57.2", - "@opentelemetry/sdk-trace-node": "^2.0.1", "@reduxjs/toolkit": "^1.9.5", + "@sentry/nextjs": "^10.32.1", "@testing-library/user-event": "^14.6.1", "@types/cookie": "^0.5.1", "@types/intro.js": "^5.1.1", @@ -0,0 +1,9 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_PROJECT_URL, + release: process.env.NEXT_PUBLIC_RELEASE ?? 'not-stated', + tracesSampleRate: 1, + enableLogs: true, + sendDefaultPii: true, +}); @@ -0,0 +1,9 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_PROJECT_URL, + release: process.env.NEXT_PUBLIC_RELEASE ?? 'not-stated', + tracesSampleRate: 1, + enableLogs: true, + sendDefaultPii: true, +}); @@ -23,7 +23,6 @@ services: placement: constraints: - node.role == worker - - node.labels.type != observer labels: - traefik.enable=true - traefik.swarm.network=infrastructure