Notification Setting düzenlemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-09-23 09:55:22 +03:00
parent 368756f0ac
commit c79cee0df7
7 changed files with 307 additions and 166 deletions

View file

@ -1929,20 +1929,20 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.NotificationChannel.Sms", "key": "App.NotificationChannel.Sms",
"en": "SMS", "en": "Sms",
"tr": "SMS" "tr": "Sms"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.NotificationChannel.Mail", "key": "App.NotificationChannel.Mail",
"en": "E-mail", "en": "Mail",
"tr": "E-posta" "tr": "E-posta"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.NotificationChannel.Rocket", "key": "App.NotificationChannel.Rocket",
"en": "Rocket.Chat", "en": "Rocket",
"tr": "Rocket.Chat" "tr": "Rocket"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -1953,14 +1953,14 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.NotificationChannel.UiActivity", "key": "App.NotificationChannel.UiActivity",
"en": "Activity", "en": "UI Activity",
"tr": "Etkinlik" "tr": "Arayüz Etkinliği"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.NotificationChannel.UiToast", "key": "App.NotificationChannel.UiToast",
"en": "In-app", "en": "UI Toast",
"tr": "Uygulama içi" "tr": "Arayüz Bildirimi"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -1980,6 +1980,30 @@
"en": "Reset to default", "en": "Reset to default",
"tr": "Varsayılana döndür" "tr": "Varsayılana döndür"
}, },
{
"resourceName": "Platform",
"key": "Abp.Identity.NotificationSettings.Description",
"en": "Choose how you want to be notified for each notification type.",
"tr": "Her bildirim türü için nasıl haberdar olmak istediğinizi seçin."
},
{
"resourceName": "Platform",
"key": "Abp.Identity.NotificationSettings.Type",
"en": "Notification type",
"tr": "Bildirim türü"
},
{
"resourceName": "Platform",
"key": "Abp.Identity.NotificationSettings.Customized",
"en": "Customized by you",
"tr": "Sizin tarafınızdan değiştirildi"
},
{
"resourceName": "Platform",
"key": "Abp.Identity.NotificationSettings.Fixed",
"en": "Fixed by the administrator",
"tr": "Yönetici tarafından sabitlenmiş"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "Abp.Identity.NotificationSettings.NoRule", "key": "Abp.Identity.NotificationSettings.NoRule",
@ -19578,12 +19602,6 @@
"en": "DELETE", "en": "DELETE",
"tr": "DELETE" "tr": "DELETE"
}, },
{
"resourceName": "Platform",
"key": "App.StaticLookup.Desktop",
"en": "Desktop",
"tr": "Masaüstü"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.StaticLookup.Double", "key": "App.StaticLookup.Double",
@ -19674,12 +19692,6 @@
"en": "List", "en": "List",
"tr": "Liste" "tr": "Liste"
}, },
{
"resourceName": "Platform",
"key": "App.StaticLookup.Mail",
"en": "Mail",
"tr": "E-posta"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.StaticLookup.MailQueueWorker", "key": "App.StaticLookup.MailQueueWorker",
@ -19914,12 +19926,6 @@
"en": "Reference", "en": "Reference",
"tr": "Referans" "tr": "Referans"
}, },
{
"resourceName": "Platform",
"key": "App.StaticLookup.Rocket",
"en": "Rocket",
"tr": "Rocket"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.StaticLookup.Role", "key": "App.StaticLookup.Role",
@ -20028,12 +20034,6 @@
"en": "Time", "en": "Time",
"tr": "Saat" "tr": "Saat"
}, },
{
"resourceName": "Platform",
"key": "App.StaticLookup.UiActivity",
"en": "UI Activity",
"tr": "Arayüz Etkinliği"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.StaticLookup.UInt16", "key": "App.StaticLookup.UInt16",
@ -20052,12 +20052,6 @@
"en": "UInt64", "en": "UInt64",
"tr": "UInt64" "tr": "UInt64"
}, },
{
"resourceName": "Platform",
"key": "App.StaticLookup.UiToast",
"en": "UI Toast",
"tr": "Arayüz Bildirimi"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.Platform.User", "key": "App.Platform.User",

View file

@ -235,7 +235,7 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
SeedSyncInsert = true, SeedSyncInsert = true,
SeedSyncUpdate = true, SeedSyncUpdate = true,
SeedSyncDelete = true, SeedSyncDelete = true,
SortMode = GridOptions.SortModeSingle, SortMode = GridOptions.SortModeMultiple,
FilterRowJson = DefaultFilterRowJson(), FilterRowJson = DefaultFilterRowJson(),
HeaderFilterJson = DefaultHeaderFilterJson(), HeaderFilterJson = DefaultHeaderFilterJson(),
SearchPanelJson = DefaultSearchPanelJson(), SearchPanelJson = DefaultSearchPanelJson(),
@ -454,6 +454,8 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
Visible = true, Visible = true,
IsActive = true, IsActive = true,
SortIndex = 2,
SortDirection = GridColumnOptions.SortOrderAsc,
LookupJson = JsonSerializer.Serialize(new LookupDto LookupJson = JsonSerializer.Serialize(new LookupDto
{ {
DataSourceType = UiLookupDataSourceTypeEnum.Query, DataSourceType = UiLookupDataSourceTypeEnum.Query,
@ -7238,8 +7240,8 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
}), }),
CommandColumnJson = JsonSerializer.Serialize(new CommandColumnDto[] { CommandColumnJson = JsonSerializer.Serialize(new CommandColumnDto[] {
new CommandColumnDto() { new CommandColumnDto() {
Hint = "Create", Hint = "App.Platform.Create",
Text = "Create", Text = "App.Platform.Create",
AuthName = listFormName, AuthName = listFormName,
DialogName = "CreateNotification", DialogName = "CreateNotification",
DialogParameters = JsonSerializer.Serialize(new { name = "id", id = "@Id" }), DialogParameters = JsonSerializer.Serialize(new { name = "id", id = "@Id" }),

View file

@ -64,14 +64,14 @@ public static class ListFormSeeder_DefaultJsons
ValueExpr = "key", ValueExpr = "key",
LookupQuery = JsonSerializer.Serialize(new LookupDataDto[] LookupQuery = JsonSerializer.Serialize(new LookupDataDto[]
{ {
new() { Key = "Sms", Name = "App.Sender.Sms" }, new() { Key = "Sms", Name = "App.NotificationChannel.Sms" },
new() { Key = "Mail", Name = "App.StaticLookup.Mail" }, new() { Key = "Mail", Name = "App.NotificationChannel.Mail" },
new() { Key = "Rocket", Name = "App.StaticLookup.Rocket" }, new() { Key = "Rocket", Name = "App.NotificationChannel.Rocket" },
new() { Key = "Desktop", Name = "App.StaticLookup.Desktop" }, new() { Key = "Desktop", Name = "App.NotificationChannel.Desktop" },
new() { Key = "UiActivity", Name = "App.StaticLookup.UiActivity" }, new() { Key = "UiActivity", Name = "App.NotificationChannel.UiActivity" },
new() { Key = "UiToast", Name = "App.StaticLookup.UiToast" }, new() { Key = "UiToast", Name = "App.NotificationChannel.UiToast" },
new() { Key = "WhatsApp", Name = "App.Sender.WhatsApp" }, new() { Key = "WhatsApp", Name = "App.NotificationChannel.WhatsApp" },
new() { Key = "Telegram", Name = "App.Sender.Telegram" }, new() { Key = "Telegram", Name = "App.NotificationChannel.Telegram" },
}), }),
}); });

View file

@ -1,3 +1,15 @@
import type { IconType } from 'react-icons'
import {
FaBell,
FaDesktop,
FaEnvelope,
FaListUl,
FaRocketchat,
FaSms,
FaTelegramPlane,
FaWhatsapp,
} from 'react-icons/fa'
const NotificationChannels = { const NotificationChannels = {
Sms: 'Sms', Sms: 'Sms',
Mail: 'Mail', Mail: 'Mail',
@ -20,4 +32,16 @@ export const NotificationChannelColors: Record<string, string> = {
Telegram: 'bg-purple-900', Telegram: 'bg-purple-900',
} }
/** Kanal kayıt defterinin görsel yüzü; yeni kanal eklendiğinde ikon da burada tanımlanır. */
export const NotificationChannelIcons: Record<string, IconType> = {
Sms: FaSms,
Mail: FaEnvelope,
Rocket: FaRocketchat,
Desktop: FaDesktop,
UiActivity: FaListUl,
UiToast: FaBell,
WhatsApp: FaWhatsapp,
Telegram: FaTelegramPlane,
}
export default NotificationChannels export default NotificationChannels

View file

@ -37,6 +37,5 @@ export const postMyNotificationRule = (data: {
export const deleteMyNotificationRules = (notificationTypeId: string) => export const deleteMyNotificationRules = (notificationTypeId: string) =>
apiService.fetchData<NotificationRuleDto[]>({ apiService.fetchData<NotificationRuleDto[]>({
method: 'DELETE', method: 'DELETE',
url: `/api/app/notification-rule/my-notification-rules`, url: `/api/app/notification-rule/my-notification-rules/${notificationTypeId}`,
params: { notificationTypeId },
}) })

View file

@ -7,16 +7,8 @@ import classNames from 'classnames'
type CategoryTitleProps = CommonProps type CategoryTitleProps = CommonProps
const ticketCheckboxes = [ /** Kanal listesi kayit defterinden gelir; etiket her ekranda ayni dil anahtarindan okunur. */
{ label: NotificationChannels.Sms, value: NotificationChannels.Sms }, const channels = Object.values(NotificationChannels)
{ label: NotificationChannels.Mail, value: NotificationChannels.Mail },
{ label: NotificationChannels.Rocket, value: NotificationChannels.Rocket },
{ label: NotificationChannels.Desktop, value: NotificationChannels.Desktop },
{ label: NotificationChannels.UiActivity, value: NotificationChannels.UiActivity },
{ label: NotificationChannels.UiToast, value: NotificationChannels.UiToast },
{ label: NotificationChannels.WhatsApp, value: NotificationChannels.WhatsApp },
{ label: NotificationChannels.Telegram, value: NotificationChannels.Telegram },
]
const CategoryTitle = ({ children, className }: CategoryTitleProps) => { const CategoryTitle = ({ children, className }: CategoryTitleProps) => {
return ( return (
@ -64,9 +56,9 @@ const LogFilter = ({
<CategoryTitle className="mb-3 text-gray-500 dark:text-gray-400"> <CategoryTitle className="mb-3 text-gray-500 dark:text-gray-400">
{translate('::Abp.Identity.ActivityLogs.Channels')} {translate('::Abp.Identity.ActivityLogs.Channels')}
</CategoryTitle> </CategoryTitle>
{ticketCheckboxes.map((checkbox) => ( {channels.map((channel) => (
<Checkbox key={checkbox.value} className="mb-3 dark:text-white" value={checkbox.value}> <Checkbox key={channel} className="mb-3 dark:text-white" value={channel}>
{checkbox.label} {translate(`::App.NotificationChannel.${channel}`)}
</Checkbox> </Checkbox>
))} ))}
</Checkbox.Group> </Checkbox.Group>

View file

@ -1,5 +1,8 @@
import { Button, Checkbox, Spinner, Table } from '@/components/ui' import { Button, Spinner, Switcher, Tooltip } from '@/components/ui'
import NotificationChannels from '@/constants/notification-channel.enum' import NotificationChannels, {
NotificationChannelColors,
NotificationChannelIcons,
} from '@/constants/notification-channel.enum'
import { NotificationRuleDto, NotificationTypeDto } from '@/proxy/notification/models' import { NotificationRuleDto, NotificationTypeDto } from '@/proxy/notification/models'
import { import {
deleteMyNotificationRules, deleteMyNotificationRules,
@ -8,49 +11,64 @@ import {
postMyNotificationRule, postMyNotificationRule,
} from '@/services/notification-rule.service' } from '@/services/notification-rule.service'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import classNames from 'classnames'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { FaHistory } from 'react-icons/fa' import type { MouseEvent } from 'react'
import { FaHistory, FaLock } from 'react-icons/fa'
const { Tr, Th, Td, THead, TBody } = Table
/** Matris sütunları kanal kayıt defterinden gelir; yeni bir kanal eklendiğinde ekran kendiliğinden güncel olur. */ /** Matris sütunları kanal kayıt defterinden gelir; yeni bir kanal eklendiğinde ekran kendiliğinden güncel olur. */
const channels = Object.values(NotificationChannels) const channels = Object.values(NotificationChannels)
const ruleKey = (notificationTypeId: string, channel: string) => `${notificationTypeId}|${channel}`
/** Sunucuda karşılığı olmayan tür/kanal kesişimi kapalı sayılır; matris boşluk bırakmaz. */
const emptyRule = (notificationTypeId: string, channel: string): NotificationRuleDto => ({
id: '',
notificationTypeId,
channel,
isActive: false,
isFixed: false,
isCustomized: false,
})
const ChannelIcon = ({ channel, size = 'md' }: { channel: string; size?: 'sm' | 'md' }) => {
const Icon = NotificationChannelIcons[channel]
return (
<span
className={classNames(
'flex items-center justify-center rounded-full text-white',
size === 'sm' ? 'h-6 w-6 text-xs' : 'h-7 w-7 text-sm',
NotificationChannelColors[channel] ?? 'bg-gray-400',
)}
>
{Icon && <Icon />}
</span>
)
}
const NotificationSettings = () => { const NotificationSettings = () => {
const { translate } = useLocalization() const { translate } = useLocalization()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [rules, setRules] = useState<Record<string, NotificationRuleDto>>({})
const [list, setList] = useState<NotificationRuleDto[][]>([])
const [notificationTypes, setNotificationTypes] = useState<NotificationTypeDto[]>([]) const [notificationTypes, setNotificationTypes] = useState<NotificationTypeDto[]>([])
// Yalnızca kaydedilen hücre/satır kilitlenir; matrisin tamamını kilitlemek tek tıkta ekranı donduruyordu.
const [pending, setPending] = useState<string[]>([])
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true)
try { try {
const [typesResponse, rulesResponse] = await Promise.all([ const [typesResponse, rulesResponse] = await Promise.all([
getMyNotificationTypes(), getMyNotificationTypes(),
getMyNotificationRules(), getMyNotificationRules(),
]) ])
const types = typesResponse.data ?? [] setNotificationTypes(typesResponse.data ?? [])
const rules = rulesResponse.data ?? [] setRules(
Object.fromEntries(
setNotificationTypes(types) (rulesResponse.data ?? []).map((rule) => [
setList( ruleKey(rule.notificationTypeId, rule.channel),
types.map((type) => rule,
channels.map( ]),
(channel) =>
rules.find(
(rule) => rule.channel === channel && rule.notificationTypeId === type.id,
) ?? {
id: '',
notificationTypeId: type.id,
channel,
isActive: false,
isFixed: false,
isCustomized: false,
},
),
), ),
) )
} finally { } finally {
@ -63,10 +81,29 @@ const NotificationSettings = () => {
fetchData() fetchData()
}, [fetchData]) }, [fetchData])
const onClickCheckbox = async (checked: boolean, rule: NotificationRuleDto) => { const getRule = (notificationTypeId: string, channel: string) =>
// Kayıt sürerken kutular kilitlenir; art arda tıklamada son dönen yanıt kazanır ve rules[ruleKey(notificationTypeId, channel)] ?? emptyRule(notificationTypeId, channel)
// ekran sunucudaki gerçeğin tersine düşerdi.
setSaving(true) const isPending = (key: string) => pending.includes(key)
const withPending = async (key: string, action: () => Promise<unknown>) => {
setPending((current) => [...current, key])
try {
await action()
} finally {
setPending((current) => current.filter((item) => item !== key))
}
}
const onToggle = (checked: boolean, rule: NotificationRuleDto) => {
const key = ruleKey(rule.notificationTypeId, rule.channel)
// Anahtar hemen döner, sunucu yanıtından sonra gerçek durum geri yazılır; hata olursa eski değere döner.
setRules((current) => ({
...current,
[key]: { ...rule, isActive: checked, isCustomized: true },
}))
return withPending(key, async () => {
try { try {
await postMyNotificationRule({ await postMyNotificationRule({
channel: rule.channel, channel: rule.channel,
@ -74,83 +111,176 @@ const NotificationSettings = () => {
isActive: checked, isActive: checked,
}) })
await fetchData() await fetchData()
} finally { } catch {
setSaving(false) setRules((current) => ({ ...current, [key]: rule }))
} }
})
} }
const resetMyNotifications = async (notificationTypeId: string) => { const resetMyNotifications = (notificationTypeId: string) =>
setSaving(true) withPending(notificationTypeId, async () => {
try {
await deleteMyNotificationRules(notificationTypeId) await deleteMyNotificationRules(notificationTypeId)
await fetchData() await fetchData()
} finally { })
setSaving(false)
} const isCustomized = (notificationTypeId: string) =>
channels.some((channel) => getRule(notificationTypeId, channel).isCustomized)
const renderSwitcher = (notificationTypeId: string, channel: string) => {
const rule = getRule(notificationTypeId, channel)
const busy = isPending(ruleKey(notificationTypeId, channel)) || isPending(notificationTypeId)
if (rule.isFixed) {
return (
<Tooltip title={translate('::Abp.Identity.NotificationSettings.Fixed')}>
<span className={rule.isActive ? 'text-emerald-600' : 'text-gray-400'}>
<FaLock />
</span>
</Tooltip>
)
} }
return ( return (
<> <Switcher
{loading ? ( checked={rule.isActive}
<div className="flex items-center justify-center"> isLoading={busy}
<Spinner size={40} /> disabled={busy}
</div> // Switcher denetimli kipte eski degeri geri veriyor; yeni deger kuraldan turetilir.
) : ( onChange={() => onToggle(!rule.isActive, rule)}
<> />
{list.length ? ( )
<Table compact={true}> }
<THead>
<Tr> const renderResetButton = (notificationTypeId: string) => (
<Th></Th> <Tooltip title={translate('::Abp.Identity.NotificationSettings.Reset')}>
{channels.map((channel) => (
<Th key={channel} className="text-center">
{translate(`::App.NotificationChannel.${channel}`)}
</Th>
))}
</Tr>
</THead>
<TBody>
{notificationTypes.map((type, m) => (
<Tr key={type.id}>
<Th>
<Button <Button
shape="circle" shape="circle"
variant="plain" variant="plain"
type="button" type="button"
size="sm" size="xs"
disabled={saving} disabled={!isCustomized(notificationTypeId) || isPending(notificationTypeId)}
title={translate('::Abp.Identity.NotificationSettings.Reset')}
icon={<FaHistory />} icon={<FaHistory />}
onClick={async (e) => { onClick={(e: MouseEvent) => {
e.preventDefault() e.preventDefault()
resetMyNotifications(type.id) resetMyNotifications(notificationTypeId)
}} }}
/> />
<span title={type.description ?? undefined}>{type.name}</span> </Tooltip>
</Th> )
{channels.map((channel, n) => (
<Td key={type.id + channel} className="text-center"> const renderTypeTitle = (type: NotificationTypeDto) => (
<Checkbox <>
name={type.id + channel} <div className="flex items-center gap-2">
disabled={list[m][n].isFixed || saving} <span className="font-semibold">{type.name}</span>
checked={list[m][n].isActive} {isCustomized(type.id) && <span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" />}
color={list[m][n].isCustomized ? 'red-500' : undefined} </div>
onChange={(e) => onClickCheckbox(e, list[m][n])} {type.description && <p className="mt-0.5 text-xs text-gray-500">{type.description}</p>}
></Checkbox> </>
</Td> )
))}
</Tr> if (loading) {
))} return (
</TBody> <div className="flex items-center justify-center py-10">
</Table> <Spinner size={40} />
) : ( </div>
<p className="text-center"> )
}
if (!notificationTypes.length) {
return (
<p className="py-10 text-center text-gray-500">
{translate('::Abp.Identity.NotificationSettings.NoRule')} {translate('::Abp.Identity.NotificationSettings.NoRule')}
</p> </p>
)} )
</> }
)}
</> return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-2">
<p className="text-sm text-gray-500">
{translate('::Abp.Identity.NotificationSettings.Description')}
</p>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-amber-500" />
{translate('::Abp.Identity.NotificationSettings.Customized')}
</span>
<span className="flex items-center gap-1.5">
<FaLock />
{translate('::Abp.Identity.NotificationSettings.Fixed')}
</span>
</div>
</div>
{/* Geniş ekranda matris, dar ekranda kart listesi: sekiz kanal dar ekranda tek satıra sığmıyor. */}
<div className="hidden overflow-x-auto rounded-xl border border-gray-200 md:block dark:border-gray-700">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-semibold dark:bg-gray-700">
{translate('::Abp.Identity.NotificationSettings.Type')}
</th>
{channels.map((channel) => (
<th key={channel} className="px-3 py-3 font-semibold">
<span className="flex flex-col items-center gap-1.5">
<ChannelIcon channel={channel} />
<span className="text-xs whitespace-nowrap">
{translate(`::App.NotificationChannel.${channel}`)}
</span>
</span>
</th>
))}
<th className="w-12 px-2 py-3" />
</tr>
</thead>
<tbody>
{notificationTypes.map((type) => (
<tr
key={type.id}
className="border-t border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700"
>
<td className="sticky left-0 z-10 bg-white px-4 py-3 dark:bg-gray-800">
{renderTypeTitle(type)}
</td>
{channels.map((channel) => (
<td key={channel} className="px-3 py-3">
<div className="flex justify-center">{renderSwitcher(type.id, channel)}</div>
</td>
))}
<td className="px-2 py-3 text-center">{renderResetButton(type.id)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 md:hidden">
{notificationTypes.map((type) => (
<div key={type.id} className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<div className="flex items-start justify-between gap-2">
<div>{renderTypeTitle(type)}</div>
{renderResetButton(type.id)}
</div>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{channels.map((channel) => (
<div
key={channel}
className="flex items-center justify-between gap-3 rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-700"
>
<span className="flex items-center gap-2">
<ChannelIcon channel={channel} size="sm" />
<span className="text-sm">
{translate(`::App.NotificationChannel.${channel}`)}
</span>
</span>
{renderSwitcher(type.id, channel)}
</div>
))}
</div>
</div>
))}
</div>
</div>
) )
} }