@startsimpli/ui 0.4.15 → 0.4.17
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 +2 -1
- package/src/components/__tests__/error-surfaces.test.tsx +70 -0
- package/src/components/error/GlobalError.tsx +261 -0
- package/src/components/error/NotFound.tsx +78 -0
- package/src/components/error/RouteErrorBoundary.tsx +175 -0
- package/src/components/error/index.ts +6 -0
- package/src/components/index.ts +5 -0
- package/src/components/team/__tests__/team-settings-page.test.tsx +146 -0
- package/src/components/team/index.ts +5 -0
- package/src/components/team/pages/DomainsSettingsPage.tsx +289 -0
- package/src/components/team/pages/TeamSettingsPage.tsx +423 -0
- package/src/components/team/pages/domains-settings-page-default-class-names.ts +89 -0
- package/src/components/team/pages/index.ts +33 -0
- package/src/components/team/pages/team-settings-page-default-class-names.ts +116 -0
- package/src/components/team/pages/types.ts +135 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for the shared TeamSettingsPage composer (startsim-o7s).
|
|
3
|
+
*
|
|
4
|
+
* The real auth hooks are mocked so the test doesn't spin up an AuthProvider
|
|
5
|
+
* — we just confirm the page wires the supplied api shape through to the
|
|
6
|
+
* presentational components (MembersTable, invitations table, danger zone).
|
|
7
|
+
*/
|
|
8
|
+
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'
|
|
9
|
+
import { TeamSettingsPage } from '../pages/TeamSettingsPage'
|
|
10
|
+
import { DomainsSettingsPage } from '../pages/DomainsSettingsPage'
|
|
11
|
+
|
|
12
|
+
jest.mock('@startsimpli/auth', () => {
|
|
13
|
+
const noop = () => {}
|
|
14
|
+
return {
|
|
15
|
+
__esModule: true,
|
|
16
|
+
useAuth: () => ({
|
|
17
|
+
user: { id: 'u1', email: 'q@x.com' },
|
|
18
|
+
logout: jest.fn().mockResolvedValue(undefined),
|
|
19
|
+
}),
|
|
20
|
+
buildCentralAuthUrl: (flow: string, opts: { app: string }) =>
|
|
21
|
+
`https://auth.startsimpli.com/${flow}?app=${opts.app}`,
|
|
22
|
+
useMembershipFromApi: () => ({
|
|
23
|
+
company: { id: 'c1', slug: 'acme', name: 'Acme' },
|
|
24
|
+
currentTeam: { id: 't1', slug: 'core', name: 'Core', companyId: 'c1' },
|
|
25
|
+
role: 'owner',
|
|
26
|
+
isOwner: true,
|
|
27
|
+
isAdmin: true,
|
|
28
|
+
isMemberOrAbove: true,
|
|
29
|
+
canInvite: true,
|
|
30
|
+
memberships: [],
|
|
31
|
+
isLoading: false,
|
|
32
|
+
error: null,
|
|
33
|
+
refresh: jest.fn().mockResolvedValue(undefined),
|
|
34
|
+
}),
|
|
35
|
+
useInvitations: ({ fetchInvitations, autoFetch: _ }: { fetchInvitations: () => Promise<unknown>; autoFetch?: boolean }) => ({
|
|
36
|
+
pending: [],
|
|
37
|
+
isLoading: false,
|
|
38
|
+
error: null,
|
|
39
|
+
refresh: async () => {
|
|
40
|
+
await fetchInvitations()
|
|
41
|
+
},
|
|
42
|
+
revoke: noop,
|
|
43
|
+
bulkInvite: async () => ({ invited: [] }),
|
|
44
|
+
}),
|
|
45
|
+
useDomainClaims: () => ({
|
|
46
|
+
claims: [],
|
|
47
|
+
isLoading: false,
|
|
48
|
+
error: null,
|
|
49
|
+
refresh: jest.fn().mockResolvedValue(undefined),
|
|
50
|
+
create: jest.fn(),
|
|
51
|
+
verifyDns: jest.fn(),
|
|
52
|
+
initiateEmail: jest.fn(),
|
|
53
|
+
verifyEmail: jest.fn(),
|
|
54
|
+
revoke: jest.fn(),
|
|
55
|
+
}),
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
function buildApi() {
|
|
60
|
+
return {
|
|
61
|
+
teams: {
|
|
62
|
+
myTeams: jest.fn().mockResolvedValue([]),
|
|
63
|
+
retrieve: jest.fn(),
|
|
64
|
+
members: jest.fn().mockResolvedValue([
|
|
65
|
+
{
|
|
66
|
+
id: 'm1',
|
|
67
|
+
userId: 'u1',
|
|
68
|
+
teamId: 't1',
|
|
69
|
+
role: 'owner',
|
|
70
|
+
joinedAt: '2025-01-01',
|
|
71
|
+
user: { id: 'u1', email: 'q@x.com', firstName: 'Quinn' },
|
|
72
|
+
},
|
|
73
|
+
]),
|
|
74
|
+
updateRole: jest.fn().mockResolvedValue(undefined),
|
|
75
|
+
removeMember: jest.fn().mockResolvedValue(undefined),
|
|
76
|
+
bulkInvite: jest.fn().mockResolvedValue({ invited: [], skipped: [] }),
|
|
77
|
+
},
|
|
78
|
+
teamInvitations: {
|
|
79
|
+
list: jest.fn().mockResolvedValue({ results: [] }),
|
|
80
|
+
create: jest.fn(),
|
|
81
|
+
revoke: jest.fn().mockResolvedValue(undefined),
|
|
82
|
+
},
|
|
83
|
+
companies: {
|
|
84
|
+
retrieve: jest.fn(),
|
|
85
|
+
},
|
|
86
|
+
domainClaims: {
|
|
87
|
+
list: jest.fn().mockResolvedValue({ results: [] }),
|
|
88
|
+
create: jest.fn(),
|
|
89
|
+
verifyDns: jest.fn(),
|
|
90
|
+
verifyEmailInitiate: jest.fn(),
|
|
91
|
+
verifyEmailCode: jest.fn(),
|
|
92
|
+
revoke: jest.fn(),
|
|
93
|
+
},
|
|
94
|
+
} as any
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('TeamSettingsPage', () => {
|
|
98
|
+
it('renders header + members table after fetch', async () => {
|
|
99
|
+
const api = buildApi()
|
|
100
|
+
render(<TeamSettingsPage api={api} centralAuthApp="vault" />)
|
|
101
|
+
expect(screen.getByText('Team & members')).toBeInTheDocument()
|
|
102
|
+
expect(screen.getByText('Acme · Core')).toBeInTheDocument()
|
|
103
|
+
await waitFor(() => {
|
|
104
|
+
expect(api.teams.members).toHaveBeenCalledWith('core')
|
|
105
|
+
})
|
|
106
|
+
expect(screen.getByText(/Quinn/)).toBeInTheDocument()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('shows the danger-zone Leave button for the current team', async () => {
|
|
110
|
+
const api = buildApi()
|
|
111
|
+
render(<TeamSettingsPage api={api} centralAuthApp="vault" />)
|
|
112
|
+
await waitFor(() => expect(api.teams.members).toHaveBeenCalled())
|
|
113
|
+
expect(screen.getByText('Danger zone')).toBeInTheDocument()
|
|
114
|
+
expect(screen.getByRole('button', { name: /Leave team/i })).toBeInTheDocument()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('exposes the +Invite trigger when the current user is admin', async () => {
|
|
118
|
+
const api = buildApi()
|
|
119
|
+
render(<TeamSettingsPage api={api} centralAuthApp="vault" />)
|
|
120
|
+
await waitFor(() => expect(api.teams.members).toHaveBeenCalled())
|
|
121
|
+
expect(screen.getByRole('button', { name: '+ Invite' })).toBeInTheDocument()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('DomainsSettingsPage', () => {
|
|
126
|
+
it('renders the title + Add Domain trigger when admin + has companyId', async () => {
|
|
127
|
+
const api = buildApi()
|
|
128
|
+
render(<DomainsSettingsPage api={api} />)
|
|
129
|
+
expect(screen.getByText('Email domains')).toBeInTheDocument()
|
|
130
|
+
expect(screen.getByRole('button', { name: /Add domain/i })).toBeInTheDocument()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('opens the add-domain form when clicked + validates input', async () => {
|
|
134
|
+
const api = buildApi()
|
|
135
|
+
render(<DomainsSettingsPage api={api} />)
|
|
136
|
+
act(() => {
|
|
137
|
+
fireEvent.click(screen.getByRole('button', { name: /Add domain/i }))
|
|
138
|
+
})
|
|
139
|
+
const input = screen.getByLabelText(/Domain/i) as HTMLInputElement
|
|
140
|
+
fireEvent.change(input, { target: { value: 'nope' } })
|
|
141
|
+
fireEvent.click(screen.getByRole('button', { name: /Add domain/i }))
|
|
142
|
+
expect(
|
|
143
|
+
await screen.findByText(/valid domain/i),
|
|
144
|
+
).toBeInTheDocument()
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -55,3 +55,8 @@ export {
|
|
|
55
55
|
type DomainVerificationMethod,
|
|
56
56
|
type DomainClaimLite,
|
|
57
57
|
} from './types'
|
|
58
|
+
|
|
59
|
+
// Page-level composers (startsim-o7s) — drop-in /settings/team and
|
|
60
|
+
// /settings/domains pages. Each app wires `api` + a className-slot
|
|
61
|
+
// override map; total ≤10 LOC per page.
|
|
62
|
+
export * from './pages'
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DomainsSettingsPage — shared composer for /settings/domains (startsim-o7s).
|
|
5
|
+
*
|
|
6
|
+
* Renders the list of EmailDomainClaim rows for the active company, the
|
|
7
|
+
* add-domain form, and the per-card verify-DNS / verify-email actions.
|
|
8
|
+
* Admin gating is read off useMembership; only admins see Add + Revoke.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as React from 'react'
|
|
12
|
+
import { useDomainClaims, useMembershipFromApi } from '@startsimpli/auth'
|
|
13
|
+
import type { DomainClaimRow } from '@startsimpli/auth'
|
|
14
|
+
import { DomainClaimCard } from '../DomainClaimCard'
|
|
15
|
+
import type { DomainClaimLite } from '../types'
|
|
16
|
+
import {
|
|
17
|
+
DOMAINS_SETTINGS_PAGE_DEFAULTS,
|
|
18
|
+
type DomainsSettingsPageClassNames,
|
|
19
|
+
} from './domains-settings-page-default-class-names'
|
|
20
|
+
import type { DomainsSettingsApi } from './types'
|
|
21
|
+
|
|
22
|
+
export interface DomainsSettingsPageProps {
|
|
23
|
+
/** The @startsimpli/api client. */
|
|
24
|
+
api: DomainsSettingsApi
|
|
25
|
+
/** Per-slot className overrides. */
|
|
26
|
+
classNames?: DomainsSettingsPageClassNames
|
|
27
|
+
/**
|
|
28
|
+
* Optional override for the company id. Defaults to the company resolved
|
|
29
|
+
* by useMembership() on the current user.
|
|
30
|
+
*/
|
|
31
|
+
companyId?: string
|
|
32
|
+
/** Back link href. Defaults to '/'. */
|
|
33
|
+
backHref?: string
|
|
34
|
+
/** Back link text. Defaults to '← Back'. */
|
|
35
|
+
backLabel?: string
|
|
36
|
+
/** Title. Defaults to 'Email domains'. */
|
|
37
|
+
title?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Loose domain pattern — backend is the canonical validator. */
|
|
41
|
+
const DOMAIN_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/i
|
|
42
|
+
|
|
43
|
+
function toClaimLite(c: DomainClaimRow): DomainClaimLite {
|
|
44
|
+
return {
|
|
45
|
+
id: c.id,
|
|
46
|
+
companyId: c.companyId,
|
|
47
|
+
domain: c.domain,
|
|
48
|
+
verified: c.verified,
|
|
49
|
+
verificationMethod: c.verificationMethod,
|
|
50
|
+
verificationToken: c.verificationToken,
|
|
51
|
+
verifiedAt: c.verifiedAt,
|
|
52
|
+
createdAt: c.createdAt,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function DomainsSettingsPage({
|
|
57
|
+
api,
|
|
58
|
+
classNames,
|
|
59
|
+
companyId: companyIdProp,
|
|
60
|
+
backHref = '/',
|
|
61
|
+
backLabel = '← Back',
|
|
62
|
+
title = 'Email domains',
|
|
63
|
+
}: DomainsSettingsPageProps) {
|
|
64
|
+
const cls = { ...DOMAINS_SETTINGS_PAGE_DEFAULTS, ...(classNames ?? {}) }
|
|
65
|
+
const membership = useMembershipFromApi(api)
|
|
66
|
+
const { company, isAdmin, isLoading: membershipLoading } = membership
|
|
67
|
+
const companyId = companyIdProp ?? company?.id
|
|
68
|
+
|
|
69
|
+
const claims = useDomainClaims({
|
|
70
|
+
fetchClaims: async () => {
|
|
71
|
+
if (!companyId) return []
|
|
72
|
+
const res = await api.domainClaims.list({ companyId })
|
|
73
|
+
return res.results.map((c) => ({
|
|
74
|
+
id: c.id,
|
|
75
|
+
companyId: c.companyId,
|
|
76
|
+
domain: c.domain,
|
|
77
|
+
verified: c.verified,
|
|
78
|
+
verificationMethod: c.verificationMethod,
|
|
79
|
+
verificationToken: c.verificationToken,
|
|
80
|
+
verifiedAt: c.verifiedAt,
|
|
81
|
+
createdAt: c.createdAt,
|
|
82
|
+
}))
|
|
83
|
+
},
|
|
84
|
+
createClaim: async ({ companyId: cid, domain }) => {
|
|
85
|
+
const c = await api.domainClaims.create({ companyId: cid, domain })
|
|
86
|
+
return {
|
|
87
|
+
id: c.id,
|
|
88
|
+
companyId: c.companyId,
|
|
89
|
+
domain: c.domain,
|
|
90
|
+
verified: c.verified,
|
|
91
|
+
verificationMethod: c.verificationMethod,
|
|
92
|
+
verificationToken: c.verificationToken,
|
|
93
|
+
verifiedAt: c.verifiedAt,
|
|
94
|
+
createdAt: c.createdAt,
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
verifyDns: async (id) => {
|
|
98
|
+
const c = await api.domainClaims.verifyDns(id)
|
|
99
|
+
return {
|
|
100
|
+
id: c.id,
|
|
101
|
+
companyId: c.companyId,
|
|
102
|
+
domain: c.domain,
|
|
103
|
+
verified: c.verified,
|
|
104
|
+
verificationMethod: c.verificationMethod,
|
|
105
|
+
verificationToken: c.verificationToken,
|
|
106
|
+
verifiedAt: c.verifiedAt,
|
|
107
|
+
createdAt: c.createdAt,
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
initiateEmailVerification: (id) => api.domainClaims.verifyEmailInitiate(id),
|
|
111
|
+
submitEmailCode: async (id, code) => {
|
|
112
|
+
const c = await api.domainClaims.verifyEmailCode(id, code)
|
|
113
|
+
return {
|
|
114
|
+
id: c.id,
|
|
115
|
+
companyId: c.companyId,
|
|
116
|
+
domain: c.domain,
|
|
117
|
+
verified: c.verified,
|
|
118
|
+
verificationMethod: c.verificationMethod,
|
|
119
|
+
verificationToken: c.verificationToken,
|
|
120
|
+
verifiedAt: c.verifiedAt,
|
|
121
|
+
createdAt: c.createdAt,
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
revokeClaim: (id) => api.domainClaims.revoke(id),
|
|
125
|
+
autoFetch: !!companyId,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const [showAdd, setShowAdd] = React.useState(false)
|
|
129
|
+
const [domain, setDomain] = React.useState('')
|
|
130
|
+
const [addError, setAddError] = React.useState('')
|
|
131
|
+
const [submitting, setSubmitting] = React.useState(false)
|
|
132
|
+
|
|
133
|
+
async function handleAdd(e: React.FormEvent) {
|
|
134
|
+
e.preventDefault()
|
|
135
|
+
setAddError('')
|
|
136
|
+
const clean = domain.trim().toLowerCase()
|
|
137
|
+
if (!clean) {
|
|
138
|
+
setAddError('Domain is required')
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (!DOMAIN_RE.test(clean)) {
|
|
142
|
+
setAddError('Enter a valid domain like "example.com"')
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (!companyId) {
|
|
146
|
+
setAddError('No company in scope')
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
setSubmitting(true)
|
|
150
|
+
try {
|
|
151
|
+
await claims.create({ companyId, domain: clean })
|
|
152
|
+
setDomain('')
|
|
153
|
+
setShowAdd(false)
|
|
154
|
+
} catch (err) {
|
|
155
|
+
setAddError(err instanceof Error ? err.message : 'Could not add domain')
|
|
156
|
+
} finally {
|
|
157
|
+
setSubmitting(false)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (membershipLoading) {
|
|
162
|
+
return <p className={cls.loadingText}>Loading…</p>
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!companyId) {
|
|
166
|
+
return (
|
|
167
|
+
<div className={cls.root}>
|
|
168
|
+
<a href={backHref} className={cls.backLink}>
|
|
169
|
+
{backLabel}
|
|
170
|
+
</a>
|
|
171
|
+
<h1 className={cls.title}>{title}</h1>
|
|
172
|
+
<p className={cls.subtitle}>
|
|
173
|
+
You need a company to claim domains. Accept a team invitation first.
|
|
174
|
+
</p>
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className={cls.root}>
|
|
181
|
+
<div className={cls.header}>
|
|
182
|
+
<a href={backHref} className={cls.backLink}>
|
|
183
|
+
{backLabel}
|
|
184
|
+
</a>
|
|
185
|
+
<div className={cls.titleRow}>
|
|
186
|
+
<div>
|
|
187
|
+
<h1 className={cls.title}>{title}</h1>
|
|
188
|
+
<p className={cls.subtitle}>
|
|
189
|
+
Verified domains auto-join new signups to your team. Add a domain
|
|
190
|
+
you own.
|
|
191
|
+
</p>
|
|
192
|
+
</div>
|
|
193
|
+
{isAdmin && !showAdd && (
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
onClick={() => setShowAdd(true)}
|
|
197
|
+
className={cls.primaryButton}
|
|
198
|
+
>
|
|
199
|
+
+ Add domain
|
|
200
|
+
</button>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{showAdd && (
|
|
206
|
+
<form onSubmit={handleAdd} className={cls.formCard}>
|
|
207
|
+
<label htmlFor="domain" className={cls.label}>
|
|
208
|
+
Domain
|
|
209
|
+
</label>
|
|
210
|
+
<input
|
|
211
|
+
id="domain"
|
|
212
|
+
type="text"
|
|
213
|
+
value={domain}
|
|
214
|
+
onChange={(e) => setDomain(e.target.value)}
|
|
215
|
+
placeholder="acme.com"
|
|
216
|
+
className={cls.input}
|
|
217
|
+
disabled={submitting}
|
|
218
|
+
autoFocus
|
|
219
|
+
/>
|
|
220
|
+
{addError && <p className={cls.errorText}>{addError}</p>}
|
|
221
|
+
<div className={cls.formActions}>
|
|
222
|
+
<button type="submit" disabled={submitting} className={cls.primaryButton}>
|
|
223
|
+
{submitting ? 'Adding…' : 'Add domain'}
|
|
224
|
+
</button>
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={() => {
|
|
228
|
+
setShowAdd(false)
|
|
229
|
+
setDomain('')
|
|
230
|
+
setAddError('')
|
|
231
|
+
}}
|
|
232
|
+
className={cls.cancelButton}
|
|
233
|
+
>
|
|
234
|
+
Cancel
|
|
235
|
+
</button>
|
|
236
|
+
</div>
|
|
237
|
+
</form>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
{claims.isLoading ? (
|
|
241
|
+
<p className={cls.loadingText}>Loading domains…</p>
|
|
242
|
+
) : claims.error ? (
|
|
243
|
+
<div className="space-y-2">
|
|
244
|
+
<p className={cls.errorText}>Could not load domains.</p>
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={() => void claims.refresh()}
|
|
248
|
+
className={cls.retryButton}
|
|
249
|
+
>
|
|
250
|
+
Retry
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
) : claims.claims.length === 0 ? (
|
|
254
|
+
<div className={cls.emptyState}>
|
|
255
|
+
<p className={cls.emptyText}>
|
|
256
|
+
No domains claimed yet. Add one to auto-join new signups from your
|
|
257
|
+
team's email domain.
|
|
258
|
+
</p>
|
|
259
|
+
</div>
|
|
260
|
+
) : (
|
|
261
|
+
<div className={cls.list}>
|
|
262
|
+
{claims.claims.map((c) => (
|
|
263
|
+
<DomainClaimCard
|
|
264
|
+
key={c.id}
|
|
265
|
+
claim={toClaimLite(c)}
|
|
266
|
+
classNames={cls.claimCard}
|
|
267
|
+
onVerifyDns={async () => {
|
|
268
|
+
await claims.verifyDns(c.id)
|
|
269
|
+
}}
|
|
270
|
+
onInitiateEmail={async () => {
|
|
271
|
+
await claims.initiateEmail(c.id)
|
|
272
|
+
}}
|
|
273
|
+
onVerifyEmail={async (_claim, code) => {
|
|
274
|
+
await claims.verifyEmail(c.id, code)
|
|
275
|
+
}}
|
|
276
|
+
onRevoke={
|
|
277
|
+
isAdmin
|
|
278
|
+
? async () => {
|
|
279
|
+
await claims.revoke(c.id)
|
|
280
|
+
}
|
|
281
|
+
: undefined
|
|
282
|
+
}
|
|
283
|
+
/>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
289
|
+
}
|