355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||
import type { MetricCsvMapping } from "@rakazo/contracts";
|
||
import {
|
||
Button,
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
Field,
|
||
FieldLabel,
|
||
Input,
|
||
NativeSelect,
|
||
NativeSelectOption,
|
||
} from "@rakazo/ui-web";
|
||
import { useEffect, useId, useMemo, useState } from "react";
|
||
import type { CsvDelimiter, MappingColumn } from "../../../lib/media-library";
|
||
import {
|
||
csvHeaders,
|
||
firstCsvLine,
|
||
guessColumns,
|
||
guessDelimiter,
|
||
guessPublicationKey,
|
||
MAPPING_COLUMNS,
|
||
} from "../../../lib/media-library";
|
||
import { rpcErrorMessage } from "../../../lib/media-studio";
|
||
import { rpc } from "../../../lib/rpc";
|
||
|
||
const MAX_CSV_CHARS = 5_000_000;
|
||
const NEW = "";
|
||
|
||
type Draft = Omit<MetricCsvMapping, "columns"> & {
|
||
columns: Partial<Record<MappingColumn, string>>;
|
||
};
|
||
|
||
function draftFrom(mapping: MetricCsvMapping): Draft {
|
||
return { ...mapping, columns: { ...mapping.columns } };
|
||
}
|
||
|
||
function toMapping(draft: Draft): MetricCsvMapping | null {
|
||
const { publication, observedAt } = draft.columns;
|
||
if (!publication || !observedAt || !draft.name.trim()) return null;
|
||
if (!/^[A-Za-z]{3}$/.test(draft.defaultCurrency)) return null;
|
||
const columns: MetricCsvMapping["columns"] = { publication, observedAt };
|
||
for (const key of MAPPING_COLUMNS) {
|
||
const value = draft.columns[key];
|
||
if (value) columns[key] = value;
|
||
}
|
||
return {
|
||
...draft,
|
||
name: draft.name.trim(),
|
||
defaultCurrency: draft.defaultCurrency.toUpperCase(),
|
||
columns,
|
||
};
|
||
}
|
||
|
||
export function CsvImportDialog({
|
||
open,
|
||
onOpenChange,
|
||
onImported,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
onImported: () => void;
|
||
}) {
|
||
const { t } = useLingui();
|
||
const fileId = useId();
|
||
const mappingId = useId();
|
||
const nameId = useId();
|
||
const [mappings, setMappings] = useState<MetricCsvMapping[]>([]);
|
||
const [csv, setCsv] = useState<string | null>(null);
|
||
const [fileName, setFileName] = useState("");
|
||
const [draft, setDraft] = useState<Draft | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [result, setResult] = useState<{
|
||
imported: number;
|
||
skipped: Array<{ row: number; reason: string }>;
|
||
} | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setCsv(null);
|
||
setFileName("");
|
||
setDraft(null);
|
||
setError(null);
|
||
setResult(null);
|
||
rpc.media.metrics
|
||
.mappings({})
|
||
.then(setMappings)
|
||
.catch(() => setMappings([]));
|
||
}, [open]);
|
||
|
||
const headers = useMemo(
|
||
() => (csv && draft ? csvHeaders(csv, draft.delimiter) : []),
|
||
[csv, draft?.delimiter, draft],
|
||
);
|
||
|
||
const labels: Record<MappingColumn, string> = {
|
||
publication: t`Publication`,
|
||
observedAt: t`Date`,
|
||
views: t`Views`,
|
||
clicks: t`Clicks`,
|
||
conversions: t`Purchases`,
|
||
revenue: t`Revenue`,
|
||
currency: t`Currency`,
|
||
};
|
||
const keyLabels: Record<MetricCsvMapping["publicationKey"], string> = {
|
||
utm_content: t`UTM content`,
|
||
tracking_url: t`Tracking link`,
|
||
external_url: t`Post link`,
|
||
id: t`Publication ID`,
|
||
};
|
||
|
||
const readFile = async (file: File) => {
|
||
setError(null);
|
||
setResult(null);
|
||
if (file.size > MAX_CSV_CHARS) {
|
||
setError(t`This file is too large.`);
|
||
return;
|
||
}
|
||
const text = await file.text();
|
||
setCsv(text);
|
||
setFileName(file.name);
|
||
const delimiter: CsvDelimiter = guessDelimiter(firstCsvLine(text));
|
||
const found = csvHeaders(text, delimiter);
|
||
const matching = mappings.find((mapping) =>
|
||
Object.values(mapping.columns).every((column) => !column || found.includes(column)),
|
||
);
|
||
if (matching) {
|
||
setDraft(draftFrom(matching));
|
||
return;
|
||
}
|
||
const columns = guessColumns(found);
|
||
setDraft({
|
||
name: file.name.replace(/\.[^.]+$/, "").slice(0, 60) || t`Import`,
|
||
delimiter,
|
||
columns,
|
||
publicationKey: guessPublicationKey(columns.publication),
|
||
revenueUnit: "major",
|
||
defaultCurrency: "EUR",
|
||
});
|
||
};
|
||
|
||
const mapping = draft ? toMapping(draft) : null;
|
||
|
||
const runImport = async () => {
|
||
if (!csv || !mapping) return;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const saved = await rpc.media.metrics.saveMapping(mapping);
|
||
if (!saved.id) throw new Error(t`Could not save the mapping.`);
|
||
const outcome = await rpc.media.metrics.importCsv({ mappingId: saved.id, csv });
|
||
setResult(outcome);
|
||
onImported();
|
||
} catch (cause) {
|
||
setError(rpcErrorMessage(cause, t`Import failed.`));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent data-testid="media-csv-import" className="sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
<Trans>Import results</Trans>
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{result ? (
|
||
<div className="flex flex-col gap-2 text-ui-body-sm">
|
||
<p data-testid="media-csv-imported">
|
||
<Plural value={result.imported} one="# row imported" other="# rows imported" />
|
||
</p>
|
||
{result.skipped.length > 0 ? (
|
||
<ul className="max-h-40 overflow-y-auto text-muted-foreground">
|
||
{result.skipped.slice(0, 20).map((skip) => (
|
||
<li key={skip.row}>
|
||
<Trans>
|
||
Row {skip.row}: {skip.reason}
|
||
</Trans>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
<DialogFooter>
|
||
<Button onClick={() => onOpenChange(false)}>
|
||
<Trans>Done</Trans>
|
||
</Button>
|
||
</DialogFooter>
|
||
</div>
|
||
) : (
|
||
<form
|
||
className="flex flex-col gap-4"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
void runImport();
|
||
}}
|
||
>
|
||
<Field>
|
||
<FieldLabel htmlFor={fileId}>
|
||
<Trans>CSV file</Trans>
|
||
</FieldLabel>
|
||
<Input
|
||
id={fileId}
|
||
type="file"
|
||
accept=".csv,text/csv,text/plain"
|
||
onChange={(event) => {
|
||
const file = event.target.files?.[0];
|
||
if (file) void readFile(file);
|
||
}}
|
||
/>
|
||
</Field>
|
||
{draft && csv ? (
|
||
<>
|
||
{mappings.length > 0 ? (
|
||
<Field>
|
||
<FieldLabel htmlFor={mappingId}>
|
||
<Trans>Mapping</Trans>
|
||
</FieldLabel>
|
||
<NativeSelect
|
||
id={mappingId}
|
||
className="w-full"
|
||
value={draft.id ?? NEW}
|
||
onChange={(event) => {
|
||
const chosen = mappings.find((row) => row.id === event.target.value);
|
||
setDraft(chosen ? draftFrom(chosen) : { ...draft, id: undefined });
|
||
}}
|
||
>
|
||
<NativeSelectOption value={NEW}>{t`New mapping`}</NativeSelectOption>
|
||
{mappings.map((row) => (
|
||
<NativeSelectOption key={row.id} value={row.id ?? NEW}>
|
||
{row.name}
|
||
</NativeSelectOption>
|
||
))}
|
||
</NativeSelect>
|
||
</Field>
|
||
) : null}
|
||
<Field>
|
||
<FieldLabel htmlFor={nameId}>
|
||
<Trans>Platform</Trans>
|
||
</FieldLabel>
|
||
<Input
|
||
id={nameId}
|
||
value={draft.name}
|
||
maxLength={60}
|
||
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
|
||
/>
|
||
</Field>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{MAPPING_COLUMNS.map((column) => (
|
||
<Field key={column}>
|
||
<FieldLabel htmlFor={`${mappingId}-${column}`}>{labels[column]}</FieldLabel>
|
||
<NativeSelect
|
||
id={`${mappingId}-${column}`}
|
||
size="sm"
|
||
className="w-full"
|
||
value={draft.columns[column] ?? ""}
|
||
onChange={(event) =>
|
||
setDraft({
|
||
...draft,
|
||
columns: {
|
||
...draft.columns,
|
||
[column]: event.target.value || undefined,
|
||
},
|
||
})
|
||
}
|
||
>
|
||
<NativeSelectOption value="">–</NativeSelectOption>
|
||
{headers.map((header) => (
|
||
<NativeSelectOption key={header} value={header}>
|
||
{header}
|
||
</NativeSelectOption>
|
||
))}
|
||
</NativeSelect>
|
||
</Field>
|
||
))}
|
||
<Field>
|
||
<FieldLabel htmlFor={`${mappingId}-key`}>
|
||
<Trans>Publication is</Trans>
|
||
</FieldLabel>
|
||
<NativeSelect
|
||
id={`${mappingId}-key`}
|
||
size="sm"
|
||
className="w-full"
|
||
value={draft.publicationKey}
|
||
onChange={(event) =>
|
||
setDraft({
|
||
...draft,
|
||
publicationKey: event.target.value as MetricCsvMapping["publicationKey"],
|
||
})
|
||
}
|
||
>
|
||
{(Object.keys(keyLabels) as MetricCsvMapping["publicationKey"][]).map(
|
||
(key) => (
|
||
<NativeSelectOption key={key} value={key}>
|
||
{keyLabels[key]}
|
||
</NativeSelectOption>
|
||
),
|
||
)}
|
||
</NativeSelect>
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel htmlFor={`${mappingId}-unit`}>
|
||
<Trans>Revenue in</Trans>
|
||
</FieldLabel>
|
||
<div className="flex gap-2">
|
||
<NativeSelect
|
||
id={`${mappingId}-unit`}
|
||
size="sm"
|
||
className="w-full"
|
||
value={draft.revenueUnit}
|
||
onChange={(event) =>
|
||
setDraft({
|
||
...draft,
|
||
revenueUnit: event.target.value as MetricCsvMapping["revenueUnit"],
|
||
})
|
||
}
|
||
>
|
||
<NativeSelectOption value="major">1.00</NativeSelectOption>
|
||
<NativeSelectOption value="cents">100</NativeSelectOption>
|
||
</NativeSelect>
|
||
<Input
|
||
aria-label={t`Currency`}
|
||
className="h-7! w-16 uppercase"
|
||
maxLength={3}
|
||
value={draft.defaultCurrency}
|
||
onChange={(event) =>
|
||
setDraft({ ...draft, defaultCurrency: event.target.value.toUpperCase() })
|
||
}
|
||
/>
|
||
</div>
|
||
</Field>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
{error ? (
|
||
<p role="alert" className="text-ui-body-sm text-destructive">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
<DialogFooter>
|
||
<Button type="submit" disabled={busy || !mapping || !csv}>
|
||
{fileName ? <Trans>Import {fileName}</Trans> : <Trans>Import</Trans>}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|