From d5ed5e27d17822ba5954669e08cdd64c48b01ac1 Mon Sep 17 00:00:00 2001 From: Daniil Mironenko Date: Fri, 9 Jan 2026 20:49:49 +0300 Subject: [PATCH] feat: alerts --- api/v1.yaml | 172 +++++++++++++++ app/entities/category/index.ts | 5 + app/entities/category/lib/categories.lib.ts | 18 ++ app/entities/method/index.ts | 1 + app/entities/method/lib/methods.lib.ts | 18 ++ app/entities/token-subscription/index.ts | 1 + .../store/token-subscription.store.ts | 101 +++++++++ .../ui/filter-category.ui.tsx | 9 +- .../ui/search-token-query.ui.tsx | 8 +- app/features/token-subscribe-dialog/index.ts | 1 + .../ui/token-subscribe-dialog.ui.tsx | 168 +++++++++++++++ .../api/search-token-result.api.ts | 12 +- .../ui/search-token-result.ui.tsx | 158 +++++++++----- .../api/token-subscriptions.api.ts | 41 ++++ app/pages/token-subscriptions/index.ts | 2 + .../ui/token-subscriptions.ui.tsx | 141 +++++++++++++ app/routes.ts | 1 + app/routes/home-layout.tsx | 4 +- app/routes/token-subscriptions.tsx | 11 + app/shared/api/gen/v1.d.ts | 197 ++++++++++++++++++ app/shared/api/index.ts | 5 + app/shared/api/mocks.ts | 1 + app/shared/api/models.ts | 16 ++ app/shared/api/routes.ts | 1 + app/shared/config/routes.ts | 1 + app/shared/ui/badge.tsx | 48 +++++ 26 files changed, 1071 insertions(+), 70 deletions(-) create mode 100644 app/entities/category/index.ts create mode 100644 app/entities/category/lib/categories.lib.ts create mode 100644 app/entities/method/index.ts create mode 100644 app/entities/method/lib/methods.lib.ts create mode 100644 app/entities/token-subscription/index.ts create mode 100644 app/entities/token-subscription/store/token-subscription.store.ts create mode 100644 app/features/token-subscribe-dialog/index.ts create mode 100644 app/features/token-subscribe-dialog/ui/token-subscribe-dialog.ui.tsx create mode 100644 app/pages/token-subscriptions/api/token-subscriptions.api.ts create mode 100644 app/pages/token-subscriptions/index.ts create mode 100644 app/pages/token-subscriptions/ui/token-subscriptions.ui.tsx create mode 100644 app/routes/token-subscriptions.tsx create mode 100644 app/shared/ui/badge.tsx diff --git a/api/v1.yaml b/api/v1.yaml index 8f7d1ba..7453be5 100644 --- a/api/v1.yaml +++ b/api/v1.yaml @@ -304,6 +304,133 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /api/v1/user/subs/token: + post: + tags: [user] + summary: Subscribe user to specified token + operationId: subscribeUserToToken + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SubscribeUserToTokenRequest' + responses: + '200': + description: Successfully saved user search query + content: + application/json: + schema: + $ref: '#/components/schemas/SubscribeUserToTokenResponse' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict - already subscribed + 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's token subs + operationId: getUserTokenSubs + security: + - cookieAuth: [] + parameters: + - in: query + name: offset + schema: + type: integer + format: uint64 + default: 0 + required: false + - in: query + name: limit + schema: + type: integer + format: uint64 + default: 20 + required: false + responses: + '200': + description: Successfully retrieved user's token subs + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserTokenSub' + '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' + delete: + tags: [user] + summary: Delete user's token subscription + operationId: deleteUserTokenSub + security: + - cookieAuth: [] + parameters: + - in: query + name: id + schema: + type: string + minLength: 16 + maxLength: 128 + required: true + responses: + '200': + description: Successfully deleted user's token subscription + '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: @@ -451,6 +578,51 @@ components: email: type: string required: [username, email] + SubscribeUserToTokenRequest: + type: object + properties: + token: + type: string + minLength: 1 + maxLength: 128 + category: + type: string + minLength: 1 + maxLength: 128 + threshold: + type: number + method: + type: string + minLength: 1 + maxLength: 128 + required: [token, category, threshold] + SubscribeUserToTokenResponse: + type: object + properties: + id: + type: string + required: [id] + UserTokenSub: + type: object + properties: + id: + type: string + token: + type: string + category: + type: string + method: + type: string + current_interest: + type: number + format: float64 + previous_interest: + type: number + format: float64 + last_scan: + type: string + format: date-time + required: [id, token, category, method, current_interest, previous_interest, last_scan] Error: type: object diff --git a/app/entities/category/index.ts b/app/entities/category/index.ts new file mode 100644 index 0000000..1a6f492 --- /dev/null +++ b/app/entities/category/index.ts @@ -0,0 +1,5 @@ +export { + categories, + categoryOptions, + mapCategoryToLabel, +} from './lib/categories.lib'; diff --git a/app/entities/category/lib/categories.lib.ts b/app/entities/category/lib/categories.lib.ts new file mode 100644 index 0000000..64e5b71 --- /dev/null +++ b/app/entities/category/lib/categories.lib.ts @@ -0,0 +1,18 @@ +export const categories = { + news: 'Новости', + marketplace: 'Маркетплейсы', + review: 'Отзывы', +} as const; + +type Category = keyof typeof categories; + +export const mapCategoryToLabel = (category: Category) => { + return categories[category]; +}; + +export const categoryOptions = (Object.keys(categories) as Category[]).map( + (key) => ({ + value: key, + label: categories[key], + }), +); diff --git a/app/entities/method/index.ts b/app/entities/method/index.ts new file mode 100644 index 0000000..b7402fc --- /dev/null +++ b/app/entities/method/index.ts @@ -0,0 +1 @@ +export { mapMethodToLabel, methodOptions, methods } from './lib/methods.lib'; diff --git a/app/entities/method/lib/methods.lib.ts b/app/entities/method/lib/methods.lib.ts new file mode 100644 index 0000000..4c8ef2d --- /dev/null +++ b/app/entities/method/lib/methods.lib.ts @@ -0,0 +1,18 @@ +import type { UserTokenSub } from '@app/shared/api'; + +export const methods: Record = { + denormalized: 'Ненормированный интерес', + global_median: 'Нормирование глобальной медианой', + category_median: 'Нормирование медианой категории', +} as const; + +type Method = keyof typeof methods; + +export const mapMethodToLabel = (method: Method) => { + return methods[method]; +}; + +export const methodOptions = (Object.keys(methods) as Method[]).map((key) => ({ + value: key, + label: methods[key], +})); diff --git a/app/entities/token-subscription/index.ts b/app/entities/token-subscription/index.ts new file mode 100644 index 0000000..782c65a --- /dev/null +++ b/app/entities/token-subscription/index.ts @@ -0,0 +1 @@ +export { useTokenSubscriptionsStore } from './store/token-subscription.store'; diff --git a/app/entities/token-subscription/store/token-subscription.store.ts b/app/entities/token-subscription/store/token-subscription.store.ts new file mode 100644 index 0000000..934e087 --- /dev/null +++ b/app/entities/token-subscription/store/token-subscription.store.ts @@ -0,0 +1,101 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface TokenSubscriptionItem { + id: string; + token: string; + category: string; + method: string; + current_interest: number; + previous_interest: number; + last_scan: string; +} + +interface TokenSubscriptionsStore { + _items: Record; + subscriptions: TokenSubscriptionItem[]; + + setSubscriptions: (subscriptions: TokenSubscriptionItem[]) => void; + isTokenSubscribed: (token: string) => boolean; + getSubscriptionID: (token: string) => string | undefined; + subscribe: ( + id: string, + token: string, + category: string, + method: string, + current_interest: number, + previous_interest: number, + last_scan: string, + ) => void; + unsubscribe: (token: string) => void; +} + +export const useTokenSubscriptionsStore = create()( + persist( + (set, get) => ({ + _items: {}, + + subscriptions: [], + setSubscriptions: (subscriptions: TokenSubscriptionItem[]) => { + set({ + subscriptions, + _items: subscriptions.reduce( + (acc, q) => { + acc[q.token] = q.id; + return acc; + }, + {} as Record, + ), + }); + }, + + getSubscriptionID: (token: string) => { + return get()._items[token]; + }, + + isTokenSubscribed: (token: string) => { + return get()._items[token] !== undefined; + }, + + subscribe: ( + id: string, + token: string, + category: string, + method: string, + current_interest: number, + previous_interest: number, + last_scan: string, + ) => { + set((state) => ({ + _items: { ...state._items, [token]: id }, + subscriptions: [ + ...state.subscriptions, + { + id, + token, + category, + method, + current_interest, + previous_interest, + last_scan, + }, + ], + })); + }, + + unsubscribe: (token: string) => { + set((state) => { + const newItems = { ...state._items }; + delete newItems[token]; + return { + _items: newItems, + subscriptions: state.subscriptions.filter((q) => q.token !== token), + }; + }); + }, + }), + { + name: 'token-subscriptions', + }, + ), +); diff --git a/app/features/search-token-query/ui/filter-category.ui.tsx b/app/features/search-token-query/ui/filter-category.ui.tsx index 9f7666b..6d3d6dc 100644 --- a/app/features/search-token-query/ui/filter-category.ui.tsx +++ b/app/features/search-token-query/ui/filter-category.ui.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router'; +import { categoryOptions } from '@app/entities/category'; import { Select, SelectContent, @@ -9,12 +10,6 @@ import { SelectValue, } from '@shared/ui/select'; -const CATEGORIES = [ - { value: 'news', label: 'Новости' }, - { value: 'marketplace', label: 'Маркетплейсы' }, - { value: 'review', label: 'Отзывы' }, -]; - export const FilterCategory = () => { const [searchParams, setSearchParams] = useSearchParams(); const [category, setCategory] = useState( @@ -41,7 +36,7 @@ export const FilterCategory = () => { - {CATEGORIES.map((item) => ( + {categoryOptions.map((item) => ( { const formatQuery = (input: string): string => { const trimmed = input.trim(); - - if (isQuery(trimmed)) { - return trimmed; - } else { - return `token('${trimmed}')`; - } + return trimmed; }; const onSubmit = (e: React.FormEvent) => { diff --git a/app/features/token-subscribe-dialog/index.ts b/app/features/token-subscribe-dialog/index.ts new file mode 100644 index 0000000..c07aba1 --- /dev/null +++ b/app/features/token-subscribe-dialog/index.ts @@ -0,0 +1 @@ +export { TokenSubscribeDialog } from './ui/token-subscribe-dialog.ui'; diff --git a/app/features/token-subscribe-dialog/ui/token-subscribe-dialog.ui.tsx b/app/features/token-subscribe-dialog/ui/token-subscribe-dialog.ui.tsx new file mode 100644 index 0000000..1127ac9 --- /dev/null +++ b/app/features/token-subscribe-dialog/ui/token-subscribe-dialog.ui.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useState } from 'react'; + +import { toast } from 'sonner'; + +import { mapCategoryToLabel } from '@app/entities/category'; +import { methodOptions } from '@app/entities/method'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@app/shared/ui/select'; +import { apiRoutes, POST, type SubscribeUserToTokenRequest } from '@shared/api'; +import { Button } from '@shared/ui/button'; +import { + Dialog, + DialogContent, + 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'; + +interface TokenSubscribeDialogProps { + token: string; + category?: string | undefined; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const TokenSubscribeDialog = ({ + token, + category, + open, + onOpenChange, +}: TokenSubscribeDialogProps) => { + const [isLoading, setIsLoading] = useState(false); + const [threshold, setThreshold] = useState(1.2); + const [method, setMethod] = useState(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + setIsLoading(true); + + const { error, response } = await POST(apiRoutes.tokenSubscriptions, { + body: { + token: token, + category: category, + threshold: threshold, + method: method, + } as SubscribeUserToTokenRequest, + }); + if (response.status === 400) { + toast.warning( + 'Вы ввели некорретные данные. Исправьте и попробуйте снова', + ); + return; + } else if (response.status === 404) { + toast.warning( + 'Токен не существует, поэтому подписаться на него нельзя', + ); + return; + } + if (error !== undefined) { + toast.error( + 'Не получилось подписаться на токен. Попробуйте позже или сообщите нам', + ); + return; + } + + toast.success('Вы успешно подписались на токен'); + onOpenChange(false); + } finally { + setIsLoading(false); + } + }; + + return ( + + +
+ + Подписаться на токен + + +
+
+ + +
+ +
+ + +
+ +
+ + setThreshold(Number(e.target.value))} + /> +
+ +
+ +
+
+ + + + +
+
+
+ ); +}; 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 0f056f0..53ce945 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 @@ -34,14 +34,22 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { ); throw redirect(routes.searchToken); } - return [result]; + return { + data: [result], + }; } - return await POST(apiRoutes.searchToken, { + const result = await POST(apiRoutes.searchToken, { body: { token: query, category: category, start: start, } as SearchTokenInfoRequest, }); + + if (result?.response.status === 404) { + result.data = []; + } + + return result; } 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 83377f5..3941d2c 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 @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Navigate, useLoaderData, @@ -5,12 +6,15 @@ import { useSearchParams, } from 'react-router'; -import { Bookmark, BookmarkCheck } from 'lucide-react'; +import { Bell, BellRing, Bookmark, BookmarkCheck } from 'lucide-react'; import { toast } from 'sonner'; +import { useTokenSubscriptionsStore } from '@app/entities/token-subscription'; +import type { DeleteUserTokenSubParams } from '@app/shared/api/models'; import { useSavedQueriesStore } from '@entities/auth/store/query.store'; -import type { SearchResult, SearchResultRecord } from '@entities/token'; +import { isQuery, type SearchResultRecord } from '@entities/token'; import { SearchTokenQuery } from '@features/search-token-query'; +import { TokenSubscribeDialog } from '@features/token-subscribe-dialog'; import { apiRoutes, DELETE, @@ -18,6 +22,7 @@ import { POST, type SaveUserQueryRequest, type SaveUserQueryResponse, + type TokenInfo, } from '@shared/api'; import { routes } from '@shared/config/routes'; import { Button } from '@shared/ui/button'; @@ -70,13 +75,19 @@ export const SearchTokenResultPage = () => { const { isQuerySaved, saveQuery, removeQuery, getQueryID } = useSavedQueriesStore(); - if ('error' in loaderData) { + const { isTokenSubscribed, getSubscriptionID, unsubscribe } = + useTokenSubscriptionsStore(); + const [isSubscribeDialogOpen, setIsSubscribeDialogOpen] = useState(false); + + if ('error' in loaderData && loaderData.response.status !== 404) { console.error('loader error:', loaderData.error); return ; } - const tokenInfo: SearchResult = - loaderData.length > 0 ? loaderData[0] : { token: query, records: [] }; + const tokenInfo: TokenInfo = + loaderData.data.length > 0 + ? loaderData.data[0] + : { token: query, records: [] }; const interestData: TokenInterestChartDataItem[] = tokenInfo.records.map( (item: SearchResultRecord) => ({ @@ -122,56 +133,99 @@ export const SearchTokenResultPage = () => { } }; + const handleTokenSubscribe = async (token: string) => { + const isSubscribed = isTokenSubscribed(token); + const subscriptionID = getSubscriptionID(token); + + if (isSubscribed) { + const { error } = await DELETE(apiRoutes.tokenSubscriptions, { + params: { + query: { id: subscriptionID } as unknown as DeleteUserTokenSubParams, + }, + }); + if (error) { + toast.error('Не получилось отписаться от токена. Попробуйте позже'); + return; + } + unsubscribe(token); + } else { + setIsSubscribeDialogOpen(true); + } + }; + return ( -
-
-
- - - + + {!isQuery(query) && ( + )} - +
+ + + + {isLoading ? ( + + ) : ( + <> +
+ +
+ + + + )}
- - - - {isLoading ? ( - - ) : ( - <> -
- -
- - - - )}
- + + ); }; diff --git a/app/pages/token-subscriptions/api/token-subscriptions.api.ts b/app/pages/token-subscriptions/api/token-subscriptions.api.ts new file mode 100644 index 0000000..e2dcc70 --- /dev/null +++ b/app/pages/token-subscriptions/api/token-subscriptions.api.ts @@ -0,0 +1,41 @@ +import { + apiRoutes, + GET, + type GetUserTokenSubsParams, + type GetUserTokenSubsResponse, +} from '@shared/api'; + +import type { Route } from './+types/token-subscriptions'; + +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, response } = await GET(apiRoutes.tokenSubscriptions, { + params: { + query: { + limit: ITEMS_PER_PAGE, + offset: offset, + } as GetUserTokenSubsParams, + }, + }); + if (error !== undefined && response.status != 404) { + return { error }; + } + + let subscriptions = data as unknown as GetUserTokenSubsResponse; + if (response.status === 404) { + subscriptions = []; + } + + return { + subscriptions: subscriptions, + pagination: { + page: page, + }, + }; +} diff --git a/app/pages/token-subscriptions/index.ts b/app/pages/token-subscriptions/index.ts new file mode 100644 index 0000000..97bb582 --- /dev/null +++ b/app/pages/token-subscriptions/index.ts @@ -0,0 +1,2 @@ +export { clientLoader } from './api/token-subscriptions.api'; +export { TokenSubscriptionsPage } from './ui/token-subscriptions.ui'; diff --git a/app/pages/token-subscriptions/ui/token-subscriptions.ui.tsx b/app/pages/token-subscriptions/ui/token-subscriptions.ui.tsx new file mode 100644 index 0000000..ba4a5c5 --- /dev/null +++ b/app/pages/token-subscriptions/ui/token-subscriptions.ui.tsx @@ -0,0 +1,141 @@ +import { useEffect } from 'react'; +import { useLoaderData, useNavigate } from 'react-router'; + +import { Info, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { mapCategoryToLabel } from '@app/entities/category'; +import { mapMethodToLabel } from '@app/entities/method'; +import { useTokenSubscriptionsStore } from '@app/entities/token-subscription'; +import type { + DeleteUserTokenSubParams, + UserTokenSub, +} from '@app/shared/api/models'; +import { routes } from '@app/shared/config/routes'; +import { Badge } from '@app/shared/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@app/shared/ui/tooltip'; +import { Pagination } from '@app/widgets/pagination'; +import { apiRoutes, DELETE } from '@shared/api'; +import { Button } from '@shared/ui/button'; +import { Label } from '@shared/ui/label'; + +import type { Route } from './+types/token-subscriptions'; + +export const TokenSubscriptionsPage = () => { + const data = useLoaderData(); + const { subscriptions, setSubscriptions, unsubscribe } = + useTokenSubscriptionsStore(); + + const navigate = useNavigate(); + + useEffect(() => { + if (!('error' in data)) { + setSubscriptions(data.subscriptions); + } else { + toast.error('Не получилось загрузить подписки. Попробуйте позже'); + } + }, [data, setSubscriptions]); + + const handleUnsubscribeToken = async ( + tokenSubscriptionID: string, + token: string, + ) => { + const { error } = await DELETE(apiRoutes.tokenSubscriptions, { + params: { + query: { id: tokenSubscriptionID } as DeleteUserTokenSubParams, + }, + }); + + if (error) { + toast.error('Не получилось отписаться от токена. Попробуйте позже'); + return; + } + + unsubscribe(token); + }; + + const page = data.pagination?.page ?? 1; + + return ( +
+
+
+ + + + + + + + +

+ Подписывайтесь на токены при поиске аналитики, чтобы получать + алерты на электронную почту при изменении интереса к токену +

+
+
+
+
+ + {subscriptions.length === 0 ? ( +
+ Здесь будут появляться токены, на которые вы подписаны +
+ ) : ( +
+ {subscriptions.map((subscription: UserTokenSub) => ( +
+
+

{ + const params = new URLSearchParams(); + params.set('query', subscription.token); + + navigate({ + pathname: routes.searchResult, + search: params.toString(), + }); + }} + > + {subscription.token} +

+ +
+ + {mapCategoryToLabel(subscription.category)} + + + + {mapMethodToLabel[subscription.method]} + +
+
+ + +
+ ))} +
+ )} + + {subscriptions.length > 0 && } +
+
+ ); +}; diff --git a/app/routes.ts b/app/routes.ts index 504e76d..abc21e0 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -7,5 +7,6 @@ export default [ route('search', 'routes/search-token.tsx'), route('search/result', 'routes/search-token-result.tsx'), route('saved', 'routes/saved-queries.tsx'), + route('subscriptions', 'routes/token-subscriptions.tsx'), ]), ] satisfies RouteConfig; diff --git a/app/routes/home-layout.tsx b/app/routes/home-layout.tsx index 8ab9f13..3ac0b28 100644 --- a/app/routes/home-layout.tsx +++ b/app/routes/home-layout.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Outlet } from 'react-router'; -import { AlertTriangle, Clock, Search } from 'lucide-react'; +import { Bell, Clock, Search } from 'lucide-react'; import { routes } from '@shared/config/routes'; import { useSidebarConfig } from '@shared/lib/hooks/use-sidebar-config'; @@ -11,7 +11,7 @@ import { clientLoader, HomeSidebar } from '@widgets/layouts/home-sidebar'; const navigationItems = [ { title: 'Поиск', url: routes.searchToken, icon: Search }, { title: 'Сохраненные', url: routes.savedQueries, icon: Clock }, - { title: 'Алерты', url: '#', icon: AlertTriangle, disabled: true }, + { title: 'Подписки', url: routes.tokenSubscriptions, icon: Bell }, ]; const HomeLayout = () => { diff --git a/app/routes/token-subscriptions.tsx b/app/routes/token-subscriptions.tsx new file mode 100644 index 0000000..a98b9a8 --- /dev/null +++ b/app/routes/token-subscriptions.tsx @@ -0,0 +1,11 @@ +import { + clientLoader, + TokenSubscriptionsPage, +} from '@pages/token-subscriptions'; + +const TokenSubscriptions = () => { + return ; +}; + +export { clientLoader }; +export default TokenSubscriptions; diff --git a/app/shared/api/gen/v1.d.ts b/app/shared/api/gen/v1.d.ts index 0d1aa22..cd1489f 100644 --- a/app/shared/api/gen/v1.d.ts +++ b/app/shared/api/gen/v1.d.ts @@ -108,6 +108,25 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/user/subs/token': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get user's token subs */ + get: operations['getUserTokenSubs']; + put?: never; + /** Subscribe user to specified token */ + post: operations['subscribeUserToToken']; + /** Delete user's token subscription */ + delete: operations['deleteUserTokenSub']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -175,6 +194,27 @@ export interface components { username: string; email: string; }; + SubscribeUserToTokenRequest: { + token: string; + category: string; + threshold: number; + method?: string; + }; + SubscribeUserToTokenResponse: { + id: string; + }; + UserTokenSub: { + id: string; + token: string; + category: string; + method: string; + /** Format: float64 */ + current_interest: number; + /** Format: float64 */ + previous_interest: number; + /** Format: date-time */ + last_scan: string; + }; Error: { error: string; }; @@ -573,4 +613,161 @@ export interface operations { }; }; }; + getUserTokenSubs: { + parameters: { + query?: { + offset?: number; + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully retrieved user's token subs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserTokenSub'][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + subscribeUserToToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['SubscribeUserToTokenRequest']; + }; + }; + responses: { + /** @description Successfully saved user search query */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SubscribeUserToTokenResponse']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Conflict - already subscribed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + deleteUserTokenSub: { + parameters: { + query: { + id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully deleted user's token subscription */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; } diff --git a/app/shared/api/index.ts b/app/shared/api/index.ts index c8dd546..a4d68c3 100644 --- a/app/shared/api/index.ts +++ b/app/shared/api/index.ts @@ -5,13 +5,18 @@ export type { GetMeResponse, GetUserQueriesParams, GetUserQueriesResponse, + GetUserTokenSubsParams, + GetUserTokenSubsResponse, SaveUserQueryRequest, SaveUserQueryResponse, SearchTokenInfoRequest, SearchTokenInfoResponse, + SubscribeUserToTokenRequest, + SubscribeUserToTokenResponse, TokenInfo, TokenRecord, UserQuery, + UserTokenSub, VKAuthCallbackRequest, VKAuthCallbackResponse, VKAuthRegisterRequest, diff --git a/app/shared/api/mocks.ts b/app/shared/api/mocks.ts index 0675d4f..e2047f9 100644 --- a/app/shared/api/mocks.ts +++ b/app/shared/api/mocks.ts @@ -8,6 +8,7 @@ export const mockFetch = async (input: RequestInfo, init?: RequestInit) => { JSON.stringify([ { token: 'россия', + category: 'news', records: [ { timestamp: '2025-11-30', diff --git a/app/shared/api/models.ts b/app/shared/api/models.ts index 10cdd84..abad663 100644 --- a/app/shared/api/models.ts +++ b/app/shared/api/models.ts @@ -30,3 +30,19 @@ export type DeleteUserQueryParams = operations['deleteUserSearchQuery']['parameters']['query']; export type GetMeResponse = components['schemas']['UserInfoResponse']; + +// token subscriptions + +export type UserTokenSub = components['schemas']['UserTokenSub']; + +export type GetUserTokenSubsParams = + operations['getUserTokenSubs']['parameters']['query']; +export type GetUserTokenSubsResponse = UserTokenSub[]; + +export type SubscribeUserToTokenRequest = + components['schemas']['SubscribeUserToTokenRequest']; +export type SubscribeUserToTokenResponse = + components['schemas']['SubscribeUserToTokenResponse']; + +export type DeleteUserTokenSubParams = + operations['deleteUserTokenSub']['parameters']['query']; diff --git a/app/shared/api/routes.ts b/app/shared/api/routes.ts index 8c52280..d55ed93 100644 --- a/app/shared/api/routes.ts +++ b/app/shared/api/routes.ts @@ -5,4 +5,5 @@ export const apiRoutes = { searchToken: '/api/v1/token/search', savedQueries: '/api/v1/user/query', getMe: '/api/v1/auth/me', + tokenSubscriptions: '/api/v1/user/subs/token', }; diff --git a/app/shared/config/routes.ts b/app/shared/config/routes.ts index 4859037..b1a7e1f 100644 --- a/app/shared/config/routes.ts +++ b/app/shared/config/routes.ts @@ -3,6 +3,7 @@ export const routes = { searchToken: '/home/search', searchResult: '/home/search/result', savedQueries: '/home/saved', + tokenSubscriptions: '/home/subscriptions', }; export const BASE_URL = import.meta.env.VXR_BASE_URL; diff --git a/app/shared/ui/badge.tsx b/app/shared/ui/badge.tsx new file mode 100644 index 0000000..e6d8244 --- /dev/null +++ b/app/shared/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; + +import { cva, type VariantProps } from 'class-variance-authority'; + +import { Slot } from '@radix-ui/react-slot'; + +import { cn } from '@shared/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: + 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<'span'> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : 'span'; + + return ( + + ); +} + +export { Badge, badgeVariants };