Enhance app management with 'IsRecommended' feature
CI / Backend (build + test) (push) Successful in 1m15s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s

- Added 'IsRecommended' property to app-related data models, allowing apps to be marked as recommended.
- Updated API endpoints for creating and updating apps to include 'IsRecommended' in request bodies.
- Modified database schema to accommodate the new 'IsRecommended' field.
- Enhanced frontend components to display recommended apps with a star icon and updated forms to manage this property.
- Improved sorting logic in app listings to prioritize recommended apps.
- Updated documentation to reflect changes in API and data models.
This commit is contained in:
Leonid Pershin
2026-07-14 19:19:43 +03:00
parent 701c3a1d51
commit 8c53fcded2
24 changed files with 1154 additions and 31 deletions
@@ -24,6 +24,7 @@ export function AppFormDialog({ app, open, onOpenChange }: { app?: AdminAppDto;
const [iconUrl, setIconUrl] = useState(app?.iconUrl ?? '')
const [sortOrder, setSortOrder] = useState(String(app?.sortOrder ?? 0))
const [isEnabled, setIsEnabled] = useState(app?.isEnabled ?? true)
const [isRecommended, setIsRecommended] = useState(app?.isRecommended ?? false)
const isControlled = open !== undefined
const dialogOpen = isControlled ? open : internalOpen
@@ -32,8 +33,26 @@ export function AppFormDialog({ app, open, onOpenChange }: { app?: AdminAppDto;
const mutation = useMutation({
mutationFn: () =>
app
? updateApp(app.id, name.trim(), downloadUrl.trim(), operatingSystem, description.trim() || undefined, iconUrl.trim() || undefined, Number(sortOrder), isEnabled)
: createApp(name.trim(), downloadUrl.trim(), operatingSystem, description.trim() || undefined, iconUrl.trim() || undefined, Number(sortOrder)),
? updateApp(
app.id,
name.trim(),
downloadUrl.trim(),
operatingSystem,
description.trim() || undefined,
iconUrl.trim() || undefined,
Number(sortOrder),
isEnabled,
isRecommended,
)
: createApp(
name.trim(),
downloadUrl.trim(),
operatingSystem,
description.trim() || undefined,
iconUrl.trim() || undefined,
Number(sortOrder),
isRecommended,
),
onSuccess: async () => {
toast.success(app ? t('admin.apps.updated') : t('admin.apps.created'))
await queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
@@ -97,6 +116,10 @@ export function AppFormDialog({ app, open, onOpenChange }: { app?: AdminAppDto;
<Label htmlFor="sortOrder">{t('admin.apps.sortOrder')}</Label>
<Input id="sortOrder" type="number" min={0} value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isRecommended} onChange={(e) => setIsRecommended(e.target.checked)} />
{t('admin.apps.recommended')}
</label>
{app && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
+21 -2
View File
@@ -12,10 +12,19 @@ export function createApp(
description: string | undefined,
iconUrl: string | undefined,
sortOrder: number,
isRecommended: boolean,
) {
return apiRequest<AdminAppDto>('/admin/apps', {
method: 'POST',
body: { name, downloadUrl, operatingSystem, description: description ?? null, iconUrl: iconUrl ?? null, sortOrder },
body: {
name,
downloadUrl,
operatingSystem,
description: description ?? null,
iconUrl: iconUrl ?? null,
sortOrder,
isRecommended,
},
})
}
@@ -28,10 +37,20 @@ export function updateApp(
iconUrl: string | undefined,
sortOrder: number,
isEnabled: boolean,
isRecommended: boolean,
) {
return apiRequest<AdminAppDto>(`/admin/apps/${id}`, {
method: 'PUT',
body: { name, downloadUrl, operatingSystem, description: description ?? null, iconUrl: iconUrl ?? null, sortOrder, isEnabled },
body: {
name,
downloadUrl,
operatingSystem,
description: description ?? null,
iconUrl: iconUrl ?? null,
sortOrder,
isEnabled,
isRecommended,
},
})
}
+10 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Download } from 'lucide-react'
import { Download, Star } from 'lucide-react'
import { Card, CardHeader, CardTitle } from '@/shared/ui/card'
import { cn } from '@/shared/lib/cn'
import type { OsPlatform } from '@/shared/api/types'
@@ -58,7 +58,15 @@ export function AppsCatalog() {
<Download className="h-6 w-6 text-muted-foreground" />
)}
<div>
<CardTitle className="text-sm">{app.name}</CardTitle>
<CardTitle className="flex items-center gap-1.5 text-sm">
{app.name}
{app.isRecommended && (
<Star
className="h-3.5 w-3.5 shrink-0 fill-yellow-400 text-yellow-400"
aria-label={t('instructions.recommended')}
/>
)}
</CardTitle>
{app.description && <p className="text-xs text-muted-foreground">{app.description}</p>}
</div>
</CardHeader>
+4
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Star } from 'lucide-react'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Badge } from '@/shared/ui/badge'
@@ -58,6 +59,9 @@ function AdminAppsPage() {
.map((app) => (
<div key={app.id} className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
<div className="flex items-center gap-2">
{app.isRecommended && (
<Star className="h-3.5 w-3.5 shrink-0 fill-yellow-400 text-yellow-400" aria-label={t('admin.apps.recommended')} />
)}
<span>{app.name}</span>
{!app.isEnabled && <Badge variant="outline">{t('admin.apps.disabled')}</Badge>}
</div>
+2
View File
@@ -102,6 +102,7 @@ export type ClientAppDto = {
downloadUrl: string
description: string | null
iconUrl: string | null
isRecommended: boolean
}
/** GET /api/apps — сгруппировано по ОС на бэкенде; отсутствующая ОС значит "нет приложений". */
@@ -212,6 +213,7 @@ export type AdminAppDto = {
iconUrl: string | null
sortOrder: number
isEnabled: boolean
isRecommended: boolean
}
export type StatsDto = {
+4
View File
@@ -108,6 +108,7 @@ const resources = {
step3: 'Импортируйте ссылку или отсканируйте QR в приложении — готово.',
appsTitle: 'Приложения',
noApps: 'Каталог приложений пока пуст.',
recommended: 'Рекомендуем',
os: {
IOS: 'iOS',
Android: 'Android',
@@ -311,6 +312,7 @@ const resources = {
sortOrder: 'Порядок',
enabled: 'Включено',
disabled: 'отключено',
recommended: 'Рекомендовано',
empty: 'Каталог приложений пуст.',
created: 'Приложение добавлено.',
updated: 'Приложение обновлено.',
@@ -513,6 +515,7 @@ const resources = {
step3: 'Import the link or scan the QR code in the app — done.',
appsTitle: 'Apps',
noApps: 'The app catalog is empty right now.',
recommended: 'Recommended',
os: {
IOS: 'iOS',
Android: 'Android',
@@ -716,6 +719,7 @@ const resources = {
sortOrder: 'Sort order',
enabled: 'Enabled',
disabled: 'disabled',
recommended: 'Recommended',
empty: 'The app catalog is empty.',
created: 'App added.',
updated: 'App updated.',