@terrafirmagreg-team/discussions 0.0.0-stage → 1.2.1

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/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@terrafirmagreg-team/discussions",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.2.1",
4
+ "type": "module",
5
+ "description": "GitHub Discussions comments and feedback",
6
+ "license": "LGPL-3.0-only",
7
+ "exports": {
8
+ "./lib": "./src/lib/index.ts",
9
+ "./lib/*": [
10
+ "./src/lib/*.ts",
11
+ "./src/lib/*/index.ts"
12
+ ],
13
+ "./components/*": [
14
+ "./src/components/*.tsx",
15
+ "./src/components/*/index.tsx",
16
+ "./src/components/*/index.ts"
17
+ ],
18
+ "./hooks/*": [
19
+ "./src/hooks/*.ts",
20
+ "./src/hooks/*/index.ts"
21
+ ],
22
+ "./locales": "./src/locales.ts"
23
+ },
24
+ "dependencies": {
25
+ "@floating-ui/react-dom": "^2.1.9",
26
+ "@fuma-comment/react": "1.6.0",
27
+ "@fuma-comment/server": "1.6.0",
28
+ "@terrafirmagreg-team/auth": "1.2.1",
29
+ "@terrafirmagreg-team/data": "1.2.1",
30
+ "@terrafirmagreg-team/translate": "1.2.1",
31
+ "@terrafirmagreg-team/ui": "1.2.1",
32
+ "class-variance-authority": "^0.7.1",
33
+ "fuma-comment-github-discussions": "1.1.1",
34
+ "lucide-react": "^0.577.0",
35
+ "server-only": "^0.0.1",
36
+ "swr": "^2.4.2",
37
+ "zod": "4.6.7"
38
+ },
39
+ "peerDependencies": {
40
+ "next": ">=15",
41
+ "react": ">=19"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^26.6.5",
45
+ "@types/react": "^19.3.0",
46
+ "@types/react-dom": "^19.3.0"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/TerraFirmaGreg-Team/Web-Foundation.git",
51
+ "directory": "packages/discussions"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "files": [
57
+ "src"
58
+ ]
6
59
  }
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+
3
+ import { Comments as FumaComments } from '@fuma-comment/react';
4
+ import { useCommentsEnabled } from '../hooks/comments/use-enabled';
5
+ import { usePageThread } from '../hooks/comments/use-thread';
6
+ import { redirectToCommentsLogin } from '../lib/comments/auth';
7
+
8
+ export function Comments({
9
+ page,
10
+ apiUrl = '/api/comments',
11
+ }: {
12
+ page?: string;
13
+ apiUrl?: string;
14
+ }) {
15
+ const enabled = useCommentsEnabled(`${apiUrl}/config`);
16
+ const thread = usePageThread(page);
17
+ if (!enabled) return null;
18
+ return (
19
+ <div className="not-prose mt-8">
20
+ <FumaComments
21
+ page={thread}
22
+ apiUrl={apiUrl}
23
+ auth={{
24
+ type: 'api',
25
+ signIn: () => redirectToCommentsLogin(apiUrl),
26
+ }}
27
+ mention={{ enabled: true }}
28
+ />
29
+ </div>
30
+ );
31
+ }
32
+
@@ -0,0 +1,8 @@
1
+ import { commentsView, type CommentsViewProps } from '../lib/comments';
2
+
3
+ export function CommentsSlot(props: CommentsViewProps) {
4
+ const View = commentsView();
5
+ // eslint-disable-next-line react-hooks/static-components
6
+ return View ? <View {...props} /> : null;
7
+ }
8
+
@@ -0,0 +1,366 @@
1
+ 'use client';
2
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
3
+ import { injectHighlightStyle } from '@terrafirmagreg-team/ui/lib/utils/highlight-style';
4
+ import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/react-dom';
5
+ import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
6
+ import { CornerDownRightIcon } from 'lucide-react';
7
+ import {
8
+ type CSSProperties,
9
+ type HTMLAttributes,
10
+ type ReactNode,
11
+ type SyntheticEvent,
12
+ useEffect,
13
+ useEffectEvent,
14
+ useRef,
15
+ useState,
16
+ useTransition,
17
+ } from 'react';
18
+ import {
19
+ actionResponse,
20
+ blockFeedback,
21
+ type ActionResponse,
22
+ type BlockFeedback,
23
+ } from '../../lib/feedback-schema';
24
+ import { z } from 'zod/mini';
25
+ import { usePathname } from 'next/navigation';
26
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
27
+ import { useSubmissionStorage } from '../../hooks/feedback/use-submission-storage';
28
+
29
+ const blockFeedbackResult = z.extend(blockFeedback, {
30
+ response: actionResponse,
31
+ });
32
+
33
+ export interface FeedbackTextProps {
34
+ onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
35
+ children?: ReactNode;
36
+ }
37
+
38
+ export function FeedbackText({ onSendAction, children }: FeedbackTextProps) {
39
+ const [popup, _setPopup] = useState<{
40
+ mode: 'tooltip' | 'expanded';
41
+ blockId: string;
42
+ selection: string;
43
+ range: Range;
44
+ } | null>(null);
45
+
46
+ const containerRef = useRef<HTMLDivElement>(null);
47
+ const { refs, floatingStyles } = useFloating({
48
+ open: popup !== null,
49
+ placement: 'bottom',
50
+ middleware: [offset(6), flip(), shift({ padding: 8 })],
51
+ whileElementsMounted: autoUpdate,
52
+ });
53
+
54
+ function expandPopup() {
55
+ if (popup?.mode !== 'tooltip') return;
56
+
57
+ const highlight = new Highlight(popup.range);
58
+ CSS.highlights.set('fd-feedback-text', highlight);
59
+ injectHighlightStyle(
60
+ 'fd-feedback-text',
61
+ 'background-color:var(--color-fd-primary);color:var(--color-fd-primary-foreground)',
62
+ );
63
+
64
+ _setPopup({ ...popup, mode: 'expanded' });
65
+ }
66
+
67
+ function closePopup() {
68
+ if (popup?.mode === 'expanded') {
69
+ CSS.highlights.delete('fd-feedback-text');
70
+ }
71
+
72
+ _setPopup(null);
73
+ }
74
+
75
+ const updateSelectionPopover = useEffectEvent(() => {
76
+ if (popup && popup.mode === 'expanded') return;
77
+
78
+ const container = containerRef.current;
79
+ const selection = window.getSelection();
80
+
81
+ if (!container || !selection || selection.isCollapsed || selection.rangeCount === 0) {
82
+ closePopup();
83
+ return;
84
+ }
85
+
86
+ const range = selection.getRangeAt(0).cloneRange();
87
+ if (!container.contains(range.commonAncestorContainer)) {
88
+ closePopup();
89
+ return;
90
+ }
91
+
92
+ const selectionText = selection.toString().trim();
93
+ if (selectionText.length === 0 || selectionText.includes('\n')) {
94
+ closePopup();
95
+ return;
96
+ }
97
+
98
+ const element =
99
+ range.startContainer instanceof Element
100
+ ? range.startContainer
101
+ : range.startContainer.parentElement;
102
+ const blockId = element?.closest('[data-block="feedback"]')?.id;
103
+ if (!blockId) {
104
+ closePopup();
105
+ return;
106
+ }
107
+
108
+ refs.setReference({
109
+ getBoundingClientRect() {
110
+ return range.getBoundingClientRect();
111
+ },
112
+ contextElement: container,
113
+ });
114
+
115
+ _setPopup({ mode: 'tooltip', range, selection: selectionText, blockId });
116
+ });
117
+
118
+ const closeOnEscape = useEffectEvent((event: KeyboardEvent) => {
119
+ if (popup === null) return;
120
+ if (event.key === 'Escape') closePopup();
121
+ });
122
+
123
+ const closeOnPointerDown = useEffectEvent((event: PointerEvent) => {
124
+ const target = event.target;
125
+ if (popup === null || !(target instanceof Node)) return;
126
+
127
+ if (
128
+ refs.floating.current?.contains(target) ||
129
+ (popup.mode === 'tooltip' && containerRef.current?.contains(target))
130
+ ) {
131
+ return;
132
+ }
133
+
134
+ closePopup();
135
+ });
136
+
137
+ useEffect(() => {
138
+ let frame: number | null = null;
139
+
140
+ function scheduleSelectionPopover() {
141
+ if (frame !== null) window.cancelAnimationFrame(frame);
142
+
143
+ frame = window.requestAnimationFrame(() => {
144
+ frame = null;
145
+ updateSelectionPopover();
146
+ });
147
+ }
148
+
149
+ document.addEventListener('selectionchange', scheduleSelectionPopover);
150
+ document.addEventListener('keydown', closeOnEscape);
151
+ document.addEventListener('pointerdown', closeOnPointerDown);
152
+
153
+ return () => {
154
+ document.removeEventListener('keydown', closeOnEscape);
155
+ document.removeEventListener('pointerdown', closeOnPointerDown);
156
+ document.removeEventListener('selectionchange', scheduleSelectionPopover);
157
+ if (frame !== null) window.cancelAnimationFrame(frame);
158
+ };
159
+ }, []);
160
+
161
+ return (
162
+ <>
163
+ <div
164
+ ref={containerRef}
165
+ className="prose-no-margin"
166
+ >
167
+ {children}
168
+ </div>
169
+
170
+ {popup && (
171
+ <FeedbackTextPopup
172
+ popup={popup}
173
+ refs={refs}
174
+ floatingStyles={floatingStyles}
175
+ expandPopup={expandPopup}
176
+ closePopup={closePopup}
177
+ onSendAction={onSendAction}
178
+ />
179
+ )}
180
+ </>
181
+ );
182
+ }
183
+
184
+ function FeedbackTextPopup({
185
+ popup,
186
+ refs,
187
+ floatingStyles,
188
+ expandPopup,
189
+ closePopup,
190
+ onSendAction,
191
+ }: {
192
+ popup: {
193
+ mode: 'tooltip' | 'expanded';
194
+ blockId: string;
195
+ selection: string;
196
+ range: Range;
197
+ };
198
+ refs: ReturnType<typeof useFloating>['refs'];
199
+ floatingStyles: CSSProperties;
200
+ expandPopup: () => void;
201
+ closePopup: () => void;
202
+ onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
203
+ }) {
204
+ const t = useTranslations();
205
+ return (
206
+ <div
207
+ ref={(el) => refs.setFloating(el)}
208
+ className={cn(
209
+ 'not-prose z-40 text-sm bg-fd-popover text-fd-popover-foreground border overflow-hidden shadow-lg rounded-xl w-30 h-9.5 box-content transition-[width,height]',
210
+ popup.mode === 'expanded' ? 'w-[300px] h-32 max-w-[98vw]' : 'select-none',
211
+ )}
212
+ style={floatingStyles}
213
+ >
214
+ {popup.mode === 'tooltip' ? (
215
+ <div className="w-30 h-9.5 p-1">
216
+ <button
217
+ className={cn(
218
+ buttonVariants({ variant: 'ghost', size: 'sm' }),
219
+ 'size-full gap-1.5',
220
+ )}
221
+ onClick={expandPopup}
222
+ >
223
+ <CornerDownRightIcon className="size-4 text-fd-muted-foreground" />
224
+ {t('Feedback', { note: 'ui.feedback.tooltip' })}
225
+ </button>
226
+ </div>
227
+ ) : (
228
+ <FeedbackTextForm
229
+ blockId={popup.blockId}
230
+ selection={popup.selection}
231
+ onSendAction={onSendAction}
232
+ onClose={closePopup}
233
+ container={{ className: 'p-2 w-[300px] h-32 max-w-[98vw] animate-fd-fade-in' }}
234
+ />
235
+ )}
236
+ </div>
237
+ );
238
+ }
239
+
240
+ function FeedbackTextForm({
241
+ blockId,
242
+ selection,
243
+ onSendAction,
244
+ onClose,
245
+ container,
246
+ }: {
247
+ container: HTMLAttributes<HTMLElement>;
248
+ blockId: string;
249
+ selection: string;
250
+ onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
251
+ onClose: () => void;
252
+ }) {
253
+ const t = useTranslations();
254
+ const pathname = usePathname();
255
+ const { previous, setPrevious } = useSubmissionStorage(`${pathname}-${blockId}`, (v) => {
256
+ const result = blockFeedbackResult.safeParse(v);
257
+ if (result.success) return result.data;
258
+ return null;
259
+ });
260
+ const [message, setMessage] = useState('');
261
+ const [isPending, startTransition] = useTransition();
262
+
263
+ function submit(e?: SyntheticEvent) {
264
+ startTransition(async () => {
265
+ const feedback: BlockFeedback = {
266
+ blockId,
267
+ blockBody: selection,
268
+ url: location.href,
269
+ message,
270
+ };
271
+
272
+ const response = await onSendAction(feedback);
273
+ setPrevious({
274
+ response,
275
+ ...feedback,
276
+ });
277
+ setMessage('');
278
+ });
279
+
280
+ e?.preventDefault();
281
+ }
282
+
283
+ if (previous)
284
+ return (
285
+ <div
286
+ {...container}
287
+ className={cn(
288
+ 'flex flex-col items-center justify-center gap-2 text-fd-muted-foreground text-center',
289
+ container.className,
290
+ )}
291
+ >
292
+ <p>{t('Thank you for your feedback!', { note: 'ui.feedback.thanks' })}</p>
293
+ <div className="flex flex-row items-center gap-2">
294
+ <a
295
+ href={previous.response?.githubUrl}
296
+ rel="noreferrer noopener"
297
+ target="_blank"
298
+ className={cn(
299
+ buttonVariants({
300
+ variant: 'default',
301
+ }),
302
+ 'text-xs',
303
+ )}
304
+ >
305
+ {t('View on GitHub', { note: 'ui.feedback.view_on_github' })}
306
+ </a>
307
+
308
+ <button
309
+ className={cn(
310
+ buttonVariants({
311
+ variant: 'secondary',
312
+ }),
313
+ 'text-xs',
314
+ )}
315
+ onClick={() => {
316
+ setPrevious(null);
317
+ }}
318
+ >
319
+ {t('Submit Again', { note: 'ui.feedback.submit_again' })}
320
+ </button>
321
+ </div>
322
+ </div>
323
+ );
324
+
325
+ return (
326
+ <form
327
+ {...container}
328
+ className={cn('flex flex-col gap-2', container.className)}
329
+ onSubmit={submit}
330
+ >
331
+ <textarea
332
+ autoFocus
333
+ required
334
+ value={message}
335
+ onChange={(e) => setMessage(e.target.value)}
336
+ className="border rounded-lg bg-fd-secondary text-fd-secondary-foreground p-3 resize-none focus-visible:outline-none placeholder:text-fd-muted-foreground"
337
+ placeholder={t('Leave your feedback…', { note: 'ui.feedback.placeholder' })}
338
+ onKeyDown={(e) => {
339
+ if (e.nativeEvent.isComposing || e.keyCode === 229) return;
340
+ if (!e.shiftKey && e.key === 'Enter') {
341
+ submit(e);
342
+ }
343
+ }}
344
+ />
345
+ <div className="grid grid-cols-2 gap-2 mt-auto">
346
+ <button
347
+ type="submit"
348
+ className={cn(buttonVariants({ variant: 'default', size: 'sm' }), 'gap-1.5')}
349
+ disabled={isPending}
350
+ >
351
+ <CornerDownRightIcon className="size-4" />
352
+ {t('Submit', { note: 'ui.feedback.submit' })}
353
+ </button>
354
+ <button
355
+ type="button"
356
+ className={cn(buttonVariants({ variant: 'secondary', size: 'sm' }), 'gap-1.5')}
357
+ disabled={isPending}
358
+ onClick={onClose}
359
+ >
360
+ {t('Close', { note: 'ui.feedback.close' })}
361
+ </button>
362
+ </div>
363
+ </form>
364
+ );
365
+ }
366
+
@@ -0,0 +1,181 @@
1
+ 'use client';
2
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
3
+ import { buttonVariants } from '@terrafirmagreg-team/ui/components/ui/button';
4
+ import { ThumbsDown, ThumbsUp } from 'lucide-react';
5
+ import {
6
+ type SyntheticEvent,
7
+ useState,
8
+ useTransition,
9
+ } from 'react';
10
+ import { Collapsible, CollapsibleContent } from '@terrafirmagreg-team/ui/components/ui/collapsible';
11
+ import { cva } from 'class-variance-authority';
12
+ import {
13
+ actionResponse,
14
+ pageFeedback,
15
+ type ActionResponse,
16
+ type PageFeedback,
17
+ } from '../../lib/feedback-schema';
18
+ import { z } from 'zod/mini';
19
+ import { usePathname } from 'next/navigation';
20
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
21
+ import { useSubmissionStorage } from '../../hooks/feedback/use-submission-storage';
22
+
23
+ const rateButtonVariants = cva(
24
+ 'inline-flex items-center gap-2 px-3 py-2 rounded-full font-medium border text-sm [&_svg]:size-4 disabled:cursor-not-allowed',
25
+ {
26
+ variants: {
27
+ active: {
28
+ true: 'bg-fd-accent text-fd-accent-foreground [&_svg]:fill-current',
29
+ false: 'text-fd-muted-foreground',
30
+ },
31
+ },
32
+ },
33
+ );
34
+
35
+ const pageFeedbackResult = z.extend(pageFeedback, {
36
+ response: actionResponse,
37
+ });
38
+
39
+ export function Feedback({
40
+ onSendAction,
41
+ }: {
42
+ onSendAction: (feedback: PageFeedback) => Promise<ActionResponse>;
43
+ }) {
44
+ const t = useTranslations();
45
+ const pathname = usePathname();
46
+ const { previous, setPrevious } = useSubmissionStorage(pathname, (v) => {
47
+ const result = pageFeedbackResult.safeParse(v);
48
+ return result.success ? result.data : null;
49
+ });
50
+ const [opinion, setOpinion] = useState<'good' | 'bad' | null>(null);
51
+ const [message, setMessage] = useState('');
52
+ const [isPending, startTransition] = useTransition();
53
+
54
+ function submit(e?: SyntheticEvent) {
55
+ if (opinion == null) return;
56
+
57
+ startTransition(async () => {
58
+ const feedback: PageFeedback = {
59
+ url: location.href,
60
+ opinion: opinion!,
61
+ message,
62
+ };
63
+
64
+ const response = await onSendAction(feedback);
65
+ setPrevious({
66
+ response,
67
+ ...feedback,
68
+ });
69
+ setMessage('');
70
+ setOpinion(null);
71
+ });
72
+
73
+ e?.preventDefault();
74
+ }
75
+
76
+ const activeOpinion = previous?.opinion ?? opinion;
77
+
78
+ return (
79
+ <Collapsible
80
+ open={opinion !== null || previous !== null}
81
+ onOpenChange={(v) => {
82
+ if (!v) setOpinion(null);
83
+ }}
84
+ className="border-y py-3"
85
+ >
86
+ <div className="flex flex-row items-center gap-2">
87
+ <p className="text-sm font-medium pe-2">{t('How is this page?', { note: 'ui.feedback.question' })}</p>
88
+ <button
89
+ disabled={previous !== null}
90
+ className={cn(
91
+ rateButtonVariants({
92
+ active: activeOpinion === 'good',
93
+ }),
94
+ )}
95
+ onClick={() => {
96
+ setOpinion('good');
97
+ }}
98
+ >
99
+ <ThumbsUp />
100
+ {t('Good', { note: 'ui.feedback.good' })}
101
+ </button>
102
+ <button
103
+ disabled={previous !== null}
104
+ className={cn(
105
+ rateButtonVariants({
106
+ active: activeOpinion === 'bad',
107
+ }),
108
+ )}
109
+ onClick={() => {
110
+ setOpinion('bad');
111
+ }}
112
+ >
113
+ <ThumbsDown />
114
+ {t('Bad', { note: 'ui.feedback.bad' })}
115
+ </button>
116
+ </div>
117
+ <CollapsibleContent className="mt-3">
118
+ {previous ? (
119
+ <div className="px-3 py-6 flex flex-col items-center gap-3 bg-fd-card text-fd-muted-foreground text-sm text-center rounded-xl">
120
+ <p>{t('Thank you for your feedback!', { note: 'ui.feedback.thanks' })}</p>
121
+ <div className="flex flex-row items-center gap-2">
122
+ <a
123
+ href={previous.response?.githubUrl}
124
+ rel="noreferrer noopener"
125
+ target="_blank"
126
+ className={cn(
127
+ buttonVariants({
128
+ variant: 'default',
129
+ }),
130
+ 'text-xs',
131
+ )}
132
+ >
133
+ {t('View on GitHub', { note: 'ui.feedback.view_on_github' })}
134
+ </a>
135
+
136
+ <button
137
+ className={cn(
138
+ buttonVariants({
139
+ variant: 'secondary',
140
+ }),
141
+ 'text-xs',
142
+ )}
143
+ onClick={() => {
144
+ setOpinion(previous.opinion);
145
+ setPrevious(null);
146
+ }}
147
+ >
148
+ {t('Submit Again', { note: 'ui.feedback.submit_again' })}
149
+ </button>
150
+ </div>
151
+ </div>
152
+ ) : (
153
+ <form className="flex flex-col gap-3" onSubmit={submit}>
154
+ <textarea
155
+ autoFocus
156
+ required
157
+ value={message}
158
+ onChange={(e) => setMessage(e.target.value)}
159
+ className="border rounded-lg bg-fd-secondary text-fd-secondary-foreground p-3 resize-none focus-visible:outline-none placeholder:text-fd-muted-foreground"
160
+ placeholder={t('Leave your feedback…', { note: 'ui.feedback.placeholder' })}
161
+ onKeyDown={(e) => {
162
+ if (e.nativeEvent.isComposing || e.keyCode === 229) return;
163
+ if (!e.shiftKey && e.key === 'Enter') {
164
+ submit(e);
165
+ }
166
+ }}
167
+ />
168
+ <button
169
+ type="submit"
170
+ className={cn(buttonVariants({ variant: 'outline' }), 'w-fit px-3')}
171
+ disabled={isPending}
172
+ >
173
+ {t('Submit', { note: 'ui.feedback.submit' })}
174
+ </button>
175
+ </form>
176
+ )}
177
+ </CollapsibleContent>
178
+ </Collapsible>
179
+ );
180
+ }
181
+
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useSyncExternalStore } from 'react';
4
+
5
+ let enabled: boolean | null = null;
6
+ let pending: Promise<void> | null = null;
7
+ const listeners = new Set<() => void>();
8
+
9
+ function makeSubscribe(configUrl: string) {
10
+ return function subscribe(listener: () => void) {
11
+ listeners.add(listener);
12
+ if (enabled === null && !pending) {
13
+ pending = fetch(configUrl)
14
+ .then((res) => (res.ok ? res.json() : null))
15
+ .then((data) => {
16
+ enabled = Boolean(data?.enabled);
17
+ })
18
+ .catch(() => {
19
+ enabled = false;
20
+ })
21
+ .finally(() => listeners.forEach((notify) => notify()));
22
+ }
23
+ return () => {
24
+ listeners.delete(listener);
25
+ };
26
+ };
27
+ }
28
+
29
+ export function useCommentsEnabled(configUrl: string): boolean {
30
+ const subscribe = useMemo(() => makeSubscribe(configUrl), [configUrl]);
31
+ return useSyncExternalStore(
32
+ subscribe,
33
+ () => enabled === true,
34
+ () => false,
35
+ );
36
+ }
37
+