Binary files a/public/images/dashboard.png and b/public/images/dashboard.png differ @@ -1,11 +1,12 @@ -import axios from 'axios' +import axios, { AxiosResponse } from 'axios' import { IShortModel } from '#/entities/model-entity' +import { MessageSend, sendMediaMessage } from '#/entities/message' import { getApiUrl } from '#/shared/lib/constants' -export async function getAudio(token?: string): Promise { +async function fetchMlModelsByCategory(category: string, token?: string): Promise { try { - const { data } = await axios.get(getApiUrl() + '/ml_models/?category=audio', { + const { data } = await axios.get(getApiUrl() + `/ml_models/?category=${encodeURIComponent(category)}`, { headers: { Authorization: `Bearer ${token}`, }, @@ -15,3 +16,97 @@ export async function getAudio(token?: string): Promise { return [] } } + +export async function getAudio(token?: string): Promise { + return fetchMlModelsByCategory('audio', token) +} + +export async function getVoiceCloneModels(token?: string): Promise { + return fetchMlModelsByCategory('voice', token) +} + +export interface MediaVoiceItem { + uid: string + title: string + file: string +} + +export async function getMediaVoices(token?: string): Promise { + try { + const { data } = await axios.get(getApiUrl() + '/api/media/voices/?kind=voice', { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + return Array.isArray(data) ? data : [] + } catch { + return [] + } +} + +export async function getMediaPresets(token?: string): Promise { + try { + const { data } = await axios.get(getApiUrl() + '/api/media/presets/?kind=voice', { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + return Array.isArray(data) ? data : [] + } catch { + return [] + } +} + +export async function getVoicesAndPresets(token?: string): Promise<{ voices: MediaVoiceItem[]; presets: MediaVoiceItem[] }> { + const [voices, presets] = await Promise.all([getMediaVoices(token), getMediaPresets(token)]) + return { voices, presets } +} + +/** POST /api/media/voices/ — загрузить голос (file обязателен: mp3, ogg, wav; title опционален) */ +export async function createMediaVoice(file: File, token?: string, title?: string): Promise> { + const formData = new FormData() + formData.append('file', file) + if (title != null && title !== '') { + formData.append('title', title) + } + + return axios.post(getApiUrl() + '/api/media/voices/', formData, { + withCredentials: true, + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + }, + }) +} + +/** DELETE /api/media/voices/{voice_id}/ */ +export async function deleteMediaVoice(voiceId: string, token?: string): Promise> { + return axios.delete(getApiUrl() + `/api/media/voices/${encodeURIComponent(voiceId)}/`, { + withCredentials: true, + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + }, + }) +} + +/** PATCH /api/media/voices/{voice_id}/ — сменить title */ +export async function patchMediaVoiceTitle(voiceId: string, title: string, token?: string): Promise> { + return axios.patch( + getApiUrl() + `/api/media/voices/${encodeURIComponent(voiceId)}/`, + { title }, + { + withCredentials: true, + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } + ) +} + +/** POST /media/voice/{model} — генерация (тот же FormData/JSON, что у остальных media POST) */ +export function postVoiceCloneGeneration(model: string | null, dataForSend: MessageSend | FormData, token?: string) { + return sendMediaMessage(model, 'voice', dataForSend, token) +} @@ -4,9 +4,12 @@ import { Message, MessageSend } from '../types' import { getApiUrl } from '#/shared/lib/constants' - - -export async function sendMediaMessage(model: string | null, modelType: 'video' | 'image' | 'audio', dataForSend: MessageSend | FormData, token?: string) { +export async function sendMediaMessage( + model: string | null, + modelType: 'video' | 'image' | 'audio' | 'voice', + dataForSend: MessageSend | FormData, + token?: string +) { const HeaderDataType = dataForSend instanceof FormData ? 'multipart/form-data' : 'application/json' return await axios.post(getApiUrl() + `/media/${modelType}/${model}`, dataForSend, { @@ -19,7 +22,7 @@ export async function sendMediaMessage(model: string | null, modelType: 'vide }) } -export async function getImagesBySlug(slug: string, token: string, type:'image' | 'video' | 'audio', offset?: number, limit = 10) { +export async function getImagesBySlug(slug: string, token: string, type: 'image' | 'video' | 'audio' | 'voice', offset?: number, limit = 10) { return await axios.get(getApiUrl() + `/media/${type}/${slug}?limit=${limit}&offset=${offset}`, { validateStatus: (status) => status < 500, headers: { @@ -13,12 +13,23 @@ import styles from './card.module.scss' export interface AudioModelCardProps extends IShortModel { accessed_models: string[] | null + /** Базовый сегмент URL списка моделей (например audio, voice-cloning) */ + modelsBasePath?: string } -export function AudioModelCard({ description, image, title, slug, accessed_models, blocked, tags }: AudioModelCardProps) { +export function AudioModelCard({ + description, + image, + title, + slug, + accessed_models, + blocked, + tags, + modelsBasePath = 'audio', +}: AudioModelCardProps) { const link = useMemo(() => { - return accessed_models && !accessed_models.includes(slug) ? '/subscription' : `audio/${slug}` - }, [accessed_models]) + return accessed_models && !accessed_models.includes(slug) ? '/subscription' : `${modelsBasePath}/${slug}` + }, [accessed_models, slug, modelsBasePath]) return ( @@ -29,7 +29,15 @@ export function useGlobalSettings() { makePrivateRequest(async (type: string, value: any) => { const option = settings.find((x) => x.type === type) - if (!option) return showMessage('Ошибка присвоения настроек') + if (!option) { + // Если настройки нет, создаём её + const { status, data: newSetting } = await postUserSettings({ device, type, value }) + + if (status !== 200) return showMessage('Ошибка создания настроек') + + setSettings((s) => [...s, newSetting]) + return + } const { status } = await updateUserSettings(option.id, value) @@ -37,7 +45,7 @@ export function useGlobalSettings() { setSettings((s) => [...s.filter((x) => x.type !== type), { ...option, value }]) }), - [settings, data] + [settings, data, device] ) const fetchUserSettings = makePrivateRequest(async () => { @@ -33,11 +33,13 @@ export type ResponseAllInfo = { } payment_plan: { uid: string + is_recurring: boolean plan: { uid: string price: string individual: boolean tokens_per_plan: string + is_corporate: boolean title: string duration: string accessed_models: string[] | null @@ -74,10 +76,12 @@ const initialState: UserState & ResponseAllInfo = { account_type: 'regular', payment_plan: { uid: '', + is_recurring: false, plan: { uid: '', price: '', tokens_per_plan: '', + is_corporate: false, individual: false, duration: '', title: '', @@ -9,6 +9,7 @@ import styles2 from '#/widgets/business-persons/ui/persons-list/persons-list.mod import { InviteRoles, inviteRoles } from '../../invite-person-in-business/lib/constants' import { updateLimit } from '../api/set-limit' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' interface LimitModalProps { person: ResponseGetPersons | null @@ -20,28 +21,35 @@ interface LimitModalProps { addList: (newUser: ResponseGetPersons, list: 'personal' | 'security') => void } -export const LimitModal: React.FC = ({ - person, - onClose, - updateLimitProp, - list, - setList, - addList, - listType, -}) => { +export const LimitModal: React.FC = ({ person, onClose, updateLimitProp, list, setList, addList, listType }) => { const [limit, setLimit] = useState(person?.token_limit ? Math.floor(+person.token_limit).toString() : '0') const [role, setRole] = useState('Сотрудник') + const [originalRole, setOriginalRole] = useState('Сотрудник') const { data } = useSession() + const { showMessage } = useShowDataStore() + + const getRoleFromAccountType = (accountType: string | undefined): InviteRoles => { + switch (accountType) { + case 'business_account': + return 'Сотрудник' + case 'business_admin': + return 'Администратор' + case 'business_security': + return 'Сотрудник безопасности' + default: + return 'Сотрудник' + } + } const handleChangeBalance = () => { + const originalRoleValue = getRoleFromAccountType(person?.account_type) + updateLimit(person?.email, limit, data?.access, role).then((res) => { if (res === null) { return } else if (list && res.account_type !== person?.account_type) { if ( - (res.account_type === 'business_host' || - res.account_type === 'business_account' || - res.account_type === 'business_admin') && + (res.account_type === 'business_host' || res.account_type === 'business_account' || res.account_type === 'business_admin') && listType === 'security' ) { setList(list.filter((el) => el.email !== res.email)) @@ -52,19 +60,19 @@ export const LimitModal: React.FC = ({ } } - updateLimitProp(res.token_limit, person?.email) + if (role !== originalRoleValue) { + showMessage(`Роль пользователя ${person?.email} успешно изменена на "${role}"!`, 'success') + } else { + updateLimitProp(res.token_limit, person?.email) + } }) } useEffect(() => { - if (person?.account_type === 'business_account') { - setRole('Сотрудник') - } - if (person?.account_type === 'business_admin') { - setRole('Администратор') - } - if (person?.account_type === 'business_security') { - setRole('Сотрудник безопасности') + if (person) { + const roleFromAccount = getRoleFromAccountType(person.account_type) + setRole(roleFromAccount) + setOriginalRole(roleFromAccount) } }, [person]) @@ -84,13 +92,7 @@ export const LimitModal: React.FC = ({ Изменение лимита токенов - setLimit(e.target.value)} - fullWidth - sx={{ ...InputStyleDark }} - /> + setLimit(e.target.value)} fullWidth sx={{ ...InputStyleDark }} /> @@ -10,7 +10,7 @@ import { formDataHelper } from '#/widgets/messages' export function useCreateMediaMessage( showError: (message: string) => void, type: string, - modelType: 'video' | 'image' | 'audio', + modelType: 'video' | 'image' | 'audio' | 'voice', device: Device, setMessages: Dispatch>, mobileScrollContainer: RefObject @@ -9,7 +9,7 @@ import { getImagesBySlug, Message } from '#/entities/message' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import { Device } from '#/shared/lib/types/entities' -export function useMediaBotPagination(deviceType: Device, type:'image' | 'video' | 'audio') { +export function useMediaBotPagination(deviceType: Device, type: 'image' | 'video' | 'audio' | 'voice') { const refScrollMobile = useRef(null) const refScrollDesktop = useRef(null) const mobileScrollContainer = useRef(null) @@ -0,0 +1,16 @@ +import { useEffect } from 'react' + +import { getModalById, LOW_BALANCE_OFFER } from '#/features/modals' + +import { LowBalanceOfferPlate } from './low-balance-offer-plate' + + +export const LowBalanceOfferOnStart = () => { + const modal = getModalById(LOW_BALANCE_OFFER) + + useEffect(() => { + modal.setState(true) + }, []) + + return +} @@ -0,0 +1,47 @@ +.container { + width: 100%; + max-width: 570px; + padding: 32px 0 0 0; + display: flex; + flex-direction: column; + background-color: #151518; + align-items: flex-start; +} + +.text { + color: #8B8B8B; + font-size: 17px; + line-height: 1.5; + margin: 16px 0 24px; +} + +.text_heading { + font-weight: 800; + font-size: 48px; + line-height: 42px; +} + +.offersWrapper { + display: flex; + flex-direction: row; + justify-content: center; + flex-wrap: wrap; + gap: 20px; + width: 100%; + margin-bottom: 24px; + border-radius: 30px; + box-shadow: + 0 0 20px rgba(100, 180, 255, 0.12), + 0 0 40px rgba(80, 150, 255, 0.08), +} + +.link { + font-weight: 600; + font-style: SemiBold; + font-size: 16px; + align-self: center; + + p { + color:#80808E; + } +} @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react' +import Link from 'next/link' +import { useSession } from 'next-auth/react' + +import { LOW_BALANCE_OFFER, getModalById, PlateTemplate } from '#/features/modals' +import { c } from '#/shared' +import { accountApi } from '#/shared/api/account-endpoints' +import { getDeviceType } from '#/shared/lib/helpers' +import { IOffer } from '#/views/subscription' +import Offer from '#/views/subscription/ui/offer' + +import styles from './low-balance-offer-plate.module.scss' + +export const LowBalanceOfferPlate = () => { + const modal = getModalById(LOW_BALANCE_OFFER) + const { data } = useSession() + const [offers, setOffers] = useState(null) + + useEffect(() => { + if (data?.access) { + accountApi.getPaymentsPlans(data.access).then((res) => { + if (res) setOffers(res) + }) + } + }, [data?.access]) + + const handleClose = () => { + modal.setState(false) + } + + const offerIndex = modal.getStoreProperty('offerIndex') ?? 1 + const offerUid = modal.getStoreProperty('offerUid'); + const notEnoughtTokens = modal.getStoreProperty('notEnoughtTokens') + + const offerToShow = offerUid + ? offers?.find((o) => o.uid === offerUid) + : offerIndex + ? offers?.[offerIndex] + : offers?.[1] + + return ( + +
e.stopPropagation()}> + {notEnoughtTokens ? ( +

У вас не осталось
токенов...

+ ) : ( +

Осталось
мало токенов...

+ )} +

+ Подобрали для Вас наиболее подходящий тариф,
+ взгляните на преимущества! +

+
+ {offers === null ? ( +

Загрузка тарифов...

+ ) : offerToShow ? ( + + ) : null} +
+ +

Нет, я хочу посмотреть все тарифы

+ +
+
+ ) +} @@ -0,0 +1,80 @@ +import { useEffect, useRef } from 'react' +import axios from 'axios' + +import { useAppSelector } from '#/app/store/store' +import { getModalById, LOW_BALANCE_OFFER } from '#/features/modals' +import { LowBalanceOfferPlate } from './low-balance-offer-plate' +import type { ResponseAllInfo } from '#/entities/user-account/model/user-type-slice' +import { useFeatureFlag } from '#/shared/lib/hooks/use-feature-flag' +import { balanceSelector } from '#/shared/lib/selectors' + + +export const LowBalanceOfferTrigger = () => { + const balance = useAppSelector(balanceSelector) + const me = useAppSelector((state) => state.user) as ResponseAllInfo & { status: string; referral: string } + const isFewTokensModalEnabled = useFeatureFlag('few-tokens-modal-enabled') + const prevBalanceRef = useRef(null) + const modal = getModalById(LOW_BALANCE_OFFER) + + useEffect(() => { + const interceptorId = axios.interceptors.response.use( + (response) => { + if (response.status === 402 && response?.data?.detail?.includes('Баланс')) { + invokeLowBalanceOffer(true) + } + return response + }, + ) + + return () => { + axios.interceptors.response.eject(interceptorId) + } + }, []) + + useEffect(() => { + + if (!isFewTokensModalEnabled) return + + // Пропускаем первый рендер (начальная загрузка баланса) + if (prevBalanceRef.current === null) { + prevBalanceRef.current = balance + return + } + + if (prevBalanceRef.current !== balance) { + prevBalanceRef.current = balance + + if (typeof window !== 'undefined' && sessionStorage.getItem('low-balance-offer-shown') === 'true') { + return + } + + invokeLowBalanceOffer(); + } + }, [balance, me, modal, isFewTokensModalEnabled]) + + const invokeLowBalanceOffer = (notEnoughtTokens: boolean = false) => { + const tokensPerPlan = Number(me.payment_plan?.plan?.tokens_per_plan) + const isCorporate = me.payment_plan?.plan?.is_corporate + const isFreePlan = tokensPerPlan <= 10 + const threshold = tokensPerPlan * 0.15 + const freePlanThreshold = tokensPerPlan * 0.3 + + if (isCorporate) { + return + } + + if (!isFreePlan && balance < threshold) { + modal.setState(true, { offerUid: me.payment_plan?.plan?.uid, me, notEnoughtTokens }) + sessionStorage.setItem('low-balance-offer-shown', 'true') + return + } + + if (isFreePlan && balance < freePlanThreshold) { + modal.setState(true, { offerIndex: 1, notEnoughtTokens }) + sessionStorage.setItem('low-balance-offer-shown', 'true') + return + } + } + + return +} @@ -0,0 +1,3 @@ +export { LowBalanceOfferPlate } from './ui/low-balance-offer-plate' +export { LowBalanceOfferOnStart } from './ui/low-balance-offer-on-start' +export { LowBalanceOfferTrigger } from './ui/low-balance-offer-trigger' @@ -2,3 +2,6 @@ export const PLATE_CHANGE_PASSWORD = 'plate-change-password' export const RESEND_INVATION_PASSWORD = 'resend-invation-password' export const ERROR_REPORT = 'error-report' export const BUSINESS_ERROR_REPORT = 'business-error-report' +export const LOW_BALANCE_OFFER = 'low-balance-offer' +export const SUBSCRIPTION_CHANGE_NOTIFICATION = 'subscription-change-notification' +export const PLATE_ADD_VOICE = 'plate-add-voice' @@ -13,11 +13,12 @@ function makeModalInstance(set: Set, get: Get, key: string) { store: {}, setState(state, store) { const modals = get().modals; + const currentModal = modals[key]; - store = store ?? {}; + const newStore = store !== undefined ? store : (currentModal?.store ?? {}); set(() => ({ - modals: { ...modals, [this.id]: { ...modals[key], state, store } }, + modals: { ...modals, [this.id]: { ...modals[key], state, store: newStore } }, })); }, setStoreProperty(key, value) { @@ -3,7 +3,7 @@ width: 100%; max-width: calc(100dvw); height: 100dvh; - background-color: rgba(0, 0, 0, 0.4); + background-color: rgba(0, 0, 0, 0.7); top: 0; left: 0; transition: all 300ms ease-in-out; @@ -66,7 +66,7 @@ &__content { background-color: var(--new-ui-main-color); - border-radius: 20px; + border-radius: 16px; position: relative; overflow: hidden; height: fit-content; @@ -20,6 +20,8 @@ export interface PlatesTemplateProps { animationClass?: string animationBehaviorClass?: string headerClassName?: string + /** Перекрывает фон контента плашки (например `#151518`) */ + contentBackgroundColor?: string variant?: Variant } @@ -35,6 +37,7 @@ export default function PlatesTemplate({ animationBehaviorClass = styles['template__animation-behavior'], closeModal = () => {}, headerClassName, + contentBackgroundColor, variant = 'primary', }: PlatesTemplateProps) { const { setModal, getModal } = usePlatesStore() @@ -64,6 +67,7 @@ export default function PlatesTemplate({ modal.state ? '' : animationBehaviorClass, styles[`template__content_${variant}`] )} + style={contentBackgroundColor ? { backgroundColor: contentBackgroundColor } : undefined} onClick={(e) => e.stopPropagation()} >