82 lines
3.0 KiB
TypeScript
82 lines
3.0 KiB
TypeScript
"use client"
|
|
|
|
// "Export my data": one call, and the answer saved as a JSON file in the browser. A section that
|
|
// could not be exported is named on the page as well as in the file, so a person never takes a
|
|
// partial export for a whole one.
|
|
|
|
import { useTransport } from "@conusai-kit/ui"
|
|
import { type ReactElement, useId, useState } from "react"
|
|
import type { AccountExport } from "../contract/procedures.js"
|
|
|
|
export interface AccountExportLabels {
|
|
readonly heading: string
|
|
readonly body: string
|
|
readonly submit: string
|
|
readonly busy: string
|
|
readonly done: string
|
|
/** With `{sections}` still in it. */
|
|
readonly partial: string
|
|
readonly filename: string
|
|
/** The app's `app.failed` message, with `{detail}` still in it. */
|
|
readonly failed: string
|
|
/** A readable name per section key; a key without one is shown as it is. */
|
|
readonly sectionNames: Readonly<Record<string, string>>
|
|
}
|
|
|
|
type State =
|
|
| { readonly kind: "idle" | "busy" | "done" }
|
|
| { readonly kind: "partial"; readonly sections: string }
|
|
| { readonly kind: "failed"; readonly detail: string }
|
|
|
|
function save(exported: AccountExport, filename: string): void {
|
|
const href = URL.createObjectURL(new Blob([JSON.stringify(exported, null, 2)], { type: "application/json" }))
|
|
const link = document.createElement("a")
|
|
link.href = href
|
|
link.download = `${filename}-${exported.exportedAt.slice(0, 10)}.json`
|
|
document.body.append(link)
|
|
link.click()
|
|
link.remove()
|
|
setTimeout(() => URL.revokeObjectURL(href), 0)
|
|
}
|
|
|
|
export function AccountExportPanel({ labels }: { labels: AccountExportLabels }): ReactElement {
|
|
const { call, describe } = useTransport()
|
|
const headingId = useId()
|
|
const [state, setState] = useState<State>({ kind: "idle" })
|
|
|
|
const run = async (): Promise<void> => {
|
|
setState({ kind: "busy" })
|
|
try {
|
|
const exported = (await call("account.data.export", {})) as AccountExport
|
|
save(exported, labels.filename)
|
|
const missing = Object.entries(exported.sections)
|
|
.filter(([, section]) => section.status !== "ok")
|
|
.map(([key]) => labels.sectionNames[key] ?? key)
|
|
setState(exported.complete ? { kind: "done" } : { kind: "partial", sections: missing.join(", ") })
|
|
} catch (error) {
|
|
setState({ kind: "failed", detail: describe(error) })
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section aria-labelledby={headingId}>
|
|
<h2 id={headingId}>{labels.heading}</h2>
|
|
<p>{labels.body}</p>
|
|
<button type="button" onClick={run} disabled={state.kind === "busy"} aria-busy={state.kind === "busy"}>
|
|
{state.kind === "busy" ? labels.busy : labels.submit}
|
|
</button>
|
|
{state.kind === "done" ? <p role="status">{labels.done}</p> : null}
|
|
{state.kind === "partial" ? (
|
|
<p role="alert" className="app-error">
|
|
{labels.partial.replace("{sections}", state.sections)}
|
|
</p>
|
|
) : null}
|
|
{state.kind === "failed" ? (
|
|
<p role="alert" className="app-error">
|
|
{labels.failed.replace("{detail}", state.detail)}
|
|
</p>
|
|
) : null}
|
|
</section>
|
|
)
|
|
}
|