@@ -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
+
+
+
+
+
@@ -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