@actuate-media/cms-admin 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/views/seo-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-settings.render.test.js +152 -0
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -0
- package/dist/__tests__/views/settings-save-footer.render.test.js +16 -9
- package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/MediaPickerModal.d.ts +15 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +17 -1
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/views/Settings.js +2 -2
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/SeoSettingsTab.d.ts +7 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -0
- package/dist/views/settings/SeoSettingsTab.js +126 -0
- package/dist/views/settings/SeoSettingsTab.js.map +1 -0
- package/dist/views/settings/useSeoSettings.d.ts +79 -0
- package/dist/views/settings/useSeoSettings.d.ts.map +1 -0
- package/dist/views/settings/useSeoSettings.js +278 -0
- package/dist/views/settings/useSeoSettings.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/views/seo-settings.render.test.tsx +219 -0
- package/src/__tests__/views/settings-save-footer.render.test.tsx +16 -9
- package/src/components/MediaPickerModal.tsx +39 -2
- package/src/views/Settings.tsx +2 -2
- package/src/views/settings/SeoSettingsTab.tsx +658 -0
- package/src/views/settings/useSeoSettings.ts +374 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { cmsApi } from '../../lib/api.js'
|
|
5
|
+
import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Settings → SEO ("Meta Defaults / Verification / Sitemap") form state.
|
|
9
|
+
*
|
|
10
|
+
* Single source of truth: every field reads from and writes back to the SAME
|
|
11
|
+
* shared SEO config service used by Settings → General and the /seo workspace.
|
|
12
|
+
* - meta defaults + verification → `/seo/config` (`site` overrides)
|
|
13
|
+
* - sitemap auto-generate / ping → `/seo/crawl-settings` (`module`)
|
|
14
|
+
* - sitemap status (read-only) → `/seo/sitemap/status`
|
|
15
|
+
*
|
|
16
|
+
* It deliberately does NOT own site URL or the document robots defaults — those
|
|
17
|
+
* are owned by Settings → General (also via `/seo/config`) and must not be
|
|
18
|
+
* duplicated here.
|
|
19
|
+
*/
|
|
20
|
+
export interface SeoSettingsForm {
|
|
21
|
+
/** Site-wide title template (`%page% — %site%`), applied when a page has none. */
|
|
22
|
+
titleTemplate: string
|
|
23
|
+
/** Fallback meta description when a page/post defines none. */
|
|
24
|
+
defaultMetaDescription: string
|
|
25
|
+
/**
|
|
26
|
+
* Fallback social (Open Graph / Twitter) image URL. Kept as a denormalized
|
|
27
|
+
* cache of the selected asset's public URL so the synchronous public renderer
|
|
28
|
+
* doesn't need a DB lookup. Empty = use the dynamic /og.png fallback.
|
|
29
|
+
*/
|
|
30
|
+
defaultOgImage: string
|
|
31
|
+
/** Canonical reference to the selected media asset (source of truth). */
|
|
32
|
+
defaultSocialImageAssetId: string
|
|
33
|
+
googleVerification: string
|
|
34
|
+
bingVerification: string
|
|
35
|
+
yandexVerification: string
|
|
36
|
+
pinterestVerification: string
|
|
37
|
+
facebookDomainVerification: string
|
|
38
|
+
/** Rebuild the sitemap whenever content is published (`module`). */
|
|
39
|
+
autoGenerateSitemap: boolean
|
|
40
|
+
/** Notify Google/Bing when the sitemap changes (`module`). */
|
|
41
|
+
pingOnPublish: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SitemapSummary {
|
|
45
|
+
sitemapUrl: string
|
|
46
|
+
lastGeneratedAt: string | null
|
|
47
|
+
pagesIncluded: number
|
|
48
|
+
pagesExcluded: number
|
|
49
|
+
warnings: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EMPTY_FORM: SeoSettingsForm = {
|
|
53
|
+
titleTemplate: '',
|
|
54
|
+
defaultMetaDescription: '',
|
|
55
|
+
defaultOgImage: '',
|
|
56
|
+
defaultSocialImageAssetId: '',
|
|
57
|
+
googleVerification: '',
|
|
58
|
+
bingVerification: '',
|
|
59
|
+
yandexVerification: '',
|
|
60
|
+
pinterestVerification: '',
|
|
61
|
+
facebookDomainVerification: '',
|
|
62
|
+
autoGenerateSitemap: true,
|
|
63
|
+
pingOnPublish: true,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Recommended max length for the default meta description (SERP truncation). */
|
|
67
|
+
export const META_DESCRIPTION_MAX = 160
|
|
68
|
+
/** Below this, the description is likely too thin to be useful. */
|
|
69
|
+
export const META_DESCRIPTION_MIN = 70
|
|
70
|
+
|
|
71
|
+
// Mirror of cms-core's `SITE_TITLE_TOKENS` (kept tiny + stable so the client can
|
|
72
|
+
// validate without importing the full cms-core module graph). The server
|
|
73
|
+
// re-validates on save via `validateTitleTemplate`, so this is a UX aid only.
|
|
74
|
+
const SITE_TITLE_TOKENS = ['page', 'site', 'type', 'separator', 'author', 'date', 'category', 'tag']
|
|
75
|
+
const TOKEN_RE = /%(\w+)%/g
|
|
76
|
+
|
|
77
|
+
export function validateTitleTemplateClient(template: string): {
|
|
78
|
+
valid: boolean
|
|
79
|
+
unknownTokens: string[]
|
|
80
|
+
} {
|
|
81
|
+
if (!template || template.trim() === '') return { valid: true, unknownTokens: [] }
|
|
82
|
+
const known = new Set(SITE_TITLE_TOKENS)
|
|
83
|
+
const unknown: string[] = []
|
|
84
|
+
let hasKnown = false
|
|
85
|
+
for (const m of template.matchAll(TOKEN_RE)) {
|
|
86
|
+
const t = m[1]!
|
|
87
|
+
if (known.has(t)) hasKnown = true
|
|
88
|
+
else if (!unknown.includes(t)) unknown.push(t)
|
|
89
|
+
}
|
|
90
|
+
const literal = template.replace(TOKEN_RE, '').trim()
|
|
91
|
+
if (unknown.length > 0) return { valid: false, unknownTokens: unknown }
|
|
92
|
+
if (!hasKnown && literal === '') return { valid: false, unknownTokens: [] }
|
|
93
|
+
return { valid: true, unknownTokens: [] }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validate(form: SeoSettingsForm): ValidationIssue[] {
|
|
97
|
+
const issues: ValidationIssue[] = []
|
|
98
|
+
const tpl = validateTitleTemplateClient(form.titleTemplate)
|
|
99
|
+
if (!tpl.valid) {
|
|
100
|
+
issues.push({
|
|
101
|
+
field: 'titleTemplate',
|
|
102
|
+
message:
|
|
103
|
+
tpl.unknownTokens.length > 0
|
|
104
|
+
? `Unsupported token${tpl.unknownTokens.length > 1 ? 's' : ''}: ${tpl.unknownTokens
|
|
105
|
+
.map((t) => `%${t}%`)
|
|
106
|
+
.join(', ')}`
|
|
107
|
+
: 'Template must include at least one token or some literal text.',
|
|
108
|
+
severity: 'error',
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
const desc = form.defaultMetaDescription.trim()
|
|
112
|
+
if (desc.length > META_DESCRIPTION_MAX) {
|
|
113
|
+
issues.push({
|
|
114
|
+
field: 'defaultMetaDescription',
|
|
115
|
+
message: `Default description is ${desc.length} characters — search engines truncate around ${META_DESCRIPTION_MAX}.`,
|
|
116
|
+
severity: 'warning',
|
|
117
|
+
})
|
|
118
|
+
} else if (desc.length > 0 && desc.length < META_DESCRIPTION_MIN) {
|
|
119
|
+
issues.push({
|
|
120
|
+
field: 'defaultMetaDescription',
|
|
121
|
+
message: `Default description is short (${desc.length} characters). Aim for ${META_DESCRIPTION_MIN}–${META_DESCRIPTION_MAX}.`,
|
|
122
|
+
severity: 'warning',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
return issues
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface SeoConfigResponse {
|
|
129
|
+
static?: { site?: Record<string, any> | null }
|
|
130
|
+
overrides?: { site?: Record<string, any> | null } | null
|
|
131
|
+
effective?: { site?: Record<string, any> | null }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type SaveState = 'idle' | 'saving' | 'success' | 'error'
|
|
135
|
+
|
|
136
|
+
export interface UseSeoSettings {
|
|
137
|
+
loading: boolean
|
|
138
|
+
loadError: string | null
|
|
139
|
+
form: SeoSettingsForm
|
|
140
|
+
setField: <K extends keyof SeoSettingsForm>(key: K, value: SeoSettingsForm[K]) => void
|
|
141
|
+
dirty: boolean
|
|
142
|
+
issues: ValidationIssue[]
|
|
143
|
+
hasErrors: boolean
|
|
144
|
+
sources: Record<string, SettingsSourceMetadata>
|
|
145
|
+
/** Effective site name (read-only here; edited in General/advanced) for preview. */
|
|
146
|
+
siteName: string
|
|
147
|
+
/** Effective site URL for preview + warnings. */
|
|
148
|
+
siteUrl: string
|
|
149
|
+
isProduction: boolean
|
|
150
|
+
sitemap: SitemapSummary | null
|
|
151
|
+
saveState: SaveState
|
|
152
|
+
saveError: string | null
|
|
153
|
+
save: () => Promise<void>
|
|
154
|
+
reset: () => void
|
|
155
|
+
refetch: () => void
|
|
156
|
+
regenerateSitemap: () => Promise<void>
|
|
157
|
+
regenerating: boolean
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function useSeoSettings(canEdit: boolean): UseSeoSettings {
|
|
161
|
+
const [loading, setLoading] = useState(true)
|
|
162
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
163
|
+
const [baseline, setBaseline] = useState<SeoSettingsForm>(EMPTY_FORM)
|
|
164
|
+
const [form, setForm] = useState<SeoSettingsForm>(EMPTY_FORM)
|
|
165
|
+
const [overrideSite, setOverrideSite] = useState<Record<string, any>>({})
|
|
166
|
+
const [sources, setSources] = useState<Record<string, SettingsSourceMetadata>>({})
|
|
167
|
+
const [siteName, setSiteName] = useState('')
|
|
168
|
+
const [siteUrl, setSiteUrl] = useState('')
|
|
169
|
+
const [isProduction, setIsProduction] = useState(false)
|
|
170
|
+
const [sitemap, setSitemap] = useState<SitemapSummary | null>(null)
|
|
171
|
+
const [saveState, setSaveState] = useState<SaveState>('idle')
|
|
172
|
+
const [saveError, setSaveError] = useState<string | null>(null)
|
|
173
|
+
const [regenerating, setRegenerating] = useState(false)
|
|
174
|
+
const [reloadKey, setReloadKey] = useState(0)
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
let cancelled = false
|
|
178
|
+
setLoading(true)
|
|
179
|
+
setLoadError(null)
|
|
180
|
+
Promise.all([
|
|
181
|
+
cmsApi<SeoConfigResponse>('/seo/config'),
|
|
182
|
+
cmsApi<Record<string, any>>('/seo/crawl-settings'),
|
|
183
|
+
cmsApi<SitemapSummary>('/seo/sitemap/status'),
|
|
184
|
+
cmsApi<{ environment?: string }>('/seo/index-status'),
|
|
185
|
+
])
|
|
186
|
+
.then(([seoRes, crawlRes, sitemapRes, statusRes]) => {
|
|
187
|
+
if (cancelled) return
|
|
188
|
+
if (seoRes.error && seoRes.status !== 404) setLoadError(seoRes.error)
|
|
189
|
+
|
|
190
|
+
const seo = seoRes.data ?? {}
|
|
191
|
+
const effectiveSite = seo.effective?.site ?? {}
|
|
192
|
+
const staticSite = seo.static?.site ?? {}
|
|
193
|
+
const overSite = seo.overrides?.site ?? {}
|
|
194
|
+
const verification = effectiveSite.verification ?? {}
|
|
195
|
+
const crawl = crawlRes.data ?? {}
|
|
196
|
+
|
|
197
|
+
const loaded: SeoSettingsForm = {
|
|
198
|
+
titleTemplate: String(effectiveSite.titleTemplate ?? ''),
|
|
199
|
+
defaultMetaDescription: String(effectiveSite.defaultMetaDescription ?? ''),
|
|
200
|
+
defaultOgImage: String(effectiveSite.defaultOgImage ?? ''),
|
|
201
|
+
defaultSocialImageAssetId: String(effectiveSite.defaultSocialImageAssetId ?? ''),
|
|
202
|
+
googleVerification: String(verification.googleSearchConsole ?? ''),
|
|
203
|
+
bingVerification: String(verification.bingWebmasterTools ?? ''),
|
|
204
|
+
yandexVerification: String(verification.yandex ?? ''),
|
|
205
|
+
pinterestVerification: String(verification.pinterest ?? ''),
|
|
206
|
+
facebookDomainVerification: String(verification.facebookDomain ?? ''),
|
|
207
|
+
autoGenerateSitemap: crawl.autoRegenerateSitemapOnPublish !== false,
|
|
208
|
+
pingOnPublish: crawl.pingSearchEnginesOnPublish !== false,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// `editable` reflects the field's MANAGEMENT source (config vs database),
|
|
212
|
+
// not the user's role — role gates the inputs via `canEdit`. This keeps
|
|
213
|
+
// the source badge (and the field's accessible label) stable across
|
|
214
|
+
// roles, and only surfaces it when a value is locked by config.
|
|
215
|
+
const configManaged = (key: string): SettingsSourceMetadata =>
|
|
216
|
+
staticSite[key]
|
|
217
|
+
? { source: 'config', editable: false, reason: 'Set in actuate.config.ts' }
|
|
218
|
+
: { source: 'database', editable: true }
|
|
219
|
+
|
|
220
|
+
setSources({
|
|
221
|
+
titleTemplate: configManaged('titleTemplate'),
|
|
222
|
+
defaultMetaDescription: configManaged('defaultMetaDescription'),
|
|
223
|
+
defaultOgImage: configManaged('defaultOgImage'),
|
|
224
|
+
verification: { source: 'database', editable: true },
|
|
225
|
+
sitemap: { source: 'database', editable: true },
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
setBaseline(loaded)
|
|
229
|
+
setForm(loaded)
|
|
230
|
+
setOverrideSite(overSite ?? {})
|
|
231
|
+
setSiteName(String(effectiveSite.siteName ?? ''))
|
|
232
|
+
setSiteUrl(String(effectiveSite.siteUrl ?? ''))
|
|
233
|
+
if (statusRes.data?.environment)
|
|
234
|
+
setIsProduction(statusRes.data.environment === 'production')
|
|
235
|
+
if (!sitemapRes.error && sitemapRes.data) setSitemap(sitemapRes.data)
|
|
236
|
+
})
|
|
237
|
+
.catch(() => {
|
|
238
|
+
if (!cancelled) setLoadError('Failed to load SEO settings.')
|
|
239
|
+
})
|
|
240
|
+
.finally(() => {
|
|
241
|
+
if (!cancelled) setLoading(false)
|
|
242
|
+
})
|
|
243
|
+
return () => {
|
|
244
|
+
cancelled = true
|
|
245
|
+
}
|
|
246
|
+
}, [reloadKey, canEdit])
|
|
247
|
+
|
|
248
|
+
const setField = useCallback(
|
|
249
|
+
<K extends keyof SeoSettingsForm>(key: K, value: SeoSettingsForm[K]) => {
|
|
250
|
+
setForm((prev) => ({ ...prev, [key]: value }))
|
|
251
|
+
setSaveState('idle')
|
|
252
|
+
},
|
|
253
|
+
[],
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
const dirty = useMemo(
|
|
257
|
+
() => (Object.keys(form) as Array<keyof SeoSettingsForm>).some((k) => form[k] !== baseline[k]),
|
|
258
|
+
[form, baseline],
|
|
259
|
+
)
|
|
260
|
+
const issues = useMemo(() => validate(form), [form])
|
|
261
|
+
const hasErrors = issues.some((i) => i.severity === 'error')
|
|
262
|
+
|
|
263
|
+
const refetchSitemap = useCallback(async () => {
|
|
264
|
+
const res = await cmsApi<SitemapSummary>('/seo/sitemap/status')
|
|
265
|
+
if (!res.error && res.data) setSitemap(res.data)
|
|
266
|
+
}, [])
|
|
267
|
+
|
|
268
|
+
const save = useCallback(async () => {
|
|
269
|
+
if (!canEdit || hasErrors) return
|
|
270
|
+
setSaveState('saving')
|
|
271
|
+
setSaveError(null)
|
|
272
|
+
|
|
273
|
+
const siteChanged =
|
|
274
|
+
form.titleTemplate !== baseline.titleTemplate ||
|
|
275
|
+
form.defaultMetaDescription !== baseline.defaultMetaDescription ||
|
|
276
|
+
form.defaultOgImage !== baseline.defaultOgImage ||
|
|
277
|
+
form.defaultSocialImageAssetId !== baseline.defaultSocialImageAssetId ||
|
|
278
|
+
form.googleVerification !== baseline.googleVerification ||
|
|
279
|
+
form.bingVerification !== baseline.bingVerification ||
|
|
280
|
+
form.yandexVerification !== baseline.yandexVerification ||
|
|
281
|
+
form.pinterestVerification !== baseline.pinterestVerification ||
|
|
282
|
+
form.facebookDomainVerification !== baseline.facebookDomainVerification
|
|
283
|
+
const sitemapChanged =
|
|
284
|
+
form.autoGenerateSitemap !== baseline.autoGenerateSitemap ||
|
|
285
|
+
form.pingOnPublish !== baseline.pingOnPublish
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
// 1. Meta defaults + verification → shared /seo/config (merge over the
|
|
289
|
+
// current override site so General's siteUrl/robots aren't wiped).
|
|
290
|
+
if (siteChanged) {
|
|
291
|
+
const nextSite: Record<string, any> = {
|
|
292
|
+
...overrideSite,
|
|
293
|
+
titleTemplate: form.titleTemplate.trim() || undefined,
|
|
294
|
+
defaultMetaDescription: form.defaultMetaDescription.trim() || undefined,
|
|
295
|
+
defaultOgImage: form.defaultOgImage.trim() || undefined,
|
|
296
|
+
defaultSocialImageAssetId: form.defaultSocialImageAssetId.trim() || undefined,
|
|
297
|
+
verification: {
|
|
298
|
+
...(overrideSite.verification ?? {}),
|
|
299
|
+
googleSearchConsole: form.googleVerification.trim() || undefined,
|
|
300
|
+
bingWebmasterTools: form.bingVerification.trim() || undefined,
|
|
301
|
+
yandex: form.yandexVerification.trim() || undefined,
|
|
302
|
+
pinterest: form.pinterestVerification.trim() || undefined,
|
|
303
|
+
facebookDomain: form.facebookDomainVerification.trim() || undefined,
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
const res = await cmsApi('/seo/config', {
|
|
307
|
+
method: 'PUT',
|
|
308
|
+
body: JSON.stringify({ site: nextSite }),
|
|
309
|
+
})
|
|
310
|
+
if (res.error) throw new Error(res.error)
|
|
311
|
+
setOverrideSite(nextSite)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 2. Sitemap toggles → shared /seo/crawl-settings (`module`).
|
|
315
|
+
if (sitemapChanged) {
|
|
316
|
+
const res = await cmsApi('/seo/crawl-settings', {
|
|
317
|
+
method: 'PUT',
|
|
318
|
+
body: JSON.stringify({
|
|
319
|
+
autoRegenerateSitemapOnPublish: form.autoGenerateSitemap,
|
|
320
|
+
pingSearchEnginesOnPublish: form.pingOnPublish,
|
|
321
|
+
}),
|
|
322
|
+
})
|
|
323
|
+
if (res.error) throw new Error(res.error)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
setBaseline(form)
|
|
327
|
+
setSaveState('success')
|
|
328
|
+
if (sitemapChanged) await refetchSitemap()
|
|
329
|
+
} catch (err) {
|
|
330
|
+
setSaveState('error')
|
|
331
|
+
setSaveError(err instanceof Error ? err.message : 'Failed to save SEO settings.')
|
|
332
|
+
}
|
|
333
|
+
}, [canEdit, hasErrors, form, baseline, overrideSite, refetchSitemap])
|
|
334
|
+
|
|
335
|
+
const reset = useCallback(() => {
|
|
336
|
+
setForm(baseline)
|
|
337
|
+
setSaveState('idle')
|
|
338
|
+
setSaveError(null)
|
|
339
|
+
}, [baseline])
|
|
340
|
+
|
|
341
|
+
const refetch = useCallback(() => setReloadKey((k) => k + 1), [])
|
|
342
|
+
|
|
343
|
+
const regenerateSitemap = useCallback(async () => {
|
|
344
|
+
setRegenerating(true)
|
|
345
|
+
try {
|
|
346
|
+
const res = await cmsApi<SitemapSummary>('/seo/sitemap/regenerate', { method: 'POST' })
|
|
347
|
+
if (!res.error && res.data) setSitemap((prev) => ({ ...(prev ?? res.data!), ...res.data! }))
|
|
348
|
+
} finally {
|
|
349
|
+
setRegenerating(false)
|
|
350
|
+
}
|
|
351
|
+
}, [])
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
loading,
|
|
355
|
+
loadError,
|
|
356
|
+
form,
|
|
357
|
+
setField,
|
|
358
|
+
dirty,
|
|
359
|
+
issues,
|
|
360
|
+
hasErrors,
|
|
361
|
+
sources,
|
|
362
|
+
siteName,
|
|
363
|
+
siteUrl,
|
|
364
|
+
isProduction,
|
|
365
|
+
sitemap,
|
|
366
|
+
saveState,
|
|
367
|
+
saveError,
|
|
368
|
+
save,
|
|
369
|
+
reset,
|
|
370
|
+
refetch,
|
|
371
|
+
regenerateSitemap,
|
|
372
|
+
regenerating,
|
|
373
|
+
}
|
|
374
|
+
}
|