@ciromaciel/dashboard-kit 1.0.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/README.md +40 -0
- package/package.json +46 -0
- package/src/auth-labels.js +77 -0
- package/src/components/ActiveResource.jsx +68 -0
- package/src/components/AuthLoader.jsx +66 -0
- package/src/components/AuthScreen.jsx +32 -0
- package/src/components/CardSkeleton.jsx +82 -0
- package/src/components/CommandCenter.css +57 -0
- package/src/components/CommandCenter.jsx +185 -0
- package/src/components/DashboardFrame.jsx +97 -0
- package/src/components/DataTable.jsx +153 -0
- package/src/components/DeleteModal.jsx +341 -0
- package/src/components/EmptyState.jsx +55 -0
- package/src/components/GroupLabel.jsx +28 -0
- package/src/components/HoldToConfirm.jsx +151 -0
- package/src/components/ListSkeleton.jsx +55 -0
- package/src/components/MenuItem.jsx +56 -0
- package/src/components/Metric.jsx +178 -0
- package/src/components/MoreResults.jsx +137 -0
- package/src/components/NavItem.jsx +57 -0
- package/src/components/PageHeader.jsx +124 -0
- package/src/components/PlanBanner.jsx +88 -0
- package/src/components/ResourceItem.jsx +93 -0
- package/src/components/SectionCard.jsx +212 -0
- package/src/components/SidebarShell.jsx +271 -0
- package/src/components/SupportModal.jsx +269 -0
- package/src/components/TableSkeleton.jsx +43 -0
- package/src/components/TableToolbar.jsx +136 -0
- package/src/components/Terminal.jsx +217 -0
- package/src/components/Wordmark.jsx +52 -0
- package/src/components/ZenLabel.jsx +30 -0
- package/src/components/notify-theme.js +103 -0
- package/src/components/notify.js +65 -0
- package/src/components/table-grid.js +27 -0
- package/src/hooks/useExclusiveModal.js +49 -0
- package/src/hooks/usePaginatedList.js +206 -0
- package/src/index.js +99 -0
- package/src/tokens.css +47 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { ActionIcon, Box, ScrollArea, Stack, Text, TextInput } from '@mantine/core'
|
|
2
|
+
import { IconSearch, IconX } from '@tabler/icons-react'
|
|
3
|
+
|
|
4
|
+
import { GroupLabel } from './GroupLabel'
|
|
5
|
+
import { ListSkeleton } from './ListSkeleton'
|
|
6
|
+
import { NavItem } from './NavItem'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A coluna lateral de um painel, por CONFIGURAÇÃO em vez de por cópia.
|
|
10
|
+
*
|
|
11
|
+
* A comparação que originou este componente: `payments` e `messages` tinham 596
|
|
12
|
+
* de 612 linhas de código IDÊNTICAS (97,4%), e as 16 que diferiam eram o array
|
|
13
|
+
* de seções, o import de ícones e um identificador traduzido. Os outros cinco
|
|
14
|
+
* seguiam o mesmo desenho com outros substantivos.
|
|
15
|
+
*
|
|
16
|
+
* A FORMA DA COLUNA É UMA SÓ, e ela conta uma história em duas metades:
|
|
17
|
+
*
|
|
18
|
+
* FORA de um recurso — busca, ação de criar, e a lista do que existe.
|
|
19
|
+
* DENTRO de um recurso — o nome dele, as seções DELE, e a volta.
|
|
20
|
+
*
|
|
21
|
+
* As duas nunca convivem: empilhá-las fazia a coluna rolar e escondia as
|
|
22
|
+
* seções, que são o motivo de se ter entrado no recurso.
|
|
23
|
+
*
|
|
24
|
+
* OS TRÊS SLOTS DE EXTENSÃO existem porque três painéis precisam de algo que os
|
|
25
|
+
* outros quatro não têm — e cada um é usado UMA vez, que é o teste de "isto é
|
|
26
|
+
* do produto":
|
|
27
|
+
*
|
|
28
|
+
* `renderGroups` o Auth tem dois níveis (organização → aplicação) e precisa
|
|
29
|
+
* de cabeçalho de grupo interativo.
|
|
30
|
+
* `partition` o Hoster fatia a lista em Fixados/Recentes/Todos — a única
|
|
31
|
+
* resposta na casa ao problema "dezenove projetos não cabem".
|
|
32
|
+
* `footer` o cartão de usuário, que o Functions monta diferente por
|
|
33
|
+
* causa do modo `dev-user`, sem sessão do Auth.
|
|
34
|
+
*
|
|
35
|
+
* A BUSCA APARECE POR LIMIAR, não sempre: com três itens ela é cromo, e o custo
|
|
36
|
+
* de não tê-la é zero. `searchThreshold: 0` a mantém fixa; `null` a remove (é o
|
|
37
|
+
* caso do Monitors, que não tem lista).
|
|
38
|
+
*
|
|
39
|
+
* Nada de quota entra aqui. O painel resolve o gate e passa `createAllowed`
|
|
40
|
+
* booleano — a coluna não sabe o que é um plano.
|
|
41
|
+
*/
|
|
42
|
+
export function SidebarShell({
|
|
43
|
+
// ── fora do recurso ──────────────────────────────────────────────────
|
|
44
|
+
items = [],
|
|
45
|
+
loading = false,
|
|
46
|
+
groupLabel,
|
|
47
|
+
emptyLabel = 'Nenhum item',
|
|
48
|
+
emptySearchLabel = 'Nenhum resultado',
|
|
49
|
+
searchPlaceholder,
|
|
50
|
+
searchThreshold = 0,
|
|
51
|
+
searchFields = ['name'],
|
|
52
|
+
term = '',
|
|
53
|
+
onTermChange,
|
|
54
|
+
renderItem,
|
|
55
|
+
renderGroups,
|
|
56
|
+
partition,
|
|
57
|
+
// ── a ação de criar ──────────────────────────────────────────────────
|
|
58
|
+
createIcon,
|
|
59
|
+
createLabel,
|
|
60
|
+
createBlockedLabel,
|
|
61
|
+
createAllowed = true,
|
|
62
|
+
onCreate,
|
|
63
|
+
onCreateBlocked,
|
|
64
|
+
// ── dentro do recurso ────────────────────────────────────────────────
|
|
65
|
+
activeResource,
|
|
66
|
+
backIcon,
|
|
67
|
+
backLabel,
|
|
68
|
+
onBack,
|
|
69
|
+
sections = [],
|
|
70
|
+
currentSection,
|
|
71
|
+
onSectionChange,
|
|
72
|
+
// ── o que fica em volta ──────────────────────────────────────────────
|
|
73
|
+
header,
|
|
74
|
+
accountNav,
|
|
75
|
+
footer,
|
|
76
|
+
children,
|
|
77
|
+
}) {
|
|
78
|
+
const busca = term.trim().toLowerCase()
|
|
79
|
+
|
|
80
|
+
const filtrados = busca
|
|
81
|
+
? items.filter(item =>
|
|
82
|
+
searchFields.some(campo =>
|
|
83
|
+
String(item[campo] ?? '')
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.includes(busca),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
: items
|
|
89
|
+
|
|
90
|
+
// `null` remove a busca; `0` a mantém fixa; um número a revela a partir
|
|
91
|
+
// dali. Quem já digitou continua vendo o campo mesmo que o filtro tenha
|
|
92
|
+
// reduzido a lista abaixo do limiar — tirá-lo no meio da digitação seria
|
|
93
|
+
// arrancar o que a pessoa está usando.
|
|
94
|
+
const mostrarBusca = searchThreshold !== null && !activeResource && (items.length >= searchThreshold || Boolean(term))
|
|
95
|
+
|
|
96
|
+
const faixas = partition ? partition(filtrados) : null
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<Stack
|
|
100
|
+
gap={2}
|
|
101
|
+
h="100%"
|
|
102
|
+
p="16px 12px"
|
|
103
|
+
align="stretch"
|
|
104
|
+
w="100%"
|
|
105
|
+
style={{ overflow: 'hidden' }}
|
|
106
|
+
>
|
|
107
|
+
{header}
|
|
108
|
+
|
|
109
|
+
{mostrarBusca && (
|
|
110
|
+
<Box
|
|
111
|
+
px={12}
|
|
112
|
+
pb={8}
|
|
113
|
+
w="100%"
|
|
114
|
+
>
|
|
115
|
+
<TextInput
|
|
116
|
+
value={term}
|
|
117
|
+
onChange={e => onTermChange?.(e.currentTarget.value)}
|
|
118
|
+
onKeyDown={e => e.key === 'Escape' && onTermChange?.('')}
|
|
119
|
+
placeholder={searchPlaceholder}
|
|
120
|
+
aria-label={searchPlaceholder}
|
|
121
|
+
leftSection={
|
|
122
|
+
<IconSearch
|
|
123
|
+
size={14}
|
|
124
|
+
stroke={1.5}
|
|
125
|
+
/>
|
|
126
|
+
}
|
|
127
|
+
rightSection={
|
|
128
|
+
term ? (
|
|
129
|
+
<ActionIcon
|
|
130
|
+
size="xs"
|
|
131
|
+
color="gray"
|
|
132
|
+
variant="transparent"
|
|
133
|
+
onClick={() => onTermChange?.('')}
|
|
134
|
+
aria-label="Limpar busca"
|
|
135
|
+
>
|
|
136
|
+
<IconX
|
|
137
|
+
size={14}
|
|
138
|
+
stroke={1.5}
|
|
139
|
+
/>
|
|
140
|
+
</ActionIcon>
|
|
141
|
+
) : null
|
|
142
|
+
}
|
|
143
|
+
styles={{ input: { fontSize: 12, background: 'var(--mantine-color-gray-1)' } }}
|
|
144
|
+
/>
|
|
145
|
+
</Box>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{activeResource ? (
|
|
149
|
+
<NavItem
|
|
150
|
+
icon={backIcon}
|
|
151
|
+
label={backLabel}
|
|
152
|
+
onClick={onBack}
|
|
153
|
+
/>
|
|
154
|
+
) : (
|
|
155
|
+
<>
|
|
156
|
+
{/*
|
|
157
|
+
Criar fica ANTES da lista: com muitos recursos a lista
|
|
158
|
+
rola, e uma ação no fim de uma área de rolagem é uma ação
|
|
159
|
+
que se procura.
|
|
160
|
+
|
|
161
|
+
No limite do plano o item NÃO some — ele leva para os
|
|
162
|
+
planos. Escondê-lo deixaria o limite invisível, e a pessoa
|
|
163
|
+
procuraria o botão em vez de descobrir que precisa de
|
|
164
|
+
upgrade.
|
|
165
|
+
*/}
|
|
166
|
+
{onCreate && (
|
|
167
|
+
<NavItem
|
|
168
|
+
icon={createIcon}
|
|
169
|
+
label={createAllowed ? createLabel : createBlockedLabel}
|
|
170
|
+
muted={!createAllowed}
|
|
171
|
+
onClick={createAllowed ? onCreate : onCreateBlocked}
|
|
172
|
+
/>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{groupLabel && <GroupLabel>{groupLabel}</GroupLabel>}
|
|
176
|
+
</>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{/*
|
|
180
|
+
A lista rola sozinha e o resto da coluna fica parado: com muitos
|
|
181
|
+
recursos, o cartão do usuário continua onde o dedo espera achá-lo.
|
|
182
|
+
|
|
183
|
+
`scrollbars="y"` porque a rolagem horizontal não deve existir numa
|
|
184
|
+
coluna de largura fixa — e ela aparecia, com o conteúdo mais largo
|
|
185
|
+
ditando a largura da coluna.
|
|
186
|
+
*/}
|
|
187
|
+
{!activeResource && (
|
|
188
|
+
<ScrollArea.Autosize
|
|
189
|
+
style={{ flex: 1, width: '100%' }}
|
|
190
|
+
type="auto"
|
|
191
|
+
scrollbars="y"
|
|
192
|
+
className="zen-sidebar-scroll"
|
|
193
|
+
>
|
|
194
|
+
<Stack
|
|
195
|
+
gap={2}
|
|
196
|
+
w="100%"
|
|
197
|
+
>
|
|
198
|
+
{loading ? (
|
|
199
|
+
<ListSkeleton />
|
|
200
|
+
) : filtrados.length === 0 ? (
|
|
201
|
+
<Box py={16}>
|
|
202
|
+
<Text
|
|
203
|
+
fz={10}
|
|
204
|
+
fw={800}
|
|
205
|
+
c="gray.4"
|
|
206
|
+
tt="uppercase"
|
|
207
|
+
lts="1.5px"
|
|
208
|
+
ta="center"
|
|
209
|
+
>
|
|
210
|
+
{term ? emptySearchLabel : emptyLabel}
|
|
211
|
+
</Text>
|
|
212
|
+
</Box>
|
|
213
|
+
) : renderGroups ? (
|
|
214
|
+
renderGroups(filtrados)
|
|
215
|
+
) : faixas ? (
|
|
216
|
+
faixas.map(faixa => (
|
|
217
|
+
<Box key={faixa.label ?? 'sem-faixa'}>
|
|
218
|
+
{faixa.label && <GroupLabel>{faixa.label}</GroupLabel>}
|
|
219
|
+
{faixa.items.map(renderItem)}
|
|
220
|
+
</Box>
|
|
221
|
+
))
|
|
222
|
+
) : (
|
|
223
|
+
filtrados.map(renderItem)
|
|
224
|
+
)}
|
|
225
|
+
</Stack>
|
|
226
|
+
</ScrollArea.Autosize>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
<Stack
|
|
230
|
+
gap={2}
|
|
231
|
+
flex={activeResource ? 1 : 'none'}
|
|
232
|
+
w="100%"
|
|
233
|
+
align="center"
|
|
234
|
+
>
|
|
235
|
+
{activeResource && (
|
|
236
|
+
<>
|
|
237
|
+
{activeResource}
|
|
238
|
+
{sections.map(item => (
|
|
239
|
+
<NavItem
|
|
240
|
+
key={item.id}
|
|
241
|
+
icon={item.icon}
|
|
242
|
+
label={item.label}
|
|
243
|
+
active={currentSection === item.id}
|
|
244
|
+
onClick={() => onSectionChange?.(item)}
|
|
245
|
+
/>
|
|
246
|
+
))}
|
|
247
|
+
</>
|
|
248
|
+
)}
|
|
249
|
+
</Stack>
|
|
250
|
+
|
|
251
|
+
{accountNav}
|
|
252
|
+
|
|
253
|
+
{/*
|
|
254
|
+
O rodapé é o cartão do usuário: Docs e Suporte vivem dentro do
|
|
255
|
+
menu dele, junto de Conta e Assinatura. Na coluna, competiriam com
|
|
256
|
+
a lista — que é o assunto da coluna.
|
|
257
|
+
*/}
|
|
258
|
+
{footer && (
|
|
259
|
+
<Box
|
|
260
|
+
pt={16}
|
|
261
|
+
w="100%"
|
|
262
|
+
style={{ borderTop: 'var(--rlg-border)' }}
|
|
263
|
+
>
|
|
264
|
+
{footer}
|
|
265
|
+
</Box>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{children}
|
|
269
|
+
</Stack>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { Box, Button, Group, Modal, ScrollArea, Stack, Text, Title } from '@mantine/core'
|
|
3
|
+
import { IconMail } from '@tabler/icons-react'
|
|
4
|
+
|
|
5
|
+
import { ZenLabel } from './ZenLabel'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Support — one e-mail, with the technical context already attached, and the
|
|
9
|
+
* API's real state measured on the spot.
|
|
10
|
+
*
|
|
11
|
+
* This modal operates nothing. It convinces you there is someone on the other
|
|
12
|
+
* side, which is why the display is 36px.
|
|
13
|
+
*
|
|
14
|
+
* ONE FORM, NO EXCEPTIONS — a deliberate call, and it costs something.
|
|
15
|
+
*
|
|
16
|
+
* Four dashboards carried a copy. Messages and Payments were the same file
|
|
17
|
+
* except for one string. The other two had diverged, and adopting this drops
|
|
18
|
+
* what made them different:
|
|
19
|
+
*
|
|
20
|
+
* - Monitors rendered the state through `StatusDot`, its only emitter of
|
|
21
|
+
* state colour, with FOUR values (up, degraded, down, no data). Here there
|
|
22
|
+
* are three, so "degraded" no longer has its own reading in this modal.
|
|
23
|
+
* `StatusDot` stays in charge everywhere else in that dashboard.
|
|
24
|
+
* - Hoster ran this modal through `react-i18next` and built its context as a
|
|
25
|
+
* padEnd-aligned table. The text is Portuguese literal now, like the other
|
|
26
|
+
* six panels.
|
|
27
|
+
*
|
|
28
|
+
* The only thing a product still supplies is `contextRows` — the lines that
|
|
29
|
+
* name ITS resources (a project, a domain, a monitor). Everything else is the
|
|
30
|
+
* same in all four.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} productName Names the mail subject: "Suporte · RiLiGar <name>".
|
|
33
|
+
* @param {string} apiUrl The root that gets measured. `GET /` must be public.
|
|
34
|
+
* @param {string[]} contextRows Lines appended to the mail body, already formatted.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const DEFAULT_SUPPORT_EMAIL = 'team@myinfrastructure.click'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Measures the API when the modal opens, and again on every opening — the
|
|
41
|
+
* state ten minutes ago does not describe the state now.
|
|
42
|
+
*
|
|
43
|
+
* `status` leaves 'idle' on the RESPONSE, not before it: writing 'loading' in
|
|
44
|
+
* the effect body would be a synchronous setState, a cascading render to say
|
|
45
|
+
* what the absence of a response already says. The status line treats 'idle'
|
|
46
|
+
* and 'loading' as the same "verificando…".
|
|
47
|
+
*/
|
|
48
|
+
export function useHealthCheck(enabled, apiUrl) {
|
|
49
|
+
const [health, setHealth] = useState({ status: 'idle' })
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!enabled || !apiUrl) return
|
|
53
|
+
|
|
54
|
+
let cancelled = false
|
|
55
|
+
const startedAt = performance.now()
|
|
56
|
+
|
|
57
|
+
fetch(`${apiUrl}/`, { cache: 'no-store' })
|
|
58
|
+
.then(response => {
|
|
59
|
+
const elapsed = Math.round(performance.now() - startedAt)
|
|
60
|
+
if (cancelled) return
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
setHealth({ status: 'down', code: response.status, elapsed })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
setHealth({ status: 'up', elapsed })
|
|
66
|
+
})
|
|
67
|
+
.catch(() => {
|
|
68
|
+
if (cancelled) return
|
|
69
|
+
setHealth({ status: 'down' })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
cancelled = true
|
|
74
|
+
setHealth({ status: 'idle' })
|
|
75
|
+
}
|
|
76
|
+
}, [enabled, apiUrl])
|
|
77
|
+
|
|
78
|
+
return health
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The status light.
|
|
83
|
+
*
|
|
84
|
+
* `--rlg-status-up` is the system's only green, and it exists because this is
|
|
85
|
+
* NOT the success of an action (that is gray.9 + IconCheck): it is the measured
|
|
86
|
+
* state of an external system, and it never appears without the word next to
|
|
87
|
+
* it.
|
|
88
|
+
*/
|
|
89
|
+
export function HealthRow({ health, host }) {
|
|
90
|
+
const label = health.status === 'up' ? 'No ar' : health.status === 'down' ? 'Sem resposta' : 'Verificando…'
|
|
91
|
+
const color = health.status === 'up' ? 'var(--rlg-status-up)' : health.status === 'down' ? 'var(--mantine-color-red-6)' : 'var(--mantine-color-gray-4)'
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Group
|
|
95
|
+
justify="space-between"
|
|
96
|
+
wrap="nowrap"
|
|
97
|
+
gap={12}
|
|
98
|
+
px={{ base: 16, sm: 20 }}
|
|
99
|
+
py={14}
|
|
100
|
+
style={{ border: 'var(--rlg-border)', background: 'var(--mantine-color-gray-1)' }}
|
|
101
|
+
>
|
|
102
|
+
<Group
|
|
103
|
+
gap={8}
|
|
104
|
+
wrap="nowrap"
|
|
105
|
+
miw={0}
|
|
106
|
+
>
|
|
107
|
+
<Box
|
|
108
|
+
w={7}
|
|
109
|
+
h={7}
|
|
110
|
+
style={{ borderRadius: '50%', background: color, flex: 'none' }} // design-system-ignore: semáforo de status
|
|
111
|
+
/>
|
|
112
|
+
<Text
|
|
113
|
+
fz={12}
|
|
114
|
+
fw={600}
|
|
115
|
+
c="gray.9"
|
|
116
|
+
lineClamp={1}
|
|
117
|
+
>
|
|
118
|
+
{label}
|
|
119
|
+
</Text>
|
|
120
|
+
</Group>
|
|
121
|
+
<Text
|
|
122
|
+
fz={10}
|
|
123
|
+
fw={800}
|
|
124
|
+
c="gray.4"
|
|
125
|
+
ff="monospace"
|
|
126
|
+
style={{ whiteSpace: 'nowrap' }}
|
|
127
|
+
>
|
|
128
|
+
{host}
|
|
129
|
+
{health.elapsed ? ` · ${health.elapsed}ms` : ''}
|
|
130
|
+
</Text>
|
|
131
|
+
</Group>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function SupportModal({
|
|
136
|
+
opened,
|
|
137
|
+
onClose,
|
|
138
|
+
productName,
|
|
139
|
+
apiUrl,
|
|
140
|
+
contextRows = [],
|
|
141
|
+
supportEmail = DEFAULT_SUPPORT_EMAIL,
|
|
142
|
+
label = '+ Suporte',
|
|
143
|
+
title = 'Fale com quem',
|
|
144
|
+
titleTail = 'escreveu o código.',
|
|
145
|
+
description = 'Não há fila nem robô. O e-mail abaixo já vai com o contexto técnico anexado — conta, projeto e o estado da API medido agora.',
|
|
146
|
+
subject,
|
|
147
|
+
}) {
|
|
148
|
+
const health = useHealthCheck(opened, apiUrl)
|
|
149
|
+
|
|
150
|
+
/*
|
|
151
|
+
* A warning, not an exception — same posture as `Wordmark`, and for the same
|
|
152
|
+
* reason. Without `productName` the mail subject reads "Suporte · RiLiGar
|
|
153
|
+
* undefined"; without `apiUrl` the status line measures nothing and the body
|
|
154
|
+
* reports "não verificado". Neither breaks the build, and neither shows up
|
|
155
|
+
* until someone reads the e-mail that arrived.
|
|
156
|
+
*
|
|
157
|
+
* Verified by sabotage: removing `productName` from a panel's adapter builds
|
|
158
|
+
* green and ships the broken subject.
|
|
159
|
+
*/
|
|
160
|
+
if (typeof console !== 'undefined') {
|
|
161
|
+
if (!productName) console.warn('[dashboard-kit] <SupportModal> without `productName`: the mail subject reads "Suporte · RiLiGar undefined". Pass `productName`.')
|
|
162
|
+
if (!apiUrl) console.warn('[dashboard-kit] <SupportModal> without `apiUrl`: nothing is measured and the e-mail reports "não verificado".')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The host shown on the status line — the domain, without the protocol. */
|
|
166
|
+
const host = apiUrl?.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
|
167
|
+
|
|
168
|
+
const apiState = health.status === 'up' ? `no ar (${health.elapsed}ms)` : health.status === 'down' ? 'sem resposta' : 'não verificado'
|
|
169
|
+
|
|
170
|
+
/*
|
|
171
|
+
* The body goes out already filled in with what always gets asked back.
|
|
172
|
+
* `contextRows` is what the product adds — it is the only part that differs,
|
|
173
|
+
* and passing it in beats maintaining four copies of the whole modal.
|
|
174
|
+
*/
|
|
175
|
+
const body = ['Descreva o que aconteceu:', '', '', '---', ...contextRows, `API: ${host}`, `Estado da API: ${apiState}`, `Navegador: ${navigator.userAgent}`].join('\n')
|
|
176
|
+
|
|
177
|
+
const mailSubject = subject ?? `Suporte · RiLiGar ${productName}`
|
|
178
|
+
const mailto = `mailto:${supportEmail}?subject=${encodeURIComponent(mailSubject)}&body=${encodeURIComponent(body)}`
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<Modal
|
|
182
|
+
opened={opened}
|
|
183
|
+
onClose={onClose}
|
|
184
|
+
size={620}
|
|
185
|
+
padding={0}
|
|
186
|
+
withCloseButton={false}
|
|
187
|
+
scrollAreaComponent={ScrollArea.Autosize}
|
|
188
|
+
>
|
|
189
|
+
<Stack
|
|
190
|
+
gap={0}
|
|
191
|
+
p={{ base: 20, sm: 32 }}
|
|
192
|
+
>
|
|
193
|
+
<ZenLabel>{label}</ZenLabel>
|
|
194
|
+
|
|
195
|
+
<Title
|
|
196
|
+
order={2}
|
|
197
|
+
fw={900}
|
|
198
|
+
fz={{ base: 26, sm: 36 }}
|
|
199
|
+
lh={1.05}
|
|
200
|
+
mt={12}
|
|
201
|
+
ls="-0.04em"
|
|
202
|
+
c="gray.9"
|
|
203
|
+
>
|
|
204
|
+
{title}{' '}
|
|
205
|
+
<Text
|
|
206
|
+
span
|
|
207
|
+
inherit
|
|
208
|
+
c="gray.6"
|
|
209
|
+
>
|
|
210
|
+
{titleTail}
|
|
211
|
+
</Text>
|
|
212
|
+
</Title>
|
|
213
|
+
|
|
214
|
+
<Text
|
|
215
|
+
fz={14}
|
|
216
|
+
c="gray.6"
|
|
217
|
+
lh={1.65}
|
|
218
|
+
maw={520}
|
|
219
|
+
mt={14}
|
|
220
|
+
>
|
|
221
|
+
{description}
|
|
222
|
+
</Text>
|
|
223
|
+
|
|
224
|
+
<Box
|
|
225
|
+
mt={28}
|
|
226
|
+
pt={24}
|
|
227
|
+
style={{ borderTop: 'var(--rlg-border)' }}
|
|
228
|
+
>
|
|
229
|
+
<Stack gap={16}>
|
|
230
|
+
<HealthRow
|
|
231
|
+
health={health}
|
|
232
|
+
host={host}
|
|
233
|
+
/>
|
|
234
|
+
|
|
235
|
+
<Group gap={8}>
|
|
236
|
+
<Button
|
|
237
|
+
color="gray.9"
|
|
238
|
+
component="a"
|
|
239
|
+
href={mailto}
|
|
240
|
+
leftSection={
|
|
241
|
+
<IconMail
|
|
242
|
+
size={16}
|
|
243
|
+
stroke={1.5}
|
|
244
|
+
/>
|
|
245
|
+
}
|
|
246
|
+
>
|
|
247
|
+
Escrever para o suporte
|
|
248
|
+
</Button>
|
|
249
|
+
<Button
|
|
250
|
+
variant="outline"
|
|
251
|
+
color="gray.9"
|
|
252
|
+
onClick={onClose}
|
|
253
|
+
>
|
|
254
|
+
Fechar
|
|
255
|
+
</Button>
|
|
256
|
+
</Group>
|
|
257
|
+
|
|
258
|
+
<Text
|
|
259
|
+
fz={12}
|
|
260
|
+
c="gray.5"
|
|
261
|
+
>
|
|
262
|
+
{supportEmail}
|
|
263
|
+
</Text>
|
|
264
|
+
</Stack>
|
|
265
|
+
</Box>
|
|
266
|
+
</Stack>
|
|
267
|
+
</Modal>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Box, Skeleton } from '@mantine/core'
|
|
2
|
+
import { useMediaQuery } from '@mantine/hooks'
|
|
3
|
+
import { templateFor, ROW_PADDING } from './table-grid'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Skeleton beats spinner: it reproduces the REAL grid of the content, so the
|
|
7
|
+
* layout doesn't jump when the data arrives (Law 3 — and one of the spec's
|
|
8
|
+
* blockers: "metric or table without a skeleton shaped like the content").
|
|
9
|
+
*
|
|
10
|
+
* That's why it takes the SAME `columns` as the DataTable and derives the
|
|
11
|
+
* gridTemplateColumns and the padding from them — there is no way for the two
|
|
12
|
+
* grids to diverge, because they are the same expression.
|
|
13
|
+
*/
|
|
14
|
+
export function TableSkeleton({ columns, rows = 5 }) {
|
|
15
|
+
const wide = useMediaQuery('(min-width: 48em)', true)
|
|
16
|
+
const visible = wide ? columns : columns.filter(col => !col.hideBelow)
|
|
17
|
+
const template = templateFor(visible)
|
|
18
|
+
|
|
19
|
+
return Array.from({ length: rows }, (_, i) => (
|
|
20
|
+
<Box
|
|
21
|
+
key={i}
|
|
22
|
+
style={{
|
|
23
|
+
display: 'grid',
|
|
24
|
+
gridTemplateColumns: template,
|
|
25
|
+
gap: 16,
|
|
26
|
+
alignItems: 'center',
|
|
27
|
+
padding: ROW_PADDING,
|
|
28
|
+
borderBottom: i === rows - 1 ? 'none' : 'var(--rlg-border)',
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
{visible.map(col => (
|
|
32
|
+
<Box key={col.key}>
|
|
33
|
+
{col.label || col.skeleton ? (
|
|
34
|
+
<Skeleton
|
|
35
|
+
height={14}
|
|
36
|
+
width={col.skeletonWidth || '60%'}
|
|
37
|
+
/>
|
|
38
|
+
) : null}
|
|
39
|
+
</Box>
|
|
40
|
+
))}
|
|
41
|
+
</Box>
|
|
42
|
+
))
|
|
43
|
+
}
|