feat: add notification preferences UI to repair admin page

- Add notification preferences API endpoints to OpenAPI types
- Create NotificationPreferences component with toggle switches
- Integrate notification preferences into repair admin page
- Support GET and PUT operations for notification preferences
- Add optimistic UI updates with error handling
- Include mobile-responsive design
This commit is contained in:
Claude 2025-11-23 08:02:30 +00:00
parent b29b59b4eb
commit 3c3ebe66e9
No known key found for this signature in database
3 changed files with 302 additions and 0 deletions

View file

@ -0,0 +1,157 @@
import { useEffect, useState } from "react"
import { Card, CardBody, CardHeader, Switch, Spinner, Button } from "@heroui/react"
import { saturdayClient } from "../../utils/client"
import type { components } from "../../types/saturday"
type NotificationPreferenceItem = components["schemas"]["NotificationPreferenceItem"]
type UpdateNotificationPreferencesInputBody = components["schemas"]["UpdateNotificationPreferencesInputBody"]
interface NotificationPreferencesProps {
token: string
}
export default function NotificationPreferences({ token }: NotificationPreferencesProps) {
const [preferences, setPreferences] = useState<NotificationPreferenceItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState<string>("")
const [successMessage, setSuccessMessage] = useState<string>("")
useEffect(() => {
loadPreferences()
}, [token])
const loadPreferences = async () => {
if (!token) return
setIsLoading(true)
setErrorMessage("")
try {
const { data, error } = await saturdayClient.GET("/notification-preferences", {
params: {
header: {
Authorization: `Bearer ${token}`,
},
},
})
if (error || !data) {
setErrorMessage("加载通知偏好设置失败")
return
}
setPreferences(data)
} catch (err) {
setErrorMessage("加载通知偏好设置时出错")
} finally {
setIsLoading(false)
}
}
const handleToggle = async (notificationType: string, newValue: boolean) => {
setIsSaving(true)
setErrorMessage("")
setSuccessMessage("")
// Optimistically update UI
const previousPreferences = [...preferences]
setPreferences(preferences.map(pref =>
pref.notificationType === notificationType
? { ...pref, enabled: newValue }
: pref
))
try {
// Create the request body with all preferences
const requestBody: UpdateNotificationPreferencesInputBody = preferences.reduce((acc, pref) => {
const enabled = pref.notificationType === notificationType ? newValue : pref.enabled
acc[pref.notificationType] = enabled
return acc
}, {} as UpdateNotificationPreferencesInputBody)
const { error } = await saturdayClient.PUT("/notification-preferences", {
params: {
header: {
Authorization: `Bearer ${token}`,
},
},
body: requestBody,
})
if (error) {
// Revert on error
setPreferences(previousPreferences)
setErrorMessage("更新通知偏好设置失败")
return
}
setSuccessMessage("通知偏好设置已更新")
// Clear success message after 3 seconds
setTimeout(() => setSuccessMessage(""), 3000)
} catch (err) {
// Revert on error
setPreferences(previousPreferences)
setErrorMessage("更新通知偏好设置时出错")
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<Card className="w-full">
<CardBody className="flex justify-center items-center py-8">
<Spinner label="加载中..." />
</CardBody>
</Card>
)
}
return (
<Card className="w-full">
<CardHeader className="flex flex-col items-start gap-2 pb-4">
<h3 className="text-lg sm:text-xl font-bold"></h3>
<p className="text-sm text-gray-500"></p>
</CardHeader>
<CardBody className="gap-4">
{preferences.map((pref) => (
<div
key={pref.notificationType}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4 py-2 border-b border-gray-100 last:border-0"
>
<div className="flex-1">
<p className="text-sm sm:text-base font-medium">{pref.description}</p>
<p className="text-xs text-gray-400 mt-1">{pref.notificationType}</p>
</div>
<Switch
isSelected={pref.enabled}
onValueChange={(value) => handleToggle(pref.notificationType, value)}
isDisabled={isSaving}
size="sm"
/>
</div>
))}
{/* Messages */}
{errorMessage && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mt-2">
<p className="text-sm text-red-800">{errorMessage}</p>
</div>
)}
{successMessage && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mt-2">
<p className="text-sm text-green-800">{successMessage}</p>
</div>
)}
{preferences.length === 0 && !errorMessage && (
<div className="text-center py-4 text-gray-500">
<p className="text-sm"></p>
</div>
)}
</CardBody>
</Card>
)
}

View file

@ -34,6 +34,7 @@ import type { PublicMember } from "../../store/member"
import type { UserInfoResponse } from "@logto/browser"
import { getAvailableEventActions, type EventAction, type IdentityContext } from "./EventAction"
import { ExportExcelModal } from "./ExportEventDialog"
import NotificationPreferences from "./NotificationPreferences"
type PublicEvent = components["schemas"]["PublicEvent"]
@ -531,6 +532,14 @@ export default function App() {
: <></>
}
</div>
{/* Notification Preferences Section */}
{token && (
<div className="mt-6">
<NotificationPreferences token={token} />
</div>
)}
<div className="my-8 flex flex-col gap-4">
{/* Mobile Cards Layout */}
<div className="block sm:hidden">

View file

@ -403,6 +403,24 @@ export interface paths {
patch?: never
trace?: never
}
"/notification-preferences": {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Get notification preferences */
get: operations["get-notification-preferences"]
/** Update notification preferences */
put: operations["update-notification-preferences"]
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
"/ping": {
parameters: {
query?: never
@ -664,6 +682,23 @@ export interface components {
Int64: number
Valid: boolean
}
"NotificationPreferenceItem": {
/**
* @description Description of the notification type
* @example
*/
description: string
/**
* @description Whether this notification type is enabled
* @example true
*/
enabled: boolean
/**
* @description Notification type identifier
* @example new_event_created
*/
notificationType: string
}
"PingResponse": {
/**
* Format: uri
@ -732,6 +767,29 @@ export interface components {
qq?: string
size?: string
}
"UpdateNotificationPreferencesInputBody": {
/**
* Format: uri
* @description A URL to the JSON Schema for this object.
* @example https://api.nbtca.space/schemas/UpdateNotificationPreferencesInputBody.json
*/
readonly $schema?: string
/**
* @description Enable/disable notifications for events assigned to me
* @example true
*/
event_assigned_to_me: boolean
/**
* @description Enable/disable notifications for event status changes
* @example true
*/
event_status_changed: boolean
/**
* @description Enable/disable notifications for new events created
* @example true
*/
new_event_created: boolean
}
"UpdateMemberAvatarInputBody": {
/**
* Format: uri
@ -2108,6 +2166,84 @@ export interface operations {
}
}
}
"get-notification-preferences": {
parameters: {
query?: never
header?: {
/** @description Bearer token or JWT token */
Authorization?: string
}
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description OK */
200: {
headers: {
"X-Limit"?: number | null
"X-Offset"?: number | null
"X-Page"?: number | null
"X-Total-Count"?: number | null
"X-Total-Pages"?: number | null
[name: string]: unknown
}
content: {
"application/json": components["schemas"]["NotificationPreferenceItem"][] | null
}
}
/** @description Error */
default: {
headers: {
[name: string]: unknown
}
content: {
"application/problem+json": components["schemas"]["ErrorModel"]
}
}
}
}
"update-notification-preferences": {
parameters: {
query?: never
header?: {
/** @description Bearer token or JWT token */
Authorization?: string
}
path?: never
cookie?: never
}
requestBody: {
content: {
"application/json": components["schemas"]["UpdateNotificationPreferencesInputBody"]
}
}
responses: {
/** @description OK */
200: {
headers: {
"X-Limit"?: number | null
"X-Offset"?: number | null
"X-Page"?: number | null
"X-Total-Count"?: number | null
"X-Total-Pages"?: number | null
[name: string]: unknown
}
content: {
"application/json": string
}
}
/** @description Error */
default: {
headers: {
[name: string]: unknown
}
content: {
"application/problem+json": components["schemas"]["ErrorModel"]
}
}
}
}
"ping": {
parameters: {
query?: never