mirror of
https://github.com/hanzoai/trust.git
synced 2026-10-30 14:54:58 +00:00
236 lines
8.4 KiB
TypeScript
236 lines
8.4 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { ExternalLink } from '@hanzogui/lucide-icons-2';
|
|
import { Anchor } from '@hanzo/ui/primitives/Anchor';
|
|
import { Button } from '@hanzo/ui/primitives/Button';
|
|
import { Separator } from '@hanzo/ui/primitives/Separator';
|
|
import { Spinner } from '@hanzo/ui/primitives/Spinner';
|
|
import { Text } from '@hanzo/ui/primitives/Text';
|
|
import { XStack } from '@hanzo/ui/primitives/XStack';
|
|
import { YStack } from '@hanzo/ui/primitives/YStack';
|
|
import { FieldRow, FieldSwitch, FieldText, FieldTextArea } from '@hanzo/ui/product/Field';
|
|
import { ask, file, room, type Room } from '@/lib/dataroom';
|
|
import { day } from '@/lib/trust';
|
|
|
|
/** An address the grant can actually be sent to. The only thing worth refusing here. */
|
|
const reachable = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
|
|
|
|
type Panel = { at: 'shut' } | { at: 'reading' } | { at: 'open'; room: Room } | { at: 'unreachable'; why: string };
|
|
type Sent = { at: 'idle' } | { at: 'sending' } | { at: 'done'; state: string } | { at: 'refused'; why: string };
|
|
|
|
/**
|
|
* Asking for what is not published.
|
|
*
|
|
* The room is read when the panel is opened, not on every visit: a trust centre
|
|
* is read far more often than it is asked of. What comes back is the list of
|
|
* everything the centre holds — readable now, or released through a grant — so a
|
|
* reader can see what exists before asking for it.
|
|
*
|
|
* The write IS the answer. A form that accepts an address and drops it is worse
|
|
* than no form, so this reports the recorded state or it reports that nothing was
|
|
* recorded, and never a thank-you it cannot back.
|
|
*/
|
|
export function Request() {
|
|
const [panel, setPanel] = useState<Panel>({ at: 'shut' });
|
|
const [sent, setSent] = useState<Sent>({ at: 'idle' });
|
|
const [email, setEmail] = useState('');
|
|
const [party, setParty] = useState('');
|
|
const [reason, setReason] = useState('');
|
|
const [accepted, setAccepted] = useState(false);
|
|
|
|
const look = () => {
|
|
setPanel({ at: 'reading' });
|
|
room()
|
|
.then((r) => setPanel({ at: 'open', room: r }))
|
|
.catch((e: unknown) => setPanel({ at: 'unreachable', why: e instanceof Error ? e.message : String(e) }));
|
|
};
|
|
|
|
const send = (nda: string) => {
|
|
setSent({ at: 'sending' });
|
|
ask({
|
|
email: email.trim(),
|
|
...(party.trim() ? { party: party.trim() } : {}),
|
|
...(reason.trim() ? { reason: reason.trim() } : {}),
|
|
...(nda ? { accept: true } : {}),
|
|
})
|
|
.then((r) => setSent({ at: 'done', state: r.state }))
|
|
.catch((e: unknown) => setSent({ at: 'refused', why: e instanceof Error ? e.message : String(e) }));
|
|
};
|
|
|
|
if (panel.at === 'shut') {
|
|
return (
|
|
<XStack gap="$3" items="center" flexWrap="wrap">
|
|
<Button size="sm" rounded="$10" onPress={look}>
|
|
Request access
|
|
</Button>
|
|
<Text fontSize="$2" color="$color10">
|
|
Anything an independent auditor put their name to is released through a grant.
|
|
</Text>
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
if (panel.at === 'reading') {
|
|
return (
|
|
<XStack gap="$3" items="center">
|
|
<Spinner size={16} />
|
|
<Text fontSize="$2" color="$color10">
|
|
Reading the document room.
|
|
</Text>
|
|
</XStack>
|
|
);
|
|
}
|
|
|
|
if (panel.at === 'unreachable') {
|
|
return (
|
|
<YStack gap="$3" p="$5" rounded={24} borderWidth={1} borderColor="$borderColor" bg="$color1" role="alert">
|
|
<Text fontSize="$3" color="$color12">
|
|
The document room did not answer, so a request cannot be recorded right now.
|
|
</Text>
|
|
<Text fontSize="$2" color="$color10" className="hz-mono path">
|
|
{panel.why}
|
|
</Text>
|
|
<XStack>
|
|
<Button size="sm" variant="outline" rounded="$10" onPress={look}>
|
|
Try again
|
|
</Button>
|
|
</XStack>
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
const { items, nda } = panel.room;
|
|
const held = items.filter((i) => i.available === 'on request');
|
|
const now = items.filter((i) => i.available === 'now');
|
|
|
|
return (
|
|
<YStack gap="$5" p="$5" rounded={24} borderWidth={1} borderColor="$borderColor" bg="$color1">
|
|
{items.length === 0 ? (
|
|
<Text fontSize="$3" color="$color11">
|
|
The document room holds nothing at present. A request recorded here is still answered by a person.
|
|
</Text>
|
|
) : (
|
|
<YStack gap="$3">
|
|
{now.map((i) => (
|
|
<XStack key={i.id} gap="$3" items="baseline" justify="space-between" flexWrap="wrap">
|
|
<YStack gap="$0.5" flex={1} minW={220}>
|
|
<Anchor href={file(i.id)} target="_blank" rel="noreferrer">
|
|
<XStack gap="$1.5" items="center">
|
|
<Text fontSize="$3" color="$color12">
|
|
{i.name}
|
|
</Text>
|
|
<ExternalLink size={13} color="$color10" />
|
|
</XStack>
|
|
</Anchor>
|
|
{i.summary ? (
|
|
<Text fontSize="$2" color="$color10">
|
|
{i.summary}
|
|
</Text>
|
|
) : null}
|
|
</YStack>
|
|
<Text fontSize="$1" color="$color10" className="hz-tnum">
|
|
{i.kind} · {day(i.updatedAt)}
|
|
</Text>
|
|
</XStack>
|
|
))}
|
|
|
|
{held.map((i) => (
|
|
<XStack key={i.id} gap="$3" items="baseline" justify="space-between" flexWrap="wrap">
|
|
<YStack gap="$0.5" flex={1} minW={220}>
|
|
<Text fontSize="$3" color="$color12">
|
|
{i.name}
|
|
</Text>
|
|
<Text fontSize="$2" color="$color10">
|
|
{i.signed === 'auditor' ? 'Signed by an independent auditor' : 'Released on request'}
|
|
</Text>
|
|
</YStack>
|
|
<Text fontSize="$1" color="$color10" className="hz-tnum">
|
|
{i.kind} · {day(i.updatedAt)}
|
|
</Text>
|
|
</XStack>
|
|
))}
|
|
</YStack>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
{nda ? (
|
|
<YStack gap="$2">
|
|
<Text fontSize="$1" fontWeight="600" color="$color10">
|
|
Terms, verbatim
|
|
</Text>
|
|
<YStack
|
|
p="$3"
|
|
rounded="$5"
|
|
borderWidth={1}
|
|
borderColor="$borderColor"
|
|
maxH={200}
|
|
style={{ overflowY: 'auto' }}
|
|
>
|
|
<Text fontSize="$2" color="$color11">
|
|
{nda}
|
|
</Text>
|
|
</YStack>
|
|
<FieldRow label="I accept these terms">
|
|
<FieldSwitch checked={accepted} onChange={setAccepted} disabled={sent.at !== 'idle'} />
|
|
</FieldRow>
|
|
</YStack>
|
|
) : null}
|
|
|
|
<YStack gap="$2">
|
|
<FieldRow label="Email">
|
|
<FieldText
|
|
value={email}
|
|
onChange={setEmail}
|
|
placeholder="where the grant is sent"
|
|
autoComplete="email"
|
|
id="ask-email"
|
|
disabled={sent.at !== 'idle'}
|
|
/>
|
|
</FieldRow>
|
|
<FieldRow label="Company">
|
|
<FieldText value={party} onChange={setParty} placeholder="optional" disabled={sent.at !== 'idle'} />
|
|
</FieldRow>
|
|
<FieldRow label="Reason">
|
|
<FieldTextArea value={reason} onChange={setReason} rows={3} disabled={sent.at !== 'idle'} />
|
|
</FieldRow>
|
|
</YStack>
|
|
|
|
{sent.at === 'done' ? (
|
|
<Text fontSize="$3" color="$color12" role="status">
|
|
Recorded, and {sent.state}. Somebody here decides it; the grant goes to {email.trim()}.
|
|
</Text>
|
|
) : sent.at === 'refused' ? (
|
|
<YStack gap="$1" role="alert">
|
|
<Text fontSize="$3" color="$color12">
|
|
Nothing was recorded, so nobody has your request.
|
|
</Text>
|
|
<Text fontSize="$2" color="$color10" className="hz-mono path">
|
|
{sent.why}
|
|
</Text>
|
|
</YStack>
|
|
) : (
|
|
<XStack gap="$3" items="center" flexWrap="wrap">
|
|
<Button
|
|
size="sm"
|
|
rounded="$10"
|
|
disabled={sent.at === 'sending' || !reachable(email) || (!!nda && !accepted)}
|
|
isLoading={sent.at === 'sending'}
|
|
onPress={() => send(nda ?? '')}
|
|
>
|
|
Send the request
|
|
</Button>
|
|
<Text fontSize="$2" color="$color10">
|
|
{!reachable(email)
|
|
? 'An address the grant can be sent to is the one thing this needs.'
|
|
: nda && !accepted
|
|
? 'Accept the terms above to send.'
|
|
: 'It is written down and answered by a person.'}
|
|
</Text>
|
|
</XStack>
|
|
)}
|
|
</YStack>
|
|
);
|
|
}
|