Versiyon güncelleme

This commit is contained in:
Sedat Öztürk 2025-09-19 23:36:10 +03:00
parent 6766d1129d
commit 9e85780623
14 changed files with 371 additions and 250 deletions

View file

@ -7528,7 +7528,7 @@
{ {
"key": "admin.changeLog", "key": "admin.changeLog",
"path": "/admin/changeLog", "path": "/admin/changeLog",
"componentPath": "@/views/docs/ChangeLog", "componentPath": "@/views/version/ChangeLog",
"routeType": "protected", "routeType": "protected",
"authority": [] "authority": []
}, },

View file

@ -7,7 +7,7 @@
"AttachmentsPath": "C:\\Private\\Projects\\sozsoft\\configs\\mail-queue\\attachments", "AttachmentsPath": "C:\\Private\\Projects\\sozsoft\\configs\\mail-queue\\attachments",
"CdnPath": "C:\\Private\\Projects\\sozsoft\\configs\\docker\\data\\cdn", "CdnPath": "C:\\Private\\Projects\\sozsoft\\configs\\docker\\data\\cdn",
"ImportPath": "C:\\Private\\Projects\\sozsoft\\configs\\docker\\data\\import", "ImportPath": "C:\\Private\\Projects\\sozsoft\\configs\\docker\\data\\import",
"Version": "1.0.4" "Version": "1.0.1"
}, },
"ConnectionStrings": { "ConnectionStrings": {
"SqlServer": "Server=localhost;Database=KURS;User Id=sa;password=NvQp8s@l;Trusted_Connection=False;TrustServerCertificate=True;", "SqlServer": "Server=localhost;Database=KURS;User Id=sa;password=NvQp8s@l;Trusted_Connection=False;TrustServerCertificate=True;",

1
ui/.gitignore vendored
View file

@ -9,6 +9,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dev-dist
dist-ssr dist-ssr
build build
*.local *.local

View file

@ -82,7 +82,7 @@ define(['./workbox-a959eb95'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "/index.html", "url": "/index.html",
"revision": "0.agtbclbpej8" "revision": "0.jq60tbu0hgg"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/index.html"), {

View file

@ -1,12 +1,12 @@
{ {
"name": "kurs-platform-ui", "name": "kurs-platform-ui",
"private": true, "private": true,
"version": "1.0.4", "version": "1.0.1",
"elstarVersion": "2.1.6", "elstarVersion": "2.1.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "vite build", "build": "vite build && node scripts/write-version.js",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.json", "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.json",
"lint:fix": "npm run lint -- --fix", "lint:fix": "npm run lint -- --fix",

35
ui/public/version.json Normal file
View file

@ -0,0 +1,35 @@
{
"commit": "6766d11",
"releases": [
{
"version": "1.0.5",
"buildDate": "2025-09-19",
"changeLog": [
"Form ekranındaki Butonlar güncellemeleri yapıldı",
"Edit Form ekranındaki Info butonu eklendi.",
"New Form ekranındaki Geri butonu eklendi."
]
},
{
"version": "1.0.4",
"buildDate": "2025-09-19",
"changeLog": [
"Subformlar üzerinde extra filters ve Widget çalışmaları yapıldı."
]
},
{
"version": "1.0.3",
"buildDate": "2025-09-19",
"changeLog": [
"Manage Grid üzerinde Extra filtre tanımlaması yapıldı."
]
},
{
"version": "1.0.2",
"buildDate": "2025-09-16",
"changeLog": [
"Genel Static olan Url bilgileri kaldırıldı."
]
}
]
}

View file

@ -0,0 +1,46 @@
import fs from "fs"
import { execSync } from "child_process"
function safeExec(cmd) {
try {
return execSync(cmd, { stdio: ["pipe", "pipe", "ignore"] }).toString().trim()
} catch {
return null
}
}
// Tüm tag isimlerini al
const rawTags = safeExec("git tag --list --sort=creatordate")
if (!rawTags) {
console.log("> No git tags found, skipping version.json")
process.exit(0)
}
const tags = rawTags
.split("\n")
.filter(Boolean)
.map((tag) => {
const date = safeExec(`git log -1 --format=%ad --date=short ${tag}`)
const messageRaw = safeExec(`git tag -l --format="%(contents)" ${tag}`)
const changeLog = messageRaw
? messageRaw.split("\n").map((s) => s.trim()).filter(Boolean)
: []
return {
version: tag.replace(/^v/, ""), // v1.0.5 → 1.0.5
buildDate: date,
changeLog
}
})
const commit = safeExec("git rev-parse --short HEAD")
const versionInfo = {
commit,
releases: tags.reverse()
}
fs.writeFileSync("public/version.json", JSON.stringify(versionInfo, null, 2))
console.log("> Version file written to public/version.json:", versionInfo)

View file

@ -1,57 +0,0 @@
// src/components/UpdateNotifier.jsx
import { useState, useEffect } from 'react'
const UpdateNotifier = () => {
const [updateAvailable, setUpdateAvailable] = useState(false)
useEffect(() => {
if ('serviceWorker' in navigator) {
const checkUpdate = () => {
navigator.serviceWorker.getRegistration().then(registration => {
if (registration) {
registration.addEventListener('updatefound', () => {
setUpdateAvailable(true)
})
}
})
}
checkUpdate()
const interval = setInterval(checkUpdate, 30000) // 30 saniyede bir kontrol
return () => clearInterval(interval)
}
}, [])
const handleUpdate = () => {
window.location.reload()
}
if (!updateAvailable) return null
return (
<div style={{
position: 'fixed',
bottom: '20px',
right: '20px',
padding: '12px',
background: '#FF99C8',
color: 'white',
borderRadius: '8px',
zIndex: 1000
}}>
<p>Yeni güncelleme mevcut!</p>
<button onClick={handleUpdate} style={{
background: 'white',
color: '#FF99C8',
border: 'none',
padding: '8px 16px',
borderRadius: '4px',
cursor: 'pointer'
}}>
Yenile
</button>
</div>
)
}
export default UpdateNotifier

View file

@ -15,7 +15,7 @@ import useLocale from '@/utils/hooks/useLocale'
import { useDynamicRoutes } from '@/routes/dynamicRoutesContext' import { useDynamicRoutes } from '@/routes/dynamicRoutesContext'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { hasSubdomain } from '@/utils/subdomain' import { hasSubdomain } from '@/utils/subdomain'
import UpdateNotifier from '../UpdateNotifier' import UpdateNotifier from '../../views/version/UpdateNotifier'
export type LayoutType = export type LayoutType =
| typeof LAYOUT_TYPE_CLASSIC | typeof LAYOUT_TYPE_CLASSIC

View file

@ -1,13 +1,14 @@
import classNames from 'classnames' import { useEffect, useState } from "react"
import Container from '@/components/shared/Container' import classNames from "classnames"
import { APP_NAME } from '@/constants/app.constant' import Container from "@/components/shared/Container"
import { PAGE_CONTAINER_GUTTER_X } from '@/constants/theme.constant' import { APP_NAME } from "@/constants/app.constant"
import { useStoreActions, useStoreState } from '@/store' import { PAGE_CONTAINER_GUTTER_X } from "@/constants/theme.constant"
import { Link, useNavigate } from 'react-router-dom' import { useStoreActions, useStoreState } from "@/store"
import { ROUTES_ENUM } from '@/routes/route.constant' import { Link, useNavigate } from "react-router-dom"
import UiDialog from '@/views/shared/UiDialog' import { ROUTES_ENUM } from "@/routes/route.constant"
import UiDialog from "@/views/shared/UiDialog"
export type FooterPageContainerType = 'gutterless' | 'contained' export type FooterPageContainerType = "gutterless" | "contained"
type FooterProps = { type FooterProps = {
pageContainerType: FooterPageContainerType pageContainerType: FooterPageContainerType
@ -21,7 +22,20 @@ const FooterContent = () => {
const apiConfig = useStoreState((state) => state.abpConfig.config?.extraProperties) const apiConfig = useStoreState((state) => state.abpConfig.config?.extraProperties)
const uiMode = import.meta.env.MODE const uiMode = import.meta.env.MODE
const reactAppVersion = import.meta.env.VITE_REACT_APP_VERSION
const [latestVersion, setLatestVersion] = useState<string | null>(null)
// version.json'dan en güncel UI versiyonunu al
useEffect(() => {
fetch("/version.json?ts=" + Date.now())
.then((res) => res.json())
.then((data) => {
if (data?.releases?.length > 0) {
setLatestVersion(data.releases[0].version) // en güncel hep en üstte
}
})
.catch(() => setLatestVersion(null))
}, [])
return ( return (
<> <>
@ -40,24 +54,25 @@ const FooterContent = () => {
{apiConfig && ( {apiConfig && (
<span> <span>
<b>API: </b> <b>API: </b>
{apiConfig['environment'].toString()}:{apiConfig['version'].toString()} {apiConfig["environment"].toString()}:{apiConfig["version"].toString()}
</span> </span>
)} )}
</div> </div>
</Link> </Link>
</div> </div>
{reactAppVersion != currentUiVersion && (
{latestVersion && latestVersion !== currentUiVersion && (
<UiDialog <UiDialog
key={`version-${reactAppVersion }`} key={`version-${latestVersion}`}
isOpen={true} isOpen={true}
type="info" type="info"
onConfirm={() => { onConfirm={() => {
setUiVersion(reactAppVersion ) setUiVersion(latestVersion)
navigate(ROUTES_ENUM.protected.admin.changeLog) navigate(ROUTES_ENUM.protected.admin.changeLog)
}} }}
title="🎉 Yeni Güncelleme" title="🎉 Yeni Güncelleme"
> >
Sözsoft Kurs Platform Sistemi güncellendi. Sözsoft Kurs Platform Sistemi <b>v{latestVersion}</b> sürümüne güncellendi.
<p>Detayları, "Güncelleme Günlüğü" ekranında görebilirsiniz.</p> <p>Detayları, "Güncelleme Günlüğü" ekranında görebilirsiniz.</p>
</UiDialog> </UiDialog>
)} )}
@ -65,12 +80,14 @@ const FooterContent = () => {
) )
} }
export default function Footer({ pageContainerType = 'contained' }: FooterProps) { export default function Footer({ pageContainerType = "contained" }: FooterProps) {
return ( return (
<footer <footer
className={classNames(`print:hidden footer flex flex-auto items-center h-6 ${PAGE_CONTAINER_GUTTER_X}`)} className={classNames(
`print:hidden footer flex flex-auto items-center h-6 ${PAGE_CONTAINER_GUTTER_X}`
)}
> >
{pageContainerType === 'contained' ? ( {pageContainerType === "contained" ? (
<Container> <Container>
<FooterContent /> <FooterContent />
</Container> </Container>

View file

@ -1,81 +0,0 @@
import AdaptableCard from '@/components/shared/AdaptableCard'
import Container from '@/components/shared/Container'
import { useLocalization } from '@/utils/hooks/useLocalization'
import type { ReactNode } from 'react'
import { Helmet } from 'react-helmet'
type Log = {
version: string
date: string
updateContent: string[]
}
type LogProps = Omit<Log, 'updateContent'> & {
border?: boolean
children?: ReactNode
}
const logData: Log[] = [
{
version: '1.0.3',
date: '04 Şubat 2025',
updateContent: ['[Fix] GridBoxEditorComponent bug', '[Fix] TagBoxEditorComponent bug'],
},
{
version: '1.0.2',
date: '27 Ocak 2025',
updateContent: ['[Add] jspdf kurulumu', '[Add] exceljs kurulumu', '[Add] file-saver kurulumu'],
},
{
version: '1.0.1',
date: '23 Ocak 2025',
updateContent: ['[Fix] Bağımlılık güvenlik açığı'],
},
{
version: '1.0.0',
date: '20 Ocak 2025',
updateContent: ['[Update] İlk yeni sürüm'],
},
]
const Log = (props: LogProps) => {
return (
<div className={`py-4 ${props.border && 'border-bottom'}`}>
<div className="flex items-center">
<h5 className="font-weight-normal mb-0 mr-3">{props.version}</h5>
<code>{props.date}</code>
</div>
<div className="api-container p-0 border-0 mt-3">{props.children}</div>
</div>
)
}
const Changelog = () => {
const { translate } = useLocalization()
return (
<Container>
<Helmet
titleTemplate="%s | Sözsoft Kurs Platform"
title={translate('::' + 'App.ChangeLog')}
defaultTitle="Sözsoft Kurs Platform"
></Helmet>
<AdaptableCard>
<h4>Platform Güncelleme Günlüğü</h4>
{logData.map((elm) => (
<Log key={elm.version} version={`v${elm.version}`} date={elm.date}>
{elm.updateContent.length > 0 ? (
<ul>
{elm.updateContent.map((item, i) => (
<li key={i}>- {item}</li>
))}
</ul>
) : null}
</Log>
))}
</AdaptableCard>
</Container>
)
}
export default Changelog

View file

@ -0,0 +1,77 @@
import { useEffect, useState } from "react"
import AdaptableCard from "@/components/shared/AdaptableCard"
import Container from "@/components/shared/Container"
import { HiTag, HiCheckCircle } from "react-icons/hi"
import { useLocalization } from "@/utils/hooks/useLocalization"
import { Helmet } from "react-helmet"
type Release = {
version: string
buildDate: string
changeLog: string[]
}
const Log = ({ version, date, children }: { version: string; date: string; children?: React.ReactNode }) => {
return (
<div className="relative pl-4 sm:pl-32 py-4 group">
<div className="flex flex-col sm:flex-row items-start mb-1 group-last:before:hidden before:absolute before:left-2 sm:before:left-0 before:h-full before:px-px before:bg-slate-200 sm:before:ml-[6.5rem] before:self-start before:-translate-x-1/2 before:translate-y-3 after:absolute after:left-2 sm:after:left-0 after:w-2 after:h-2 after:bg-indigo-600 after:border-4 after:box-content after:border-slate-50 after:rounded-full sm:after:ml-[6.5rem] after:-translate-x-1/2 after:translate-y-1.5">
<time className="sm:absolute left-0 translate-y-0.5 inline-flex items-center justify-center text-xs font-semibold uppercase w-20 h-6 mb-3 sm:mb-0 text-emerald-600 bg-emerald-100 rounded-full">
{date}
</time>
<div className="flex items-center text-xl font-bold text-gray-900">
<HiTag className="mr-2 text-indigo-500" />
v{version}
</div>
</div>
<div className="text-slate-500">{children}</div>
</div>
)
}
const Changelog = () => {
const { translate } = useLocalization()
const [releases, setReleases] = useState<Release[]>([])
useEffect(() => {
fetch("/version.json?ts=" + Date.now())
.then((res) => res.json())
.then((data) => {
if (data?.releases) {
setReleases(data.releases)
}
})
.catch(() => setReleases([]))
}, [])
return (
<Container>
<Helmet
titleTemplate="%s | Sözsoft Kurs Platform"
title={translate("::App.ChangeLog")}
defaultTitle="Sözsoft Kurs Platform"
/>
<AdaptableCard>
<div className="p-2">
<div className="space-y-1">
{releases.map((rel) => (
<Log key={rel.version} version={rel.version} date={rel.buildDate}>
{rel.changeLog?.length > 0 && (
<ul className="list-none mt-2 space-y-2">
{rel.changeLog.map((item, i) => (
<li key={i} className="flex items-start">
<HiCheckCircle className="w-4 h-4 text-emerald-500 mr-2 flex-shrink-0" />
<span>{item}</span>
</li>
))}
</ul>
)}
</Log>
))}
</div>
</div>
</AdaptableCard>
</Container>
)
}
export default Changelog

View file

@ -0,0 +1,71 @@
import { ROUTES_ENUM } from '@/routes/route.constant'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { HiOutlineRefresh, HiX } from 'react-icons/hi'
import { useStoreState, useStoreActions } from '@/store'
const UpdateNotifier = () => {
const [lastUiVersion, setLastUiVersion] = useState('')
const [updateAvailable, setUpdateAvailable] = useState(false)
const navigate = useNavigate()
const { currentUiVersion } = useStoreState((s) => s.locale)
const { setUiVersion } = useStoreActions((s) => s.locale)
useEffect(() => {
const checkVersion = async () => {
try {
const res = await fetch('/version.json?ts=' + Date.now())
const data = await res.json()
const latestVersion = data?.releases?.[0]?.version
if (latestVersion && latestVersion !== currentUiVersion) {
setUpdateAvailable(true)
setLastUiVersion(latestVersion)
}
} catch (err) {
console.error('Version check failed', err)
}
}
checkVersion()
const interval = setInterval(checkVersion, 30000) // 30sde bir kontrol
return () => clearInterval(interval)
}, [currentUiVersion])
if (!updateAvailable) return null
return (
<div
className="fixed bottom-5 right-5 z-50 flex items-center justify-between gap-4 rounded-lg bg-sky-500 p-4 text-white shadow-lg animate-bounce"
>
<div className="flex items-center gap-3">
<HiOutlineRefresh className="h-8 w-8 animate-spin" />
<div>
<p className="font-semibold">Yeni güncelleme mevcut!</p>
<p className="text-sm">En son özellikler için sayfayı yenileyin.</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setUiVersion(lastUiVersion)
navigate(ROUTES_ENUM.protected.admin.changeLog)
setUpdateAvailable(false)
}}
className="rounded bg-white px-4 py-2 text-sm font-bold text-sky-600 transition hover:bg-sky-100"
>
Yenile
</button>
<button
onClick={() => setUpdateAvailable(false)}
className="rounded-full p-1 transition hover:bg-sky-600"
aria-label="Kapat"
>
<HiX className="h-5 w-5" />
</button>
</div>
</div>
)
}
export default UpdateNotifier

View file

@ -19,7 +19,8 @@ export default defineConfig(async ({ mode }) => {
return { return {
plugins: [ plugins: [
react(), react(),
VitePWA({ mode === 'production'
? VitePWA({
// Deploy'dan sonra otomatik güncelle // Deploy'dan sonra otomatik güncelle
registerType: 'autoUpdate', registerType: 'autoUpdate',
// Kayıt kodunu otomatik enjekte et (virtual:pwa-register yazmadan da çalışır) // Kayıt kodunu otomatik enjekte et (virtual:pwa-register yazmadan da çalışır)
@ -27,7 +28,7 @@ export default defineConfig(async ({ mode }) => {
// Dev ortamında SW'yi aç, prod'da kapalı tut (build edilmiş SW prod'da zaten aktif olur) // Dev ortamında SW'yi aç, prod'da kapalı tut (build edilmiş SW prod'da zaten aktif olur)
devOptions: { devOptions: {
enabled: mode !== 'production', enabled: mode !== 'production',
type: 'module' // Modern module worker kullan type: 'module', // Modern module worker kullan
}, },
workbox: { workbox: {
@ -39,13 +40,15 @@ export default defineConfig(async ({ mode }) => {
skipWaiting: true, skipWaiting: true,
// Eski workbox cache'lerini temizle // Eski workbox cache'lerini temizle
cleanupOutdatedCaches: true, cleanupOutdatedCaches: mode === 'production',
// SPA fallback'i API çağrılarına uygulama // SPA fallback'i API çağrılarına uygulama
navigateFallbackDenylist: [/^\/api\//], navigateFallbackDenylist: [/^\/api\//],
// ⭐⭐ BU KISMI EKLEYİN: Cache sorununu çözecek runtime caching // ⭐⭐ BU KISMI EKLEYİN: Cache sorununu çözecek runtime caching
runtimeCaching: [ runtimeCaching:
mode === 'production'
? [
{ {
urlPattern: /\.(?:js|css|html|json)$/, urlPattern: /\.(?:js|css|html|json)$/,
handler: 'NetworkFirst', handler: 'NetworkFirst',
@ -53,12 +56,12 @@ export default defineConfig(async ({ mode }) => {
cacheName: 'static-resources', cacheName: 'static-resources',
expiration: { expiration: {
maxEntries: 50, maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60 // 24 saat maxAgeSeconds: 24 * 60 * 60, // 24 saat
}, },
cacheableResponse: { cacheableResponse: {
statuses: [0, 200] statuses: [0, 200],
} },
} },
}, },
{ {
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/, urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/,
@ -67,15 +70,16 @@ export default defineConfig(async ({ mode }) => {
cacheName: 'images', cacheName: 'images',
expiration: { expiration: {
maxEntries: 100, maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 gün maxAgeSeconds: 30 * 24 * 60 * 60, // 30 gün
} },
} },
} },
], ]
: [],
// ⭐ YENİ EKLENEN: Additional navigation route for SPA // ⭐ YENİ EKLENEN: Additional navigation route for SPA
navigateFallback: '/index.html', navigateFallback: '/index.html',
navigateFallbackAllowlist: [/^(?!\/__).*/] navigateFallbackAllowlist: [/^(?!\/__).*/],
}, },
manifest: { manifest: {
@ -89,25 +93,26 @@ export default defineConfig(async ({ mode }) => {
src: '/img/logo/logo-400.png', src: '/img/logo/logo-400.png',
sizes: '400x400', sizes: '400x400',
type: 'image/png', type: 'image/png',
purpose: 'any maskable' purpose: 'any maskable',
}, },
{ {
src: '/img/logo/logo-192.png', src: '/img/logo/logo-192.png',
sizes: '192x192', sizes: '192x192',
type: 'image/png', type: 'image/png',
purpose: 'any maskable' purpose: 'any maskable',
}, },
{ {
src: '/img/logo/logo-512.png', src: '/img/logo/logo-512.png',
sizes: '512x512', sizes: '512x512',
type: 'image/png', type: 'image/png',
purpose: 'any maskable' purpose: 'any maskable',
} },
], ],
categories: ['business', 'productivity'], categories: ['business', 'productivity'],
description: 'Sözsoft Kurs Platform Application' description: 'Sözsoft Kurs Platform Application',
}, },
}), })
: null,
], ],
server: { server: {
@ -116,8 +121,8 @@ export default defineConfig(async ({ mode }) => {
// ⭐ YENİ EKLENEN: Hot reload için polling // ⭐ YENİ EKLENEN: Hot reload için polling
watch: { watch: {
usePolling: true, usePolling: true,
interval: 1000 interval: 1000,
} },
}, },
assetsInclude: ['**/*.md'], assetsInclude: ['**/*.md'],
@ -133,7 +138,14 @@ export default defineConfig(async ({ mode }) => {
build: { build: {
outDir: 'dist', outDir: 'dist',
sourcemap: false, sourcemap: false,
emptyOutDir: true, // ✅ Build öncesi otomatik temizlik emptyOutDir: true,
rollupOptions: {
output: {
entryFileNames: `assets/[name].[hash].js`,
chunkFileNames: `assets/[name].[hash].js`,
assetFileNames: `assets/[name].[hash].[ext]`,
},
},
}, },
preview: { preview: {
@ -147,9 +159,9 @@ export default defineConfig(async ({ mode }) => {
define: { define: {
'process.env': {}, 'process.env': {},
// ⭐ YENİ EKLENEN: Version tracking için global değişkenler // ⭐ YENİ EKLENEN: Version tracking için global değişkenler
__APP_VERSION__: JSON.stringify(process.env.npm_package_version || '1.0.0'), __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || '1.0.0'),
__BUILD_DATE__: JSON.stringify(new Date().toISOString()), __BUILD_DATE__: JSON.stringify(new Date().toISOString()),
__APP_MODE__: JSON.stringify(mode) __APP_MODE__: JSON.stringify(mode),
}, },
} }
}) })