File size: 4,988 Bytes
557ab38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
/**
 * ResultsPanel — the right column when we have a result. Composes:
 *   - ConfidenceInkwell (top)
 *   - MetricsStrip (cost / latency / tokens / model)
 *   - JsonView (extracted data)
 *   - WarningsList
 *
 * Empty and error states are handled inline — no separate components needed
 * for something this small.
 */
import { AnimatePresence, motion } from "motion/react";

import type { APIError } from "@/lib/api";
import type { ExtractResponse } from "@/types";

import { ConfidenceInkwell } from "./ConfidenceInkwell";
import { JsonView } from "./JsonView";
import { MetricsStrip } from "./MetricsStrip";
import { WarningsList } from "./WarningsList";

interface Props {
  status: "idle" | "loading" | "success" | "error";
  response: ExtractResponse | null;
  error: APIError | null;
}

const ITEM = {
  hidden: { y: 24, opacity: 0 },
  visible: {
    y: 0,
    opacity: 1,
    transition: { duration: 0.7, ease: [0.16, 1, 0.3, 1] },
  },
};

export function ResultsPanel({ status, response, error }: Props) {
  return (
    <AnimatePresence mode="wait">
      {status === "idle" && <Empty key="empty" />}
      {status === "loading" && <Loading key="loading" />}
      {status === "error" && error && <ErrorView key="error" error={error} />}
      {status === "success" && response && (
        <motion.div
          key="result"
          initial="hidden"
          animate="visible"
          exit={{ opacity: 0, transition: { duration: 0.3 } }}
          variants={{ visible: { transition: { staggerChildren: 0.08 } } }}
          className="flex flex-col gap-8"
        >
          <motion.div variants={ITEM}>
            <ConfidenceInkwell score={response.result.overall_confidence} />
          </motion.div>
          <motion.div variants={ITEM}>
            <MetricsStrip metrics={response.metrics} />
          </motion.div>
          <motion.div variants={ITEM}>
            <p className="eyebrow mb-3">Extracted data</p>
            <JsonView data={response.result.data} />
          </motion.div>
          <motion.div variants={ITEM}>
            <p className="eyebrow mb-3">Warnings</p>
            <WarningsList warnings={response.result.warnings} />
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

/* ------------------------------------------------------------------------ */

function Empty() {
  return (
    <motion.div
      initial={{ opacity: 0, y: 12 }}
      animate={{ opacity: 1, y: 0, transition: { duration: 0.6 } }}
      exit={{ opacity: 0 }}
      className="relative flex h-full min-h-[420px] flex-col items-start justify-center gap-4 rounded-[6px] border border-dashed border-[var(--rule)] bg-transparent p-8"
    >
      <p className="eyebrow">Awaiting document</p>
      <p className="font-display text-[38px] leading-[1] text-[var(--ink-strong)]">
        The result will land here —
      </p>
      <p className="max-w-[420px] text-[14px] leading-[1.6] text-[var(--ink-soft)]">
        Confidence, cost, latency, and the extracted JSON. Every field
        cross-checked against a Pydantic schema before it reaches you.
      </p>
      <div className="mt-6 flex items-center gap-3 text-[12px] text-[var(--ink-mute)]">
        <span className="h-px w-8 bg-[var(--rule)]" />
        <span>Try one of the samples on the left</span>
      </div>
    </motion.div>
  );
}

function Loading() {
  return (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      className="flex flex-col gap-6"
    >
      <p className="eyebrow">Extracting…</p>
      <p className="font-display text-[36px] leading-[1] text-[var(--ink-strong)]">
        Reading the sheet.
      </p>
      <div className="flex flex-col gap-3">
        {[0, 1, 2, 3].map((i) => (
          <Skeleton key={i} delay={i * 0.15} />
        ))}
      </div>
    </motion.div>
  );
}

function Skeleton({ delay }: { delay: number }) {
  return (
    <motion.div
      initial={{ opacity: 0.4, backgroundColor: "var(--rule)" }}
      animate={{
        opacity: [0.4, 0.7, 0.4],
        transition: { duration: 1.8, repeat: Infinity, delay },
      }}
      className="h-3 rounded-[2px]"
      style={{ width: `${60 + Math.random() * 30}%` }}
    />
  );
}

function ErrorView({ error }: { error: APIError }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 12 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0 }}
      className="flex flex-col gap-3 rounded-[6px] border-l-2 border-[var(--accent)] bg-[var(--accent-soft)] p-6"
    >
      <p className="eyebrow" style={{ color: "var(--accent)" }}>
        Extraction failed
      </p>
      <p className="font-display text-[26px] leading-[1.1] text-[var(--ink-strong)]">
        {error.message}
      </p>
      <p className="font-mono text-[12px] text-[var(--ink-soft)]">
        code: {error.code}
        {error.requestId && ` · request_id: ${error.requestId}`}
      </p>
    </motion.div>
  );
}