diff --git a/.env b/.env
index 4ee96a5..8b4227b 100644
--- a/.env
+++ b/.env
@@ -1 +1,3 @@
+VXR_BASE_URL=https://vixar.tech
VXR_API_BASE_URL=https://vixar.tech
+VXR_VKID_CLIENT_ID=54390662
diff --git a/.env.development b/.env.development
index b09e978..86a9932 100644
--- a/.env.development
+++ b/.env.development
@@ -1,2 +1,3 @@
+VXR_BASE_URL=http://localhost
VXR_API_BASE_URL=http://localhost:8000
VXR_API_MOCK=true
diff --git a/api/v1.yaml b/api/v1.yaml
new file mode 100644
index 0000000..7876812
--- /dev/null
+++ b/api/v1.yaml
@@ -0,0 +1,409 @@
+openapi: 3.1.0
+
+info:
+ version: 1.0.0
+ title: Vixar API
+ description: Public API for Vixar
+
+servers:
+ - url: http://localhost:8000/api/v1/
+
+paths:
+ /api/v1/token/search:
+ post:
+ tags: [token]
+ summary: Get info for specified token
+ operationId: searchTokenInfo
+ security:
+ - cookieAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SearchTokenInfoRequest'
+ responses:
+ '200':
+ description: Successfully retrieved interest data
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/TokenInfo'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '404':
+ description: Token not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ /api/v1/auth/vk/callback:
+ post:
+ tags: [auth]
+ summary: VK oauth callback, used to get tokens and user info from vk
+ operationId: vkAuthCallback
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VkAuthCallbackRequest'
+ responses:
+ '200':
+ description: Successfully authenticated with vk
+ headers:
+ Set-Cookie:
+ description: Session cookie
+ schema:
+ type: string
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VkAuthCallbackResponse'
+ '400':
+ description: Bad request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '406':
+ description: Not acceptable
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ /api/v1/auth/vk/register:
+ post:
+ tags: [auth]
+ summary: VK oauth register, used to register new users via vk
+ operationId: vkAuthRegister
+ security:
+ - cookieAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VkAuthRegisterRequest'
+ responses:
+ '200':
+ description: Successfully registered with vk
+ '400':
+ description: Bad request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '401':
+ description: Unauthorized (should be session for vk user registration)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '409':
+ description: Conflict - user already exists
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ /api/v1/auth/logout:
+ post:
+ tags: [auth]
+ summary: Logout user
+ operationId: logoutUser
+ security:
+ - cookieAuth: []
+ responses:
+ '200':
+ description: Successfully logged out
+ headers:
+ Set-Cookie:
+ description: Clear session cookie
+ schema:
+ type: string
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ /api/v1/user/query:
+ post:
+ tags: [user]
+ summary: Save user search query
+ operationId: saveUserQuery
+ security:
+ - cookieAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SaveUserQueryRequest'
+ responses:
+ '200':
+ description: Successfully saved user search query
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SaveUserQueryResponse'
+ '400':
+ description: Bad request
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ get:
+ tags: [user]
+ summary: Get user search queries
+ operationId: getUserSearchQueries
+ security:
+ - cookieAuth: []
+ parameters:
+ - in: query
+ name: offset
+ schema:
+ type: integer
+ default: 0
+ required: true # TODO: проверить можно ли убрать
+ - in: query
+ name: limit
+ schema:
+ type: integer
+ default: 20
+ required: true
+ responses:
+ '200':
+ description: Successfully retrieved user search queries
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/UserSearchQuery'
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ delete:
+ tags: [user]
+ summary: Delete user search query
+ operationId: deleteUserSearchQuery
+ security:
+ - cookieAuth: []
+ parameters:
+ - in: query
+ name: id
+ schema:
+ type: string
+ minLength: 16
+ maxLength: 128
+ required: true
+ responses:
+ '200':
+ description: Successfully deleted user search query
+ '401':
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '404':
+ description: Not found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ description: Internal server error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
+components:
+ securitySchemes:
+ cookieAuth:
+ type: apiKey
+ in: cookie
+ name: session_id
+
+ schemas:
+ SearchTokenInfoRequest:
+ type: object
+ properties:
+ token:
+ type: string
+ minLength: 1
+ maxLength: 255
+ start:
+ type: string
+ format: date-time
+ end:
+ type: string
+ format: date-time
+ required: [token, start]
+ TokenInfo:
+ type: object
+ properties:
+ token:
+ type: string
+ records:
+ type: array
+ items:
+ $ref: '#/components/schemas/TokenRecord'
+ required: [token, records]
+ TokenRecord:
+ type: object
+ properties:
+ timestamp:
+ type: string
+ features:
+ type: object
+ properties:
+ interest:
+ type: integer
+ format: int64
+ interest_normalized:
+ type: number
+ format: float64
+ sentiment:
+ type: integer
+ format: int16
+ required: [interest, interest_normalized, sentiment]
+ required: [timestamp, features]
+ VkAuthCallbackRequest:
+ type: object
+ properties:
+ code:
+ type: string
+ minLength: 1
+ maxLength: 256
+ state:
+ type: string
+ minLength: 1
+ maxLength: 256
+ code_verifier:
+ type: string
+ minLength: 1
+ maxLength: 256
+ device_id:
+ type: string
+ minLength: 1
+ maxLength: 256
+ redirect_uri:
+ type: string
+ minLength: 1
+ maxLength: 256
+ required: [code, state, code_verifier, device_id, redirect_uri]
+ VkAuthCallbackResponse:
+ type: object
+ properties:
+ user_exists:
+ type: boolean
+ username:
+ type: string
+ email:
+ type: string
+ vkid:
+ type: integer
+ format: int64
+ required: [user_exists, username, email, vkid]
+ VkAuthRegisterRequest:
+ type: object
+ properties:
+ email:
+ type: string
+ minLength: 4
+ maxLength: 64
+ format: email
+ username:
+ type: string
+ minLength: 4
+ maxLength: 64
+ vkid:
+ type: integer
+ format: int64
+ required: [email, username, vkid]
+ SaveUserQueryRequest:
+ type: object
+ properties:
+ query:
+ type: string
+ minLength: 1
+ maxLength: 1024
+ required: [query]
+ SaveUserQueryResponse:
+ type: object
+ properties:
+ id:
+ type: string
+ required: [id]
+ UserSearchQuery:
+ type: object
+ properties:
+ id:
+ type: string
+ query:
+ type: string
+ searchDate:
+ type: string
+ format: date-time
+ required: [id, query, searchDate]
+
+ Error:
+ type: object
+ properties:
+ error:
+ type: string
+ required: [error]
diff --git a/app/entities/auth/index.ts b/app/entities/auth/index.ts
new file mode 100644
index 0000000..98d4d0e
--- /dev/null
+++ b/app/entities/auth/index.ts
@@ -0,0 +1 @@
+export { useUserStore } from './store/user.store';
diff --git a/app/entities/auth/store/query.store.ts b/app/entities/auth/store/query.store.ts
new file mode 100644
index 0000000..1d896e8
--- /dev/null
+++ b/app/entities/auth/store/query.store.ts
@@ -0,0 +1,44 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+interface SavedQueriesStore {
+ _items: Record;
+
+ getQueryID: (query: string) => string | undefined;
+ isQuerySaved: (query: string) => boolean;
+ saveQuery: (id: string, query: string) => void;
+ removeQuery: (query: string) => void;
+}
+
+export const useSavedQueriesStore = create()(
+ persist(
+ (set, get) => ({
+ _items: {},
+
+ getQueryID: (query: string) => {
+ return get()._items[query];
+ },
+
+ isQuerySaved: (query: string) => {
+ return get()._items[query] !== undefined;
+ },
+
+ saveQuery: (id: string, query: string) => {
+ set((state) => ({
+ _items: { ...state._items, [query]: id },
+ }));
+ },
+
+ removeQuery: (query: string) => {
+ set((state) => {
+ const newItems = { ...state._items };
+ delete newItems[query];
+ return { _items: newItems };
+ });
+ },
+ }),
+ {
+ name: 'saved-queries',
+ },
+ ),
+);
diff --git a/app/entities/auth/store/user.store.ts b/app/entities/auth/store/user.store.ts
new file mode 100644
index 0000000..9e2aff8
--- /dev/null
+++ b/app/entities/auth/store/user.store.ts
@@ -0,0 +1,37 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export interface AuthUser {
+ vkid: number;
+ username: string;
+ email: string;
+}
+
+interface UserState {
+ user?: AuthUser;
+ pendingAuth?: {
+ username: string;
+ email: string;
+ vkid: number;
+ code: string;
+ state: string;
+ deviceID: string;
+ codeVerifier: string;
+ };
+ setUser: (user: AuthUser) => void;
+ setPendingAuth: (pending: UserState['pendingAuth'] | undefined) => void;
+ logout: () => void;
+}
+
+export const useUserStore = create()(
+ persist(
+ (set) => ({
+ user: undefined,
+ pendingAuth: undefined,
+ setUser: (user) => set({ user, pendingAuth: undefined }),
+ setPendingAuth: (pending) => set({ pendingAuth: pending }),
+ logout: () => set({ user: undefined, pendingAuth: undefined }),
+ }),
+ { name: 'user' },
+ ),
+);
diff --git a/app/entities/token/api/search.api.ts b/app/entities/token/api/search.api.ts
deleted file mode 100644
index 5bd1865..0000000
--- a/app/entities/token/api/search.api.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type {
- SearchResultRequest,
- SearchResultResponse,
-} from '@entities/token';
-import { mockRequest, request, withDefault } from '@shared/api';
-import { apiRoutes } from '@shared/config/routes';
-
-import { searchResultResponseMock } from './search.mock';
-
-export async function searchToken({ token, start, end }: SearchResultRequest) {
- const mock = await mockRequest(
- searchResultResponseMock,
- );
- if (mock) {
- return mock;
- }
-
- return withDefault(
- request(
- apiRoutes.searchToken,
- 'post',
- { token, start, end },
- ),
- [],
- );
-}
diff --git a/app/entities/token/api/search.mock.ts b/app/entities/token/api/search.mock.ts
deleted file mode 100644
index a665f84..0000000
--- a/app/entities/token/api/search.mock.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { type SearchResultResponse } from '@entities/token';
-
-export const searchResultResponseMock: SearchResultResponse = [
- {
- token: 'чип',
- records: [
- {
- timestamp: '2025-10-15',
- features: {
- interest: 384,
- interest_normalized: 0.6666666666666666,
- sentiment: 50,
- },
- },
- {
- timestamp: '2025-10-16',
- features: {
- interest: 448,
- interest_normalized: 0.7777777777777778,
- sentiment: 24,
- },
- },
- {
- timestamp: '2025-10-17',
- features: {
- interest: 512,
- interest_normalized: 0.8888888888888888,
- sentiment: 29,
- },
- },
- {
- timestamp: '2025-10-18',
- features: {
- interest: 576,
- interest_normalized: 1,
- sentiment: -13,
- },
- },
- {
- timestamp: '2025-10-19',
- features: {
- interest: 480,
- interest_normalized: 0.8333333333333334,
- sentiment: -22,
- },
- },
- {
- timestamp: '2025-10-20',
- features: {
- interest: 528,
- interest_normalized: 0.9166666666666666,
- sentiment: 17,
- },
- },
- ],
- },
-];
diff --git a/app/entities/token/index.ts b/app/entities/token/index.ts
index 5b9362c..54598e0 100644
--- a/app/entities/token/index.ts
+++ b/app/entities/token/index.ts
@@ -1,8 +1 @@
-export { searchToken } from './api/search.api';
export { isQuery } from './lib/token.lib';
-export type {
- SearchResult,
- SearchResultRecord,
- SearchResultRequest,
- SearchResultResponse,
-} from './types/search.types';
diff --git a/app/entities/token/types/search.types.ts b/app/entities/token/types/search.types.ts
deleted file mode 100644
index eae2819..0000000
--- a/app/entities/token/types/search.types.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-export type SearchResultRequest = {
- token: string;
- start: string;
- end?: string;
-};
-
-export type SearchResultRecord = {
- timestamp: string;
- features: {
- interest: number;
- interest_normalized: number;
- sentiment: number;
- };
-};
-
-export type SearchResult = {
- token: string;
- records: SearchResultRecord[];
-};
-
-export type SearchResultResponse = SearchResult[];
diff --git a/app/features/search-token-query/model/eval-query.model.ts b/app/features/search-token-query/model/eval-query.model.ts
index 126c505..50bfe45 100644
--- a/app/features/search-token-query/model/eval-query.model.ts
+++ b/app/features/search-token-query/model/eval-query.model.ts
@@ -1,8 +1,9 @@
-import {
- type SearchResult,
- type SearchResultRecord,
- searchToken,
-} from '@entities/token';
+import { type SearchResult, type SearchResultRecord } from '@entities/token';
+import { apiRoutes, POST } from '@shared/api';
+import type {
+ SearchTokenInfoRequest,
+ SearchTokenInfoResponse,
+} from '@shared/api/models';
import { createQueryEval, extractTokens } from '../lib/query-parser.lib';
@@ -105,34 +106,54 @@ export const executeQuerySearch = async (
try {
const tokens = extractTokens(query);
if (tokens.length === 0) {
- const result = await searchToken({
- token: query,
- start: new Date(
- new Date().setFullYear(new Date().getFullYear() - 1),
- ).toISOString(),
+ const { data, error } = await POST(apiRoutes.searchToken, {
+ body: {
+ token: query,
+ start: new Date(
+ new Date().setFullYear(new Date().getFullYear() - 1),
+ ).toISOString(),
+ } as SearchTokenInfoRequest,
});
- if ('error' in result) {
+ if (error !== undefined) {
return { error: `failed to fetch data for token: ${query}` };
}
- return result.length > 0 ? result[0] : { token: query, records: [] };
+
+ const response = data as unknown as SearchTokenInfoResponse;
+ if (response.length == 0) {
+ return { token: query, records: [] };
+ }
+
+ return response[0];
}
const evaluator = createQueryEval(query);
const searchPromises = tokens.map((token) =>
- searchToken({
- token,
- start: new Date(
- new Date().setFullYear(new Date().getFullYear() - 1),
- ).toISOString(),
+ POST(apiRoutes.searchToken, {
+ body: {
+ token: token,
+ start: new Date(
+ new Date().setFullYear(new Date().getFullYear() - 1),
+ ).toISOString(),
+ } as SearchTokenInfoRequest,
}),
);
const results = await Promise.all(searchPromises);
const allTimestamps = new Set();
- results.forEach((res) => {
- if (Array.isArray(res) && res.length > 0 && res[0]) {
- res[0].records.forEach((r) => allTimestamps.add(r.timestamp));
+ results.forEach(({ data, error }) => {
+ if (error) {
+ return { error: `failed to fetch data for token: ${query}` };
+ }
+
+ if (data && Array.isArray(data)) {
+ data.forEach((tokenInfo) => {
+ tokenInfo?.records?.forEach((record) => {
+ if (record?.timestamp) {
+ allTimestamps.add(record.timestamp);
+ }
+ });
+ });
}
});
@@ -152,7 +173,9 @@ export const executeQuerySearch = async (
results.forEach((res, index) => {
const tokenName = tokens[index];
const records =
- Array.isArray(res) && res.length > 0 && res[0] ? res[0].records : [];
+ Array.isArray(res.data) && res.data.length > 0 && res.data[0]
+ ? res.data[0].records
+ : [];
denseTokenData[tokenName] = fillMissingDates(records, fullDateRange);
});
diff --git a/app/features/vkid-auth/index.ts b/app/features/vkid-auth/index.ts
new file mode 100644
index 0000000..1f4bed8
--- /dev/null
+++ b/app/features/vkid-auth/index.ts
@@ -0,0 +1,2 @@
+export { RegisterDialog } from './ui/register-dialog.ui';
+export { VKIDButton } from './ui/vkid-button.ui';
diff --git a/app/features/vkid-auth/ui/register-dialog.ui.tsx b/app/features/vkid-auth/ui/register-dialog.ui.tsx
new file mode 100644
index 0000000..274ca02
--- /dev/null
+++ b/app/features/vkid-auth/ui/register-dialog.ui.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router';
+
+import { toast } from 'sonner';
+
+import { useUserStore } from '@entities/auth';
+import { apiRoutes, POST, type VKAuthRegisterRequest } from '@shared/api';
+import { routes } from '@shared/config/routes';
+import { Button } from '@shared/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@shared/ui/dialog';
+import { Input } from '@shared/ui/input';
+import { Label } from '@shared/ui/label';
+import { Spinner } from '@shared/ui/spinner';
+
+export const RegisterDialog = () => {
+ const { pendingAuth, setPendingAuth, setUser } = useUserStore();
+ const [isLoading, setIsLoading] = useState(false);
+ const navigate = useNavigate();
+ const [isDialogVisible, setIsDialogVisible] = useState(!!pendingAuth);
+
+ useEffect(() => {
+ if (pendingAuth) {
+ setIsDialogVisible(true);
+ }
+ }, [pendingAuth]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!pendingAuth) return;
+
+ try {
+ setIsLoading(true);
+
+ const { error, response } = await POST(apiRoutes.registerUser, {
+ body: {
+ vkid: pendingAuth.vkid,
+ username: pendingAuth.username,
+ email: pendingAuth.email,
+ } as VKAuthRegisterRequest,
+ });
+ if (response.status === 400) {
+ toast.warning(
+ 'Вы ввели некорретные данные. Исправьте и попробуйте снова',
+ );
+ return;
+ }
+ if (error !== undefined) {
+ toast.error(
+ 'Не получилось авторизоваться через VK ID. Попробуйте позже или сообщите нам',
+ );
+ return;
+ }
+ } finally {
+ setIsLoading(false);
+ }
+
+ setUser({
+ vkid: pendingAuth.vkid,
+ username: pendingAuth.username,
+ email: pendingAuth.email,
+ });
+
+ navigate(routes.searchToken);
+
+ setPendingAuth(undefined);
+ setIsDialogVisible(false);
+ };
+
+ return (
+
+ );
+};
diff --git a/app/features/vkid-auth/ui/vkid-button.ui.tsx b/app/features/vkid-auth/ui/vkid-button.ui.tsx
new file mode 100644
index 0000000..f7295b1
--- /dev/null
+++ b/app/features/vkid-auth/ui/vkid-button.ui.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import { useNavigate } from 'react-router';
+
+import * as VKID from '@vkid/sdk';
+import { toast } from 'sonner';
+
+import { useUserStore } from '@entities/auth';
+import type {
+ VKAuthCallbackRequest,
+ VKAuthCallbackResponse,
+} from '@shared/api';
+import { apiRoutes, POST } from '@shared/api';
+import { BASE_URL, routes } from '@shared/config/routes';
+import { initVKID } from '@shared/config/vkid';
+import { genCodeVerifier } from '@shared/lib/utils/pkce/pkce';
+
+interface Props {
+ containerID: string;
+ active?: boolean;
+}
+
+export const VKIDButton = ({ containerID, active = true }: Props) => {
+ const initialized = useRef(false);
+ const { setUser, setPendingAuth } = useUserStore();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (!active || initialized.current) return;
+
+ const container = document.getElementById(containerID);
+ if (!container) return;
+
+ const codeVerifier = genCodeVerifier();
+ const state = crypto.randomUUID();
+
+ initVKID({ state, codeVerifier });
+
+ const oneTap = new VKID.OneTap();
+
+ oneTap
+ .render({ container, fastAuthEnabled: false, showAlternativeLogin: true })
+ .on(VKID.OneTapInternalEvents.LOGIN_SUCCESS, async (payload: never) => {
+ const { code, state: payloadState, device_id } = payload;
+
+ const { data, error } = await POST(apiRoutes.authCallback, {
+ body: {
+ code: code,
+ state: payloadState,
+ device_id: device_id,
+ code_verifier: codeVerifier,
+ redirect_uri: BASE_URL,
+ } as VKAuthCallbackRequest,
+ });
+ if (error !== undefined) {
+ toast.error(
+ 'Не получилось авторизоваться через VK ID. Попробуйте позже или сообщите нам',
+ );
+ return;
+ }
+ const response = data as unknown as VKAuthCallbackResponse;
+
+ if (response.vkid) {
+ if (response.user_exists) {
+ setUser({
+ vkid: response.vkid,
+ username: response.username || '',
+ email: response.email || '',
+ });
+ navigate(routes.searchToken);
+ } else {
+ setPendingAuth({
+ vkid: response.vkid,
+ code: code,
+ state: payloadState,
+ deviceID: device_id,
+ codeVerifier: codeVerifier,
+ username: response.username ?? '',
+ email: response.email ?? '',
+ });
+ }
+ }
+ })
+ .on(VKID.OneTapInternalEvents.NOT_AUTHORIZED, async (_: never) => {
+ toast.error(
+ 'Не получилось авторизоваться через VK ID. Попробуйте позже или сообщите нам',
+ );
+ });
+
+ initialized.current = true;
+ }, [containerID, active, setUser, setPendingAuth, navigate]);
+
+ return ;
+};
diff --git a/app/pages/landing/ui/hero.ui.tsx b/app/pages/landing/ui/hero.ui.tsx
index 337c63b..f5ca097 100644
--- a/app/pages/landing/ui/hero.ui.tsx
+++ b/app/pages/landing/ui/hero.ui.tsx
@@ -1,10 +1,5 @@
'use client';
-import { Link } from 'react-router';
-
-import { routes } from '@shared/config/routes';
-import { Button } from '@shared/ui/button';
-
import dashboardScreenshot from './dashboard.png';
export const HeroSection = () => {
@@ -22,17 +17,6 @@ export const HeroSection = () => {
интереса и спрос. Поможем выбрать продукт или идею, которая
востребована на рынке
-
-
-
diff --git a/app/pages/landing/ui/landing.ui.tsx b/app/pages/landing/ui/landing.ui.tsx
index 925facd..46ad404 100644
--- a/app/pages/landing/ui/landing.ui.tsx
+++ b/app/pages/landing/ui/landing.ui.tsx
@@ -1,3 +1,4 @@
+import { RegisterDialog } from '@features/vkid-auth';
import { LandingNavbar } from '@widgets/layouts/landing-navbar';
import { FeaturesSection } from './features.ui';
@@ -13,6 +14,7 @@ export const LandingPage = () => {
+
);
};
diff --git a/app/pages/saved-queries/api/saved-queries.api.ts b/app/pages/saved-queries/api/saved-queries.api.ts
new file mode 100644
index 0000000..a9256f2
--- /dev/null
+++ b/app/pages/saved-queries/api/saved-queries.api.ts
@@ -0,0 +1,37 @@
+import {
+ apiRoutes,
+ GET,
+ type GetUserQueriesParams,
+ type GetUserQueriesResponse,
+} from '@shared/api';
+
+import type { Route } from './+types/saved-queries';
+
+const ITEMS_PER_PAGE = 20;
+
+export async function clientLoader({ request }: Route.ClientLoaderArgs) {
+ const url = new URL(request.url);
+ const page = parseInt(url.searchParams.get('page') || '1');
+
+ const offset = (page - 1) * ITEMS_PER_PAGE;
+
+ const { data, error } = await GET(apiRoutes.savedQueries, {
+ params: {
+ query: {
+ limit: ITEMS_PER_PAGE,
+ offset: offset,
+ } as GetUserQueriesParams,
+ },
+ });
+ if (error !== undefined) {
+ return { error };
+ }
+ const queries = data as unknown as GetUserQueriesResponse;
+
+ return {
+ queries: queries,
+ pagination: {
+ page: page,
+ },
+ };
+}
diff --git a/app/pages/saved-queries/index.ts b/app/pages/saved-queries/index.ts
new file mode 100644
index 0000000..b6779b9
--- /dev/null
+++ b/app/pages/saved-queries/index.ts
@@ -0,0 +1,2 @@
+export { clientLoader } from './api/saved-queries.api';
+export { SavedQueriesPage } from './ui/saved-queries.ui';
diff --git a/app/pages/saved-queries/ui/pagination.ui.tsx b/app/pages/saved-queries/ui/pagination.ui.tsx
new file mode 100644
index 0000000..90aa2ce
--- /dev/null
+++ b/app/pages/saved-queries/ui/pagination.ui.tsx
@@ -0,0 +1,46 @@
+import { useNavigate } from 'react-router';
+
+import {
+ Pagination as PaginationBase,
+ PaginationContent,
+ PaginationItem,
+ PaginationNext,
+ PaginationPrevious,
+} from '@shared/ui/pagination';
+
+interface PaginationProps {
+ page: number;
+}
+
+export const Pagination = ({ page }: PaginationProps) => {
+ const navigate = useNavigate();
+
+ const switchPage = (newPage: number) => {
+ navigate(`?page=${newPage}`, { preventScrollReset: true });
+ };
+
+ const prevPage = page - 1;
+ const nextPage = page + 1;
+
+ const isPrevActive = page > 1;
+
+ return (
+
+
+
+ {isPrevActive ? (
+ switchPage(prevPage)} />
+ ) : (
+
+ )}
+
+
+ switchPage(nextPage)} />
+
+
+
+ );
+};
diff --git a/app/pages/saved-queries/ui/saved-queries.ui.tsx b/app/pages/saved-queries/ui/saved-queries.ui.tsx
new file mode 100644
index 0000000..86700f3
--- /dev/null
+++ b/app/pages/saved-queries/ui/saved-queries.ui.tsx
@@ -0,0 +1,100 @@
+import { useLoaderData } from 'react-router';
+
+import { Trash2 } from 'lucide-react';
+import { toast } from 'sonner';
+
+import { useSavedQueriesStore } from '@entities/auth/store/query.store';
+import {
+ apiRoutes,
+ DELETE,
+ type DeleteUserQueryParams,
+ type UserQuery,
+} from '@shared/api';
+import { Button } from '@shared/ui/button';
+import { Label } from '@shared/ui/label';
+
+import type { Route } from './+types/saved-queries';
+import { Pagination } from './pagination.ui';
+
+export const SavedQueriesPage = () => {
+ const { removeQuery } = useSavedQueriesStore();
+
+ const data = useLoaderData();
+ if ('error' in data) {
+ toast.error(
+ 'Не получилось загрузить сохраненные запросы. Попробуйте позже',
+ );
+ return null;
+ }
+
+ const handleDeleteQuery = async (queryID: string, query: string) => {
+ const { error } = await DELETE(apiRoutes.savedQueries, {
+ params: {
+ query: {
+ id: queryID,
+ } as DeleteUserQueryParams,
+ },
+ });
+ if (error) {
+ toast.error(
+ 'Не получилось удалить запрос из сохраненных. Попробуйте позже',
+ );
+ return;
+ }
+
+ removeQuery(query);
+ };
+
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString('ru-RU', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ };
+
+ const { queries, pagination } = data;
+ const page = pagination?.page ?? 1;
+
+ return (
+
+
+
+
+ {queries.length === 0 ? (
+
+ Здесь будут появляться сохраненные запросы
+
+ ) : (
+
+ {queries.map((query: UserQuery) => (
+
+
+
{query.query}
+
+ {formatDate(query.searchDate)}
+
+
+
+
+ ))}
+
+ )}
+
+ {queries.length > 0 &&
}
+
+
+ );
+};
diff --git a/app/pages/search-token-result/api/search-token-result.api.ts b/app/pages/search-token-result/api/search-token-result.api.ts
index cf5a46b..4e98887 100644
--- a/app/pages/search-token-result/api/search-token-result.api.ts
+++ b/app/pages/search-token-result/api/search-token-result.api.ts
@@ -1,23 +1,33 @@
-import { type SearchResultResponse, searchToken } from '@entities/token';
+import { redirect } from 'react-router';
+
+import { toast } from 'sonner';
+
import { isQuery } from '@entities/token';
import { executeQuerySearch } from '@features/search-token-query';
+import { apiRoutes, POST } from '@shared/api';
+import type { SearchTokenInfoRequest } from '@shared/api/models';
+import { routes } from '@shared/config/routes';
import type { Route } from './+types/search-token-result';
-export async function clientLoader({
- request,
-}: Route.ClientLoaderArgs): Promise {
+export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const url = new URL(request.url);
const query = url.searchParams.get('query');
if (!query) {
- return { error: 'query parameter is missing' };
+ toast.warning(
+ 'Мы не смогли ничего найти по вашему запросу. Пробоуйте что-то другое',
+ );
+ throw redirect(routes.searchToken);
}
if (isQuery(query)) {
const result = await executeQuerySearch(query);
- if ('error' in result) {
- return result;
+ if ('error' in result || !result?.records?.length) {
+ toast.warning(
+ 'Мы не смогли ничего найти по вашему запросу. Пробоуйте что-то другое',
+ );
+ throw redirect(routes.searchToken);
}
return [result];
}
@@ -29,5 +39,11 @@ export async function clientLoader({
).toISOString();
const end = url.searchParams.get('end') ?? undefined;
- return await searchToken({ token: query, start, end });
+ return await POST(apiRoutes.searchToken, {
+ body: {
+ token: query,
+ start: start,
+ end: end,
+ } as SearchTokenInfoRequest,
+ });
}
diff --git a/app/pages/search-token-result/ui/search-token-result.ui.tsx b/app/pages/search-token-result/ui/search-token-result.ui.tsx
index 8b1f05f..24eeea2 100644
--- a/app/pages/search-token-result/ui/search-token-result.ui.tsx
+++ b/app/pages/search-token-result/ui/search-token-result.ui.tsx
@@ -5,9 +5,22 @@ import {
useSearchParams,
} from 'react-router';
+import { Bookmark, BookmarkCheck } from 'lucide-react';
+import { toast } from 'sonner';
+
+import { useSavedQueriesStore } from '@entities/auth/store/query.store';
import type { SearchResult, SearchResultRecord } from '@entities/token';
import { SearchTokenQuery } from '@features/search-token-query';
+import {
+ apiRoutes,
+ DELETE,
+ type DeleteUserQueryParams,
+ POST,
+ type SaveUserQueryRequest,
+ type SaveUserQueryResponse,
+} from '@shared/api';
import { routes } from '@shared/config/routes';
+import { Button } from '@shared/ui/button';
import { Card, CardContent, CardHeader } from '@shared/ui/card';
import { Label } from '@shared/ui/label';
import { Skeleton } from '@shared/ui/skeleton';
@@ -51,6 +64,9 @@ export const SearchTokenResultPage = () => {
const query = searchParams.get('query') || '';
const isLoading = navigation.state === 'loading';
+ const { isQuerySaved, saveQuery, removeQuery, getQueryID } =
+ useSavedQueriesStore();
+
if ('error' in loaderData) {
console.error('loader error:', loaderData.error);
return ;
@@ -73,10 +89,56 @@ export const SearchTokenResultPage = () => {
}),
);
+ const handleSaveQuery = async (query: string) => {
+ const isSaved = isQuerySaved(query);
+ const queryID = getQueryID(query);
+
+ if (isSaved) {
+ const { error } = await DELETE(apiRoutes.savedQueries, {
+ params: {
+ query: { id: queryID } as unknown as DeleteUserQueryParams,
+ },
+ });
+ if (error) {
+ toast.error(
+ 'Не получилось удалить сохраненный запрос. Попробуйте позже',
+ );
+ return;
+ }
+ removeQuery(query);
+ } else {
+ const { data, error } = await POST(apiRoutes.savedQueries, {
+ body: { query } as SaveUserQueryRequest,
+ });
+ if (error) {
+ toast.error('Не получилось сохранить запрос. Попробуйте позже');
+ return;
+ }
+ const response = data as unknown as SaveUserQueryResponse;
+ saveQuery(response.id, query);
+ }
+ };
+
return (
-
+
+
+
+
+
+
{isLoading ? (
diff --git a/app/root.tsx b/app/root.tsx
index 940a80c..99ce9f8 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect } from 'react';
import {
isRouteErrorResponse,
Links,
@@ -6,10 +6,12 @@ import {
Outlet,
Scripts,
ScrollRestoration,
+ useNavigate,
} from 'react-router';
import { SidebarConfigProvider } from '@shared/lib/providers/sidebar-config';
import { ThemeProvider } from '@shared/lib/providers/theme';
+import { Notification } from '@widgets/notification';
import type { Route } from './+types/root';
@@ -68,12 +70,27 @@ export const Layout = ({ children }: { children: React.ReactNode }) => {
+