message-actions.tsx233 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState, useTransition } from 'react';
5
6import { StepUpPrompt } from '@/components/step-up-prompt';
7
8import { toValidDate } from '@/lib/utils';
9
10type Status = 'no_response' | 'in_review' | 'replied' | 'closed';
11
12const STATUS_OPTIONS: readonly Status[] = ['no_response', 'in_review', 'replied', 'closed'];
13
14const STATUS_LABELS: Record<Status, string> = {
15 no_response: 'no response',
16 in_review: 'in review',
17 replied: 'replied',
18 closed: 'closed',
19};
20
21interface MessageSummary {
22 id: string;
23 status: string;
24}
25
26interface Reply {
27 id: string;
28 author: 'operator' | 'user';
29 body: string;
30 createdAt: string;
31}
32
33interface Props {
34 message: MessageSummary;
35 apiOrigin: string;
36}
37
38export function MessageActions({ message, apiOrigin }: Props) {
39 const router = useRouter();
40
41 // Status control
42 const [busy, setBusy] = useState(false);
43 const [error, setError] = useState<string | null>(null);
44 const [pendingStatus, setPendingStatus] = useState<Status | null>(null);
45 const [status, setStatus] = useState<Status>(message.status as Status);
46
47 // Reply
48 const [replyBody, setReplyBody] = useState('');
49 const [replying, setReplying] = useState(false);
50 const [replyError, setReplyError] = useState<string | null>(null);
51 const [sentReplies, setSentReplies] = useState<Reply[]>([]);
52 // When the reply hits an expired step-up, hold it so the StepUpPrompt
53 // can re-run it after the operator re-confirms — the reply text stays
54 // in `replyBody` (only cleared on success), so no re-typing.
55 const [pendingReply, setPendingReply] = useState(false);
56
57 const [, startTransition] = useTransition();
58
59 async function patchStatus(next: Status) {
60 setBusy(true);
61 setError(null);
62 try {
63 const res = await fetch(`${apiOrigin}/v1/admin/contact-messages/${message.id}`, {
64 method: 'PATCH',
65 credentials: 'include',
66 headers: { 'content-type': 'application/json' },
67 body: JSON.stringify({ status: next }),
68 });
69 if (res.status === 403) {
70 const body = (await res.json().catch(() => null)) as { code?: string } | null;
71 if (body?.code === 'step_up_required') {
72 setPendingStatus(next);
73 return;
74 }
75 }
76 if (!res.ok) {
77 const body = await res.text().catch(() => '');
78 throw new Error(body || `update failed: ${res.status}`);
79 }
80 startTransition(() => router.refresh());
81 } catch (err) {
82 setError(err instanceof Error ? err.message : 'update failed');
83 } finally {
84 setBusy(false);
85 }
86 }
87
88 async function sendReply() {
89 if (!replyBody.trim()) return;
90 setReplying(true);
91 setReplyError(null);
92 try {
93 const res = await fetch(`${apiOrigin}/v1/admin/contact-messages/${message.id}/reply`, {
94 method: 'POST',
95 credentials: 'include',
96 headers: { 'content-type': 'application/json' },
97 body: JSON.stringify({ body: replyBody.trim() }),
98 });
99 if (res.status === 403) {
100 const body = (await res.json().catch(() => null)) as { code?: string } | null;
101 if (body?.code === 'step_up_required') {
102 // Show the password re-confirm prompt right here, then retry the
103 // reply automatically on success — no need to change status first.
104 setPendingReply(true);
105 return;
106 }
107 }
108 if (!res.ok) {
109 const body = await res.text().catch(() => '');
110 throw new Error(body || `reply failed: ${res.status}`);
111 }
112 const data = (await res.json().catch(() => null)) as { reply?: Reply } | null;
113 if (data?.reply) setSentReplies((prev) => [...prev, data.reply as Reply]);
114 setReplyBody('');
115 startTransition(() => router.refresh());
116 } catch (err) {
117 setReplyError(err instanceof Error ? err.message : 'reply failed');
118 } finally {
119 setReplying(false);
120 }
121 }
122
123 return (
124 <div className="flex flex-col gap-6 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
125 <h3 className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
126 operator actions
127 </h3>
128
129 {/* just-sent replies (optimistic; the server thread re-renders on refresh) */}
130 {sentReplies.length > 0 ? (
131 <div className="flex flex-col gap-4">
132 {sentReplies.map((r) => {
133 const rd = toValidDate(r.createdAt);
134 return (
135 <div
136 key={r.id}
137 className="rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] p-6"
138 >
139 <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
140 operator ·{' '}
141 {rd ? `${rd.toISOString().slice(0, 16).replace('T', ' ')} utc` : '—'}
142 </p>
143 <pre className="mt-3 whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-[var(--color-text)]">
144 {r.body}
145 </pre>
146 </div>
147 );
148 })}
149 </div>
150 ) : null}
151
152 {/* status */}
153 <div className="flex flex-wrap items-center gap-3">
154 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">status</span>
155 <select
156 value={status}
157 disabled={busy}
158 onChange={(e) => setStatus(e.target.value as Status)}
159 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1 font-mono text-xs text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
160 >
161 {STATUS_OPTIONS.map((s) => (
162 <option key={s} value={s}>
163 {STATUS_LABELS[s]}
164 </option>
165 ))}
166 </select>
167 <button
168 type="button"
169 disabled={busy || status === (message.status as Status)}
170 onClick={() => void patchStatus(status)}
171 className="rounded-md bg-[var(--color-primary)] px-3 py-1 font-mono text-xs text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
172 >
173 {busy ? 'saving…' : 'save status'}
174 </button>
175 </div>
176
177 {error ? (
178 <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p>
179 ) : null}
180
181 {pendingStatus ? (
182 <StepUpPrompt
183 apiOrigin={apiOrigin}
184 reason="updating a message requires fresh step-up auth. confirm with your password."
185 onSuccess={async () => {
186 const next = pendingStatus;
187 setPendingStatus(null);
188 if (next) await patchStatus(next);
189 }}
190 onCancel={() => setPendingStatus(null)}
191 />
192 ) : null}
193
194 {/* reply box */}
195 <div className="flex flex-col gap-2 border-t border-[var(--color-border-subtle)] pt-4">
196 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">reply to user</span>
197 <textarea
198 value={replyBody}
199 onChange={(e) => setReplyBody(e.target.value)}
200 rows={5}
201 maxLength={10_000}
202 placeholder="your reply — this will be emailed to the customer."
203 disabled={replying}
204 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-xs text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
205 />
206 {replyError ? (
207 <p className="font-mono text-[10px] text-[var(--color-error)]">{replyError}</p>
208 ) : null}
209 {pendingReply ? (
210 <StepUpPrompt
211 apiOrigin={apiOrigin}
212 reason="sending a reply requires fresh step-up auth. confirm with your password."
213 onSuccess={async () => {
214 setPendingReply(false);
215 await sendReply();
216 }}
217 onCancel={() => setPendingReply(false)}
218 />
219 ) : null}
220 <div>
221 <button
222 type="button"
223 onClick={() => void sendReply()}
224 disabled={replying || !replyBody.trim()}
225 className="rounded-md bg-[var(--color-primary)] px-4 py-1.5 font-mono text-xs text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
226 >
227 {replying ? 'sending…' : 'send reply'}
228 </button>
229 </div>
230 </div>
231 </div>
232 );
233}