Enhance app management with 'IsRecommended' feature
- 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:
@@ -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)} />
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user